Skip to content
Merged
40 changes: 33 additions & 7 deletions .claude/hooks/pre-commit-check.sh
Original file line number Diff line number Diff line change
@@ -1,13 +1,39 @@
#!/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.
#
# 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.
# 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 <worktree> && git commit` leaves no trace in the payload's `cwd` once the shell's directory has been reset.
#
# 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

if ! output=$(uv run pre-commit run --all-files 2>&1); then
echo "pre-commit failed — fix the issues below before committing:" >&2
payload="$(cat)"

command -v python3 >/dev/null 2>&1 || exit 0

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
fi
exit 0
fi
exit 0
162 changes: 162 additions & 0 deletions .claude/hooks/pre_commit_target.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
#!/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

# 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
committing = False
for segment in segments(tokens):
if segment and segment[0] == "cd" and len(segment) > 1:
base = _resolve(base, segment[1])
moved = True
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:
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:
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())
Loading