From 476b03e609b9f1b51e88e23149580333e8f3878d Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Sat, 4 Apr 2026 15:36:14 +0000 Subject: [PATCH] feat(cli): add claw status command Extend the direct and slash status surfaces with git-aware context so workspace checks include branch freshness against origin/main, active worktrees, and the three most recent commits. Constraint: Keep the implementation scoped to rust/crates/commands/src/lib.rs and rust/crates/rusty-claude-cli/src/main.rs Rejected: A separate dedicated git-status subcommand struct layer | unnecessary complexity for a single report surface Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep claw status read-only; do not fetch or mutate git state when computing freshness Tested: cargo build --workspace; cargo test --workspace; ./target/debug/claw status Not-tested: Repositories without git installed --- rust/crates/commands/src/lib.rs | 12 +- rust/crates/rusty-claude-cli/src/main.rs | 337 ++++++++++++++++++++++- 2 files changed, 345 insertions(+), 4 deletions(-) diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index 7e6191d..c43ed38 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -60,7 +60,7 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[ SlashCommandSpec { name: "status", aliases: &[], - summary: "Show current session status", + summary: "Show current session status with branch freshness, worktrees, and recent commits", argument_hint: None, resume_supported: true, }, @@ -3713,6 +3713,16 @@ mod tests { assert!(help.contains("Resume Supported with --resume SESSION.jsonl")); } + #[test] + fn renders_status_help_with_repo_snapshot_summary() { + let help = render_slash_command_help_detail("status").expect("detail help should exist"); + assert!(help.contains("/status")); + assert!(help.contains( + "Summary Show current session status with branch freshness, worktrees, and recent commits" + )); + assert!(help.contains("Resume Supported with --resume SESSION.jsonl")); + } + #[test] fn validate_slash_command_input_rejects_extra_single_value_arguments() { // given diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 8170072..f594a51 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -1005,6 +1005,9 @@ struct StatusContext { project_root: Option, git_branch: Option, git_summary: GitWorkspaceSummary, + git_freshness: Option, + git_worktrees: Vec, + recent_commits: Vec, sandbox_status: runtime::SandboxStatus, } @@ -1058,6 +1061,63 @@ impl GitWorkspaceSummary { } } +#[derive(Debug, Clone, PartialEq, Eq)] +struct GitBranchFreshness { + base_ref: String, + ahead: usize, + behind: usize, +} + +impl GitBranchFreshness { + fn headline(&self) -> String { + match (self.ahead, self.behind) { + (0, 0) => format!("up to date with {}", self.base_ref), + (ahead, 0) => format!("ahead of {} by {ahead} commit(s)", self.base_ref), + (0, behind) => format!("behind {} by {behind} commit(s)", self.base_ref), + (ahead, behind) => format!( + "diverged from {} ({ahead} ahead, {behind} behind)", + self.base_ref + ), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct GitWorktreeEntry { + path: PathBuf, + branch: Option, + head: Option, + is_current: bool, +} + +impl GitWorktreeEntry { + fn headline(&self) -> String { + let location = self.path.display(); + let branch = self + .branch + .as_deref() + .filter(|branch| !branch.is_empty()) + .unwrap_or("detached HEAD"); + if self.is_current { + format!("* {branch} · {location}") + } else { + format!("{branch} · {location}") + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct GitCommitEntry { + short_sha: String, + subject: String, +} + +impl GitCommitEntry { + fn headline(&self) -> String { + format!("{} {}", self.short_sha, self.subject) + } +} + #[cfg(test)] fn format_unknown_slash_command_message(name: &str) -> String { let suggestions = suggest_slash_commands(name); @@ -1210,6 +1270,104 @@ fn parse_git_status_metadata(status: Option<&str>) -> (Option, Option Option { + let base_ref = "origin/main"; + if !git_ref_exists_in(repo_root, base_ref) { + return None; + } + + Some(GitBranchFreshness { + base_ref: base_ref.to_string(), + ahead: git_rev_list_count(repo_root, &format!("{base_ref}..HEAD"))?, + behind: git_rev_list_count(repo_root, &format!("HEAD..{base_ref}"))?, + }) +} + +fn load_git_worktrees(repo_root: &Path, current_worktree: &Path) -> Vec { + let Some(output) = run_git_capture_in(repo_root, &["worktree", "list", "--porcelain"]) else { + return Vec::new(); + }; + parse_git_worktrees(&output, current_worktree) +} + +fn parse_git_worktrees(output: &str, current_worktree: &Path) -> Vec { + let mut worktrees = Vec::new(); + let mut current: Option = None; + let current_worktree = normalize_path_for_compare(current_worktree); + + for line in output.lines().chain(std::iter::once("")) { + if line.is_empty() { + if let Some(worktree) = current.take() { + worktrees.push(worktree); + } + continue; + } + + if let Some(path) = line.strip_prefix("worktree ") { + if let Some(worktree) = current.take() { + worktrees.push(worktree); + } + + let path = PathBuf::from(path); + let is_current = normalize_path_for_compare(&path) == current_worktree; + current = Some(GitWorktreeEntry { + path, + branch: None, + head: None, + is_current, + }); + continue; + } + + let Some(worktree) = current.as_mut() else { + continue; + }; + + if let Some(branch) = line.strip_prefix("branch ") { + worktree.branch = Some( + branch + .strip_prefix("refs/heads/") + .unwrap_or(branch) + .to_string(), + ); + } else if let Some(head) = line.strip_prefix("HEAD ") { + worktree.head = Some(head.to_string()); + } else if line == "detached" { + worktree.branch = Some("detached HEAD".to_string()); + } + } + + worktrees +} + +fn load_recent_commits(repo_root: &Path, limit: usize) -> Vec { + let Some(output) = run_git_capture_in( + repo_root, + &["log", "-n", &limit.to_string(), "--format=%h%x09%s"], + ) else { + return Vec::new(); + }; + parse_recent_commits(&output) +} + +fn parse_recent_commits(output: &str) -> Vec { + output + .lines() + .filter_map(|line| { + let (short_sha, subject) = line.split_once('\t')?; + let short_sha = short_sha.trim(); + let subject = subject.trim(); + if short_sha.is_empty() || subject.is_empty() { + return None; + } + Some(GitCommitEntry { + short_sha: short_sha.to_string(), + subject: subject.to_string(), + }) + }) + .collect() +} + fn parse_git_status_branch(status: Option<&str>) -> Option { let status = status?; let first_line = status.lines().next()?; @@ -1281,6 +1439,22 @@ fn resolve_git_branch_for(cwd: &Path) -> Option { } } +fn git_ref_exists_in(cwd: &Path, reference: &str) -> bool { + std::process::Command::new("git") + .args(["rev-parse", "--verify", "--quiet", reference]) + .current_dir(cwd) + .output() + .map(|output| output.status.success()) + .unwrap_or(false) +} + +fn git_rev_list_count(cwd: &Path, range: &str) -> Option { + run_git_capture_in(cwd, &["rev-list", "--count", range])? + .trim() + .parse::() + .ok() +} + fn run_git_capture_in(cwd: &Path, args: &[&str]) -> Option { let output = std::process::Command::new("git") .args(args) @@ -1293,6 +1467,10 @@ fn run_git_capture_in(cwd: &Path, args: &[&str]) -> Option { String::from_utf8(output.stdout).ok() } +fn normalize_path_for_compare(path: &Path) -> PathBuf { + path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) +} + fn find_git_root_in(cwd: &Path) -> Result> { let output = std::process::Command::new("git") .args(["rev-parse", "--show-toplevel"]) @@ -3209,6 +3387,11 @@ fn status_context( let (project_root, git_branch) = parse_git_status_metadata(project_context.git_status.as_deref()); let git_summary = parse_git_workspace_summary(project_context.git_status.as_deref()); + let git_scope = project_root.clone().unwrap_or_else(|| cwd.clone()); + let current_worktree = project_root.clone().unwrap_or_else(|| cwd.clone()); + let git_freshness = load_git_branch_freshness(&git_scope); + let git_worktrees = load_git_worktrees(&git_scope, ¤t_worktree); + let recent_commits = load_recent_commits(&git_scope, 3); let sandbox_status = resolve_sandbox_status(runtime_config.sandbox(), &cwd); Ok(StatusContext { cwd, @@ -3219,6 +3402,9 @@ fn status_context( project_root, git_branch, git_summary, + git_freshness, + git_worktrees, + recent_commits, sandbox_status, }) } @@ -3229,6 +3415,38 @@ fn format_status_report( permission_mode: &str, context: &StatusContext, ) -> String { + let git_section = format!( + "Git + Freshness {} + Worktrees {} + Entries {} + Recent commits {}", + context + .git_freshness + .as_ref() + .map_or_else(|| "origin/main unavailable".to_string(), GitBranchFreshness::headline), + if context.git_worktrees.is_empty() { + "unavailable".to_string() + } else { + format!("{} active", context.git_worktrees.len()) + }, + format_multiline_detail( + &context + .git_worktrees + .iter() + .map(GitWorktreeEntry::headline) + .collect::>(), + "", + ), + format_multiline_detail( + &context + .recent_commits + .iter() + .map(GitCommitEntry::headline) + .collect::>(), + "", + ), + ); [ format!( "Status @@ -3283,6 +3501,7 @@ fn format_status_report( context.discovered_config_files, context.memory_file_count, ), + git_section, format_sandbox_report(&context.sandbox_status), ] .join( @@ -3335,6 +3554,23 @@ fn format_sandbox_report(status: &runtime::SandboxStatus) -> String { ) } +fn format_multiline_detail(lines: &[String], empty: &str) -> String { + if lines.is_empty() { + return empty.to_string(); + } + + let mut output = String::new(); + for (index, line) in lines.iter().enumerate() { + if index == 0 { + output.push_str(line); + } else { + output.push_str("\n "); + output.push_str(line); + } + } + output +} + fn format_commit_preflight_report(branch: Option<&str>, summary: GitWorkspaceSummary) -> String { format!( "Commit @@ -5545,7 +5781,7 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> { writeln!(out, " claw status")?; writeln!( out, - " Show the current local workspace status snapshot" + " Show workspace status, origin/main freshness, active worktrees, and recent commits" )?; writeln!(out, " claw sandbox")?; writeln!(out, " Show the current sandbox isolation snapshot")?; @@ -5648,12 +5884,14 @@ mod tests { format_ultraplan_report, format_unknown_slash_command, format_unknown_slash_command_message, normalize_permission_mode, parse_args, parse_git_status_branch, parse_git_status_metadata_for, parse_git_workspace_summary, - permission_policy, print_help_to, push_output_block, render_config_report, + parse_git_worktrees, parse_recent_commits, permission_policy, print_help_to, + push_output_block, render_config_report, render_diff_report, render_diff_report_for, render_memory_report, render_repl_help, render_resume_usage, resolve_model_alias, resolve_session_reference, response_to_events, resume_supported_slash_commands, run_resume_command, slash_command_completion_candidates_with_sessions, status_context, validate_no_args, - write_mcp_server_fixture, CliAction, CliOutputFormat, CliToolExecutor, GitWorkspaceSummary, + write_mcp_server_fixture, CliAction, CliOutputFormat, CliToolExecutor, + GitBranchFreshness, GitCommitEntry, GitWorkspaceSummary, GitWorktreeEntry, InternalPromptProgressEvent, InternalPromptProgressState, LiveCli, SlashCommand, StatusUsage, DEFAULT_MODEL, }; @@ -6531,6 +6769,39 @@ mod tests { untracked_files: 1, conflicted_files: 0, }, + git_freshness: Some(GitBranchFreshness { + base_ref: "origin/main".to_string(), + ahead: 1, + behind: 2, + }), + git_worktrees: vec![ + GitWorktreeEntry { + path: PathBuf::from("/tmp/project"), + branch: Some("main".to_string()), + head: Some("abc1234".to_string()), + is_current: true, + }, + GitWorktreeEntry { + path: PathBuf::from("/tmp/project-feature"), + branch: Some("feature/status".to_string()), + head: Some("def5678".to_string()), + is_current: false, + }, + ], + recent_commits: vec![ + GitCommitEntry { + short_sha: "abc1234".to_string(), + subject: "feat: add status output".to_string(), + }, + GitCommitEntry { + short_sha: "def5678".to_string(), + subject: "fix: tighten parsing".to_string(), + }, + GitCommitEntry { + short_sha: "9876fed".to_string(), + subject: "chore: wire command".to_string(), + }, + ], sandbox_status: runtime::SandboxStatus::default(), }, ); @@ -6554,6 +6825,66 @@ mod tests { assert!(status.contains("Config files loaded 2/3")); assert!(status.contains("Memory files 4")); assert!(status.contains("Suggested flow /status → /diff → /commit")); + assert!(status.contains("Freshness diverged from origin/main (1 ahead, 2 behind)")); + assert!(status.contains("Worktrees 2 active")); + assert!(status.contains("Entries * main · /tmp/project")); + assert!(status.contains("feature/status · /tmp/project-feature")); + assert!(status.contains("Recent commits abc1234 feat: add status output")); + assert!(status.contains("def5678 fix: tighten parsing")); + assert!(status.contains("9876fed chore: wire command")); + } + + #[test] + fn parses_git_worktree_list_output() { + let worktrees = parse_git_worktrees( + "worktree /tmp/repo\nHEAD abc1234\nbranch refs/heads/main\n\nworktree /tmp/repo-feature\nHEAD def5678\nbranch refs/heads/feature/status\n", + Path::new("/tmp/repo"), + ); + + assert_eq!( + worktrees, + vec![ + GitWorktreeEntry { + path: PathBuf::from("/tmp/repo"), + branch: Some("main".to_string()), + head: Some("abc1234".to_string()), + is_current: true, + }, + GitWorktreeEntry { + path: PathBuf::from("/tmp/repo-feature"), + branch: Some("feature/status".to_string()), + head: Some("def5678".to_string()), + is_current: false, + } + ] + ); + } + + #[test] + fn parses_recent_commit_lines() { + let commits = parse_recent_commits( + "abc1234\tfeat: add status\n\ + def5678\tfix: tighten parser\n\ + 9876fed\tchore: update docs\n", + ); + + assert_eq!( + commits, + vec![ + GitCommitEntry { + short_sha: "abc1234".to_string(), + subject: "feat: add status".to_string(), + }, + GitCommitEntry { + short_sha: "def5678".to_string(), + subject: "fix: tighten parser".to_string(), + }, + GitCommitEntry { + short_sha: "9876fed".to_string(), + subject: "chore: update docs".to_string(), + } + ] + ); } #[test]