Enrich status with git and project context

Extend /status with project root and git branch details derived from the local repository so the report feels closer to a real Claude Code session dashboard. This adds high-value workspace context without inventing any persisted metadata the runtime does not actually have.

Constraint: Status metadata must be computed from the current working tree at runtime and tolerate non-git directories
Rejected: Persist branch/root into session files first | a local runtime derivation is smaller and immediately useful without changing session format
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep status context opportunistic and degrade cleanly to unknown when git metadata is unavailable
Tested: cargo fmt --manifest-path ./rust/Cargo.toml --all; cargo clippy --manifest-path ./rust/Cargo.toml --workspace --all-targets -- -D warnings; cargo test --manifest-path ./rust/Cargo.toml --workspace
Not-tested: Manual non-git-directory /status run
This commit is contained in:
Yeachan-Heo
2026-03-31 21:06:51 +00:00
parent cf8d5a8389
commit 1adf11d572

View File

@@ -258,6 +258,8 @@ struct StatusContext {
loaded_config_files: usize, loaded_config_files: usize,
discovered_config_files: usize, discovered_config_files: usize,
memory_file_count: usize, memory_file_count: usize,
project_root: Option<PathBuf>,
git_branch: Option<String>,
} }
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
@@ -336,6 +338,39 @@ fn format_resume_report(session_path: &str, message_count: usize, turns: u32) ->
) )
} }
fn parse_git_status_metadata(status: Option<&str>) -> (Option<PathBuf>, Option<String>) {
let Some(status) = status else {
return (None, None);
};
let branch = status.lines().next().and_then(|line| {
line.strip_prefix("## ")
.map(|line| {
line.split(['.', ' '])
.next()
.unwrap_or_default()
.to_string()
})
.filter(|value| !value.is_empty())
});
let project_root = find_git_root().ok();
(project_root, branch)
}
fn find_git_root() -> Result<PathBuf, Box<dyn std::error::Error>> {
let output = std::process::Command::new("git")
.args(["rev-parse", "--show-toplevel"])
.current_dir(env::current_dir()?)
.output()?;
if !output.status.success() {
return Err("not a git repository".into());
}
let path = String::from_utf8(output.stdout)?.trim().to_string();
if path.is_empty() {
return Err("empty git root".into());
}
Ok(PathBuf::from(path))
}
fn run_resume_command( fn run_resume_command(
session_path: &Path, session_path: &Path,
session: &Session, session: &Session,
@@ -724,13 +759,17 @@ fn status_context(
let loader = ConfigLoader::default_for(&cwd); let loader = ConfigLoader::default_for(&cwd);
let discovered_config_files = loader.discover().len(); let discovered_config_files = loader.discover().len();
let runtime_config = loader.load()?; let runtime_config = loader.load()?;
let project_context = ProjectContext::discover(&cwd, DEFAULT_DATE)?; let project_context = ProjectContext::discover_with_git(&cwd, DEFAULT_DATE)?;
let (project_root, git_branch) =
parse_git_status_metadata(project_context.git_status.as_deref());
Ok(StatusContext { Ok(StatusContext {
cwd, cwd,
session_path: session_path.map(Path::to_path_buf), session_path: session_path.map(Path::to_path_buf),
loaded_config_files: runtime_config.loaded_entries().len(), loaded_config_files: runtime_config.loaded_entries().len(),
discovered_config_files, discovered_config_files,
memory_file_count: project_context.instruction_files.len(), memory_file_count: project_context.instruction_files.len(),
project_root,
git_branch,
}) })
} }
@@ -764,10 +803,17 @@ fn format_status_report(
format!( format!(
"Workspace "Workspace
Cwd {} Cwd {}
Project root {}
Git branch {}
Session {} Session {}
Config files loaded {}/{} Config files loaded {}/{}
Memory files {}", Memory files {}",
context.cwd.display(), context.cwd.display(),
context
.project_root
.as_ref()
.map_or_else(|| "unknown".to_string(), |path| path.display().to_string()),
context.git_branch.as_deref().unwrap_or("unknown"),
context.session_path.as_ref().map_or_else( context.session_path.as_ref().map_or_else(
|| "live-repl".to_string(), || "live-repl".to_string(),
|path| path.display().to_string() |path| path.display().to_string()
@@ -1287,9 +1333,9 @@ mod tests {
use super::{ use super::{
format_cost_report, format_model_report, format_model_switch_report, format_cost_report, format_model_report, format_model_switch_report,
format_permissions_report, format_permissions_switch_report, format_resume_report, format_permissions_report, format_permissions_switch_report, format_resume_report,
format_status_report, normalize_permission_mode, parse_args, render_init_claude_md, format_status_report, normalize_permission_mode, parse_args, parse_git_status_metadata,
render_repl_help, resume_supported_slash_commands, status_context, CliAction, SlashCommand, render_init_claude_md, render_repl_help, resume_supported_slash_commands, status_context,
StatusUsage, DEFAULT_MODEL, CliAction, SlashCommand, StatusUsage, DEFAULT_MODEL,
}; };
use runtime::{ContentBlock, ConversationMessage, MessageRole}; use runtime::{ContentBlock, ConversationMessage, MessageRole};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -1500,6 +1546,8 @@ mod tests {
loaded_config_files: 2, loaded_config_files: 2,
discovered_config_files: 3, discovered_config_files: 3,
memory_file_count: 4, memory_file_count: 4,
project_root: Some(PathBuf::from("/tmp")),
git_branch: Some("main".to_string()),
}, },
); );
assert!(status.contains("Status")); assert!(status.contains("Status"));
@@ -1509,6 +1557,8 @@ mod tests {
assert!(status.contains("Latest total 10")); assert!(status.contains("Latest total 10"));
assert!(status.contains("Cumulative total 31")); assert!(status.contains("Cumulative total 31"));
assert!(status.contains("Cwd /tmp/project")); assert!(status.contains("Cwd /tmp/project"));
assert!(status.contains("Project root /tmp"));
assert!(status.contains("Git branch main"));
assert!(status.contains("Session session.json")); assert!(status.contains("Session session.json"));
assert!(status.contains("Config files loaded 2/3")); assert!(status.contains("Config files loaded 2/3"));
assert!(status.contains("Memory files 4")); assert!(status.contains("Memory files 4"));
@@ -1522,6 +1572,16 @@ mod tests {
assert!(report.contains("Merged JSON")); assert!(report.contains("Merged JSON"));
} }
#[test]
fn parses_git_status_metadata() {
let (root, branch) = parse_git_status_metadata(Some(
"## rcc/cli...origin/rcc/cli
M src/main.rs",
));
assert_eq!(branch.as_deref(), Some("rcc/cli"));
let _ = root;
}
#[test] #[test]
fn status_context_reads_real_workspace_metadata() { fn status_context_reads_real_workspace_metadata() {
let context = status_context(None).expect("status context should load"); let context = status_context(None).expect("status context should load");