mirror of
https://github.com/khodges42/exoshell.git
synced 2026-06-14 09:58:37 +00:00
project scan
This commit is contained in:
parent
3155ca76cd
commit
36a44afe2a
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -100,7 +100,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "exoshell"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"futures-util",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "exoshell"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
edition = "2024"
|
||||
license = "GPL-3.0-or-later"
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
68
src/app.rs
68
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<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()));
|
||||
|
|
|
|||
135
src/context.rs
135
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::<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);
|
||||
|
|
|
|||
447
src/project.rs
447
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<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");
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user