mirror of
https://github.com/ultraworkers/claw-code-parity.git
synced 2026-05-28 11:51:32 +02:00
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
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -1005,6 +1005,9 @@ struct StatusContext {
|
||||
project_root: Option<PathBuf>,
|
||||
git_branch: Option<String>,
|
||||
git_summary: GitWorkspaceSummary,
|
||||
git_freshness: Option<GitBranchFreshness>,
|
||||
git_worktrees: Vec<GitWorktreeEntry>,
|
||||
recent_commits: Vec<GitCommitEntry>,
|
||||
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<String>,
|
||||
head: Option<String>,
|
||||
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<PathBuf>, Option<S
|
||||
)
|
||||
}
|
||||
|
||||
fn load_git_branch_freshness(repo_root: &Path) -> Option<GitBranchFreshness> {
|
||||
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<GitWorktreeEntry> {
|
||||
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<GitWorktreeEntry> {
|
||||
let mut worktrees = Vec::new();
|
||||
let mut current: Option<GitWorktreeEntry> = 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<GitCommitEntry> {
|
||||
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<GitCommitEntry> {
|
||||
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<String> {
|
||||
let status = status?;
|
||||
let first_line = status.lines().next()?;
|
||||
@@ -1281,6 +1439,22 @@ fn resolve_git_branch_for(cwd: &Path) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
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<usize> {
|
||||
run_git_capture_in(cwd, &["rev-list", "--count", range])?
|
||||
.trim()
|
||||
.parse::<usize>()
|
||||
.ok()
|
||||
}
|
||||
|
||||
fn run_git_capture_in(cwd: &Path, args: &[&str]) -> Option<String> {
|
||||
let output = std::process::Command::new("git")
|
||||
.args(args)
|
||||
@@ -1293,6 +1467,10 @@ fn run_git_capture_in(cwd: &Path, args: &[&str]) -> Option<String> {
|
||||
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<PathBuf, Box<dyn std::error::Error>> {
|
||||
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::<Vec<_>>(),
|
||||
"<none>",
|
||||
),
|
||||
format_multiline_detail(
|
||||
&context
|
||||
.recent_commits
|
||||
.iter()
|
||||
.map(GitCommitEntry::headline)
|
||||
.collect::<Vec<_>>(),
|
||||
"<none>",
|
||||
),
|
||||
);
|
||||
[
|
||||
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]
|
||||
|
||||
Reference in New Issue
Block a user