project scan

This commit is contained in:
K. Hodges 2026-06-10 04:42:53 -07:00
parent 3155ca76cd
commit 36a44afe2a
8 changed files with 618 additions and 54 deletions

2
Cargo.lock generated
View File

@ -100,7 +100,7 @@ dependencies = [
[[package]] [[package]]
name = "exoshell" name = "exoshell"
version = "0.10.0" version = "0.11.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"futures-util", "futures-util",

View File

@ -1,6 +1,6 @@
[package] [package]
name = "exoshell" name = "exoshell"
version = "0.10.0" version = "0.11.0"
edition = "2024" edition = "2024"
license = "GPL-3.0-or-later" license = "GPL-3.0-or-later"

View File

@ -30,7 +30,7 @@ The current implementation supports the first shell-adjacent model chat mileston
The codebase also contains the Phase 1.5 context engine foundation: context entries, provenance metadata, priority and size estimates, a session context store, provider registry, default manual/file/command-output/stdin/directory-summary providers, context REPL commands, deterministic pruning, budget checks, transcript events, startup context flags, piped stdin import, and prompt-context rendering. The codebase also contains the Phase 1.5 context engine foundation: context entries, provenance metadata, priority and size estimates, a session context store, provider registry, default manual/file/command-output/stdin/directory-summary providers, context REPL commands, deterministic pruning, budget checks, transcript events, startup context flags, piped stdin import, and prompt-context rendering.
The active milestone is Phase 3. The current implementation adds stance selection, explicit prompt assembly, visible prompt/context budget estimates, command suggestion IDs, simple risky-command warnings, command copy/explain/discard actions, a plain terminal session panel, Phase 2 help text, configurable model routing, Git-native project detection, lightweight project scans, attachable Git status context, attachable Git diff context, attachable recent commit context, and attachable repository search context. The active milestone is Phase 3. The current implementation adds stance selection, explicit prompt assembly, visible prompt/context budget estimates, command suggestion IDs, simple risky-command warnings, command copy/explain/discard actions, a plain terminal session panel, Phase 2 help text, configurable model routing, Git-native project detection, project-local Exoshell config inspection, ignore-aware lightweight project scans, attachable Git status context, attachable Git diff context, attachable recent commit context, and attachable repository search context.
The broader roadmap is tracked in [docs/PHASES.md](docs/PHASES.md) and [docs/tasks](docs/tasks). The broader roadmap is tracked in [docs/PHASES.md](docs/PHASES.md) and [docs/tasks](docs/tasks).

View File

@ -176,6 +176,17 @@ Or configure it:
root = "path/to/repo" root = "path/to/repo"
``` ```
Repositories can also provide project-local Exoshell config:
```toml
# .exoshell.toml
[project]
honor_gitignore = true
ignore = ["generated/", "*.snapshot"]
```
`.exoshell.local.toml` uses the same shape and overrides `.exoshell.toml`. Do not store secrets in project config; Exoshell reports likely secret keys in `/project config`.
## First Session ## First Session
At the prompt: At the prompt:
@ -201,6 +212,7 @@ Inspect detected Git project state:
```text ```text
exo> /project exo> /project
exo> /project config
``` ```
Preview or attach a lightweight project summary: Preview or attach a lightweight project summary:

View File

@ -84,6 +84,7 @@ contain version numbers
imply compatibility guarantees imply compatibility guarantees
contain offensive or discriminatory language contain offensive or discriminatory language
be reused be reused
just plainly mention the feature.
## Naming Guidance ## Naming Guidance
@ -94,7 +95,7 @@ Generate a new codename.
Verify that the codename has not been used previously. Verify that the codename has not been used previously.
Add the codename to release notes and changelog entries. Add the codename to release notes and changelog entries.
Preserve existing codenames in project history. Preserve existing codenames in project history.
Consider an interesting, cool sounding name.
## Historical Codenames ## Historical Codenames
Historical codenames should be tracked in docs/versioning.md below Historical codenames should be tracked in docs/versioning.md below
@ -109,3 +110,4 @@ Historical codenames should be tracked in docs/versioning.md below
* 0.8.0 commit-oracle * 0.8.0 commit-oracle
* 0.9.0 summary-relay * 0.9.0 summary-relay
* 0.10.0 search-relay * 0.10.0 search-relay
* 0.11.0 config-compass

View File

@ -13,7 +13,9 @@ use crate::context::{
use crate::formatting::render_assistant_output_with_policy; use crate::formatting::render_assistant_output_with_policy;
use crate::keybindings::render_keybindings; use crate::keybindings::render_keybindings;
use crate::project::{ use crate::project::{
ProjectError, detect_project, render_project_status, render_project_summary, summarize_project, ProjectError, detect_project, load_project_config,
render_project_config as render_project_config_report, render_project_status,
render_project_summary, summarize_project,
}; };
use crate::prompts::{Stance, assemble_prompt, render_prompt_estimate}; use crate::prompts::{Stance, assemble_prompt, render_prompt_estimate};
use crate::providers::{ChatMessage, ChatRequest, ChatResponse, ChatRole, Provider, ProviderError}; use crate::providers::{ChatMessage, ChatRequest, ChatResponse, ChatRole, Provider, ProviderError};
@ -114,6 +116,10 @@ impl App {
return self.render_project(); return self.render_project();
} }
if trimmed == "/project config" {
return self.render_project_config();
}
if trimmed == "/project scan" || trimmed == "/project scan --preview" { if trimmed == "/project scan" || trimmed == "/project scan --preview" {
return self.project_scan(trimmed == "/project scan --preview"); return self.project_scan(trimmed == "/project scan --preview");
} }
@ -370,6 +376,14 @@ impl App {
provider_options.insert("query".into(), query.trim().to_string()); provider_options.insert("query".into(), query.trim().to_string());
let path = self.project_context_path()?; let path = self.project_context_path()?;
let project_config = load_project_config(&path)?;
provider_options.insert(
"honor_gitignore".into(),
project_config.config.honor_gitignore.to_string(),
);
if !project_config.config.ignore.is_empty() {
provider_options.insert("ignore".into(), project_config.config.ignore.join("\n"));
}
self.add_context( self.add_context(
"repo_search", "repo_search",
ContextProviderRequest { ContextProviderRequest {
@ -509,6 +523,12 @@ impl App {
Ok(render_project_status(project.as_ref())) Ok(render_project_status(project.as_ref()))
} }
fn render_project_config(&self) -> Result<String, AppError> {
let path = self.project_context_path()?;
let report = load_project_config(&path)?;
Ok(render_project_config_report(&report))
}
fn render_project_status_for_panel(&self) -> String { fn render_project_status_for_panel(&self) -> String {
let Ok(cwd) = std::env::current_dir() else { let Ok(cwd) = std::env::current_dir() else {
return "Project\nstatus: unavailable".into(); return "Project\nstatus: unavailable".into();
@ -715,6 +735,7 @@ fn help_overview() -> &'static str {
"Commands: "Commands:
/context list attached context /context list attached context
/project show detected Git project and branch /project show detected Git project and branch
/project config show project-local Exoshell config
/project scan [--preview] summarize project or add summary context /project scan [--preview] summarize project or add summary context
/context stats show context and prompt budget estimates /context stats show context and prompt budget estimates
/context show <id> inspect a context entry /context show <id> inspect a context entry
@ -748,7 +769,7 @@ fn help_topic(topic: &str) -> &'static str {
"Context is explicit and session-scoped. Use /add-note, /add-file, /add-dir, or /add-output to attach material. Use /context stats before requests to inspect attached context size and prompt estimates." "Context is explicit and session-scoped. Use /add-note, /add-file, /add-dir, or /add-output to attach material. Use /context stats before requests to inspect attached context size and prompt estimates."
} }
"project" => { "project" => {
"Project detection walks upward from the current directory or configured project.root to find a Git repository. Use /project or /panel to inspect the detected root and branch. Use /project scan --preview to inspect a lightweight repository summary, or /project scan to add it as context." "Project detection walks upward from the current directory or configured project.root to find a Git repository. Use /project or /panel to inspect the detected root and branch. Use /project config to inspect .exoshell.toml, .exoshell.local.toml, ignore settings, and secret warnings. Use /project scan --preview to inspect a lightweight repository summary, or /project scan to add it as context."
} }
"git" => { "git" => {
"Use /add-git-status to attach current branch, staged files, modified files, and untracked files. Use /add-diff, /add-diff --staged, or /add-diff --staged <path> to attach read-only diff context. Use /add-commits, /add-commits --count 10, /add-commits --author=alice, or /add-commits src/app.rs to attach recent history." "Use /add-git-status to attach current branch, staged files, modified files, and untracked files. Use /add-diff, /add-diff --staged, or /add-diff --staged <path> to attach read-only diff context. Use /add-commits, /add-commits --count 10, /add-commits --author=alice, or /add-commits src/app.rs to attach recent history."
@ -1185,10 +1206,15 @@ mod tests {
.expect("project") .expect("project")
.contains("Project") .contains("Project")
); );
assert!(
app.handle_command("/help")
.expect("help")
.contains("/project config")
);
assert!( assert!(
app.handle_command("/help project") app.handle_command("/help project")
.expect("help project") .expect("help project")
.contains("Git repository") .contains(".exoshell.toml")
); );
assert!( assert!(
app.handle_command("/help git") app.handle_command("/help git")
@ -1260,11 +1286,11 @@ mod tests {
fn project_scan_preview_and_context_add_use_summary() { fn project_scan_preview_and_context_add_use_summary() {
let tempdir = tempfile::tempdir().expect("tempdir"); let tempdir = tempfile::tempdir().expect("tempdir");
let repo = tempdir.path().join("repo"); let repo = tempdir.path().join("repo");
std::fs::create_dir_all(repo.join(".git")).expect("git dir"); fs::create_dir_all(repo.join(".git")).expect("git dir");
std::fs::write(repo.join(".git").join("HEAD"), "ref: refs/heads/main\n").expect("head"); fs::write(repo.join(".git").join("HEAD"), "ref: refs/heads/main\n").expect("head");
std::fs::create_dir_all(repo.join("src")).expect("src dir"); fs::create_dir_all(repo.join("src")).expect("src dir");
std::fs::write(repo.join("Cargo.toml"), "[package]\nname = \"demo\"\n").expect("cargo"); fs::write(repo.join("Cargo.toml"), "[package]\nname = \"demo\"\n").expect("cargo");
std::fs::write(repo.join("src").join("main.rs"), "fn main() {}\n").expect("main"); fs::write(repo.join("src").join("main.rs"), "fn main() {}\n").expect("main");
let mut config = test_config(); let mut config = test_config();
config.project.root = Some(repo); config.project.root = Some(repo);
@ -1287,6 +1313,32 @@ mod tests {
); );
} }
#[test]
fn project_config_command_renders_local_config_and_warnings() {
let tempdir = tempfile::tempdir().expect("tempdir");
let repo = tempdir.path().join("repo");
fs::create_dir_all(repo.join(".git")).expect("git dir");
fs::write(repo.join(".git").join("HEAD"), "ref: refs/heads/main\n").expect("head");
fs::write(
repo.join(".exoshell.toml"),
"[project]\nignore = [\"generated/\"]\napi_key = \"bad\"\n",
)
.expect("config");
let mut config = test_config();
config.project.root = Some(repo);
let mut app = App::new(config, Box::new(NoopProvider));
let rendered = app
.handle_command("/project config")
.expect("project config");
assert!(rendered.contains("Project Config"));
assert!(rendered.contains(".exoshell.toml"));
assert!(rendered.contains("- generated/"));
assert!(rendered.contains("possible secret key"));
}
#[tokio::test] #[tokio::test]
async fn over_budget_context_fails_before_provider_request() { async fn over_budget_context_fails_before_provider_request() {
let seen = Arc::new(Mutex::new(Vec::new())); let seen = Arc::new(Mutex::new(Vec::new()));

View File

@ -5,6 +5,8 @@ use std::path::{Path, PathBuf};
use std::process::Command; use std::process::Command;
use std::str::FromStr; use std::str::FromStr;
use crate::project::RepositoryIgnoreRules;
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct ContextEntry { pub struct ContextEntry {
pub id: String, pub id: String,
@ -704,9 +706,35 @@ impl ContextProvider for RepositorySearchContextProvider {
.get("mode") .get("mode")
.map(String::as_str) .map(String::as_str)
.unwrap_or("text"); .unwrap_or("text");
let honor_gitignore = request
.provider_options
.get("honor_gitignore")
.map(|value| value == "true")
.unwrap_or(true);
let ignore_patterns = request
.provider_options
.get("ignore")
.map(|value| value.lines().map(str::to_string).collect::<Vec<_>>())
.unwrap_or_default();
let use_ripgrep = honor_gitignore && ignore_patterns.is_empty();
let ignore_rules =
RepositoryIgnoreRules::from_parts(&path, honor_gitignore, ignore_patterns).map_err(
|error| {
ContextError::InternalFailure(format!(
"failed to load repository ignore rules: {error}"
))
},
)?;
let search = match mode { let search = match mode {
"text" => search_text(&path, &query, self.max_results, self.max_file_bytes)?, "text" => search_text(
"path" => search_paths(&path, &query, self.max_results)?, &path,
&query,
self.max_results,
self.max_file_bytes,
&ignore_rules,
use_ripgrep,
)?,
"path" => search_paths(&path, &query, self.max_results, &ignore_rules)?,
other => { other => {
return Err(ContextError::InvalidInput(format!( return Err(ContextError::InvalidInput(format!(
"unsupported search mode '{other}', expected text or path" "unsupported search mode '{other}', expected text or path"
@ -734,6 +762,9 @@ impl ContextProvider for RepositorySearchContextProvider {
provenance provenance
.provider_details .provider_details
.insert("truncated".into(), search.truncated.to_string()); .insert("truncated".into(), search.truncated.to_string());
provenance
.provider_details
.insert("honor_gitignore".into(), honor_gitignore.to_string());
let content = if search.results.is_empty() { let content = if search.results.is_empty() {
format!( format!(
@ -1018,7 +1049,10 @@ fn search_text(
query: &str, query: &str,
max_results: usize, max_results: usize,
max_file_bytes: u64, max_file_bytes: u64,
ignore_rules: &RepositoryIgnoreRules,
use_ripgrep: bool,
) -> Result<SearchCollection, ContextError> { ) -> Result<SearchCollection, ContextError> {
if use_ripgrep {
let rg_output = Command::new("rg") let rg_output = Command::new("rg")
.arg("--line-number") .arg("--line-number")
.arg("--column") .arg("--column")
@ -1033,7 +1067,9 @@ fn search_text(
let usable_output = output.status.success() || output.status.code() == Some(1); let usable_output = output.status.success() || output.status.code() == Some(1);
if usable_output { if usable_output {
let stdout = String::from_utf8(output.stdout).map_err(|error| { let stdout = String::from_utf8(output.stdout).map_err(|error| {
ContextError::UnsupportedContent(format!("rg output was not valid UTF-8: {error}")) ContextError::UnsupportedContent(format!(
"rg output was not valid UTF-8: {error}"
))
})?; })?;
let scan_limit = max_results.saturating_add(1); let scan_limit = max_results.saturating_add(1);
let all_results: Vec<String> = stdout let all_results: Vec<String> = stdout
@ -1049,19 +1085,24 @@ fn search_text(
}); });
} }
} }
}
fallback_text_search(root, query, max_results, max_file_bytes) fallback_text_search(root, query, max_results, max_file_bytes, ignore_rules)
} }
fn search_paths( fn search_paths(
root: &Path, root: &Path,
query: &str, query: &str,
max_results: usize, max_results: usize,
ignore_rules: &RepositoryIgnoreRules,
) -> Result<SearchCollection, ContextError> { ) -> Result<SearchCollection, ContextError> {
let mut results = Vec::new(); let mut results = Vec::new();
let needle = query.to_lowercase(); let needle = query.to_lowercase();
let scan_limit = max_results.saturating_add(1); let scan_limit = max_results.saturating_add(1);
visit_files(root, scan_limit, &mut |path| { visit_files(root, scan_limit, &mut |path| {
if ignore_rules.is_ignored(path) {
return Ok(true);
}
let relative = relative_path(root, path); let relative = relative_path(root, path);
if relative.to_lowercase().contains(&needle) { if relative.to_lowercase().contains(&needle) {
results.push(relative); results.push(relative);
@ -1082,11 +1123,15 @@ fn fallback_text_search(
query: &str, query: &str,
max_results: usize, max_results: usize,
max_file_bytes: u64, max_file_bytes: u64,
ignore_rules: &RepositoryIgnoreRules,
) -> Result<SearchCollection, ContextError> { ) -> Result<SearchCollection, ContextError> {
let mut results = Vec::new(); let mut results = Vec::new();
let needle = query.to_lowercase(); let needle = query.to_lowercase();
let scan_limit = max_results.saturating_add(1); let scan_limit = max_results.saturating_add(1);
visit_files(root, scan_limit, &mut |path| { visit_files(root, scan_limit, &mut |path| {
if ignore_rules.is_ignored(path) {
return Ok(true);
}
let Ok(metadata) = fs::metadata(path) else { let Ok(metadata) = fs::metadata(path) else {
return Ok(true); return Ok(true);
}; };
@ -2189,8 +2234,10 @@ mod tests {
) )
.expect("write text"); .expect("write text");
let search = let ignore_rules =
fallback_text_search(tempdir.path(), "needle", 2, 256 * 1024).expect("fallback search"); RepositoryIgnoreRules::from_parts(tempdir.path(), true, Vec::new()).expect("rules");
let search = fallback_text_search(tempdir.path(), "needle", 2, 256 * 1024, &ignore_rules)
.expect("fallback search");
assert_eq!(search.engine, SearchEngine::Fallback); assert_eq!(search.engine, SearchEngine::Fallback);
assert!(search.truncated); assert!(search.truncated);
@ -2204,12 +2251,30 @@ mod tests {
let tempdir = tempfile::tempdir().expect("tempdir"); let tempdir = tempfile::tempdir().expect("tempdir");
fs::write(tempdir.path().join("large.txt"), "needle").expect("write large"); fs::write(tempdir.path().join("large.txt"), "needle").expect("write large");
let search = let ignore_rules =
fallback_text_search(tempdir.path(), "needle", 10, 2).expect("fallback search"); RepositoryIgnoreRules::from_parts(tempdir.path(), true, Vec::new()).expect("rules");
let search = fallback_text_search(tempdir.path(), "needle", 10, 2, &ignore_rules)
.expect("fallback search");
assert!(search.results.is_empty()); assert!(search.results.is_empty());
} }
#[test]
fn repository_search_honors_gitignore_in_fallback() {
let tempdir = tempfile::tempdir().expect("tempdir");
fs::write(tempdir.path().join(".gitignore"), "ignored.txt\n").expect("gitignore");
fs::write(tempdir.path().join("ignored.txt"), "needle\n").expect("ignored");
fs::write(tempdir.path().join("kept.txt"), "needle\n").expect("kept");
let ignore_rules =
RepositoryIgnoreRules::from_parts(tempdir.path(), true, Vec::new()).expect("rules");
let search = fallback_text_search(tempdir.path(), "needle", 10, 256 * 1024, &ignore_rules)
.expect("fallback search");
assert_eq!(search.results.len(), 1);
assert!(search.results[0].contains("kept.txt"));
}
#[test] #[test]
fn git_commit_count_is_bounded() { fn git_commit_count_is_bounded() {
assert_eq!(parse_git_commit_count(None).expect("default"), 5); assert_eq!(parse_git_commit_count(None).expect("default"), 5);

View File

@ -3,6 +3,8 @@ use std::fmt;
use std::fs; use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use serde::Deserialize;
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProjectInfo { pub struct ProjectInfo {
pub root: PathBuf, pub root: PathBuf,
@ -33,6 +35,32 @@ impl fmt::Display for ProjectHead {
} }
} }
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProjectLocalConfig {
pub honor_gitignore: bool,
pub ignore: Vec<String>,
}
impl Default for ProjectLocalConfig {
fn default() -> Self {
Self {
honor_gitignore: true,
ignore: Vec::new(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProjectConfigReport {
pub root: PathBuf,
pub shared_path: PathBuf,
pub local_path: PathBuf,
pub shared_loaded: bool,
pub local_loaded: bool,
pub config: ProjectLocalConfig,
pub warnings: Vec<String>,
}
pub fn detect_project( pub fn detect_project(
start: &Path, start: &Path,
root_override: Option<&Path>, root_override: Option<&Path>,
@ -63,6 +91,311 @@ pub fn render_project_status(project: Option<&ProjectInfo>) -> String {
) )
} }
pub fn load_project_config(root: &Path) -> Result<ProjectConfigReport, ProjectError> {
let shared_path = root.join(".exoshell.toml");
let local_path = root.join(".exoshell.local.toml");
let mut config = ProjectLocalConfig::default();
let mut warnings = Vec::new();
let mut shared_loaded = false;
let mut local_loaded = false;
if shared_path.exists() {
let loaded = read_project_config_file(&shared_path)?;
config = merge_project_config(config, loaded.config);
warnings.extend(loaded.warnings);
shared_loaded = true;
}
if local_path.exists() {
let loaded = read_project_config_file(&local_path)?;
config = merge_project_config(config, loaded.config);
warnings.extend(loaded.warnings);
local_loaded = true;
}
Ok(ProjectConfigReport {
root: root.to_path_buf(),
shared_path,
local_path,
shared_loaded,
local_loaded,
config,
warnings,
})
}
pub fn render_project_config(report: &ProjectConfigReport) -> String {
let mut rendered = String::new();
rendered.push_str("Project Config\n");
rendered.push_str(&format!("root: {}\n", report.root.display()));
rendered.push_str(&format!(
"shared: {} ({})\n",
report.shared_path.display(),
if report.shared_loaded {
"loaded"
} else {
"missing"
}
));
rendered.push_str(&format!(
"local: {} ({})\n",
report.local_path.display(),
if report.local_loaded {
"loaded"
} else {
"missing"
}
));
rendered.push_str(&format!(
"honor_gitignore: {}\n",
report.config.honor_gitignore
));
rendered.push_str("ignore:\n");
render_lines(&mut rendered, &report.config.ignore);
rendered.push_str("warnings:\n");
render_lines(&mut rendered, &report.warnings);
rendered.trim_end().to_string()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RepositoryIgnoreRules {
root: PathBuf,
patterns: Vec<IgnorePattern>,
}
impl RepositoryIgnoreRules {
pub fn from_project_config(
root: &Path,
config: &ProjectLocalConfig,
) -> Result<Self, ProjectError> {
Self::from_parts(root, config.honor_gitignore, config.ignore.clone())
}
pub fn from_parts(
root: &Path,
honor_gitignore: bool,
exoshell_patterns: Vec<String>,
) -> Result<Self, ProjectError> {
let mut patterns = Vec::new();
if honor_gitignore {
patterns.extend(read_gitignore_patterns(root)?);
}
patterns.extend(
exoshell_patterns
.into_iter()
.filter_map(IgnorePattern::parse),
);
Ok(Self {
root: root.to_path_buf(),
patterns,
})
}
pub fn is_ignored(&self, path: &Path) -> bool {
let relative = relative_display(&self.root, path);
if relative.is_empty() {
return false;
}
let name = path
.file_name()
.map(|name| name.to_string_lossy())
.unwrap_or_default();
let mut ignored = is_noisy_project_path(&name);
for pattern in &self.patterns {
if pattern.matches(&relative) {
ignored = !pattern.negated;
}
}
ignored
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct IgnorePattern {
pattern: String,
negated: bool,
directory_only: bool,
anchored: bool,
}
impl IgnorePattern {
fn parse(raw: String) -> Option<Self> {
let raw = raw.trim();
if raw.is_empty() || raw.starts_with('#') {
return None;
}
let (negated, raw) = raw
.strip_prefix('!')
.map(|pattern| (true, pattern))
.unwrap_or((false, raw));
let anchored = raw.starts_with('/');
let raw = raw.trim_start_matches('/');
let directory_only = raw.ends_with('/');
let pattern = raw.trim_end_matches('/').trim();
if pattern.is_empty() {
return None;
}
Some(Self {
pattern: pattern.replace('\\', "/"),
negated,
directory_only,
anchored,
})
}
fn matches(&self, relative: &str) -> bool {
let relative = relative.replace('\\', "/");
if self.directory_only {
return relative == self.pattern || relative.starts_with(&format!("{}/", self.pattern));
}
if self.anchored || self.pattern.contains('/') {
return wildcard_match(&self.pattern, &relative);
}
relative
.split('/')
.any(|segment| wildcard_match(&self.pattern, segment))
}
}
fn wildcard_match(pattern: &str, value: &str) -> bool {
if pattern == value {
return true;
}
if !pattern.contains('*') {
return false;
}
let mut remaining = value;
let anchored_start = !pattern.starts_with('*');
let anchored_end = !pattern.ends_with('*');
let parts = pattern
.split('*')
.filter(|part| !part.is_empty())
.collect::<Vec<_>>();
if parts.is_empty() {
return true;
}
for (index, part) in parts.iter().enumerate() {
let Some(position) = remaining.find(part) else {
return false;
};
if index == 0 && anchored_start && position != 0 {
return false;
}
remaining = &remaining[position + part.len()..];
}
!anchored_end || remaining.is_empty()
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)]
struct RawProjectConfigFile {
project: Option<RawProjectLocalConfig>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)]
struct RawProjectLocalConfig {
honor_gitignore: Option<bool>,
ignore: Option<Vec<String>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct LoadedProjectConfig {
config: RawProjectLocalConfig,
warnings: Vec<String>,
}
fn read_project_config_file(path: &Path) -> Result<LoadedProjectConfig, ProjectError> {
let contents = fs::read_to_string(path).map_err(|error| ProjectError::Read {
path: path.to_path_buf(),
error: error.to_string(),
})?;
let raw: RawProjectConfigFile =
toml::from_str(&contents).map_err(|error| ProjectError::ConfigParse {
path: path.to_path_buf(),
error: error.to_string(),
})?;
let value: toml::Value =
toml::from_str(&contents).map_err(|error| ProjectError::ConfigParse {
path: path.to_path_buf(),
error: error.to_string(),
})?;
let mut warnings = Vec::new();
collect_secret_warnings(path, &value, "", &mut warnings);
Ok(LoadedProjectConfig {
config: raw.project.unwrap_or_default(),
warnings,
})
}
fn merge_project_config(
mut base: ProjectLocalConfig,
override_config: RawProjectLocalConfig,
) -> ProjectLocalConfig {
if let Some(honor_gitignore) = override_config.honor_gitignore {
base.honor_gitignore = honor_gitignore;
}
if let Some(ignore) = override_config.ignore {
base.ignore = ignore;
}
base
}
fn read_gitignore_patterns(root: &Path) -> Result<Vec<IgnorePattern>, ProjectError> {
let path = root.join(".gitignore");
if !path.exists() {
return Ok(Vec::new());
}
let contents = fs::read_to_string(&path).map_err(|error| ProjectError::Read {
path,
error: error.to_string(),
})?;
Ok(contents
.lines()
.filter_map(|line| IgnorePattern::parse(line.to_string()))
.collect())
}
fn collect_secret_warnings(
path: &Path,
value: &toml::Value,
prefix: &str,
warnings: &mut Vec<String>,
) {
let Some(table) = value.as_table() else {
return;
};
for (key, value) in table {
let full_key = if prefix.is_empty() {
key.to_string()
} else {
format!("{prefix}.{key}")
};
if looks_secret_key(key) {
warnings.push(format!(
"possible secret key '{full_key}' in {}; project config should not store secrets",
path.file_name()
.map(|name| name.to_string_lossy())
.unwrap_or_else(|| path.as_os_str().to_string_lossy())
));
}
collect_secret_warnings(path, value, &full_key, warnings);
}
}
fn looks_secret_key(key: &str) -> bool {
let key = key.to_ascii_lowercase();
key.contains("secret")
|| key.contains("token")
|| key.contains("password")
|| key.contains("api_key")
|| key.contains("apikey")
}
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProjectSummary { pub struct ProjectSummary {
pub root: PathBuf, pub root: PathBuf,
@ -86,7 +419,9 @@ pub fn summarize_project(
root_override: Option<&Path>, root_override: Option<&Path>,
) -> Result<ProjectSummary, ProjectError> { ) -> Result<ProjectSummary, ProjectError> {
let project = detect_project(start, root_override)?.ok_or(ProjectError::NotDetected)?; let project = detect_project(start, root_override)?.ok_or(ProjectError::NotDetected)?;
Ok(summarize_project_root(&project)) let config = load_project_config(&project.root)?;
let ignore_rules = RepositoryIgnoreRules::from_project_config(&project.root, &config.config)?;
Ok(summarize_project_root(&project, &ignore_rules))
} }
pub fn render_project_summary(summary: &ProjectSummary) -> String { pub fn render_project_summary(summary: &ProjectSummary) -> String {
@ -120,9 +455,12 @@ pub fn render_project_summary(summary: &ProjectSummary) -> String {
rendered.trim_end().to_string() rendered.trim_end().to_string()
} }
fn summarize_project_root(project: &ProjectInfo) -> ProjectSummary { fn summarize_project_root(
project: &ProjectInfo,
ignore_rules: &RepositoryIgnoreRules,
) -> ProjectSummary {
const MAX_FILES: usize = 1_000; const MAX_FILES: usize = 1_000;
let major_directories = major_directories(&project.root); let major_directories = major_directories(&project.root, ignore_rules);
let mut languages = BTreeMap::<String, usize>::new(); let mut languages = BTreeMap::<String, usize>::new();
let mut entry_points = Vec::new(); let mut entry_points = Vec::new();
let mut build_files = Vec::new(); let mut build_files = Vec::new();
@ -139,8 +477,7 @@ fn summarize_project_root(project: &ProjectInfo) -> ProjectSummary {
for entry in entries { for entry in entries {
let entry_path = entry.path(); let entry_path = entry.path();
let name = entry.file_name().to_string_lossy().to_string(); if ignore_rules.is_ignored(&entry_path) {
if is_noisy_project_path(&name) {
continue; continue;
} }
let Ok(file_type) = entry.file_type() else { let Ok(file_type) = entry.file_type() else {
@ -204,7 +541,7 @@ fn summarize_project_root(project: &ProjectInfo) -> ProjectSummary {
} }
} }
fn major_directories(root: &Path) -> Vec<String> { fn major_directories(root: &Path, ignore_rules: &RepositoryIgnoreRules) -> Vec<String> {
let Ok(entries) = fs::read_dir(root) else { let Ok(entries) = fs::read_dir(root) else {
return Vec::new(); return Vec::new();
}; };
@ -212,7 +549,7 @@ fn major_directories(root: &Path) -> Vec<String> {
.filter_map(Result::ok) .filter_map(Result::ok)
.filter_map(|entry| { .filter_map(|entry| {
let name = entry.file_name().to_string_lossy().to_string(); let name = entry.file_name().to_string_lossy().to_string();
if is_noisy_project_path(&name) || !entry.file_type().ok()?.is_dir() { if ignore_rules.is_ignored(&entry.path()) || !entry.file_type().ok()?.is_dir() {
None None
} else { } else {
Some(format!("{name}/")) Some(format!("{name}/"))
@ -388,6 +725,8 @@ fn short_commit(value: &str) -> String {
pub enum ProjectError { pub enum ProjectError {
#[error("failed to read project metadata at {path}: {error}")] #[error("failed to read project metadata at {path}: {error}")]
Read { path: PathBuf, error: String }, Read { path: PathBuf, error: String },
#[error("failed to parse project config at {path}: {error}")]
ConfigParse { path: PathBuf, error: String },
#[error("invalid git file at {0}")] #[error("invalid git file at {0}")]
InvalidGitFile(PathBuf), InvalidGitFile(PathBuf),
#[error("project not detected")] #[error("project not detected")]
@ -494,6 +833,100 @@ mod tests {
assert!(rendered.contains("Rust")); assert!(rendered.contains("Rust"));
} }
#[test]
fn project_config_loads_shared_and_local_with_local_precedence() {
let tempdir = tempfile::tempdir().expect("tempdir");
let repo = tempdir.path().join("repo");
write_head(&repo, "main");
fs::write(
repo.join(".exoshell.toml"),
"[project]\nhonor_gitignore = true\nignore = [\"generated\"]\n",
)
.expect("shared config");
fs::write(
repo.join(".exoshell.local.toml"),
"[project]\nhonor_gitignore = false\nignore = [\"local-only\"]\napi_token = \"bad\"\n",
)
.expect("local config");
let report = load_project_config(&repo).expect("project config");
let rendered = render_project_config(&report);
assert!(report.shared_loaded);
assert!(report.local_loaded);
assert!(!report.config.honor_gitignore);
assert_eq!(report.config.ignore, vec!["local-only".to_string()]);
assert!(
report
.warnings
.iter()
.any(|warning| warning.contains("api_token"))
);
assert!(rendered.contains("honor_gitignore: false"));
assert!(rendered.contains("- local-only"));
assert!(rendered.contains("possible secret key"));
}
#[test]
fn project_summary_honors_gitignore_and_exoshell_ignore_rules() {
let tempdir = tempfile::tempdir().expect("tempdir");
let repo = tempdir.path().join("repo");
write_head(&repo, "main");
fs::create_dir_all(repo.join("src")).expect("src dir");
fs::create_dir_all(repo.join("generated")).expect("generated dir");
fs::write(repo.join(".gitignore"), "ignored.py\n").expect("gitignore");
fs::write(
repo.join(".exoshell.toml"),
"[project]\nignore = [\"generated/\"]\n",
)
.expect("project config");
fs::write(repo.join("src").join("main.rs"), "fn main() {}\n").expect("main");
fs::write(repo.join("ignored.py"), "print('ignored')\n").expect("ignored");
fs::write(repo.join("generated").join("ignored.ts"), "ignored\n").expect("generated");
let summary = summarize_project(&repo, None).expect("summary");
assert!(summary.entry_points.contains(&"src/main.rs".to_string()));
assert!(
summary
.languages
.iter()
.any(|language| language.language == "Rust")
);
assert!(
!summary
.languages
.iter()
.any(|language| language.language == "Python")
);
assert!(
!summary
.languages
.iter()
.any(|language| language.language == "TypeScript")
);
assert!(
!summary
.major_directories
.contains(&"generated/".to_string())
);
}
#[test]
fn repository_ignore_rules_support_negation_and_wildcards() {
let tempdir = tempfile::tempdir().expect("tempdir");
let rules = RepositoryIgnoreRules::from_parts(
tempdir.path(),
false,
vec!["*.log".into(), "!keep.log".into(), "cache/".into()],
)
.expect("rules");
assert!(rules.is_ignored(&tempdir.path().join("debug.log")));
assert!(!rules.is_ignored(&tempdir.path().join("keep.log")));
assert!(rules.is_ignored(&tempdir.path().join("cache").join("data.txt")));
}
fn write_head(root: &Path, branch: &str) { fn write_head(root: &Path, branch: &str) {
let git_dir = root.join(".git"); let git_dir = root.join(".git");
fs::create_dir_all(&git_dir).expect("git dir"); fs::create_dir_all(&git_dir).expect("git dir");