From cae34e0c867c0847ac91571199a9b88f8a5c6dfe Mon Sep 17 00:00:00 2001 From: "K. Hodges" Date: Wed, 10 Jun 2026 03:37:46 -0700 Subject: [PATCH] Lightweight project inventory --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 2 +- docs/quickstart.md | 8 +++ docs/versioning.md | 1 + src/app.rs | 69 ++++++++++++++++++- src/context.rs | 163 ++++++++++++++++++++++++++++++++++++++++++++- src/repl.rs | 2 + 8 files changed, 243 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a7cba60..d111de4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -100,7 +100,7 @@ dependencies = [ [[package]] name = "exoshell" -version = "0.7.0" +version = "0.8.0" dependencies = [ "async-trait", "futures-util", diff --git a/Cargo.toml b/Cargo.toml index 5f609e7..c20bc2c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "exoshell" -version = "0.7.0" +version = "0.8.0" edition = "2024" license = "GPL-3.0-or-later" diff --git a/README.md b/README.md index 65a1dfd..24a5838 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, initial Git-native project detection, attachable Git status context, and attachable Git diff 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, initial Git-native project detection, attachable Git status context, attachable Git diff context, and attachable recent commit 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 dfd83b4..b78e226 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -217,6 +217,14 @@ exo> /add-diff --staged exo> /add-diff --staged src/app.rs ``` +Attach recent commit history: + +```text +exo> /add-commits +exo> /add-commits --count 10 +exo> /add-commits --author=alice src/app.rs +``` + Ask a question: ```text diff --git a/docs/versioning.md b/docs/versioning.md index a480165..0bf6cc0 100644 --- a/docs/versioning.md +++ b/docs/versioning.md @@ -106,3 +106,4 @@ Historical codenames should be tracked in docs/versioning.md below * 0.5.0 branch-oracle * 0.6.0 status-satellite * 0.7.0 diff-lantern +* 0.8.0 commit-oracle diff --git a/src/app.rs b/src/app.rs index fe10815..c6277a6 100644 --- a/src/app.rs +++ b/src/app.rs @@ -242,6 +242,11 @@ impl App { return self.add_git_diff_context(args); } + if trimmed == "/add-commits" || trimmed.starts_with("/add-commits ") { + let args = trimmed.strip_prefix("/add-commits").unwrap_or("").trim(); + return self.add_git_commit_context(args); + } + Err(ContextError::InvalidInput(format!("unknown context command: {trimmed}")).into()) } @@ -324,6 +329,19 @@ impl App { ) } + pub fn add_git_commit_context(&mut self, input: &str) -> Result { + let options = parse_git_commit_args(input)?; + let path = self.project_context_path()?; + self.add_context( + "git_commits", + ContextProviderRequest { + path: Some(path), + provider_options: options, + ..ContextProviderRequest::default() + }, + ) + } + fn project_context_path(&self) -> Result { let cwd = std::env::current_dir().map_err(|error| ProjectError::Read { path: PathBuf::from("."), @@ -625,6 +643,7 @@ fn help_overview() -> &'static str { /add-dir attach a shallow directory summary /add-git-status attach current Git branch and status /add-diff [--staged] [path] attach unstaged or staged Git diff context +/add-commits [options] [path] attach recent Git commit history /add-output paste command output as explicit context /stance [name] show or set operator, audit, teach, or quiet /copy print a suggested command; does not execute it @@ -647,7 +666,7 @@ fn help_topic(topic: &str) -> &'static str { "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." } "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-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." } "stance" => { "Stances change the compact prompt fragment used for the next request: operator is concise and action-oriented, audit focuses on risks, teach explains more, and quiet minimizes prose while keeping safety warnings." @@ -684,6 +703,36 @@ fn parse_git_diff_args(input: &str) -> Result<(&'static str, Option), Co Ok((mode, file)) } +fn parse_git_commit_args(input: &str) -> Result, ContextError> { + let mut options = HashMap::new(); + let mut parts = input.split_whitespace(); + while let Some(part) = parts.next() { + if part == "--count" { + let count = parts + .next() + .ok_or_else(|| ContextError::InvalidInput("--count requires a value".into()))?; + options.insert("count".into(), count.to_string()); + } else if let Some(author) = part.strip_prefix("--author=") { + if author.trim().is_empty() { + return Err(ContextError::InvalidInput( + "--author requires a non-empty value".into(), + )); + } + options.insert("author".into(), author.to_string()); + } else if part.starts_with('-') { + return Err(ContextError::InvalidInput(format!( + "unknown /add-commits option: {part}" + ))); + } else if options.insert("file".into(), part.to_string()).is_some() { + return Err(ContextError::InvalidInput( + "/add-commits accepts at most one path".into(), + )); + } + } + + Ok(options) +} + #[derive(Debug, Default, PartialEq, Eq)] pub struct CliOptions { pub config_path: Option, @@ -854,7 +903,8 @@ mod tests { "stdin".to_string(), "directory_summary".to_string(), "git_status".to_string(), - "git_diff".to_string() + "git_diff".to_string(), + "git_commits".to_string() ] ); assert_eq!(app.context_store.total_size().characters, 0); @@ -1068,6 +1118,21 @@ mod tests { assert!(parse_git_diff_args("one two").is_err()); } + #[test] + fn parses_git_commit_arguments() { + assert!(parse_git_commit_args("").expect("default").is_empty()); + + let options = + parse_git_commit_args("--count 10 --author=alice src/app.rs").expect("commit args"); + assert_eq!(options.get("count"), Some(&"10".to_string())); + assert_eq!(options.get("author"), Some(&"alice".to_string())); + assert_eq!(options.get("file"), Some(&"src/app.rs".to_string())); + + assert!(parse_git_commit_args("--count").is_err()); + assert!(parse_git_commit_args("--author=").is_err()); + assert!(parse_git_commit_args("one two").is_err()); + } + #[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 719ac95..a471d4c 100644 --- a/src/context.rs +++ b/src/context.rs @@ -65,6 +65,7 @@ pub enum ContextKind { CommandOutput, DirectorySummary, GitDiff, + GitHistory, GitStatus, Log, Note, @@ -81,6 +82,7 @@ impl fmt::Display for ContextKind { Self::CommandOutput => formatter.write_str("command_output"), Self::DirectorySummary => formatter.write_str("directory_summary"), Self::GitDiff => formatter.write_str("git_diff"), + Self::GitHistory => formatter.write_str("git_history"), Self::GitStatus => formatter.write_str("git_status"), Self::Log => formatter.write_str("log"), Self::Note => formatter.write_str("note"), @@ -314,6 +316,7 @@ pub fn register_default_context_providers( registry.register(Box::new(DirectorySummaryContextProvider::default()))?; registry.register(Box::new(GitStatusContextProvider))?; registry.register(Box::new(GitDiffContextProvider::default()))?; + registry.register(Box::new(GitCommitContextProvider))?; Ok(()) } @@ -602,6 +605,57 @@ impl ContextProvider for GitStatusContextProvider { } } +pub struct GitCommitContextProvider; + +impl ContextProvider for GitCommitContextProvider { + fn metadata(&self) -> ContextProviderMetadata { + ContextProviderMetadata { + name: "git_commits".into(), + kind: ContextKind::GitHistory, + description: "captures recent Git commits and changed files".into(), + } + } + + fn collect(&self, request: ContextProviderRequest) -> Result { + let path = request + .path + .or(request.cwd) + .unwrap_or_else(|| PathBuf::from(".")); + let count = parse_git_commit_count(request.provider_options.get("count"))?; + let author = request.provider_options.get("author").cloned(); + let file = request.provider_options.get("file").cloned(); + let log = git_log_output(&path, count, author.as_deref(), file.as_deref())?; + let content = if log.trim().is_empty() { + "recent commits: none".to_string() + } else { + log + }; + + let mut provenance = ContextProvenance::new(ContextOrigin::Git); + provenance.source_path = Some(path); + provenance + .provider_details + .insert("count".into(), count.to_string()); + provenance.provider_details.insert( + "author_filter".into(), + author.unwrap_or_else(|| "none".into()), + ); + provenance + .provider_details + .insert("path_filter".into(), file.unwrap_or_else(|| "none".into())); + + Ok(ContextEntry::new( + "", + ContextKind::GitHistory, + request + .title + .unwrap_or_else(|| format!("recent git commits ({count})")), + provenance, + content, + )) + } +} + #[derive(Debug, Clone)] pub struct GitDiffContextProvider { pub max_characters: usize, @@ -761,6 +815,72 @@ fn render_path_list(rendered: &mut String, paths: &[String]) { } } +fn parse_git_commit_count(value: Option<&String>) -> Result { + let Some(value) = value else { + return Ok(5); + }; + let count = value.parse::().map_err(|_| { + ContextError::InvalidInput(format!( + "git commit count must be a positive integer: {value}" + )) + })?; + if count == 0 || count > 100 { + return Err(ContextError::InvalidInput( + "git commit count must be between 1 and 100".into(), + )); + } + Ok(count) +} + +fn git_log_output( + path: &Path, + count: usize, + author: Option<&str>, + file: Option<&str>, +) -> Result { + let mut command = Command::new("git"); + command + .arg("-C") + .arg(path) + .arg("log") + .arg(format!("--max-count={count}")) + .arg("--date=iso-strict") + .arg("--pretty=format:commit: %H%nshort: %h%nauthor: %an <%ae>%ndate: %ad%nsubject: %s") + .arg("--name-only"); + if let Some(author) = author { + command.arg(format!("--author={author}")); + } + if let Some(file) = file { + command.arg("--").arg(file); + } + + let output = command.output().map_err(|error| { + ContextError::InternalFailure(format!("failed to run git log: {error}")) + })?; + if output.status.success() { + return String::from_utf8(output.stdout).map_err(|error| { + ContextError::UnsupportedContent(format!("git log output was not valid UTF-8: {error}")) + }); + } + + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + if stderr.contains("does not have any commits yet") + || stderr.contains("your current branch") && stderr.contains("no commits") + { + return Ok(String::new()); + } + + Err(ContextError::InternalFailure(format!( + "git log failed for {}: {}", + path.display(), + if stderr.is_empty() { + output.status.to_string() + } else { + stderr + } + ))) +} + #[derive(Debug, Clone, PartialEq, Eq)] struct TruncatedContent { content: String, @@ -1659,7 +1779,8 @@ mod tests { "stdin".to_string(), "directory_summary".to_string(), "git_status".to_string(), - "git_diff".to_string() + "git_diff".to_string(), + "git_commits".to_string() ] ); } @@ -1717,6 +1838,46 @@ mod tests { ); } + #[test] + fn git_commit_provider_metadata_is_git_context() { + let metadata = GitCommitContextProvider.metadata(); + + assert_eq!(metadata.name, "git_commits"); + assert_eq!(metadata.kind, ContextKind::GitHistory); + } + + #[test] + fn git_commit_count_is_bounded() { + assert_eq!(parse_git_commit_count(None).expect("default"), 5); + assert_eq!( + parse_git_commit_count(Some(&"10".to_string())).expect("count"), + 10 + ); + assert!(parse_git_commit_count(Some(&"0".to_string())).is_err()); + assert!(parse_git_commit_count(Some(&"101".to_string())).is_err()); + assert!(parse_git_commit_count(Some(&"abc".to_string())).is_err()); + } + + #[test] + fn git_commit_provider_handles_repositories_without_commits() { + let tempdir = tempfile::tempdir().expect("tempdir"); + std::process::Command::new("git") + .arg("init") + .arg(tempdir.path()) + .output() + .expect("git init"); + + let entry = GitCommitContextProvider + .collect(ContextProviderRequest { + path: Some(tempdir.path().to_path_buf()), + ..ContextProviderRequest::default() + }) + .expect("empty git log"); + + assert_eq!(entry.kind, ContextKind::GitHistory); + assert_eq!(entry.content, "recent commits: none"); + } + #[test] fn manual_provider_rejects_empty_context() { let error = ManualContextProvider diff --git a/src/repl.rs b/src/repl.rs index f389269..cf8f9c1 100644 --- a/src/repl.rs +++ b/src/repl.rs @@ -59,6 +59,8 @@ impl Repl { || input == "/add-git-status" || input == "/add-diff" || input.starts_with("/add-diff ") + || input == "/add-commits" + || input.starts_with("/add-commits ") { match self.app.handle_command(&input) { Ok(message) => println!("{message}"),