Skip to content

fix(hooks): judge the branch the push actually comes from - #286

Open
VasiHemanth wants to merge 1 commit into
mainfrom
fix/hooks-worktree-aware-cwd
Open

fix(hooks): judge the branch the push actually comes from#286
VasiHemanth wants to merge 1 commit into
mainfrom
fix/hooks-worktree-aware-cwd

Conversation

@VasiHemanth

Copy link
Copy Markdown
Owner

Summary

Both pre-push hooks resolved the repo with git rev-parse --show-toplevel
from their own process cwd, which is the session's project root. That is not
where the push runs once Claude is in a worktree, or once the command starts
with cd <dir> && ..., so a worktree push was judged against whatever branch
the project root happened to be sitting on.

This repo does most of its work in worktrees, so the hooks were routinely
answering about the wrong branch.

What went wrong in practice

A worktree branch carrying only fix: commits was denied, because the project
root sat on a feat: branch whose UPDATE.json change was still uncommitted.
Per .claude/CLAUDE.md case 1, a pure fix: branch should have been allowed
silently.

The same path also denied a worktree checked out on main, despite the hook's
explicit "skip enforcement if we're on main" rule, because the branch it read
was never main to begin with.

The fix

Adds resolve_push_cwd() to enforce-update-json.py; both hooks call it
before asking git anything. Precedence, most explicit first:

  1. git -C <dir> in the push fragment itself
  2. the accumulated cd <dir> prefix of the same command chain
  3. the payload's cwd field, which is the worktree root after Claude enters a
    worktree
  4. the hook's own cwd, i.e. today's behaviour

Relative paths compose against whatever is current at that point. Forms that
can't be resolved to a concrete path (cd -, cd ~/x) fall back rather than
guess.

Non-worktree pushes are unaffected: the payload cwd equals the process cwd, so
every path resolves exactly as it does today.

Type of change

  • Bug fix
  • New feature (new agent support, new dashboard view, etc.)
  • Improvement / refactor
  • Docs / chore

How to test

backend/test_hooks.py (new, 15 tests) covers the resolver directly and
drives enforce-update-json.py end to end as a subprocess with a real
PreToolUse payload, over throwaway repos and real git worktree checkouts.

pytest backend/test_hooks.py

Eleven of the fifteen fail against the unfixed hooks, including the two
behavioural ones (test_worktree_push_is_judged_on_its_own_branch,
test_main_branch_is_always_allowed). The guard tests pass either way, so the
fix doesn't loosen the gate:

  • test_denies_feat_branch_without_update_json
  • test_worktree_feat_branch_still_denied (a real feat: worktree is still denied)
  • test_allows_when_update_json_is_touched
  • test_push_detection_unchanged

Verified against this repo as well: the previously-denied push from a
fix:-only worktree is now allowed, and the live feat/local-model-insights
branch is still correctly denied.

Full backend suite: 461 passed, with the same pre-existing failure set as
main (22, all unrelated local-config pollution in test_power_config /
test_pricing / test_update_check).

Checklist

  • I tested this locally
  • No secrets or API keys are included
  • PR is focused on one change
  • README or CHANGELOG updated if needed

Related issue

N/A. Found while pushing a follow-up to #285 from a worktree.

🤖 Generated with Claude Code

Both pre-push hooks resolved the repo with `git rev-parse --show-toplevel`
from their own process cwd, which is the session's project root. That is not
where the push runs once Claude is in a worktree or the command starts with
`cd <dir> &&`, so a worktree push was evaluated against whatever branch the
project root happened to be on.

Concretely: a worktree branch carrying only `fix:` commits was denied because
the project root sat on a `feat:` branch whose UPDATE.json change was still
uncommitted. A worktree checked out on `main` was denied too, despite the
hook's explicit "skip on main" rule.

Adds `resolve_push_cwd()` to enforce-update-json.py and uses it in both hooks.
Precedence, most explicit first: `git -C <dir>` in the push fragment, the
accumulated `cd <dir>` prefix of the same chain, the payload's `cwd` field
(the worktree root after EnterWorktree), then the hook's own cwd. Relative
paths compose. Unresolvable forms (`cd -`, `cd ~...`) fall back rather than
guess.

Non-worktree pushes are unaffected: the payload cwd equals the process cwd, so
every path resolves exactly as before.

backend/test_hooks.py covers the resolver and drives the hook end to end over
real throwaway repos and worktrees. Eleven of the fifteen fail against the
unfixed hooks, including the two that assert a fix-only worktree and a
main worktree are allowed, while the guard tests (feat: without UPDATE.json
denied, in a worktree too) pass either way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant