From deb916cb3065237709a2fffb6d142959f0eed71e Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Tue, 21 Apr 2026 01:34:48 -0600 Subject: [PATCH 1/3] fix(hooks): recognize `git -C ` in guard-git.sh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The subcommand grep patterns (`git\s+push`, `git\s+commit`, `git\s+reset`, etc.) did not match when a global option like `-C ` appeared between `git` and the subcommand, so `git -C push` bypassed both branch validation and the destructive-command blocklist. Parallel-session agents hit this when pushing from a cwd on a non-conforming branch to a PR worktree — the workaround `git -C push` succeeded only because the hook failed to recognize it as a push. Fix: - Normalize `$COMMAND` into `$NCOMMAND` with `git -C ` stripped, and run all subcommand greps against the normalized form. - Extend `detect_work_dir` to extract the `-C` target (in addition to the existing `cd "" &&` form) so `validate_branch_name` reads the target worktree's branch, not the cwd's. - Apply the same detection to the commit edit-log check so `git -C commit` reads session-edits.log and `diff --cached` from the target repo. Verified with a local test harness covering 18 cases: plain / `cd ... &&` / `git -C ...` forms of push, destructive blocks (add ., reset, clean, stash), and safe forms (restore --staged, log, gh pr list). --- .claude/hooks/guard-git.sh | 63 ++++++++++++++++++++++++++------------ 1 file changed, 44 insertions(+), 19 deletions(-) diff --git a/.claude/hooks/guard-git.sh b/.claude/hooks/guard-git.sh index 35ba3c8ea..b7b153208 100644 --- a/.claude/hooks/guard-git.sh +++ b/.claude/hooks/guard-git.sh @@ -26,6 +26,11 @@ if ! echo "$COMMAND" | grep -qE '(^|\s|&&\s*)(git|gh)\s+'; then exit 0 fi +# Normalize: strip `git -C ""` / `git -C ` so downstream subcommand +# patterns (git\s+push, git\s+commit, …) match regardless of whether `-C` is +# present. detect_work_dir still inspects the raw $COMMAND to find the target. +NCOMMAND=$(echo "$COMMAND" | sed -E 's/(^|\s|&&\s*)git[[:space:]]+-C[[:space:]]+"[^"]+"/\1git/g; s/(^|\s|&&\s*)git[[:space:]]+-C[[:space:]]+[^[:space:]]+/\1git/g') + deny() { local reason="$1" node -e " @@ -43,46 +48,61 @@ deny() { # --- Block dangerous commands --- # git add . / git add -A / git add --all (broad staging) -if echo "$COMMAND" | grep -qE '(^|\s|&&\s*)git\s+add\s+(\.\s*$|-A|--all)'; then +if echo "$NCOMMAND" | grep -qE '(^|\s|&&\s*)git\s+add\s+(\.\s*$|-A|--all)'; then deny "BLOCKED: 'git add .' / 'git add -A' stages ALL changes including other sessions' work. Stage specific files instead: git add " fi # git reset (unstaging / hard reset) -if echo "$COMMAND" | grep -qE '(^|\s|&&\s*)git\s+reset'; then +if echo "$NCOMMAND" | grep -qE '(^|\s|&&\s*)git\s+reset'; then deny "BLOCKED: 'git reset' can unstage or destroy other sessions' work. To unstage your own files, use: git restore --staged " fi # git checkout -- (reverting files) -if echo "$COMMAND" | grep -qE '(^|\s|&&\s*)git\s+checkout\s+--'; then +if echo "$NCOMMAND" | grep -qE '(^|\s|&&\s*)git\s+checkout\s+--'; then deny "BLOCKED: 'git checkout -- ' reverts working tree changes and may destroy other sessions' edits. If you need to discard your own changes, be explicit about which files." fi # git restore (reverting) — EXCEPT git restore --staged (safe unstaging) -if echo "$COMMAND" | grep -qE '(^|\s|&&\s*)git\s+restore'; then - if ! echo "$COMMAND" | grep -qE '(^|\s|&&\s*)git\s+restore\s+--staged'; then +if echo "$NCOMMAND" | grep -qE '(^|\s|&&\s*)git\s+restore'; then + if ! echo "$NCOMMAND" | grep -qE '(^|\s|&&\s*)git\s+restore\s+--staged'; then deny "BLOCKED: 'git restore ' reverts working tree changes and may destroy other sessions' edits. To unstage files safely, use: git restore --staged " fi fi # git clean (delete untracked files) -if echo "$COMMAND" | grep -qE '(^|\s|&&\s*)git\s+clean'; then +if echo "$NCOMMAND" | grep -qE '(^|\s|&&\s*)git\s+clean'; then deny "BLOCKED: 'git clean' deletes untracked files that may belong to other sessions." fi # git stash (hides all changes) -if echo "$COMMAND" | grep -qE '(^|\s|&&\s*)git\s+stash'; then +if echo "$NCOMMAND" | grep -qE '(^|\s|&&\s*)git\s+stash'; then deny "BLOCKED: 'git stash' hides all working tree changes including other sessions' work. In worktree mode, commit your changes directly instead." fi -# --- Branch name validation helper --- +# --- Working directory detection --- -validate_branch_name() { - # Try to get branch from the working directory where the command runs - # Extract cd target if command starts with cd "..." && ... +# Resolve the working directory a git command targets: +# - `cd "" && git ...` → the cd target +# - `git -C "" ...` → the -C target +# Falls back to empty string (caller uses cwd). +detect_work_dir() { local work_dir="" if echo "$COMMAND" | grep -qE '^\s*cd\s+'; then work_dir=$(echo "$COMMAND" | sed -nE 's/^\s*cd\s+"?([^"&]+)"?\s*&&.*/\1/p') fi + if [ -z "$work_dir" ] && echo "$COMMAND" | grep -qE 'git\s+-C\s+'; then + work_dir=$(echo "$COMMAND" | sed -nE 's/.*git[[:space:]]+-C[[:space:]]+"([^"]+)".*/\1/p;t;s/.*git[[:space:]]+-C[[:space:]]+([^[:space:]]+).*/\1/p') + fi + # Trim trailing whitespace + work_dir="${work_dir%"${work_dir##*[![:space:]]}"}" + echo "$work_dir" +} + +# --- Branch name validation helper --- + +validate_branch_name() { + local work_dir + work_dir=$(detect_work_dir) local BRANCH="" if [ -n "$work_dir" ] && [ -d "$work_dir" ]; then @@ -102,21 +122,29 @@ validate_branch_name() { # --- Branch name validation on push --- -if echo "$COMMAND" | grep -qE '(^|\s|&&\s*)git\s+push'; then +if echo "$NCOMMAND" | grep -qE '(^|\s|&&\s*)git\s+push'; then validate_branch_name fi # --- Branch name validation on gh pr create --- -if echo "$COMMAND" | grep -qE '(^|\s|&&\s*)gh\s+pr\s+create'; then +if echo "$NCOMMAND" | grep -qE '(^|\s|&&\s*)gh\s+pr\s+create'; then validate_branch_name fi # --- Commit validation against edit log --- -if echo "$COMMAND" | grep -qE '(^|\s|&&\s*)git\s+commit'; then - # Use git worktree root so each worktree session has its own edit log - PROJECT_DIR=$(git rev-parse --show-toplevel 2>/dev/null) || PROJECT_DIR="${CLAUDE_PROJECT_DIR:-.}" +if echo "$NCOMMAND" | grep -qE '(^|\s|&&\s*)git\s+commit'; then + # Resolve the target worktree so the edit log and staged-file listing come + # from the same repo the commit targets (e.g. `git -C commit`). + WORK_DIR=$(detect_work_dir) + if [ -n "$WORK_DIR" ] && [ -d "$WORK_DIR" ]; then + PROJECT_DIR=$(git -C "$WORK_DIR" rev-parse --show-toplevel 2>/dev/null) || PROJECT_DIR="${CLAUDE_PROJECT_DIR:-.}" + STAGED_FILES=$(git -C "$WORK_DIR" diff --cached --name-only 2>/dev/null) || true + else + PROJECT_DIR=$(git rev-parse --show-toplevel 2>/dev/null) || PROJECT_DIR="${CLAUDE_PROJECT_DIR:-.}" + STAGED_FILES=$(git diff --cached --name-only 2>/dev/null) || true + fi LOG_FILE="$PROJECT_DIR/.claude/session-edits.log" # If no edit log exists, allow (backward compat for sessions without tracking) @@ -127,9 +155,6 @@ if echo "$COMMAND" | grep -qE '(^|\s|&&\s*)git\s+commit'; then # Get unique edited files from log EDITED_FILES=$(awk '{print $2}' "$LOG_FILE" | sort -u) - # Get staged files - STAGED_FILES=$(git diff --cached --name-only 2>/dev/null) || true - if [ -z "$STAGED_FILES" ]; then exit 0 fi From 10c50fee055b05c6d9c2091ad718cf8d33bf11ea Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Wed, 22 Apr 2026 00:54:59 -0600 Subject: [PATCH 2/3] fix(hooks): handle multi-C and cd+-C precedence in guard-git.sh (#1004) - Normalize NCOMMAND twice so multi-`-C` chains (e.g. `git -C /a -C /b push`) collapse fully; with a single pass, the pattern cannot re-anchor on `git` after the first replacement, leaving a residual `-C /b` that would bypass subcommand matching. - Unquoted pattern now requires a non-quote first char so it does not mis-match the opening `"` of a quoted path; the previous pattern could swallow `"/p` and leave a trailing `path"` in NCOMMAND. - `detect_work_dir` gives `git -C` precedence over `cd`: `-C` is an explicit git-level override, so `cd /tmp && git -C /worktree push` now correctly reports /worktree (was /tmp). --- .claude/hooks/guard-git.sh | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/.claude/hooks/guard-git.sh b/.claude/hooks/guard-git.sh index b7b153208..6b11443b7 100644 --- a/.claude/hooks/guard-git.sh +++ b/.claude/hooks/guard-git.sh @@ -29,7 +29,12 @@ fi # Normalize: strip `git -C ""` / `git -C ` so downstream subcommand # patterns (git\s+push, git\s+commit, …) match regardless of whether `-C` is # present. detect_work_dir still inspects the raw $COMMAND to find the target. -NCOMMAND=$(echo "$COMMAND" | sed -E 's/(^|\s|&&\s*)git[[:space:]]+-C[[:space:]]+"[^"]+"/\1git/g; s/(^|\s|&&\s*)git[[:space:]]+-C[[:space:]]+[^[:space:]]+/\1git/g') +# The unquoted pattern requires a non-quote first char so it does not mis-match +# the opening `"` of a quoted path (which would leave a trailing `path"` in +# NCOMMAND). The pattern re-anchors on `git`, so multi-`-C` chains (e.g. +# `git -C /a -C /b push`) need a second pass to collapse the residual `-C`. +NCOMMAND=$(echo "$COMMAND" | sed -E 's/(^|\s|&&\s*)git[[:space:]]+-C[[:space:]]+"[^"]+"/\1git/g; s/(^|\s|&&\s*)git[[:space:]]+-C[[:space:]]+[^"[:space:]][^[:space:]]*/\1git/g') +NCOMMAND=$(echo "$NCOMMAND" | sed -E 's/(^|\s|&&\s*)git[[:space:]]+-C[[:space:]]+"[^"]+"/\1git/g; s/(^|\s|&&\s*)git[[:space:]]+-C[[:space:]]+[^"[:space:]][^[:space:]]*/\1git/g') deny() { local reason="$1" @@ -82,17 +87,19 @@ fi # --- Working directory detection --- # Resolve the working directory a git command targets: +# - `git -C "" ...` → the -C target (takes precedence — explicit git-level override) # - `cd "" && git ...` → the cd target -# - `git -C "" ...` → the -C target # Falls back to empty string (caller uses cwd). detect_work_dir() { local work_dir="" - if echo "$COMMAND" | grep -qE '^\s*cd\s+'; then - work_dir=$(echo "$COMMAND" | sed -nE 's/^\s*cd\s+"?([^"&]+)"?\s*&&.*/\1/p') - fi - if [ -z "$work_dir" ] && echo "$COMMAND" | grep -qE 'git\s+-C\s+'; then + # `git -C` is the explicit git-level override and wins over any ambient cd prefix, + # so check it first (e.g. `cd /tmp && git -C /worktree push` targets /worktree). + if echo "$COMMAND" | grep -qE 'git\s+-C\s+'; then work_dir=$(echo "$COMMAND" | sed -nE 's/.*git[[:space:]]+-C[[:space:]]+"([^"]+)".*/\1/p;t;s/.*git[[:space:]]+-C[[:space:]]+([^[:space:]]+).*/\1/p') fi + if [ -z "$work_dir" ] && echo "$COMMAND" | grep -qE '^\s*cd\s+'; then + work_dir=$(echo "$COMMAND" | sed -nE 's/^\s*cd\s+"?([^"&]+)"?\s*&&.*/\1/p') + fi # Trim trailing whitespace work_dir="${work_dir%"${work_dir##*[![:space:]]}"}" echo "$work_dir" From 1039447f04af44be20350fdd13aaeeb68018ea6c Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Thu, 23 Apr 2026 00:07:26 -0600 Subject: [PATCH 3/3] fix(hooks): resolve per-subcommand work dir in guard-git (#1004) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chained commands like 'git -C /a push && git -C /b commit' and multi-C invocations like 'git -C /ok -C /bad push' escaped branch validation because detect_work_dir extracted the first -C path via greedy anchor on '.*git', not the effective -C git would honor. - detect_work_dir now accepts an optional subcommand hint (push, commit). When given, it narrows to the &&-separated segment whose git invocation runs that subcommand. - Within the chosen segment the LAST -C wins — git's -C is cumulative, so the final -C is the effective CWD. - validate_branch_name and the commit-edit-log block pass the appropriate hint (push/commit) so each sees its own target worktree. Regression tests cover: single -C, multi -C (last wins), chained push/commit in different worktrees, quoted paths, cd-only, mixed precedence, and the original 18-case harness from the PR. --- .claude/hooks/guard-git.sh | 36 +++++++++++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/.claude/hooks/guard-git.sh b/.claude/hooks/guard-git.sh index 6b11443b7..46f967592 100644 --- a/.claude/hooks/guard-git.sh +++ b/.claude/hooks/guard-git.sh @@ -90,12 +90,35 @@ fi # - `git -C "" ...` → the -C target (takes precedence — explicit git-level override) # - `cd "" && git ...` → the cd target # Falls back to empty string (caller uses cwd). +# +# Optional arg: target subcommand hint (e.g. `push`, `commit`). When given, +# narrows the search to the `&&`-separated segment whose git invocation runs +# that subcommand, so chained commands like +# `git -C /a push && git -C /b commit -m ...` +# resolve each caller to its own worktree instead of always picking the last +# `git` token. Within the chosen segment the LAST `-C` wins (git's `-C` is +# cumulative, so the final `-C` is the effective CWD) — this closes the +# multi-`-C` bypass (`git -C /ok -C /bad push` resolves to `/bad`). detect_work_dir() { + local target_subcmd="${1:-}" local work_dir="" + local search_str="$COMMAND" + + if [ -n "$target_subcmd" ]; then + local segment + segment=$(echo "$COMMAND" | awk -v tgt="$target_subcmd" 'BEGIN{RS="&&"}{ + if ($0 ~ "git[[:space:]]+([^|;&]*[[:space:]])?" tgt "([[:space:]]|$)") { print; exit } + }') + if [ -n "$segment" ]; then + search_str="$segment" + fi + fi + # `git -C` is the explicit git-level override and wins over any ambient cd prefix, # so check it first (e.g. `cd /tmp && git -C /worktree push` targets /worktree). - if echo "$COMMAND" | grep -qE 'git\s+-C\s+'; then - work_dir=$(echo "$COMMAND" | sed -nE 's/.*git[[:space:]]+-C[[:space:]]+"([^"]+)".*/\1/p;t;s/.*git[[:space:]]+-C[[:space:]]+([^[:space:]]+).*/\1/p') + # Greedy `.*-C` anchors on the LAST `-C` in the chosen segment. + if echo "$search_str" | grep -qE 'git\s+([^&|;]*\s)?-C\s+'; then + work_dir=$(echo "$search_str" | sed -nE 's/.*-C[[:space:]]+"([^"]+)".*/\1/p;t;s/.*-C[[:space:]]+([^[:space:]]+).*/\1/p') fi if [ -z "$work_dir" ] && echo "$COMMAND" | grep -qE '^\s*cd\s+'; then work_dir=$(echo "$COMMAND" | sed -nE 's/^\s*cd\s+"?([^"&]+)"?\s*&&.*/\1/p') @@ -108,8 +131,9 @@ detect_work_dir() { # --- Branch name validation helper --- validate_branch_name() { + local subcmd="${1:-}" local work_dir - work_dir=$(detect_work_dir) + work_dir=$(detect_work_dir "$subcmd") local BRANCH="" if [ -n "$work_dir" ] && [ -d "$work_dir" ]; then @@ -130,12 +154,14 @@ validate_branch_name() { # --- Branch name validation on push --- if echo "$NCOMMAND" | grep -qE '(^|\s|&&\s*)git\s+push'; then - validate_branch_name + validate_branch_name push fi # --- Branch name validation on gh pr create --- if echo "$NCOMMAND" | grep -qE '(^|\s|&&\s*)gh\s+pr\s+create'; then + # `gh pr create` does not use `git -C`; detect_work_dir falls through to the + # `cd` path or cwd. No subcommand hint to pass. validate_branch_name fi @@ -144,7 +170,7 @@ fi if echo "$NCOMMAND" | grep -qE '(^|\s|&&\s*)git\s+commit'; then # Resolve the target worktree so the edit log and staged-file listing come # from the same repo the commit targets (e.g. `git -C commit`). - WORK_DIR=$(detect_work_dir) + WORK_DIR=$(detect_work_dir commit) if [ -n "$WORK_DIR" ] && [ -d "$WORK_DIR" ]; then PROJECT_DIR=$(git -C "$WORK_DIR" rev-parse --show-toplevel 2>/dev/null) || PROJECT_DIR="${CLAUDE_PROJECT_DIR:-.}" STAGED_FILES=$(git -C "$WORK_DIR" diff --cached --name-only 2>/dev/null) || true