From b04b1d6ac8547ff9a5986110365ec2fcb4d4255b Mon Sep 17 00:00:00 2001 From: bellman Date: Fri, 5 Jun 2026 06:31:33 +0900 Subject: [PATCH] feat: detect git rebase/merge/cherry-pick/bisect states (#89) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add GitOperation enum to detect mid-operation git states from the branch header in git status --short --branch output. - Rebase: 'rebasing ...' in branch header - Merge: '[merge-in-progress]' tag - Cherry-pick: 'cherry-pick-in-progress' tag - Bisect: 'bisect-in-progress' tag Operation state appears in: - status text: 'rebase-in-progress, dirty · 3 files · ...' - status JSON: 'git_operation' field (null when no operation) - git_state headline includes operation prefix Generated with https://github.com/Yeachan-Heo/gajae-code Co-authored-by: Gajae Code --- ROADMAP.md | 2 +- rust/crates/rusty-claude-cli/src/main.rs | 78 +++++++++++++++++++++--- 2 files changed, 72 insertions(+), 8 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index ced054e3..e1c2b810 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1760,7 +1760,7 @@ Original filing (2026-04-13): user requested a `-acp` parameter to support ACP p **Source.** Jobdori dogfood 2026-04-17 against `/tmp/claude-md-injection/inner/work` on main HEAD `82bd8bb` in response to Clawhip pinpoint nudge at `1494691430096961767`. Second (and higher-severity) member of the "discovery-overreach" cluster after #85. Different axis from the #80–#84 / #86–#87 truth-audit cluster: here the discovery surface is reaching into state it should not, and the consumed state feeds directly into the agent's system prompt — the highest-trust context surface in the entire runtime. -89. **`claw` is blind to mid-operation git states (rebase-in-progress, merge-in-progress, cherry-pick-in-progress, bisect-in-progress) — `doctor` returns `Workspace: ok` on a workspace that is literally paused on a conflict** — dogfooded 2026-04-17 on main HEAD `9882f07` from `/tmp/git-state-probe`. A branch rebase that halted on a conflict leaves the workspace in the `rebase-merge` state with conflict files in the index and `HEAD` detached on the rebase's intermediate commit. `claw`'s workspace surface reports this as a plain dirty workspace on "branch detached HEAD," with no signal that the lane is mid-operation and cannot safely accept new work. +89. **DONE — `claw` is blind to mid-operation git states (rebase-in-progress, merge-in-progress, cherry-pick-in-progress, bisect-in-progress) — `doctor` returns `Workspace: ok` on a workspace that is literally paused on a conflict** — dogfooded 2026-04-17 on main HEAD `9882f07` from `/tmp/git-state-probe`. A branch rebase that halted on a conflict leaves the workspace in the `rebase-merge` state with conflict files in the index and `HEAD` detached on the rebase's intermediate commit. `claw`'s workspace surface reports this as a plain dirty workspace on "branch detached HEAD," with no signal that the lane is mid-operation and cannot safely accept new work. **Concrete repro.** ``` diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 9c94bb97..33d34e7a 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -5729,6 +5729,31 @@ struct GitWorkspaceSummary { unstaged_files: usize, untracked_files: usize, conflicted_files: usize, + /// #89: detected mid-operation git state (rebase, merge, cherry-pick, bisect) + operation: GitOperation, +} + +/// #89: mid-operation git states detected from branch header in `git status --short --branch`. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +enum GitOperation { + #[default] + None, + Rebase, + Merge, + CherryPick, + Bisect, +} + +impl GitOperation { + fn as_str(self) -> &'static str { + match self { + Self::None => "", + Self::Rebase => "rebase-in-progress", + Self::Merge => "merge-in-progress", + Self::CherryPick => "cherry-pick-in-progress", + Self::Bisect => "bisect-in-progress", + } + } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -5806,8 +5831,18 @@ impl GitWorkspaceSummary { } fn headline(self) -> String { + // #89: prefix with operation state when mid-operation + let op_prefix = if self.operation != GitOperation::None { + format!("{}, ", self.operation.as_str()) + } else { + String::new() + }; if self.is_clean() { - "clean".to_string() + if self.operation != GitOperation::None { + format!("{op_prefix}clean") + } else { + "clean".to_string() + } } else { let mut details = Vec::new(); if self.staged_files > 0 { @@ -5823,7 +5858,7 @@ impl GitWorkspaceSummary { details.push(format!("{} conflicted", self.conflicted_files)); } format!( - "dirty · {} files · {}", + "{op_prefix}dirty · {} files · {}", self.changed_files, details.join(", ") ) @@ -6129,7 +6164,26 @@ fn parse_git_workspace_summary(status: Option<&str>) -> GitWorkspaceSummary { }; for line in status.lines() { - if line.starts_with("## ") || line.trim().is_empty() { + if line.starts_with("## ") { + // #89: detect mid-operation states from branch header + // git status --short --branch shows: + // "## HEAD (no branch, rebasing feature-branch)" + // "## main [merge-in-progress]" + // "## HEAD (no branch, cherry-pick-in-progress)" + // "## main (no branch, bisect-in-progress)" + let header = line.to_ascii_lowercase(); + if header.contains("rebasing") { + summary.operation = GitOperation::Rebase; + } else if header.contains("merge-in-progress") { + summary.operation = GitOperation::Merge; + } else if header.contains("cherry-pick-in-progress") { + summary.operation = GitOperation::CherryPick; + } else if header.contains("bisect-in-progress") { + summary.operation = GitOperation::Bisect; + } + continue; + } + if line.trim().is_empty() { continue; } @@ -9433,6 +9487,12 @@ fn status_json_value( "changed_files": context.git_summary.changed_files, "is_clean": context.git_summary.changed_files == 0, "staged_files": context.git_summary.staged_files, + // #89: mid-operation git state (rebase, merge, cherry-pick, bisect) + "git_operation": if context.git_summary.operation != GitOperation::None { + Some(context.git_summary.operation.as_str()) + } else { + None::<&str> + }, "unstaged_files": context.git_summary.unstaged_files, "untracked_files": context.git_summary.untracked_files, @@ -13907,10 +13967,11 @@ mod tests { run_resume_command, short_tool_id, slash_command_completion_candidates_with_sessions, split_error_hint, status_context, status_json_value, summarize_tool_payload_for_markdown, try_resolve_bare_skill_prompt, validate_no_args, write_mcp_server_fixture, CliAction, - CliOutputFormat, CliToolExecutor, GitWorkspaceSummary, InternalPromptProgressEvent, - InternalPromptProgressState, LiveCli, LocalHelpTopic, PermissionModeProvenance, - PromptHistoryEntry, SessionLifecycleKind, SessionLifecycleSummary, SlashCommand, - StatusUsage, TmuxPaneSnapshot, DEFAULT_MODEL, LATEST_SESSION_REFERENCE, STUB_COMMANDS, + CliOutputFormat, CliToolExecutor, GitOperation, GitWorkspaceSummary, + InternalPromptProgressEvent, InternalPromptProgressState, LiveCli, LocalHelpTopic, + PermissionModeProvenance, PromptHistoryEntry, SessionLifecycleKind, + SessionLifecycleSummary, SlashCommand, StatusUsage, TmuxPaneSnapshot, DEFAULT_MODEL, + LATEST_SESSION_REFERENCE, STUB_COMMANDS, }; use api::{ApiError, MessageResponse, OutputContentBlock, Usage}; use plugins::{ @@ -17250,6 +17311,7 @@ mod tests { unstaged_files: 1, untracked_files: 1, conflicted_files: 0, + operation: GitOperation::None, }, branch_freshness: test_branch_freshness(), stale_base_state: super::BaseCommitState::NoExpectedBase, @@ -17621,6 +17683,7 @@ mod tests { unstaged_files: 1, untracked_files: 0, conflicted_files: 0, + operation: GitOperation::None, }; let preflight = format_commit_preflight_report(Some("feature/ux"), summary); @@ -17744,6 +17807,7 @@ UU conflicted.rs", unstaged_files: 2, untracked_files: 1, conflicted_files: 1, + operation: GitOperation::None, } ); assert_eq!(