Run the pre-commit hook against the tree being committed, and only for commits - #2477
Conversation
…r commits The hook did two things wrong, both because a PreToolUse hook runs in the project directory and does not share the Bash tool's shell state. It ran `pre-commit run --all-files` in the primary checkout whatever the commit was for. Committing from a worktree therefore checked the wrong tree, and any hook that rewrites a file rewrote it over there: regenerating the OpenAPI spec dirtied another agent's working tree, whose uncommitted changes had nothing to do with the commit. The hook now works out which tree the command targets, the way worktree-guard.sh does for the same shared checkout — `-C <path>`, else a `cd` in the same command, else the payload's own working directory, else the project — and resolves that to its checkout root. Taking the path from the command text is what matters in practice, since the shell's directory is reset between calls, so `cd <worktree> && git commit` leaves nothing behind in the payload. It also ran on commands that were not commits at all, because the settings-level "if" matcher is not honoured by every Claude Code version. A `gh issue create` paid for a full run of every hook. The filter is now repeated in the script, again as worktree-guard.sh does, anchored so that `git commit-graph` and a command merely quoting `git commit` in a body do not trigger it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LtMZ49GH6LaiY5MdgtfNe2 Signed-off-by: F.N. Claessen <claessen@seita.nl>
Documentation build overview
|
There was a problem hiding this comment.
🟡 Changes recommended
The new command/target parsing has edge cases that can still trigger on non-commit commands and can mis-handle --git-dir/$HOME under set -u, risking incorrect hook behavior.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR hardens the .claude PreToolUse pre-commit hook so it only runs for real git commit invocations and runs pre-commit in the correct working tree (including linked worktrees), avoiding accidental changes in the shared primary checkout.
Changes:
- Parse the hook payload (
jq) to extract the executed command and reportedcwd, and resolve the actual git top-level directory to run hooks in. - Add an in-script “commit-only” filter (to not rely on Claude Code’s settings-level matcher).
- Improve failure output to include the directory where
pre-commitran.
File summaries
| File | Description |
|---|---|
.claude/hooks/pre-commit-check.sh |
Parse payload, detect commits, resolve correct repo root/worktree, and run pre-commit there |
Review details
- Files reviewed: 1/1 changed files
- Comments generated: 3
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Three cases from review, all of which could send the hook somewhere it should not go.
A quoted argument is not a command line. Anchoring on `;`, `&` and `|` still matched a body that
quoted a whole command, so `gh issue create --body "cd repo && git commit"` paid for a full run. The
decision is now made against the command with quoted spans blanked out, while the path extraction
still reads the original, where a quoted path is real. A heredoc body is not quoted this way and
still costs a run, which is a slow hook rather than a wrong one.
`--git-dir` names the git directory, not the working tree, and for a linked worktree it points inside
the primary checkout's `.git`, where `rev-parse --show-toplevel` fails and the hook would quietly
fall back to the project. It is no longer consulted; `--work-tree` is read instead, which does name a
tree.
Expanding `~` through `${HOME}` would abort under `set -u` if HOME were unset, and aborting is the
one thing this hook must not do. It is guarded, and falls through to the payload's working directory.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LtMZ49GH6LaiY5MdgtfNe2
Signed-off-by: F.N. Claessen <claessen@seita.nl>
There was a problem hiding this comment.
🟡 Changes recommended
The current -C/--work-tree extraction truncates quoted paths containing spaces, which can still cause pre-commit to run in the wrong checkout (undermining the PR’s main goal).
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (2)
Previously missed (1) — in code that hasn't changed since the last review.
.claude/hooks/pre-commit-check.sh:21
- Several newly added comment lines break mid-phrase (e.g. ending with “such as”, “a”, “the”), which violates the repo rule that comments/docstrings should only line-break after punctuation. Please reflow this header block so each physical line ends with punctuation (comma/semicolon/colon/period).
This issue also appears on line 32 of the same file.
.claude/hooks/pre-commit-check.sh:36
- The new comment blocks elsewhere in this file also break lines mid-phrase (e.g. around the
commit_reexplanation and target-selection explanation). To follow the repo’s comment/docstring line-break rule, please reflow those blocks too so every line ends after punctuation.
# Self-filter: only act on `git commit`. Global options such as `-C <path>` or
# `-c foo=bar` may sit between `git` and the verb, so allow a run of option
# words (each optionally followed by its value) first. The verb has to be
# followed by whitespace or the end of the command, so that `git commit-graph`
# is left alone, and `git` has to start the command or follow a `;`, `&` or `|`,
- Files reviewed: 1/1 changed files
- Comments generated: 1
- Review effort level: Lite
The value of `-C` or `--work-tree` was matched up to the first space, so a quoted path containing one was cut short. The truncated path is not a directory, `rev-parse` fails on it, and the hook falls back to the project — running the hooks in the shared primary checkout, which is what this is all meant to avoid. The `cd` branch already read to the next separator, so only the flags were affected. The value of a flag is now either a quoted run or an unquoted word, read by one helper for both flags. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LtMZ49GH6LaiY5MdgtfNe2 Signed-off-by: F.N. Claessen <claessen@seita.nl>
There was a problem hiding this comment.
🟡 Changes recommended
The current $-path handling can still cause pre-commit to run in the wrong checkout (reintroducing the original shared-primary side effects) instead of failing open when the target path is unresolvable.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 1/1 changed files
- Comments generated: 2
- Review effort level: Lite
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Felix Claessen <30658763+Flix6x@users.noreply.github.com>
A path built from a shell variable set in an earlier Bash call cannot be resolved here, and neither can one that turns out not to be a checkout. Both used to fall through to the payload's working directory and then to the project, so a commit aimed at a worktree could still have the hooks run in the shared primary checkout, rewriting files in it. That is the bug this whole change is about, reached from the other end. The hook now records whether the command pointed anywhere at all. If it did, and that path does not resolve to a checkout, the check is skipped rather than aimed elsewhere: an unchecked commit is a smaller price than hooks run against the wrong tree. When nothing was named, the payload's working directory and then the project remain the best guesses, as before. Also reflow the header comment to break only after punctuation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LtMZ49GH6LaiY5MdgtfNe2 Signed-off-by: F.N. Claessen <claessen@seita.nl>
Keeps the reflowed item 2 from 790c55e, which reads better than mine did: my split broke after "the payload's `cwd`", mid-phrase, where the convention prefers a long clause on one line. Item 1 and the comments further down had the same fault, so they follow the same style now — one clause per line, each ending at punctuation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LtMZ49GH6LaiY5MdgtfNe2 Signed-off-by: F.N. Claessen <claessen@seita.nl>
There was a problem hiding this comment.
🟡 Changes recommended
The current parsing still has concrete edge cases that can reintroduce false positives or unintended skipping due to quoting/flag extraction behavior.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
.claude/hooks/pre-commit-check.sh:56
value_afteruses a greedysedpattern (.*$1...) which selects the last occurrence of a flag in the whole command line. Combined with scanning$commandfor-C/--work-tree/cd, this can misinterpret flags appearing later in the command (or in unrelated segments) and then skip checks unexpectedly. Prefer (1) detecting these tokens on the unquoted command and within the samegit … commitsegment, and (2) extracting the first match of the flag/value pair.
value_after() {
echo "$command" | sed -nE "s/.*$1(\"[^\"]*\"|'[^']*'|[^[:space:];&|]+).*/\1/p" | head -n1
}
- Files reviewed: 1/1 changed files
- Comments generated: 1
- Review effort level: Lite
Four rounds of review, four different strings that fooled the regular expressions: a quoted body holding `&& git commit`, an escaped quote inside such a body, a path with a space in it, and a second `-C` further along the line. Each fix was correct and the next case was found anyway, which says the approach was wrong rather than the patterns. The command is now tokenised with `shlex`, in pre_commit_target.py, which reads quoting and escaping the way a shell reads them. Quoted text arrives as a single token, so it cannot look like a command, and options are read from the tokens of the git invocation itself rather than from anywhere in the line. That removes the whole class of mistake rather than the four known members of it. The hook keeps its policy: run the hooks in the tree the commit targets, and run none at all when that tree cannot be pinned down. The shell script is down to reading the payload, asking where to go, and going there. Covered by 15 cases, including every string from the four rounds: escaped quotes, quoted separators, a `-C` inside a commit message, variable paths, non-checkouts, quoted paths with spaces, `--work-tree`, successive `cd`s, `git -c foo=bar commit`, `git commit-graph`, and an unbalanced quote. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LtMZ49GH6LaiY5MdgtfNe2 Signed-off-by: F.N. Claessen <claessen@seita.nl>
There was a problem hiding this comment.
🟡 Changes recommended
New/updated docstrings in pre_commit_target.py violate the repo’s “wrap only after punctuation” convention and should be reflowed for consistency.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
.claude/hooks/pre_commit_target.py:80
- The
target_directorydocstring wraps mid-phrase (e.g. line breaks after "process" and "a"), which breaks the repo convention to wrap docstrings/comments only after punctuation. Reflow so each physical line ends with punctuation.
"""The directory whose hooks should run for this command, or None to run none.
None means either that the command does not commit, or that it points at a tree this process
cannot resolve: a path built from a shell variable set in an earlier call, or one that is not a
checkout. Running the hooks somewhere else would check the wrong files, and any hook that
- Files reviewed: 2/2 changed files
- Comments generated: 1
- Review effort level: Lite
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LtMZ49GH6LaiY5MdgtfNe2 Signed-off-by: F.N. Claessen <claessen@seita.nl>
|
Round 5 found no code findings — only the docstring line-break convention, which is the repo's documented rule rather than a matter of taste, so it is applied in 68dec1a. Every prose line in That is where I am stopping the review loop. Five rounds, and the shape of them is the useful part of the record:
Rounds 1 to 4 were all correct, all distinct, and all landed on the same failure: the hooks running in the shared primary checkout, which is the bug this PR exists to fix. Four different strings fooling four generations of regular expression is what prompted 5ad5e91, which tokenises the command with |
There was a problem hiding this comment.
🟡 Changes recommended
The new target-resolution logic can still pick the wrong checkout for commands combining cd ... with relative git -C/--work-tree (and for multi-cd sequences), which can reintroduce running hooks in the primary checkout.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 2/2 changed files
- Comments generated: 1
- Review effort level: Lite
A relative path is measured from wherever the last `cd` landed, and `cd`s compose, neither of which this parser did. `cd /tmp && git -C repo commit` resolved as `<payload cwd>/repo`, and `cd /a && cd b` kept only `b`. Both could put the hooks back in the primary checkout, which is the failure this whole change is about. A running base now moves with each `cd`, as the shell's own directory would, and `-C` or `--work-tree` is measured from it. `~` is expanded, because the shell would have expanded it before git saw it; a `$VAR` is left alone and simply will not be a checkout, so the check is skipped rather than aimed at a guess. The project directory is only reached when the command named no path at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LtMZ49GH6LaiY5MdgtfNe2 Signed-off-by: F.N. Claessen <claessen@seita.nl>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LtMZ49GH6LaiY5MdgtfNe2 Signed-off-by: F.N. Claessen <claessen@seita.nl>
There was a problem hiding this comment.
🟢 Approval recommended
The changes are narrowly scoped to hook behavior, the parsing approach is robust (token-based rather than regex), and the fail-open behavior avoids the prior risk of running hooks in the wrong checkout.
Review details
- Files reviewed: 2/2 changed files
- Comments generated: 0 new
- Review effort level: Lite
Description
.claude/hooks/pre-commit-check.shrunspre-commitbefore agit commitand blocks the commit ifit fails. It got two things wrong, both because a PreToolUse hook runs in the project directory and
does not share the Bash tool's shell state.
It ran in the wrong working tree. The script never read its payload; it ran wherever the hook
process happened to start, which is always the primary checkout. Committing from a
git worktreetherefore checked the wrong tree — and worse, a hook that rewrites files rewrote them over there. In
the case that prompted this, regenerating the OpenAPI spec left a stray
versionchange in theprimary checkout while another agent had unrelated uncommitted work in it.
It now works out which tree the command targets, in the same priority order
worktree-guard.shalready uses for the same shared checkout: an explicit
-C <path>(or--git-dir=), else acdearlier in the same command, else the working directory from the payload, else the project directory.
Whatever it lands on is resolved with
git rev-parse --show-toplevel, so a linked worktree resolvesto its own root.
Reading the path out of the command text is the part that matters in practice. The shell's working
directory is reset between Bash calls, so
cd <worktree> && git commit ...reports the primarycheckout in the payload's
cwd; only the command text says where the commit is really going.It ran on commands that were not commits. It is registered with
"if": "Bash(git commit:*)", butthat settings-level matcher is not honoured by every Claude Code version —
worktree-guard.shsaysas much in a comment and repeats its filter in the script. This one did not, so an unrelated command
paid for a full
pre-commit run --all-files: it fired on agh issue create, which is how the strayspec change happened in the first place.
The filter now lives in the script too, anchored so that
git commit-graphand a command that merelyquotes
git commitin an issue or PR body are left alone. The cost of anchoring is that an oddlywrapped commit (
time git commit ...) goes unchecked, which is the safe direction for a hook thatonly checks.
How to test
The hook is not covered by the test suite, so it was exercised by feeding it payloads directly. With
a deliberate lint error planted in the target worktree, the failure message names the directory the
hooks ran in:
cd <worktree> && git commit,cwd= primary checkoutpre-commit failed in .../fm-wt-hooksgit -C <worktree> commitgit commit,cwd= worktreegit commit,cwd=/tmp(not a repo)$CLAUDE_PROJECT_DIRgit -c user.name=x commitgit commit-graph writegh issue create --body "run git commit -s to sign off"git commiton a clean worktreeAfterwards the primary checkout was confirmed to hold only the other agent's own uncommitted work,
with no spec change left behind.
Related items
Fallout from the review of #2472, where the hook fired on a
gh issue createand regeneratedflexmeasures/ui/static/openapi-specs.jsonin the shared primary checkout.Sign-off
🤖 Generated with Claude Code
https://claude.ai/code/session_01LtMZ49GH6LaiY5MdgtfNe2