fix(gitcheck): block bare mutating git commands when worktrees are active#25
fix(gitcheck): block bare mutating git commands when worktrees are active#25
Conversation
Greptile SummaryThis PR significantly expands the git worktree enforcement guard in Key additions:
Issues found:
Confidence Score: 3/5
Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A["check_worktree_enforcement(cmd)"] --> B{worktree_active?}
B -- No --> Z[Return None - allowed]
B -- Yes --> C{cmd contains 'git'?}
C -- No --> Z
C -- Yes --> D{RE_GIT_WORKTREE matches?}
D -- Yes --> Z
D -- No --> E{RE_GIT_C matches path?}
E -- "Yes, main checkout" --> F[BLOCK: git -C on main checkout]
E -- "No / worktree path" --> G{RE_CD_PATH matches?}
G -- "Yes, main checkout" --> H[BLOCK: cd to main checkout]
G -- "No / worktree path" --> I["find_bare_mutating_git(cmd)"]
I --> J["Split cmd on RE_CMD_SEP (&&, ||, ;, |, &)"]
J --> K["For each segment: strip_shell_comment + trim"]
K --> L{RE_GIT_WORD_BOUNDARY matches?}
L -- No --> K
L -- Yes --> M{RE_CD_PATH matches segment?}
M -- "Yes (any cd present)" --> K
M -- No --> N["extract_git_subcommand(segment)"]
N --> O{had_dir_flag -C?}
O -- Yes --> K
O -- No --> P{subcommand in MUTATING_GIT_SUBCMDS?}
P -- Yes --> Q["BLOCK: Bare 'git subcmd'"]
P -- No --> K
K -- "All segments done" --> Z
style M fill:#ffe0b2,stroke:#f57c00
style J fill:#ffe0b2,stroke:#f57c00
Last reviewed commit: "fix(gitcheck): skip ..." |
|
Addressed both P1 findings from Greptile in 72ecfda: 1. cd guard narrowed to per-segment — Removed the whole-command 2. -C detection via flag-aware walker — Removed the Added regression tests for both cases ( |
|
@greptile review |
|
@greptile review |
…tive Bare git commands (add, commit, push, etc.) without -C or cd context ran in CWD (main checkout) instead of the worktree. This caused a real incident where `git commit --amend` amended the wrong commit on main. The fix splits compound commands on shell separators and checks each git invocation independently. A subcommand extractor skips global flags (-c, --no-pager) to accurately identify the git operation. 18 mutating subcommands are blocked; read-only commands (status, log, diff, fetch, branch) remain allowed bare. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Address Greptile review findings:
1. The whole-command `!RE_CD_PATH.is_match(cmd)` guard skipped the bare
git check for ANY cd (e.g. `cd /tmp && git add .`). Now cd is checked
per-segment: only a cd within the same segment as git skips detection.
2. The `-C` substring match (`segment.contains(" -C ")`) could be fooled
by quoted flags like `env GIT_SSH_COMMAND="ssh -C" git add .`. Now
extract_git_subcommand returns a GitSubcommand struct with had_dir_flag
set by the flag-aware token walker, so only actual git -C flags skip.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
… order Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
8b28791 to
f845a35
Compare
|
@greptile review |
- Clarify GIT_FLAGS_WITH_ARG doc: only -C suppresses the bare check, other flags are consumed for correct parsing only - Strip shell comments before cd check to prevent `git add . # cd /path` from falsely skipping detection - Add strip_shell_comment helper with quote awareness - Add regression tests for both cases Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
@greptile review |
Skip past multi-token quoted values like `-c "user.name=Mr Test"` so the subcommand extractor correctly identifies `add` instead of `Test"`. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
@greptile review |
1 similar comment
|
@greptile review |
…re_mutating_git The git word boundary check and extract_git_subcommand were still operating on the original segment with shell comments intact. This caused `cargo test # git add checkpoint` to falsely block (git in comment triggered detection). Now strip_shell_comment is applied once at the top of the loop and all subsequent checks use the stripped segment. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
@greptile review |
…nd detection `\bgit\b` matched `git-lfs`, `git-annex`, etc. because `-` is a word boundary. The tokenizer then treated `-lfs` as a flag and returned `push` as the subcommand, causing false blocks. Now returns None when the character after the `git` match is `-`. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
| for segment in RE_CMD_SEP.split(cmd) { | ||
| // Strip shell-comment tail so `# git add` or `# cd /path` in | ||
| // trailing comments don't trigger false positives or bypasses. | ||
| let segment = strip_shell_comment(segment.trim()); | ||
| if !RE_GIT_WORD_BOUNDARY.is_match(&segment) { | ||
| continue; | ||
| } | ||
| if RE_CD_PATH.is_match(&segment) { | ||
| continue; |
There was a problem hiding this comment.
Command substitution
&& creates a cd-bypass
RE_CMD_SEP splits on &&, ||, ;, |, and & unconditionally — including occurrences inside $(...) subshells. This means cd from inside a command substitution can appear in the same split segment as the outer git command and trigger the RE_CD_PATH exemption on line 458.
Concrete example:
git add $(cd /tmp && echo .)
Split on && → ["git add $(cd /tmp ", " echo .)"]
- Segment 1:
"git add $(cd /tmp"—RE_GIT_WORD_BOUNDARYmatches, thenRE_CD_PATHmatchescd /tmp→ segment skipped (bypass) - Segment 2:
"echo .)"— nogit→ skipped
The shell would execute git add . in the main checkout CWD (the subshell cd only changes the subshell's directory; echo . returns the literal string .), but the hook silently allows it.
The fix requires either tracking nesting depth when splitting (count $( / ) pairs) or stripping/replacing command substitutions before calling RE_CMD_SEP.split. A minimal guard is to strip anything inside $(...) before the segment loop:
// Strip command substitutions before splitting to prevent cd inside $(...) from
// matching RE_CD_PATH in the outer segment.
let cmd_stripped = RE_CMD_SUBST.replace_all(cmd, "");
for segment in RE_CMD_SEP.split(&cmd_stripped) {where RE_CMD_SUBST matches $([^)]*) (simplified; a full solution would need balanced-paren handling).
| #[test] | ||
| fn test_cd_same_segment_allows_git() { | ||
| // cd and git in the SAME shell segment (not separated by &&) | ||
| // This is unusual but the per-segment cd check handles it | ||
| let cmd = "cd /ws/repo/.worktrees/abc12345; git add ."; | ||
| // `;` splits into two segments, so git add is still bare → blocked | ||
| let reason = check_worktree_enforcement(cmd, true, "abc12345"); | ||
| assert!(reason.is_some(), "cd in different segment should block"); |
There was a problem hiding this comment.
Misleading test name and contradictory comments
The test name test_cd_same_segment_allows_git and its opening comment claim to test cd and git in the same shell segment, but the command "cd /ws/repo/.worktrees/abc12345; git add ." uses ; — which is listed in RE_CMD_SEP — so the two commands are in different segments. The inline comment on line 1063 even acknowledges this ("; splits into two segments, so git add is still bare → blocked").
Additionally, the function name says "allows" while assert!(reason.is_some(), ...) asserts the command is blocked, making the name and the assertion semantically opposite.
Suggested rename and comment cleanup:
| #[test] | |
| fn test_cd_same_segment_allows_git() { | |
| // cd and git in the SAME shell segment (not separated by &&) | |
| // This is unusual but the per-segment cd check handles it | |
| let cmd = "cd /ws/repo/.worktrees/abc12345; git add ."; | |
| // `;` splits into two segments, so git add is still bare → blocked | |
| let reason = check_worktree_enforcement(cmd, true, "abc12345"); | |
| assert!(reason.is_some(), "cd in different segment should block"); | |
| fn test_cd_semicolon_does_not_exempt_bare_git() { | |
| // `;` is a shell separator — same as `&&` for splitting purposes. | |
| // cd and git land in *different* segments, so git add has no cd context → blocked. | |
| let cmd = "cd /ws/repo/.worktrees/abc12345; git add ."; | |
| let reason = check_worktree_enforcement(cmd, true, "abc12345"); | |
| assert!(reason.is_some(), "cd in different segment should block"); |
Summary
add,commit,push,pull,merge,rebase,reset,cherry-pick,revert,stash,clean,rm,mv,restore,apply,am,checkout,switch) when worktrees are active&&,||,;,|) and check each git invocation independently — correctly handlesgit -C <wt> fetch && git add .--no-pager,-c key=value) to avoid false positives from commit messages containing words like "merge" or "add"status,log,diff,fetch,branch,remote, etc.) remain allowed bareContext
Real incident:
git add && git commit --amend --no-edit && git push --force-with-leaseran in the main checkout instead of the worktree, amending the wrong commit onmain. The old code only blocked baregit checkout/switch— all other bare mutating commands passed through.Test plan
-Cpassthrough, commit message false positives, cd-to-worktree allowance, and subcommand extractioncargo clippy -D warningscleancargo fmt --checkclean🤖 Generated with Claude Code