Skip to content

Run the pre-commit hook against the tree being committed, and only for commits - #2477

Merged
Flix6x merged 10 commits into
mainfrom
chore/pre-commit-hook-worktree-and-filter
Sep 4, 2026
Merged

Run the pre-commit hook against the tree being committed, and only for commits#2477
Flix6x merged 10 commits into
mainfrom
chore/pre-commit-hook-worktree-and-filter

Conversation

@Flix6x

@Flix6x Flix6x commented Sep 4, 2026

Copy link
Copy Markdown
Member

Description

.claude/hooks/pre-commit-check.sh runs pre-commit before a git commit and blocks the commit if
it 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 worktree
therefore 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 version change in the
primary 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.sh
already uses for the same shared checkout: an explicit -C <path> (or --git-dir=), else a cd
earlier 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 resolves
to 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 primary
checkout 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:*)", but
that settings-level matcher is not honoured by every Claude Code version — worktree-guard.sh says
as 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 a gh issue create, which is how the stray
spec change happened in the first place.

The filter now lives in the script too, anchored so that git commit-graph and a command that merely
quotes git commit in an issue or PR body are left alone. The cost of anchoring is that an oddly
wrapped commit (time git commit ...) goes unchecked, which is the safe direction for a hook that
only 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:

Payload Expected Result
cd <worktree> && git commit, cwd = primary checkout runs in the worktree pre-commit failed in .../fm-wt-hooks
git -C <worktree> commit runs in the worktree ✅ same
git commit, cwd = worktree runs in the worktree ✅ same
git commit, cwd = /tmp (not a repo) falls back to the project dir ✅ ran in $CLAUDE_PROJECT_DIR
git -c user.name=x commit still recognised as a commit ✅ ran
git commit-graph write skipped ✅ exit 0
gh issue create --body "run git commit -s to sign off" skipped ✅ exit 0, 8 ms instead of ~40 s
git commit on a clean worktree allowed ✅ exit 0

Afterwards 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 create and regenerated
flexmeasures/ui/static/openapi-specs.json in the shared primary checkout.


Sign-off

  • I agree to contribute to the project under Apache 2 License.
  • To the best of my knowledge, the proposed patch is not based on code under GPL or another incompatible license.

🤖 Generated with Claude Code

https://claude.ai/code/session_01LtMZ49GH6LaiY5MdgtfNe2

…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>
@read-the-docs-community

read-the-docs-community Bot commented Sep 4, 2026

Copy link
Copy Markdown

Documentation build overview

📚 flexmeasures | 🛠️ Build #34390969 | 📁 Comparing 753a006 against latest (48705b5)

  🔍 Preview build  

1 file changed
± api/v3_0.html

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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 reported cwd, 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-commit ran.
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.

Comment thread .claude/hooks/pre-commit-check.sh Outdated
Comment thread .claude/hooks/pre-commit-check.sh Outdated
Comment thread .claude/hooks/pre-commit-check.sh Outdated
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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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_re explanation 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

Comment thread .claude/hooks/pre-commit-check.sh Outdated
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>
@Flix6x
Flix6x requested a lite review from Copilot September 4, 2026 10:27
@Flix6x Flix6x self-assigned this Sep 4, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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

Comment thread .claude/hooks/pre-commit-check.sh Outdated
Comment thread .claude/hooks/pre-commit-check.sh Outdated
Flix6x and others added 3 commits September 4, 2026 12:38
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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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_after uses a greedy sed pattern (.*$1...) which selects the last occurrence of a flag in the whole command line. Combined with scanning $command for -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 same git … commit segment, 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

Comment thread .claude/hooks/pre-commit-check.sh Outdated
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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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_directory docstring 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

Comment thread .claude/hooks/pre_commit_target.py
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>
@Flix6x

Flix6x commented Sep 4, 2026

Copy link
Copy Markdown
Member Author

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 pre_commit_target.py now ends at punctuation, checked mechanically over the file rather than line by line, and the 12 parsing cases still pass.

That is where I am stopping the review loop. Five rounds, and the shape of them is the useful part of the record:

  • Round 1 — three findings: a quoted body could still look like a command, --git-dir names a git directory rather than a working tree, and ${HOME} could abort the hook under set -u.
  • Round 2 — a quoted path containing a space was truncated at the space.
  • Round 3 — a path the hook could not resolve fell through to a guess, instead of skipping.
  • Round 4 — an escaped quote inside a quoted body, and greedy extraction picking the last -C in the line.
  • Round 5 — docstring reflow only.

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 shlex instead of matching it, and that is the change I would want reviewed by a person rather than iterated on further.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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

Comment thread .claude/hooks/pre_commit_target.py
Flix6x and others added 2 commits September 4, 2026 13:19
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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 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

@Flix6x
Flix6x merged commit e672298 into main Sep 4, 2026
14 checks passed
@Flix6x
Flix6x deleted the chore/pre-commit-hook-worktree-and-filter branch September 4, 2026 11:35
@Flix6x Flix6x added this to the 1.1.0 milestone Sep 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants