From 15df9d627970202060747b3a256afccff234517b Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 4 Sep 2026 11:54:29 +0200 Subject: [PATCH 1/9] Run the pre-commit hook against the tree being committed, and only for commits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `, 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 && 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 Claude-Session: https://claude.ai/code/session_01LtMZ49GH6LaiY5MdgtfNe2 Signed-off-by: F.N. Claessen --- .claude/hooks/pre-commit-check.sh | 85 ++++++++++++++++++++++++++++--- 1 file changed, 79 insertions(+), 6 deletions(-) diff --git a/.claude/hooks/pre-commit-check.sh b/.claude/hooks/pre-commit-check.sh index dc9f083ae8..0abfd3bf33 100755 --- a/.claude/hooks/pre-commit-check.sh +++ b/.claude/hooks/pre-commit-check.sh @@ -2,12 +2,85 @@ # Blocking PreToolUse hook: runs pre-commit before any `git commit` and blocks # the commit if hooks fail or modify files. Exit 2 = blocking error (Claude # sees stderr and can fix before retrying). Exit 0 = allow. +# +# Two things this hook has to work out for itself, because a PreToolUse hook +# runs in the project dir and does not share the Bash tool's shell state. +# +# 1. Whether the command is a commit at all. The settings-level "if" matcher +# is not honoured by all Claude Code versions, so the filter is repeated +# here, as worktree-guard.sh does. Without it, an unrelated command such as +# `gh issue create` pays for a full `pre-commit run --all-files`. +# 2. Which working tree the commit targets. The primary checkout is shared by +# several agents, so running the hooks there when the commit is really for a +# worktree checks the wrong tree, and any hook that rewrites a file (such as +# the OpenAPI spec) dirties another agent's work. The command text is the +# most reliable signal, since `cd && git commit` leaves no trace +# in the payload's `cwd` once the shell's directory has been reset. +# +# Fails open (exit 0) on any parsing problem — never block a commit because the +# hook could not read its own payload. set -euo pipefail -if ! output=$(uv run pre-commit run --all-files 2>&1); then - echo "pre-commit failed — fix the issues below before committing:" >&2 - echo "$output" >&2 - exit 2 - fi - exit 0 +payload="$(cat)" + +command -v jq >/dev/null 2>&1 || exit 0 + +command="$(echo "$payload" | jq -r '.tool_input.command // empty' 2>/dev/null || true)" +[ -n "$command" ] || exit 0 + +# Self-filter: only act on `git commit`. Global options such as `-C ` 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 `|`, +# so that a command merely *mentioning* a commit — an issue or PR body quoting +# `git commit -s`, say — does not pay for a full run. The cost of that anchoring +# is that an oddly wrapped commit (`time git commit ...`) goes unchecked, which +# is the safe direction for a hook that only checks. +commit_re='(^|[;&|])[[:space:]]*git[[:space:]]+(-[^[:space:]]*([[:space:]]+[^-[:space:];&|][^[:space:]]*)?[[:space:]]+)*commit([[:space:]]|$)' +echo "$command" | grep -qE "$commit_re" || exit 0 + +project_dir="${CLAUDE_PROJECT_DIR:-$(pwd)}" + +# Which working tree does this commit target? In priority order: an explicit +# `-C ` / `--git-dir=` on the git invocation, else a `cd` earlier in +# the same command, else the Bash tool's own working directory from the payload, +# else the project dir. Same derivation as worktree-guard.sh, which guards the +# same shared checkout. +target="" +if echo "$command" | grep -qE '[[:space:]]-C[[:space:]]'; then + target="$(echo "$command" | grep -oE '[[:space:]]-C[[:space:]]+[^[:space:];&|]+' | head -n1 | sed -E 's/^[[:space:]]*-C[[:space:]]+//')" +elif echo "$command" | grep -qE '\-\-git-dir[=[:space:]]'; then + target="$(echo "$command" | grep -oE '\-\-git-dir[=[:space:]][^[:space:];&|]+' | head -n1 | sed -E 's/^--git-dir[=[:space:]]//')" +elif echo "$command" | grep -qE '(^|[;&|])[[:space:]]*cd[[:space:]]+'; then + target="$(echo "$command" | grep -oE '(^|[;&|])[[:space:]]*cd[[:space:]]+[^;&|]+' | tail -n1 | sed -E 's/^[;&|]?[[:space:]]*cd[[:space:]]+//')" +fi + +# Strip surrounding quotes and whitespace, and expand a leading `~`. +target="$(echo "$target" | sed -E "s/^[[:space:]]*['\"]?//; s/['\"]?[[:space:]]*$//")" +case "$target" in + "~"|"~/"*) target="${HOME}${target#\~}" ;; + *'$'*) target="" ;; # set in an earlier Bash call, whose state this hook cannot see +esac + +# Fall back to the working directory the Bash tool reports, then to the project. +if [ -z "$target" ]; then + target="$(echo "$payload" | jq -r '.cwd // empty' 2>/dev/null || true)" +fi +[ -n "$target" ] || target="$project_dir" + +# Resolve to the root of whichever checkout that is, so the hooks see the whole +# working tree. A linked worktree resolves to its own root, not the primary one. +if root="$(git -C "$target" rev-parse --show-toplevel 2>/dev/null)"; then + target="$root" +else + target="$project_dir" +fi + +if ! output=$(cd "$target" && uv run pre-commit run --all-files 2>&1); then + echo "pre-commit failed in $target — fix the issues below before committing:" >&2 + echo "$output" >&2 + exit 2 +fi +exit 0 From a3604a1f07523d1ba54333615e15441fc0136061 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 4 Sep 2026 12:17:18 +0200 Subject: [PATCH 2/9] Harden the commit hook's command and path parsing 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 Claude-Session: https://claude.ai/code/session_01LtMZ49GH6LaiY5MdgtfNe2 Signed-off-by: F.N. Claessen --- .claude/hooks/pre-commit-check.sh | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/.claude/hooks/pre-commit-check.sh b/.claude/hooks/pre-commit-check.sh index 0abfd3bf33..593dbb9be7 100755 --- a/.claude/hooks/pre-commit-check.sh +++ b/.claude/hooks/pre-commit-check.sh @@ -39,20 +39,28 @@ command="$(echo "$payload" | jq -r '.tool_input.command // empty' 2>/dev/null || # is that an oddly wrapped commit (`time git commit ...`) goes unchecked, which # is the safe direction for a hook that only checks. commit_re='(^|[;&|])[[:space:]]*git[[:space:]]+(-[^[:space:]]*([[:space:]]+[^-[:space:];&|][^[:space:]]*)?[[:space:]]+)*commit([[:space:]]|$)' -echo "$command" | grep -qE "$commit_re" || exit 0 +# Quoted text is not a command: an issue or PR body can quote a whole command +# line, separators and all. Blank quoted spans out before deciding, but keep the +# original command for the path extraction below, where a quoted path is real. +# A heredoc body is not quoted this way, so one containing a commit line still +# costs a run; the failure mode is a slow hook, not a wrong one. +unquoted="$(echo "$command" | sed -E "s/\"[^\"]*\"//g; s/'[^']*'//g")" +echo "$unquoted" | grep -qE "$commit_re" || exit 0 project_dir="${CLAUDE_PROJECT_DIR:-$(pwd)}" # Which working tree does this commit target? In priority order: an explicit -# `-C ` / `--git-dir=` on the git invocation, else a `cd` earlier in -# the same command, else the Bash tool's own working directory from the payload, -# else the project dir. Same derivation as worktree-guard.sh, which guards the -# same shared checkout. +# `-C ` / `--work-tree=` on the git invocation, else a `cd` earlier +# in the same command, else the Bash tool's own working directory from the +# payload, else the project dir. `--git-dir` is deliberately not consulted: it +# names the git directory rather than the working tree, and for a linked +# worktree it points inside the primary checkout's `.git`, which is not a tree +# to run hooks in. target="" if echo "$command" | grep -qE '[[:space:]]-C[[:space:]]'; then target="$(echo "$command" | grep -oE '[[:space:]]-C[[:space:]]+[^[:space:];&|]+' | head -n1 | sed -E 's/^[[:space:]]*-C[[:space:]]+//')" -elif echo "$command" | grep -qE '\-\-git-dir[=[:space:]]'; then - target="$(echo "$command" | grep -oE '\-\-git-dir[=[:space:]][^[:space:];&|]+' | head -n1 | sed -E 's/^--git-dir[=[:space:]]//')" +elif echo "$command" | grep -qE '\-\-work-tree[=[:space:]]'; then + target="$(echo "$command" | grep -oE '\-\-work-tree[=[:space:]][^[:space:];&|]+' | head -n1 | sed -E 's/^--work-tree[=[:space:]]//')" elif echo "$command" | grep -qE '(^|[;&|])[[:space:]]*cd[[:space:]]+'; then target="$(echo "$command" | grep -oE '(^|[;&|])[[:space:]]*cd[[:space:]]+[^;&|]+' | tail -n1 | sed -E 's/^[;&|]?[[:space:]]*cd[[:space:]]+//')" fi @@ -60,7 +68,11 @@ fi # Strip surrounding quotes and whitespace, and expand a leading `~`. target="$(echo "$target" | sed -E "s/^[[:space:]]*['\"]?//; s/['\"]?[[:space:]]*$//")" case "$target" in - "~"|"~/"*) target="${HOME}${target#\~}" ;; + "~"|"~/"*) + # Guard HOME: `set -u` would abort on an unset one, and aborting is the one + # thing this hook must not do. + if [ -n "${HOME:-}" ]; then target="${HOME}${target#\~}"; else target=""; fi + ;; *'$'*) target="" ;; # set in an earlier Bash call, whose state this hook cannot see esac From 99c0e6747110496a4e457d37fb9a656708dd5377 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 4 Sep 2026 12:27:05 +0200 Subject: [PATCH 3/9] Read a quoted path whole when it names the tree to check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01LtMZ49GH6LaiY5MdgtfNe2 Signed-off-by: F.N. Claessen --- .claude/hooks/pre-commit-check.sh | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.claude/hooks/pre-commit-check.sh b/.claude/hooks/pre-commit-check.sh index 593dbb9be7..a0082a2260 100755 --- a/.claude/hooks/pre-commit-check.sh +++ b/.claude/hooks/pre-commit-check.sh @@ -56,11 +56,17 @@ project_dir="${CLAUDE_PROJECT_DIR:-$(pwd)}" # names the git directory rather than the working tree, and for a linked # worktree it points inside the primary checkout's `.git`, which is not a tree # to run hooks in. +# A path may be quoted, and a quoted path may contain spaces, so the value of a +# flag is either a quoted run or an unquoted word. +value_after() { + echo "$command" | sed -nE "s/.*$1(\"[^\"]*\"|'[^']*'|[^[:space:];&|]+).*/\1/p" | head -n1 +} + target="" if echo "$command" | grep -qE '[[:space:]]-C[[:space:]]'; then - target="$(echo "$command" | grep -oE '[[:space:]]-C[[:space:]]+[^[:space:];&|]+' | head -n1 | sed -E 's/^[[:space:]]*-C[[:space:]]+//')" + target="$(value_after '[[:space:]]-C[[:space:]]+')" elif echo "$command" | grep -qE '\-\-work-tree[=[:space:]]'; then - target="$(echo "$command" | grep -oE '\-\-work-tree[=[:space:]][^[:space:];&|]+' | head -n1 | sed -E 's/^--work-tree[=[:space:]]//')" + target="$(value_after '\-\-work-tree[=[:space:]]+')" elif echo "$command" | grep -qE '(^|[;&|])[[:space:]]*cd[[:space:]]+'; then target="$(echo "$command" | grep -oE '(^|[;&|])[[:space:]]*cd[[:space:]]+[^;&|]+' | tail -n1 | sed -E 's/^[;&|]?[[:space:]]*cd[[:space:]]+//')" fi From 790c55e72f2db237792302bc6e90aa2bebc99bad Mon Sep 17 00:00:00 2001 From: Felix Claessen <30658763+Flix6x@users.noreply.github.com> Date: Fri, 4 Sep 2026 12:38:39 +0200 Subject: [PATCH 4/9] Reflow comment in pre-commit-check.sh 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> --- .claude/hooks/pre-commit-check.sh | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/.claude/hooks/pre-commit-check.sh b/.claude/hooks/pre-commit-check.sh index a0082a2260..193ec4f42a 100755 --- a/.claude/hooks/pre-commit-check.sh +++ b/.claude/hooks/pre-commit-check.sh @@ -10,12 +10,10 @@ # is not honoured by all Claude Code versions, so the filter is repeated # here, as worktree-guard.sh does. Without it, an unrelated command such as # `gh issue create` pays for a full `pre-commit run --all-files`. -# 2. Which working tree the commit targets. The primary checkout is shared by -# several agents, so running the hooks there when the commit is really for a -# worktree checks the wrong tree, and any hook that rewrites a file (such as -# the OpenAPI spec) dirties another agent's work. The command text is the -# most reliable signal, since `cd && git commit` leaves no trace -# in the payload's `cwd` once the shell's directory has been reset. +# 2. Which working tree the commit targets. The primary checkout is shared by several agents. +# Running the hooks there when the commit is really for a worktree checks the wrong tree, +# and any hook that rewrites a file (such as the OpenAPI spec) dirties another agent's work. +# The command text is the most reliable signal, since `cd && git commit` leaves no trace in the payload's `cwd` once the shell's directory has been reset. # # Fails open (exit 0) on any parsing problem — never block a commit because the # hook could not read its own payload. From 61d99dbf876ca89ba82f8bbded41b355f38d4068 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 4 Sep 2026 12:49:56 +0200 Subject: [PATCH 5/9] Skip the check when the commit names a tree the hook cannot resolve 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 Claude-Session: https://claude.ai/code/session_01LtMZ49GH6LaiY5MdgtfNe2 Signed-off-by: F.N. Claessen --- .claude/hooks/pre-commit-check.sh | 74 ++++++++++++++++++++----------- 1 file changed, 47 insertions(+), 27 deletions(-) diff --git a/.claude/hooks/pre-commit-check.sh b/.claude/hooks/pre-commit-check.sh index a0082a2260..6545ade85b 100755 --- a/.claude/hooks/pre-commit-check.sh +++ b/.claude/hooks/pre-commit-check.sh @@ -1,24 +1,28 @@ #!/usr/bin/env bash -# Blocking PreToolUse hook: runs pre-commit before any `git commit` and blocks -# the commit if hooks fail or modify files. Exit 2 = blocking error (Claude -# sees stderr and can fix before retrying). Exit 0 = allow. +# Blocking PreToolUse hook: runs pre-commit before any `git commit`, +# and blocks the commit if hooks fail or modify files. +# Exit 2 = blocking error (Claude sees stderr and can fix before retrying). +# Exit 0 = allow. # -# Two things this hook has to work out for itself, because a PreToolUse hook -# runs in the project dir and does not share the Bash tool's shell state. +# A PreToolUse hook runs in the project dir and does not share the Bash tool's shell state, +# so there are two things this hook has to work out for itself. # -# 1. Whether the command is a commit at all. The settings-level "if" matcher -# is not honoured by all Claude Code versions, so the filter is repeated -# here, as worktree-guard.sh does. Without it, an unrelated command such as -# `gh issue create` pays for a full `pre-commit run --all-files`. -# 2. Which working tree the commit targets. The primary checkout is shared by -# several agents, so running the hooks there when the commit is really for a -# worktree checks the wrong tree, and any hook that rewrites a file (such as -# the OpenAPI spec) dirties another agent's work. The command text is the -# most reliable signal, since `cd && git commit` leaves no trace -# in the payload's `cwd` once the shell's directory has been reset. +# 1. Whether the command is a commit at all. +# The settings-level "if" matcher is not honoured by all Claude Code versions, +# so the filter is repeated here, as worktree-guard.sh does. +# Without it, an unrelated command such as `gh issue create` pays for a full run of every hook. +# 2. Which working tree the commit targets. +# The primary checkout is shared by several agents, +# so running the hooks there when the commit is really for a worktree checks the wrong tree, +# and any hook that rewrites a file, such as the one regenerating the OpenAPI spec, +# dirties another agent's work. +# The command text is the most reliable signal here, +# since `cd && git commit` leaves no trace in the payload's `cwd` +# once the shell's directory has been reset. # -# Fails open (exit 0) on any parsing problem — never block a commit because the -# hook could not read its own payload. +# Fails open (exit 0) rather than blocking, on any parsing problem, +# and also when the command names a tree this hook cannot resolve: +# an unchecked commit is a smaller price than hooks run against the wrong checkout. set -euo pipefail @@ -62,12 +66,17 @@ value_after() { echo "$command" | sed -nE "s/.*$1(\"[^\"]*\"|'[^']*'|[^[:space:];&|]+).*/\1/p" | head -n1 } +# `named` records that the command pointed somewhere, resolvable or not. +named="" target="" if echo "$command" | grep -qE '[[:space:]]-C[[:space:]]'; then + named="yes" target="$(value_after '[[:space:]]-C[[:space:]]+')" elif echo "$command" | grep -qE '\-\-work-tree[=[:space:]]'; then + named="yes" target="$(value_after '\-\-work-tree[=[:space:]]+')" elif echo "$command" | grep -qE '(^|[;&|])[[:space:]]*cd[[:space:]]+'; then + named="yes" target="$(echo "$command" | grep -oE '(^|[;&|])[[:space:]]*cd[[:space:]]+[^;&|]+' | tail -n1 | sed -E 's/^[;&|]?[[:space:]]*cd[[:space:]]+//')" fi @@ -82,18 +91,29 @@ case "$target" in *'$'*) target="" ;; # set in an earlier Bash call, whose state this hook cannot see esac -# Fall back to the working directory the Bash tool reports, then to the project. -if [ -z "$target" ]; then - target="$(echo "$payload" | jq -r '.cwd // empty' 2>/dev/null || true)" -fi -[ -n "$target" ] || target="$project_dir" - -# Resolve to the root of whichever checkout that is, so the hooks see the whole -# working tree. A linked worktree resolves to its own root, not the primary one. -if root="$(git -C "$target" rev-parse --show-toplevel 2>/dev/null)"; then +# Resolve to the root of whichever checkout the target names, +# so the hooks see the whole working tree. +# A linked worktree resolves to its own root, rather than to the primary checkout. +if [ -n "$named" ]; then + # The command pointed somewhere. If that path cannot be resolved to a checkout, + # because it was built from a variable set in an earlier Bash call, + # or because it is not a checkout at all, then skip rather than guess: + # running the hooks in some other tree would check the wrong files, + # and any hook that rewrites one would rewrite it there. + if [ -z "$target" ] || ! root="$(git -C "$target" rev-parse --show-toplevel 2>/dev/null)"; then + exit 0 + fi target="$root" else - target="$project_dir" + # Nothing was named, so the Bash tool's own working directory is the best guess, + # and the project dir after that. + cwd="$(echo "$payload" | jq -r '.cwd // empty' 2>/dev/null || true)" + target="${cwd:-$project_dir}" + if root="$(git -C "$target" rev-parse --show-toplevel 2>/dev/null)"; then + target="$root" + else + target="$project_dir" + fi fi if ! output=$(cd "$target" && uv run pre-commit run --all-files 2>&1); then From 5ad5e91481f631d5e216247e0034dbf02aa9dd3a Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 4 Sep 2026 13:05:36 +0200 Subject: [PATCH 6/9] Tokenise the command instead of matching it 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 Claude-Session: https://claude.ai/code/session_01LtMZ49GH6LaiY5MdgtfNe2 Signed-off-by: F.N. Claessen --- .claude/hooks/pre-commit-check.sh | 96 +++---------------- .claude/hooks/pre_commit_target.py | 142 +++++++++++++++++++++++++++++ 2 files changed, 153 insertions(+), 85 deletions(-) create mode 100755 .claude/hooks/pre_commit_target.py diff --git a/.claude/hooks/pre-commit-check.sh b/.claude/hooks/pre-commit-check.sh index 7f7bfeb180..7ddb8e5350 100755 --- a/.claude/hooks/pre-commit-check.sh +++ b/.claude/hooks/pre-commit-check.sh @@ -15,99 +15,25 @@ # and any hook that rewrites a file (such as the OpenAPI spec) dirties another agent's work. # The command text is the most reliable signal, since `cd && git commit` leaves no trace in the payload's `cwd` once the shell's directory has been reset. # -# Fails open (exit 0) rather than blocking, on any parsing problem, -# and also when the command names a tree this hook cannot resolve: +# Both answers come from pre_commit_target.py, which tokenises the command rather than matching it, +# and prints the directory to run in, or nothing when there is nothing safe to run. +# See that file for why the parsing lives in Python. +# +# Fails open (exit 0) rather than blocking, whenever the answer is not clear: # an unchecked commit is a smaller price than hooks run against the wrong checkout. set -euo pipefail payload="$(cat)" -command -v jq >/dev/null 2>&1 || exit 0 - -command="$(echo "$payload" | jq -r '.tool_input.command // empty' 2>/dev/null || true)" -[ -n "$command" ] || exit 0 - -# Self-filter: only act on `git commit`. -# Global options such as `-C ` 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 `|`, so that a command merely *mentioning* a commit does not pay for a full run. -# The cost of that anchoring is that an oddly wrapped commit (`time git commit ...`) goes unchecked, which is the safe direction for a hook that only checks. -commit_re='(^|[;&|])[[:space:]]*git[[:space:]]+(-[^[:space:]]*([[:space:]]+[^-[:space:];&|][^[:space:]]*)?[[:space:]]+)*commit([[:space:]]|$)' -# Quoted text is not a command: an issue or PR body can quote a whole command line, separators and all. -# Blank quoted spans out before deciding, but keep the original command for the path extraction below, where a quoted path is real. -# A heredoc body is not quoted this way, so one containing a commit line still costs a run: a slow hook rather than a wrong one. -unquoted="$(echo "$command" | sed -E "s/\"[^\"]*\"//g; s/'[^']*'//g")" -echo "$unquoted" | grep -qE "$commit_re" || exit 0 - -project_dir="${CLAUDE_PROJECT_DIR:-$(pwd)}" - -# Which working tree does this commit target? -# In priority order: an explicit `-C ` or `--work-tree=` on the git invocation, -# else a `cd` earlier in the same command, -# else the Bash tool's own working directory from the payload, -# else the project dir. -# `--git-dir` is deliberately not consulted: it names the git directory rather than the working tree, -# and for a linked worktree it points inside the primary checkout's `.git`, which is not a tree to run hooks in. -# A path may be quoted, and a quoted path may contain spaces, -# so the value of a flag is either a quoted run or an unquoted word. -value_after() { - echo "$command" | sed -nE "s/.*$1(\"[^\"]*\"|'[^']*'|[^[:space:];&|]+).*/\1/p" | head -n1 -} +command -v python3 >/dev/null 2>&1 || exit 0 -# `named` records that the command pointed somewhere, resolvable or not. -named="" -target="" -if echo "$command" | grep -qE '[[:space:]]-C[[:space:]]'; then - named="yes" - target="$(value_after '[[:space:]]-C[[:space:]]+')" -elif echo "$command" | grep -qE '\-\-work-tree[=[:space:]]'; then - named="yes" - target="$(value_after '\-\-work-tree[=[:space:]]+')" -elif echo "$command" | grep -qE '(^|[;&|])[[:space:]]*cd[[:space:]]+'; then - named="yes" - target="$(echo "$command" | grep -oE '(^|[;&|])[[:space:]]*cd[[:space:]]+[^;&|]+' | tail -n1 | sed -E 's/^[;&|]?[[:space:]]*cd[[:space:]]+//')" -fi - -# Strip surrounding quotes and whitespace, and expand a leading `~`. -target="$(echo "$target" | sed -E "s/^[[:space:]]*['\"]?//; s/['\"]?[[:space:]]*$//")" -case "$target" in - "~"|"~/"*) - # Guard HOME: `set -u` would abort on an unset one, - # and aborting is the one thing this hook must not do. - if [ -n "${HOME:-}" ]; then target="${HOME}${target#\~}"; else target=""; fi - ;; - *'$'*) target="" ;; # set in an earlier Bash call, whose state this hook cannot see -esac - -# Resolve to the root of whichever checkout the target names, -# so the hooks see the whole working tree. -# A linked worktree resolves to its own root, rather than to the primary checkout. -if [ -n "$named" ]; then - # The command pointed somewhere. If that path cannot be resolved to a checkout, - # because it was built from a variable set in an earlier Bash call, - # or because it is not a checkout at all, then skip rather than guess: - # running the hooks in some other tree would check the wrong files, - # and any hook that rewrites one would rewrite it there. - if [ -z "$target" ] || ! root="$(git -C "$target" rev-parse --show-toplevel 2>/dev/null)"; then - exit 0 - fi - target="$root" -else - # Nothing was named, so the Bash tool's own working directory is the best guess, - # and the project dir after that. - cwd="$(echo "$payload" | jq -r '.cwd // empty' 2>/dev/null || true)" - target="${cwd:-$project_dir}" - if root="$(git -C "$target" rev-parse --show-toplevel 2>/dev/null)"; then - target="$root" - else - target="$project_dir" - fi -fi +target="$(printf '%s' "$payload" | python3 "$(dirname "${BASH_SOURCE[0]}")/pre_commit_target.py" 2>/dev/null || true)" +[ -n "$target" ] || exit 0 if ! output=$(cd "$target" && uv run pre-commit run --all-files 2>&1); then - echo "pre-commit failed in $target — fix the issues below before committing:" >&2 - echo "$output" >&2 - exit 2 + echo "pre-commit failed in $target — fix the issues below before committing:" >&2 + echo "$output" >&2 + exit 2 fi exit 0 diff --git a/.claude/hooks/pre_commit_target.py b/.claude/hooks/pre_commit_target.py new file mode 100755 index 0000000000..b2436c1456 --- /dev/null +++ b/.claude/hooks/pre_commit_target.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +"""Work out which working tree the `git commit` in a Bash command targets. + +Reads a Claude Code PreToolUse payload on stdin. +Prints the root of the tree to run the hooks in, and prints nothing at all when the command is not a +commit, or when it names a tree that cannot be resolved from here. + +The command is tokenised with `shlex`, so quoting and escaping are read the way a shell reads them. +Earlier versions of this hook matched the command with regular expressions, and each round of review +turned up another string that fooled them: a quoted body holding `&& git commit`, an escaped quote +inside such a body, a path with a space in it, a second `-C` later in the line. +Tokenising removes that whole class of mistake, because quoted text arrives as one token and cannot +look like a command. +""" + +from __future__ import annotations + +import json +import os +import shlex +import subprocess +import sys + +#: Tokens that end one command and start the next. +SEPARATORS = {"&&", "||", ";", "|", "&"} + +#: Git's own options which take their value as the following token. +#: A `--flag=value` spelling carries its value with it, so it needs no entry here. +VALUE_OPTIONS = { + "-C", + "-c", + "--git-dir", + "--work-tree", + "--namespace", + "--super-prefix", + "--config-env", + "--exec-path", +} + + +def segments(tokens: list[str]) -> list[list[str]]: + """Split a token list into the separate commands it holds.""" + found: list[list[str]] = [[]] + for token in tokens: + if token in SEPARATORS: + found.append([]) + else: + found[-1].append(token) + return found + + +def _verb_and_options(tokens: list[str]) -> tuple[str | None, dict[str, str]]: + """The git subcommand a segment invokes, and the values of git's own options before it. + + Returns `(None, {})` for anything that is not a git invocation. + """ + if not tokens or os.path.basename(tokens[0]) != "git": + return None, {} + options: dict[str, str] = {} + index = 1 + while index < len(tokens): + token = tokens[index] + if not token.startswith("-"): + return token, options + name, _, inline_value = token.partition("=") + if inline_value: + options[name] = inline_value + elif name in VALUE_OPTIONS and index + 1 < len(tokens): + index += 1 + options[name] = tokens[index] + index += 1 + return None, options + + +def target_directory(command: str, cwd: str) -> str | None: + """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 + rewrites one would rewrite it there, so nothing is the right answer. + """ + try: + tokens = shlex.split(command) + except ValueError: + return None # unbalanced quotes: not something to guess at + + named: str | None = None + latest_cd: str | None = None + committing = False + for segment in segments(tokens): + if segment and segment[0] == "cd" and len(segment) > 1: + latest_cd = segment[1] + continue + verb, options = _verb_and_options(segment) + if verb == "commit": + committing = True + # `--git-dir` is deliberately ignored: it names the git directory rather than the + # working tree, and for a linked worktree it points inside the primary checkout. + named = options.get("-C") or options.get("--work-tree") + break + if not committing: + return None + + if named or latest_cd: + return _toplevel(os.path.join(cwd, named or latest_cd or "")) + # Nothing was named, so the Bash tool's own working directory is the best guess, + # and the project directory after that. + return _toplevel(cwd) or os.environ.get("CLAUDE_PROJECT_DIR") or None + + +def _toplevel(path: str) -> str | None: + """The root of the checkout containing `path`, or None if it is not in one.""" + try: + finished = subprocess.run( + ["git", "-C", path, "rev-parse", "--show-toplevel"], + capture_output=True, + text=True, + timeout=10, + ) + except (OSError, subprocess.SubprocessError): + return None + if finished.returncode != 0: + return None + return finished.stdout.strip() or None + + +def main() -> int: + try: + payload = json.load(sys.stdin) + except (ValueError, OSError): + return 0 # never block a commit over a payload this hook cannot read + command = (payload.get("tool_input") or {}).get("command") or "" + cwd = payload.get("cwd") or os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd() + target = target_directory(command, cwd) + if target: + print(target) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 68dec1a264ed31e8e5abb28328a4ac30eaeb4ab0 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 4 Sep 2026 13:12:20 +0200 Subject: [PATCH 7/9] docs: reflow the parser's docstrings to break after punctuation Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LtMZ49GH6LaiY5MdgtfNe2 Signed-off-by: F.N. Claessen --- .claude/hooks/pre_commit_target.py | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/.claude/hooks/pre_commit_target.py b/.claude/hooks/pre_commit_target.py index b2436c1456..92e93d146d 100755 --- a/.claude/hooks/pre_commit_target.py +++ b/.claude/hooks/pre_commit_target.py @@ -2,15 +2,17 @@ """Work out which working tree the `git commit` in a Bash command targets. Reads a Claude Code PreToolUse payload on stdin. -Prints the root of the tree to run the hooks in, and prints nothing at all when the command is not a -commit, or when it names a tree that cannot be resolved from here. +Prints the root of the tree to run the hooks in, +and prints nothing at all when the command is not a commit, +or when it names a tree that cannot be resolved from here. The command is tokenised with `shlex`, so quoting and escaping are read the way a shell reads them. -Earlier versions of this hook matched the command with regular expressions, and each round of review -turned up another string that fooled them: a quoted body holding `&& git commit`, an escaped quote -inside such a body, a path with a space in it, a second `-C` later in the line. -Tokenising removes that whole class of mistake, because quoted text arrives as one token and cannot -look like a command. +Earlier versions of this hook matched the command with regular expressions, +and each round of review turned up another string that fooled them: +a quoted body holding `&& git commit`, an escaped quote inside such a body, +a path with a space in it, a second `-C` later in the line. +Tokenising removes that whole class of mistake, +because quoted text arrives as one token and cannot look like a command. """ from __future__ import annotations @@ -75,10 +77,11 @@ def _verb_and_options(tokens: list[str]) -> tuple[str | None, dict[str, str]]: def target_directory(command: str, cwd: str) -> str | None: """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 - rewrites one would rewrite it there, so nothing is the right answer. + 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 rewrites one would rewrite it there, so nothing is the right answer. """ try: tokens = shlex.split(command) @@ -95,8 +98,8 @@ def target_directory(command: str, cwd: str) -> str | None: verb, options = _verb_and_options(segment) if verb == "commit": committing = True - # `--git-dir` is deliberately ignored: it names the git directory rather than the - # working tree, and for a linked worktree it points inside the primary checkout. + # `--git-dir` is deliberately ignored: it names the git directory rather than the working tree, + # and for a linked worktree it points inside the primary checkout. named = options.get("-C") or options.get("--work-tree") break if not committing: From bd3e1920391f0173dffe27e864dc90279494d25f Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 4 Sep 2026 13:19:49 +0200 Subject: [PATCH 8/9] Follow the shell when paths compose 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 `/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 Claude-Session: https://claude.ai/code/session_01LtMZ49GH6LaiY5MdgtfNe2 Signed-off-by: F.N. Claessen --- .claude/hooks/pre_commit_target.py | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/.claude/hooks/pre_commit_target.py b/.claude/hooks/pre_commit_target.py index 92e93d146d..24d9ce5130 100755 --- a/.claude/hooks/pre_commit_target.py +++ b/.claude/hooks/pre_commit_target.py @@ -88,12 +88,16 @@ def target_directory(command: str, cwd: str) -> str | None: except ValueError: return None # unbalanced quotes: not something to guess at + # Paths compose, so follow the shell: each `cd` moves a running base, + # and a relative path is measured from wherever the previous one landed. + base = cwd + moved = False named: str | None = None - latest_cd: str | None = None committing = False for segment in segments(tokens): if segment and segment[0] == "cd" and len(segment) > 1: - latest_cd = segment[1] + base = _resolve(base, segment[1]) + moved = True continue verb, options = _verb_and_options(segment) if verb == "commit": @@ -105,13 +109,25 @@ def target_directory(command: str, cwd: str) -> str | None: if not committing: return None - if named or latest_cd: - return _toplevel(os.path.join(cwd, named or latest_cd or "")) + if named: + return _toplevel(_resolve(base, named)) + if moved: + return _toplevel(base) # Nothing was named, so the Bash tool's own working directory is the best guess, # and the project directory after that. return _toplevel(cwd) or os.environ.get("CLAUDE_PROJECT_DIR") or None +def _resolve(base: str, path: str) -> str: + """`path` as seen from `base`, the way the shell would see it. + + A `~` is expanded, since the shell would have expanded it before git ever saw it. + A `$VAR` is left alone, and will simply not be a checkout: this process cannot see the variables + of a shell that ran in an earlier call, and guessing is worse than skipping the check. + """ + return os.path.normpath(os.path.join(base, os.path.expanduser(path))) + + def _toplevel(path: str) -> str | None: """The root of the checkout containing `path`, or None if it is not in one.""" try: From 753a006fac812869e77c1fec5a69fd0a5ad16a5f Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 4 Sep 2026 13:20:23 +0200 Subject: [PATCH 9/9] docs: break one more docstring line after punctuation Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LtMZ49GH6LaiY5MdgtfNe2 Signed-off-by: F.N. Claessen --- .claude/hooks/pre_commit_target.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.claude/hooks/pre_commit_target.py b/.claude/hooks/pre_commit_target.py index 24d9ce5130..25195da8fa 100755 --- a/.claude/hooks/pre_commit_target.py +++ b/.claude/hooks/pre_commit_target.py @@ -122,8 +122,9 @@ def _resolve(base: str, path: str) -> str: """`path` as seen from `base`, the way the shell would see it. A `~` is expanded, since the shell would have expanded it before git ever saw it. - A `$VAR` is left alone, and will simply not be a checkout: this process cannot see the variables - of a shell that ran in an earlier call, and guessing is worse than skipping the check. + A `$VAR` is left alone, and will simply not be a checkout: + this process cannot see the variables of a shell that ran in an earlier call, + and guessing is worse than skipping the check. """ return os.path.normpath(os.path.join(base, os.path.expanduser(path)))