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]]
name = "exoshell"
version = "0.10.0"
version = "0.11.0"
dependencies = [
"async-trait",
"futures-util",

View File

@ -1,6 +1,6 @@
[package]
name = "exoshell"
version = "0.10.0"
version = "0.11.0"
edition = "2024"
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 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).

View File

@ -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:

View File

@ -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

View File

@ -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<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 {
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 <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."
}
"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 <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")
.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()));

View File

@ -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::<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 {
"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<SearchCollection, ContextError> {
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<String> = 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<String> = 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<SearchCollection, ContextError> {
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<SearchCollection, ContextError> {
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);

View File

@ -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<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(
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<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)]
pub struct ProjectSummary {
pub root: PathBuf,
@ -86,7 +419,9 @@ pub fn summarize_project(
root_override: Option<&Path>,
) -> Result<ProjectSummary, ProjectError> {
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::<String, usize>::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<String> {
fn major_directories(root: &Path, ignore_rules: &RepositoryIgnoreRules) -> Vec<String> {
let Ok(entries) = fs::read_dir(root) else {
return Vec::new();
};
@ -212,7 +549,7 @@ fn major_directories(root: &Path) -> Vec<String> {
.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");