From 36a44afe2a0ba61c17327127410a72df78f9c07c Mon Sep 17 00:00:00 2001 From: "K. Hodges" Date: Wed, 10 Jun 2026 04:42:53 -0700 Subject: [PATCH] project scan --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 2 +- docs/quickstart.md | 12 ++ docs/versioning.md | 4 +- src/app.rs | 68 ++++++- src/context.rs | 135 ++++++++++---- src/project.rs | 447 ++++++++++++++++++++++++++++++++++++++++++++- 8 files changed, 618 insertions(+), 54 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ba6f11b..5c1b215 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -100,7 +100,7 @@ dependencies = [ [[package]] name = "exoshell" -version = "0.10.0" +version = "0.11.0" dependencies = [ "async-trait", "futures-util", diff --git a/Cargo.toml b/Cargo.toml index d46b5bd..8b51de6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "exoshell" -version = "0.10.0" +version = "0.11.0" edition = "2024" license = "GPL-3.0-or-later" diff --git a/README.md b/README.md index 7cb6cdf..d6c3d1e 100644 --- a/README.md +++ b/README.md @@ -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 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). diff --git a/docs/quickstart.md b/docs/quickstart.md index 75240e5..33ac4e3 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -176,6 +176,17 @@ Or configure it: 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 At the prompt: @@ -201,6 +212,7 @@ Inspect detected Git project state: ```text exo> /project +exo> /project config ``` Preview or attach a lightweight project summary: diff --git a/docs/versioning.md b/docs/versioning.md index b0f5ed3..493e8c1 100644 --- a/docs/versioning.md +++ b/docs/versioning.md @@ -84,6 +84,7 @@ contain version numbers imply compatibility guarantees contain offensive or discriminatory language be reused +just plainly mention the feature. ## Naming Guidance @@ -94,7 +95,7 @@ Generate a new codename. Verify that the codename has not been used previously. Add the codename to release notes and changelog entries. Preserve existing codenames in project history. - +Consider an interesting, cool sounding name. ## Historical Codenames 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.9.0 summary-relay * 0.10.0 search-relay +* 0.11.0 config-compass diff --git a/src/app.rs b/src/app.rs index 2881528..30c30c0 100644 --- a/src/app.rs +++ b/src/app.rs @@ -13,7 +13,9 @@ use crate::context::{ use crate::formatting::render_assistant_output_with_policy; use crate::keybindings::render_keybindings; 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::providers::{ChatMessage, ChatRequest, ChatResponse, ChatRole, Provider, ProviderError}; @@ -114,6 +116,10 @@ impl App { return self.render_project(); } + if trimmed == "/project config" { + return self.render_project_config(); + } + if trimmed == "/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()); 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( "repo_search", ContextProviderRequest { @@ -509,6 +523,12 @@ impl App { Ok(render_project_status(project.as_ref())) } + fn render_project_config(&self) -> Result { + 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 { let Ok(cwd) = std::env::current_dir() else { return "Project\nstatus: unavailable".into(); @@ -715,6 +735,7 @@ fn help_overview() -> &'static str { "Commands: /context list attached context /project show detected Git project and branch +/project config show project-local Exoshell config /project scan [--preview] summarize project or add summary context /context stats show context and prompt budget estimates /context show 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." } "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" => { "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 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") .contains("Project") ); + assert!( + app.handle_command("/help") + .expect("help") + .contains("/project config") + ); assert!( app.handle_command("/help project") .expect("help project") - .contains("Git repository") + .contains(".exoshell.toml") ); assert!( app.handle_command("/help git") @@ -1260,11 +1286,11 @@ mod tests { fn project_scan_preview_and_context_add_use_summary() { let tempdir = tempfile::tempdir().expect("tempdir"); let repo = tempdir.path().join("repo"); - std::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"); - std::fs::create_dir_all(repo.join("src")).expect("src dir"); - std::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::create_dir_all(repo.join(".git")).expect("git dir"); + fs::write(repo.join(".git").join("HEAD"), "ref: refs/heads/main\n").expect("head"); + fs::create_dir_all(repo.join("src")).expect("src dir"); + fs::write(repo.join("Cargo.toml"), "[package]\nname = \"demo\"\n").expect("cargo"); + fs::write(repo.join("src").join("main.rs"), "fn main() {}\n").expect("main"); let mut config = test_config(); 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] async fn over_budget_context_fails_before_provider_request() { let seen = Arc::new(Mutex::new(Vec::new())); diff --git a/src/context.rs b/src/context.rs index 3c1e713..ae9a580 100644 --- a/src/context.rs +++ b/src/context.rs @@ -5,6 +5,8 @@ use std::path::{Path, PathBuf}; use std::process::Command; use std::str::FromStr; +use crate::project::RepositoryIgnoreRules; + #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct ContextEntry { pub id: String, @@ -704,9 +706,35 @@ impl ContextProvider for RepositorySearchContextProvider { .get("mode") .map(String::as_str) .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::>()) + .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 { - "text" => search_text(&path, &query, self.max_results, self.max_file_bytes)?, - "path" => search_paths(&path, &query, self.max_results)?, + "text" => search_text( + &path, + &query, + self.max_results, + self.max_file_bytes, + &ignore_rules, + use_ripgrep, + )?, + "path" => search_paths(&path, &query, self.max_results, &ignore_rules)?, other => { return Err(ContextError::InvalidInput(format!( "unsupported search mode '{other}', expected text or path" @@ -734,6 +762,9 @@ impl ContextProvider for RepositorySearchContextProvider { provenance .provider_details .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() { format!( @@ -1018,50 +1049,60 @@ fn search_text( query: &str, max_results: usize, max_file_bytes: u64, + ignore_rules: &RepositoryIgnoreRules, + use_ripgrep: bool, ) -> Result { - let rg_output = Command::new("rg") - .arg("--line-number") - .arg("--column") - .arg("--no-heading") - .arg("--color=never") - .arg("--fixed-strings") - .arg("--") - .arg(query) - .arg(root) - .output(); - if let Ok(output) = rg_output { - let usable_output = output.status.success() || output.status.code() == Some(1); - if usable_output { - let stdout = String::from_utf8(output.stdout).map_err(|error| { - ContextError::UnsupportedContent(format!("rg output was not valid UTF-8: {error}")) - })?; - let scan_limit = max_results.saturating_add(1); - let all_results: Vec = stdout - .lines() - .take(scan_limit) - .map(|line| normalize_search_line(root, line)) - .collect(); - let truncated = all_results.len() > max_results; - return Ok(SearchCollection { - engine: SearchEngine::Ripgrep, - results: all_results.into_iter().take(max_results).collect(), - truncated, - }); + if use_ripgrep { + let rg_output = Command::new("rg") + .arg("--line-number") + .arg("--column") + .arg("--no-heading") + .arg("--color=never") + .arg("--fixed-strings") + .arg("--") + .arg(query) + .arg(root) + .output(); + if let Ok(output) = rg_output { + let usable_output = output.status.success() || output.status.code() == Some(1); + if usable_output { + let stdout = String::from_utf8(output.stdout).map_err(|error| { + ContextError::UnsupportedContent(format!( + "rg output was not valid UTF-8: {error}" + )) + })?; + let scan_limit = max_results.saturating_add(1); + let all_results: Vec = stdout + .lines() + .take(scan_limit) + .map(|line| normalize_search_line(root, line)) + .collect(); + let truncated = all_results.len() > max_results; + return Ok(SearchCollection { + engine: SearchEngine::Ripgrep, + results: all_results.into_iter().take(max_results).collect(), + truncated, + }); + } } } - 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( root: &Path, query: &str, max_results: usize, + ignore_rules: &RepositoryIgnoreRules, ) -> Result { let mut results = Vec::new(); let needle = query.to_lowercase(); let scan_limit = max_results.saturating_add(1); visit_files(root, scan_limit, &mut |path| { + if ignore_rules.is_ignored(path) { + return Ok(true); + } let relative = relative_path(root, path); if relative.to_lowercase().contains(&needle) { results.push(relative); @@ -1082,11 +1123,15 @@ fn fallback_text_search( query: &str, max_results: usize, max_file_bytes: u64, + ignore_rules: &RepositoryIgnoreRules, ) -> Result { let mut results = Vec::new(); let needle = query.to_lowercase(); let scan_limit = max_results.saturating_add(1); visit_files(root, scan_limit, &mut |path| { + if ignore_rules.is_ignored(path) { + return Ok(true); + } let Ok(metadata) = fs::metadata(path) else { return Ok(true); }; @@ -2189,8 +2234,10 @@ mod tests { ) .expect("write text"); - let search = - fallback_text_search(tempdir.path(), "needle", 2, 256 * 1024).expect("fallback search"); + let ignore_rules = + 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!(search.truncated); @@ -2204,12 +2251,30 @@ mod tests { let tempdir = tempfile::tempdir().expect("tempdir"); fs::write(tempdir.path().join("large.txt"), "needle").expect("write large"); - let search = - fallback_text_search(tempdir.path(), "needle", 10, 2).expect("fallback search"); + let ignore_rules = + 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()); } + #[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] fn git_commit_count_is_bounded() { assert_eq!(parse_git_commit_count(None).expect("default"), 5); diff --git a/src/project.rs b/src/project.rs index 0acd90f..3c6363c 100644 --- a/src/project.rs +++ b/src/project.rs @@ -3,6 +3,8 @@ use std::fmt; use std::fs; use std::path::{Path, PathBuf}; +use serde::Deserialize; + #[derive(Debug, Clone, PartialEq, Eq)] pub struct ProjectInfo { 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, +} + +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, +} + pub fn detect_project( start: &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 { + 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, +} + +impl RepositoryIgnoreRules { + pub fn from_project_config( + root: &Path, + config: &ProjectLocalConfig, + ) -> Result { + Self::from_parts(root, config.honor_gitignore, config.ignore.clone()) + } + + pub fn from_parts( + root: &Path, + honor_gitignore: bool, + exoshell_patterns: Vec, + ) -> Result { + 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 { + 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::>(); + 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, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)] +struct RawProjectLocalConfig { + honor_gitignore: Option, + ignore: Option>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct LoadedProjectConfig { + config: RawProjectLocalConfig, + warnings: Vec, +} + +fn read_project_config_file(path: &Path) -> Result { + 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, 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, +) { + 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)] pub struct ProjectSummary { pub root: PathBuf, @@ -86,7 +419,9 @@ pub fn summarize_project( root_override: Option<&Path>, ) -> Result { 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 { @@ -120,9 +455,12 @@ pub fn render_project_summary(summary: &ProjectSummary) -> 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; - let major_directories = major_directories(&project.root); + let major_directories = major_directories(&project.root, ignore_rules); let mut languages = BTreeMap::::new(); let mut entry_points = Vec::new(); let mut build_files = Vec::new(); @@ -139,8 +477,7 @@ fn summarize_project_root(project: &ProjectInfo) -> ProjectSummary { for entry in entries { let entry_path = entry.path(); - let name = entry.file_name().to_string_lossy().to_string(); - if is_noisy_project_path(&name) { + if ignore_rules.is_ignored(&entry_path) { continue; } 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 { +fn major_directories(root: &Path, ignore_rules: &RepositoryIgnoreRules) -> Vec { let Ok(entries) = fs::read_dir(root) else { return Vec::new(); }; @@ -212,7 +549,7 @@ fn major_directories(root: &Path) -> Vec { .filter_map(Result::ok) .filter_map(|entry| { 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 } else { Some(format!("{name}/")) @@ -388,6 +725,8 @@ fn short_commit(value: &str) -> String { pub enum ProjectError { #[error("failed to read project metadata at {path}: {error}")] 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}")] InvalidGitFile(PathBuf), #[error("project not detected")] @@ -494,6 +833,100 @@ mod tests { 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) { let git_dir = root.join(".git"); fs::create_dir_all(&git_dir).expect("git dir");