mirror of
https://github.com/khodges42/exoshell.git
synced 2026-06-14 18:08:37 +00:00
add git diff context
This commit is contained in:
parent
94b283515c
commit
b6db491caa
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
106
src/app.rs
106
src/app.rs
|
|
@ -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()));
|
||||||
|
|
|
||||||
325
src/context.rs
325
src/context.rs
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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}"),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user