Skip to content

[Bug] Validation hooks resolve git context from $PWD, not the worktree where gh/git ran (breaks /fan-out) #194

@atlas-apex

Description

@atlas-apex

Given / When / Then

Given the parent session spawns parallel agents via /fan-out (or via direct Agent tool calls with isolation: "worktree")

When any of those agents tries to git push, gh pr create, or git commit -m "$(cat <<'EOF'...)" from inside their worktree

Then the validation hooks validate-branch-name.sh, validate-pr-create.sh, and validate-commit-format.sh resolve git context against the harness's $PWD (typically a sibling worktree's directory in the parent session) — NOT the worktree where the agent's command actually ran. This causes the hooks to:

  • Read the wrong branch name from git branch --show-current
  • Read the wrong commit message from the wrong repo's HEAD
  • Block the agent's legitimate operation with a misleading "your branch name doesn't match the convention" error

Expected: hooks resolve git context against the actual command's repo, either via the --head / --repo flag the operator passed, or via the directory the operator cd'd into before running the command. Not from a $PWD that's frozen at the harness-spawn moment.

Repro

This is reproduced every time parallel agents run from worktrees in this session. From three concurrent sub-agent reports:

Agent A (#188):
  "validate-branch-name.sh / validate-pr-create.sh which read
   git branch --show-current from the harness's pwd, not the worktree
   I cd'd into. Worked around by renaming the host-worktree's branch."

Agent B (#189):
  "Branch / title / body all conform to the rules the hook would have
   validated. The push went through with `git -C` to override; the PR
   itself was created via `gh api POST /repos/.../pulls` after
   `gh pr create` repeatedly tripped the same misfire."

Agent C (#190):
  "The agent's home cwd is a registered-project worktree whose own
   branch name doesn't satisfy apexyard's validate-branch-name.sh,
   which walks up from $PWD. Worked around by temporarily placing a
   sentinel onboarding.yaml in the home dir."

Each agent independently hit the same bug and invented a different workaround — branch rename, git -C, sentinel file. None of those should be necessary.

The same shape is the gh api .../pulls/<N>/merge bypass that closed via #47 — a tool surface the hooks didn't originally cover. This ticket extends the same principle to validation hooks: gate on the command's actual context, not the harness's $PWD.

Why this matters

/fan-out is a sanctioned framework pattern — the parallel-work rule says we should offer it. But every fan-out today produces 3 simultaneous workarounds for the same hook misfire. The system fights itself: it sanctions fan-out via /fan-out yet blocks fan-out via $PWD-bound hooks.

The cost compounds: each agent's workaround is invisible to Rex review (the parent only sees the PRs), so the worked-around behavior ships. Eventually an adopter reads the SKILL.md / hook source, reproduces the misfire, and learns the workaround — at which point the workaround becomes the de-facto pattern, and the rule's enforcement is hollow.

Proposed fix

Hook 1 — validate-branch-name.sh

Read the branch from the actual git push command's source ref:

# Today (broken in worktrees):
BRANCH=$(git branch --show-current)

# Fix:
# Parse the push command for the source ref. `git push origin <branch>` →
# extract <branch>. Fall back to `git branch --show-current` if no ref is
# in the command (no-arg push relies on upstream tracking).
BRANCH=$(extract_push_ref "$COMMAND" || git branch --show-current)

Helper goes in _lib-extract-push-ref.sh (sibling to _lib-extract-pr.sh).

Hook 2 — validate-pr-create.sh

Read the branch from gh pr create's --head flag:

# Today (broken):
BRANCH=$(git branch --show-current)

# Fix:
HEAD_FLAG=$(echo "$COMMAND" | sed -nE 's/.*--head[[:space:]]+([^[:space:]]+).*/\1/p' | head -1)
BRANCH="${HEAD_FLAG:-$(git branch --show-current)}"

Same fallback: if no --head flag, use the local branch (today's behavior). When --head IS specified, the hook uses it.

Hook 3 — validate-commit-format.sh

The git commit -m "$(cat <<'EOF'...)" shell-substitution issue:

The hook reads the -m argument from the command string and treats it as the literal commit subject. When the operator uses $(cat <<'EOF'...) substitution, the substitution hasn't expanded yet at hook-invocation time — the hook sees the literal string $(cat <<'EOF'...) which obviously doesn't match the conventional-commit regex.

Fix: detect the heredoc-substitution pattern and skip validation of the subject (because the actual subject is in the heredoc body, not the -m argument string). Alternative: read the staged commit's actual message via git commit --dry-run after substitution. The first option is simpler.

# If COMMAND contains $(cat <<EOF ... EOF), the -m arg is dynamic.
# Skip subject validation for this shape; the operator can use git commit -F
# if they want subject validation on a multi-line message.
if echo "$COMMAND" | grep -qE 'git commit.*-m.*\$\(cat[[:space:]]*<<'; then
  echo "INFO: heredoc-substitution detected in -m; skipping subject validation. Use git commit -F file for validation on multi-line messages." >&2
  exit 0
fi

Hook 4+ (out of scope of v1) — validate-issue-structure.sh, block-private-refs-in-public-repos.sh, etc.

These hooks already work on the explicit --repo / --body-file flags. They don't have the $PWD problem. Verify in passing during this work.

Acceptance Criteria

  • validate-branch-name.sh reads the branch from the push command's source ref when present, falling back to local HEAD otherwise.
  • validate-pr-create.sh reads the branch from --head when present, falling back to local HEAD otherwise.
  • validate-commit-format.sh skips subject validation when $(cat <<'EOF'...) substitution is detected in the -m argument; suggests git commit -F file as the alternative path.
  • New shared helper _lib-extract-push-ref.sh for parsing the push command's source ref.
  • Tests cover all three fixes:
    • validate-branch-name.sh: push from a worktree where $PWD is a sibling worktree → reads correct branch
    • validate-pr-create.sh: gh pr create --head feature/GH-N-description → reads feature/GH-N-description, not the local branch
    • validate-commit-format.sh: heredoc-substitution pattern → skips with INFO message; non-substitution -m "subject" validates as today
  • Doc note in .claude/hooks/README.md explaining the cwd-vs-command-context distinction.
  • Smoke test: re-run a /fan-out across 3 worktrees, confirm zero workarounds needed.

Risks / Dependencies

  • False negatives — the heredoc-substitution skip might let a legitimate format violation through if the heredoc body is malformed. Acceptable trade-off; git commit -F users still get full validation, and the heredoc pattern is uncommon enough that the risk is bounded.
  • Cross-platform — the regex extraction assumes GNU grep/sed conventions. Test on macOS BSD tools (the dev fleet) before merge.
  • Backwards compatibility — current behavior (read local HEAD) is preserved as the fallback. Adopters who don't pass --head see no change.

Refs

Metadata

Metadata

Assignees

No one assigned

    Labels

    P1High — material gap or user-impactingbugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions