add git diff context

This commit is contained in:
K. Hodges 2026-06-09 04:19:39 -07:00
parent 94b283515c
commit b6db491caa
8 changed files with 450 additions and 6 deletions

2
Cargo.lock generated
View File

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

View File

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

View File

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

View File

@ -203,6 +203,20 @@ Inspect detected Git project state:
exo> /project exo> /project
``` ```
Attach current Git branch and working tree state as explicit context:
```text
exo> /add-git-status
```
Attach repository changes as explicit context:
```text
exo> /add-diff
exo> /add-diff --staged
exo> /add-diff --staged src/app.rs
```
Ask a question: Ask a question:
```text ```text

View File

@ -104,3 +104,5 @@ Historical codenames should be tracked in docs/versioning.md below
* 0.3.0 stance-lantern * 0.3.0 stance-lantern
* 0.4.0 switchboard-relic * 0.4.0 switchboard-relic
* 0.5.0 branch-oracle * 0.5.0 branch-oracle
* 0.6.0 status-satellite
* 0.7.0 diff-lantern

View File

@ -1,3 +1,4 @@
use std::collections::HashMap;
use std::path::PathBuf; use std::path::PathBuf;
use std::time::Duration; use std::time::Duration;
@ -232,6 +233,15 @@ impl App {
); );
} }
if trimmed == "/add-git-status" {
return self.add_git_status_context();
}
if trimmed == "/add-diff" || trimmed.starts_with("/add-diff ") {
let args = trimmed.strip_prefix("/add-diff").unwrap_or("").trim();
return self.add_git_diff_context(args);
}
Err(ContextError::InvalidInput(format!("unknown context command: {trimmed}")).into()) Err(ContextError::InvalidInput(format!("unknown context command: {trimmed}")).into())
} }
@ -284,6 +294,47 @@ impl App {
) )
} }
pub fn add_git_status_context(&mut self) -> Result<String, AppError> {
let path = self.project_context_path()?;
self.add_context(
"git_status",
ContextProviderRequest {
path: Some(path),
..ContextProviderRequest::default()
},
)
}
pub fn add_git_diff_context(&mut self, input: &str) -> Result<String, AppError> {
let (mode, file) = parse_git_diff_args(input)?;
let mut provider_options = HashMap::new();
provider_options.insert("mode".into(), mode.to_string());
if let Some(file) = file {
provider_options.insert("file".into(), file);
}
let path = self.project_context_path()?;
self.add_context(
"git_diff",
ContextProviderRequest {
path: Some(path),
provider_options,
..ContextProviderRequest::default()
},
)
}
fn project_context_path(&self) -> Result<PathBuf, AppError> {
let cwd = std::env::current_dir().map_err(|error| ProjectError::Read {
path: PathBuf::from("."),
error: error.to_string(),
})?;
Ok(detect_project(&cwd, self.config.project.root.as_deref())?
.map(|project| project.root)
.or_else(|| self.config.project.root.clone())
.unwrap_or(cwd))
}
pub fn save_transcript(&self) -> Result<Option<PathBuf>, AppError> { pub fn save_transcript(&self) -> Result<Option<PathBuf>, AppError> {
if !self.config.transcript.enabled { if !self.config.transcript.enabled {
return Ok(None); return Ok(None);
@ -572,6 +623,8 @@ fn help_overview() -> &'static str {
/add-note <text> attach manual context /add-note <text> attach manual context
/add-file <path> attach a UTF-8 file /add-file <path> attach a UTF-8 file
/add-dir <path> attach a shallow directory summary /add-dir <path> 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-output paste command output as explicit context /add-output paste command output as explicit context
/stance [name] show or set operator, audit, teach, or quiet /stance [name] show or set operator, audit, teach, or quiet
/copy <cmd-id> print a suggested command; does not execute it /copy <cmd-id> print a suggested command; does not execute it
@ -593,6 +646,9 @@ fn help_topic(topic: &str) -> &'static str {
"project" => { "project" => {
"Project detection walks upward from the current directory or configured project.root to find a Git repository. Use /project or /panel to inspect the detected root and branch." "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 <path> to attach read-only diff context."
}
"stance" => { "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." "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."
} }
@ -603,11 +659,31 @@ fn help_topic(topic: &str) -> &'static str {
"The current line REPL does not install advanced terminal keybindings. Use /keys to see the predictable slash-command fallbacks for copy, explain, discard, context, and stance actions." "The current line REPL does not install advanced terminal keybindings. Use /keys to see the predictable slash-command fallbacks for copy, explain, discard, context, and stance actions."
} }
_ => { _ => {
"Unknown help topic. Try /help context, /help project, /help stance, /help commands, or /help keys." "Unknown help topic. Try /help context, /help project, /help git, /help stance, /help commands, or /help keys."
} }
} }
} }
fn parse_git_diff_args(input: &str) -> Result<(&'static str, Option<String>), ContextError> {
let mut mode = "unstaged";
let mut file = None;
for part in input.split_whitespace() {
if part == "--staged" {
mode = "staged";
} else if part.starts_with('-') {
return Err(ContextError::InvalidInput(format!(
"unknown /add-diff option: {part}"
)));
} else if file.replace(part.to_string()).is_some() {
return Err(ContextError::InvalidInput(
"/add-diff accepts at most one path".into(),
));
}
}
Ok((mode, file))
}
#[derive(Debug, Default, PartialEq, Eq)] #[derive(Debug, Default, PartialEq, Eq)]
pub struct CliOptions { pub struct CliOptions {
pub config_path: Option<PathBuf>, pub config_path: Option<PathBuf>,
@ -776,7 +852,9 @@ mod tests {
"file".to_string(), "file".to_string(),
"command_output".to_string(), "command_output".to_string(),
"stdin".to_string(), "stdin".to_string(),
"directory_summary".to_string() "directory_summary".to_string(),
"git_status".to_string(),
"git_diff".to_string()
] ]
); );
assert_eq!(app.context_store.total_size().characters, 0); assert_eq!(app.context_store.total_size().characters, 0);
@ -949,6 +1027,16 @@ mod tests {
.expect("help project") .expect("help project")
.contains("Git repository") .contains("Git repository")
); );
assert!(
app.handle_command("/help git")
.expect("help git")
.contains("/add-git-status")
);
assert!(
app.handle_command("/help git")
.expect("help git")
.contains("/add-diff --staged")
);
assert!( assert!(
app.handle_command("/help commands") app.handle_command("/help commands")
.expect("help") .expect("help")
@ -966,6 +1054,20 @@ mod tests {
); );
} }
#[test]
fn parses_git_diff_arguments() {
assert_eq!(
parse_git_diff_args("").expect("default"),
("unstaged", None)
);
assert_eq!(
parse_git_diff_args("--staged src/app.rs").expect("staged file"),
("staged", Some("src/app.rs".to_string()))
);
assert!(parse_git_diff_args("--cached").is_err());
assert!(parse_git_diff_args("one two").is_err());
}
#[tokio::test] #[tokio::test]
async fn over_budget_context_fails_before_provider_request() { async fn over_budget_context_fails_before_provider_request() {
let seen = Arc::new(Mutex::new(Vec::new())); let seen = Arc::new(Mutex::new(Vec::new()));

View File

@ -2,6 +2,7 @@ use std::collections::HashMap;
use std::fmt; use std::fmt;
use std::fs; use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::Command;
use std::str::FromStr; use std::str::FromStr;
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
@ -63,6 +64,8 @@ pub enum ContextKind {
File, File,
CommandOutput, CommandOutput,
DirectorySummary, DirectorySummary,
GitDiff,
GitStatus,
Log, Log,
Note, Note,
SearchResult, SearchResult,
@ -77,6 +80,8 @@ impl fmt::Display for ContextKind {
Self::File => formatter.write_str("file"), Self::File => formatter.write_str("file"),
Self::CommandOutput => formatter.write_str("command_output"), Self::CommandOutput => formatter.write_str("command_output"),
Self::DirectorySummary => formatter.write_str("directory_summary"), Self::DirectorySummary => formatter.write_str("directory_summary"),
Self::GitDiff => formatter.write_str("git_diff"),
Self::GitStatus => formatter.write_str("git_status"),
Self::Log => formatter.write_str("log"), Self::Log => formatter.write_str("log"),
Self::Note => formatter.write_str("note"), Self::Note => formatter.write_str("note"),
Self::SearchResult => formatter.write_str("search_result"), Self::SearchResult => formatter.write_str("search_result"),
@ -307,6 +312,8 @@ pub fn register_default_context_providers(
registry.register(Box::new(CommandOutputContextProvider))?; registry.register(Box::new(CommandOutputContextProvider))?;
registry.register(Box::new(StdinContextProvider))?; registry.register(Box::new(StdinContextProvider))?;
registry.register(Box::new(DirectorySummaryContextProvider::default()))?; registry.register(Box::new(DirectorySummaryContextProvider::default()))?;
registry.register(Box::new(GitStatusContextProvider))?;
registry.register(Box::new(GitDiffContextProvider::default()))?;
Ok(()) Ok(())
} }
@ -551,6 +558,267 @@ impl ContextProvider for DirectorySummaryContextProvider {
} }
} }
pub struct GitStatusContextProvider;
impl ContextProvider for GitStatusContextProvider {
fn metadata(&self) -> ContextProviderMetadata {
ContextProviderMetadata {
name: "git_status".into(),
kind: ContextKind::GitStatus,
description: "captures read-only Git branch and working tree status".into(),
}
}
fn collect(&self, request: ContextProviderRequest) -> Result<ContextEntry, ContextError> {
let path = request
.path
.or(request.cwd)
.unwrap_or_else(|| PathBuf::from("."));
let status = git_output(&path, &["status", "--porcelain=v1", "-b"], "git status")?;
let parsed = parse_git_status_porcelain(&status);
let mut provenance = ContextProvenance::new(ContextOrigin::Git);
provenance.source_path = Some(path.clone());
provenance
.provider_details
.insert("branch".into(), parsed.branch.clone());
provenance
.provider_details
.insert("staged_count".into(), parsed.staged.len().to_string());
provenance
.provider_details
.insert("modified_count".into(), parsed.modified.len().to_string());
provenance
.provider_details
.insert("untracked_count".into(), parsed.untracked.len().to_string());
Ok(ContextEntry::new(
"",
ContextKind::GitStatus,
request.title.unwrap_or_else(|| "git status".into()),
provenance,
render_git_status_context(&parsed),
))
}
}
#[derive(Debug, Clone)]
pub struct GitDiffContextProvider {
pub max_characters: usize,
}
impl Default for GitDiffContextProvider {
fn default() -> Self {
Self {
max_characters: 20_000,
}
}
}
impl ContextProvider for GitDiffContextProvider {
fn metadata(&self) -> ContextProviderMetadata {
ContextProviderMetadata {
name: "git_diff".into(),
kind: ContextKind::GitDiff,
description: "captures read-only staged or unstaged Git diffs".into(),
}
}
fn collect(&self, request: ContextProviderRequest) -> Result<ContextEntry, ContextError> {
let path = request
.path
.or(request.cwd)
.unwrap_or_else(|| PathBuf::from("."));
let mode = request
.provider_options
.get("mode")
.map(String::as_str)
.unwrap_or("unstaged");
let file = request.provider_options.get("file").cloned();
let mut args = vec!["diff"];
match mode {
"staged" => args.push("--staged"),
"unstaged" => {}
other => {
return Err(ContextError::InvalidInput(format!(
"unsupported git diff mode '{other}', expected staged or unstaged"
)));
}
}
if file.is_some() {
args.push("--");
}
if let Some(file) = file.as_deref() {
args.push(file);
}
let diff = git_output(&path, &args, "git diff")?;
let truncated = truncate_visible(&diff, self.max_characters);
let mut provenance = ContextProvenance::new(ContextOrigin::Git);
provenance.source_path = Some(path.clone());
provenance
.provider_details
.insert("mode".into(), mode.to_string());
provenance
.provider_details
.insert("file".into(), file.clone().unwrap_or_else(|| "all".into()));
provenance.provider_details.insert(
"truncated".into(),
(truncated.omitted_characters > 0).to_string(),
);
provenance.provider_details.insert(
"omitted_characters".into(),
truncated.omitted_characters.to_string(),
);
Ok(ContextEntry::new(
"",
ContextKind::GitDiff,
request
.title
.unwrap_or_else(|| git_diff_title(mode, file.as_deref())),
provenance,
if truncated.content.trim().is_empty() {
format!("mode: {mode}\ndiff: none")
} else {
format!("mode: {mode}\n{}", truncated.content)
},
))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ParsedGitStatus {
branch: String,
staged: Vec<String>,
modified: Vec<String>,
untracked: Vec<String>,
}
fn parse_git_status_porcelain(output: &str) -> ParsedGitStatus {
let mut parsed = ParsedGitStatus {
branch: "unknown".into(),
staged: Vec::new(),
modified: Vec::new(),
untracked: Vec::new(),
};
for line in output.lines() {
if let Some(branch) = line.strip_prefix("## ") {
parsed.branch = branch.trim().to_string();
continue;
}
if line.len() < 3 {
continue;
}
let mut chars = line.chars();
let index_status = chars.next().unwrap_or(' ');
let worktree_status = chars.next().unwrap_or(' ');
let path = chars.as_str().trim().to_string();
if path.is_empty() {
continue;
}
if index_status == '?' && worktree_status == '?' {
parsed.untracked.push(path);
continue;
}
if index_status != ' ' {
parsed.staged.push(path.clone());
}
if worktree_status != ' ' {
parsed.modified.push(path);
}
}
parsed
}
fn render_git_status_context(status: &ParsedGitStatus) -> String {
let mut rendered = String::new();
rendered.push_str(&format!("branch: {}\n", status.branch));
rendered.push_str("staged:\n");
render_path_list(&mut rendered, &status.staged);
rendered.push_str("modified:\n");
render_path_list(&mut rendered, &status.modified);
rendered.push_str("untracked:\n");
render_path_list(&mut rendered, &status.untracked);
rendered.trim_end().to_string()
}
fn render_path_list(rendered: &mut String, paths: &[String]) {
if paths.is_empty() {
rendered.push_str("- none\n");
} else {
for path in paths {
rendered.push_str(&format!("- {path}\n"));
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct TruncatedContent {
content: String,
omitted_characters: usize,
}
fn truncate_visible(content: &str, max_characters: usize) -> TruncatedContent {
let total = content.chars().count();
if total <= max_characters {
return TruncatedContent {
content: content.to_string(),
omitted_characters: 0,
};
}
let kept = content.chars().take(max_characters).collect::<String>();
let omitted = total.saturating_sub(max_characters);
TruncatedContent {
content: format!("{kept}\n\n[truncated: omitted {omitted} characters]"),
omitted_characters: omitted,
}
}
fn git_diff_title(mode: &str, file: Option<&str>) -> String {
match file {
Some(file) => format!("git diff ({mode}): {file}"),
None => format!("git diff ({mode})"),
}
}
fn git_output(path: &Path, args: &[&str], label: &str) -> Result<String, ContextError> {
let output = Command::new("git")
.arg("-C")
.arg(path)
.args(args)
.output()
.map_err(|error| {
ContextError::InternalFailure(format!("failed to run {label}: {error}"))
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
return Err(ContextError::InternalFailure(format!(
"{label} failed for {}: {}",
path.display(),
if stderr.is_empty() {
output.status.to_string()
} else {
stderr
}
)));
}
String::from_utf8(output.stdout).map_err(|error| {
ContextError::UnsupportedContent(format!("{label} output was not valid UTF-8: {error}"))
})
}
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
pub struct SessionContextStore { pub struct SessionContextStore {
entries: Vec<ContextEntry>, entries: Vec<ContextEntry>,
@ -1389,11 +1657,66 @@ mod tests {
"file".to_string(), "file".to_string(),
"command_output".to_string(), "command_output".to_string(),
"stdin".to_string(), "stdin".to_string(),
"directory_summary".to_string() "directory_summary".to_string(),
"git_status".to_string(),
"git_diff".to_string()
] ]
); );
} }
#[test]
fn parses_git_status_porcelain_into_context_sections() {
let parsed = parse_git_status_porcelain(
"## main...origin/main\n M src/app.rs\nM Cargo.toml\nMM src/context.rs\n?? notes.md\n",
);
assert_eq!(parsed.branch, "main...origin/main");
assert_eq!(
parsed.staged,
vec!["Cargo.toml".to_string(), "src/context.rs".to_string()]
);
assert_eq!(
parsed.modified,
vec!["src/app.rs".to_string(), "src/context.rs".to_string()]
);
assert_eq!(parsed.untracked, vec!["notes.md".to_string()]);
let rendered = render_git_status_context(&parsed);
assert!(rendered.contains("branch: main...origin/main"));
assert!(rendered.contains("staged:"));
assert!(rendered.contains("- Cargo.toml"));
assert!(rendered.contains("untracked:"));
}
#[test]
fn git_status_provider_metadata_is_git_context() {
let metadata = GitStatusContextProvider.metadata();
assert_eq!(metadata.name, "git_status");
assert_eq!(metadata.kind, ContextKind::GitStatus);
}
#[test]
fn git_diff_provider_metadata_is_git_context() {
let metadata = GitDiffContextProvider::default().metadata();
assert_eq!(metadata.name, "git_diff");
assert_eq!(metadata.kind, ContextKind::GitDiff);
}
#[test]
fn git_diff_truncation_is_visible() {
let truncated = truncate_visible("abcdef", 3);
assert_eq!(truncated.omitted_characters, 3);
assert!(truncated.content.contains("abc"));
assert!(
truncated
.content
.contains("[truncated: omitted 3 characters]")
);
}
#[test] #[test]
fn manual_provider_rejects_empty_context() { fn manual_provider_rejects_empty_context() {
let error = ManualContextProvider let error = ManualContextProvider

View File

@ -56,6 +56,9 @@ impl Repl {
|| input.starts_with("/add-note ") || input.starts_with("/add-note ")
|| input.starts_with("/add-file ") || input.starts_with("/add-file ")
|| input.starts_with("/add-dir ") || input.starts_with("/add-dir ")
|| input == "/add-git-status"
|| input == "/add-diff"
|| input.starts_with("/add-diff ")
{ {
match self.app.handle_command(&input) { match self.app.handle_command(&input) {
Ok(message) => println!("{message}"), Ok(message) => println!("{message}"),