Repository navigation
fix: sync template hook fix (compound commits, visible warnings) #3
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -13,24 +13,40 @@ | |
| import subprocess | ||
|
|
||
|
|
||
| def _is_git_commit_command(command: str) -> bool: | ||
| """Return True only when command invokes the git commit subcommand.""" | ||
| try: | ||
| tokens = shlex.split(command, posix=True) | ||
| except ValueError: | ||
| return bool(re.search(r"git\s+commit", command)) | ||
| # git global options that consume the following token as their argument | ||
| _ARG_OPTIONS = frozenset({ | ||
| "-C", "-c", "--exec-path", "--git-dir", | ||
| "--work-tree", "--namespace", "--super-prefix", | ||
| "--list-cmds", | ||
| }) | ||
| _OPERATOR_CHARS = frozenset("();<>|&") | ||
|
|
||
|
|
||
| def _command_segments(command: str) -> list: | ||
| """Split a shell command line into simple commands at &&, ||, ;, |, & and newlines. | ||
|
|
||
| Only the first git invocation used to be inspected, so `git add f && git commit` looked like a plain | ||
| `git add` and the staged-diff check silently never ran. Newlines are turned into `;` first because shlex | ||
| treats them as plain whitespace; inside quotes (a multi-line commit message) they stay part of one token. | ||
| """ | ||
| lexer = shlex.shlex(command.replace("\n", " ; "), posix=True, punctuation_chars=True) | ||
| lexer.whitespace_split = True | ||
| segments, current = [], [] | ||
| for tok in lexer: | ||
| if tok and all(ch in _OPERATOR_CHARS for ch in tok): | ||
| segments.append(current) | ||
| current = [] | ||
| else: | ||
| current.append(tok) | ||
| segments.append(current) | ||
| return [seg for seg in segments if seg] | ||
|
|
||
|
|
||
| def _segment_is_git_commit(tokens: list) -> bool: | ||
| try: | ||
| git_idx = next(i for i, t in enumerate(tokens) if t == "git" or t.endswith("/git")) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Require If a user runs 🤖 Prompt for AI Agents |
||
| except StopIteration: | ||
| return False | ||
|
|
||
| # Options that consume the following token as an argument | ||
| _ARG_OPTIONS = frozenset({ | ||
| "-C", "-c", "--exec-path", "--git-dir", | ||
| "--work-tree", "--namespace", "--super-prefix", | ||
| "--list-cmds", | ||
| }) | ||
| i = git_idx + 1 | ||
| while i < len(tokens): | ||
| tok = tokens[i] | ||
|
|
@@ -43,6 +59,26 @@ def _is_git_commit_command(command: str) -> bool: | |
| return False | ||
|
|
||
|
|
||
| def _is_git_commit_command(command: str) -> bool: | ||
| """Return True when any simple command in the line invokes the git commit subcommand.""" | ||
| try: | ||
| segments = _command_segments(command) | ||
| except ValueError: | ||
| return bool(re.search(r"git\s+commit", command)) | ||
| return any(_segment_is_git_commit(seg) for seg in segments) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: sed -n '43,164p' .claude/hooks/pre_commit_sp_check.py
cat .claude/settings.jsonRepository: i-machine-things/thread Length of output: 4641 🏁 Script executed: sed -n '1,115p' .claude/hooks/pre_commit_sp_check.py
printf '\n-- hook references --\n'
rg -n -C 3 'PreToolUse|pre_commit_sp_check|tool_input|working directory|git -C' .claude README.md CLAUDE.md 2>/dev/null || trueRepository: i-machine-things/thread Length of output: 9406 Pass the
Return the parsed 🤖 Prompt for AI Agents |
||
|
|
||
|
|
||
| def _added_lines_by_file(diff: str) -> list: | ||
| """Return (path, added line) pairs from a unified diff, so checks can be limited to the right file types.""" | ||
| pairs, path = [], "" | ||
| for line in diff.splitlines(): | ||
| if line.startswith("+++ "): | ||
| path = line[4:].removeprefix("b/") | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: sed -n '70,85p;108,132p' .claude/hooks/pre_commit_sp_check.py
sed -n '39,83p' .claude/hooks/test_pre_commit_sp_check.pyRepository: i-machine-things/thread Length of output: 3514 🏁 Script executed: set -eu
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
mkdir "$tmp/repo"
git -C "$tmp/repo" init -q
git -C "$tmp/repo" config core.quotePath true
printf 'try:\n pass\nexcept Exception:\n pass\n' > "$tmp/repo/café.py"
git -C "$tmp/repo" add "café.py"
printf '%s\n' '--- actual cached diff header ---'
git -C "$tmp/repo" diff --cached --format= --unified=0 | sed -n '1,8p'
printf '%s\n' '--- parser-relevant transformation ---'
header=$(git -C "$tmp/repo" diff --cached --format= --unified=0 | sed -n '2p')
python3 - "$header" <<'PY'
import sys
line = sys.argv[1]
path = line[4:].removeprefix("b/")
print("header_repr:", repr(line))
print("retained_path_repr:", repr(path))
print("endswith_py:", path.endswith(".py"))
PY
printf '%s\n' '--- hook source ---'
nl -ba .claude/hooks/pre_commit_sp_check.py | sed -n '72,80p;112,121p'
printf '%s\n' '--- relevant tests ---'
nl -ba .claude/hooks/test_pre_commit_sp_check.py | sed -n '44,82p'Repository: i-machine-things/thread Length of output: 3434 Decode Git-quoted paths before the With Git’s default Decode the quoted Git path, including its octal UTF-8 escapes, before removing 🤖 Prompt for AI Agents |
||
| elif line.startswith("+"): | ||
| pairs.append((path, line)) | ||
| return pairs | ||
|
|
||
|
|
||
| def get_staged_diff() -> str: | ||
| git = shutil.which("git") | ||
| if not git: | ||
|
|
@@ -60,7 +96,11 @@ def get_staged_diff() -> str: | |
| def main(): | ||
| try: | ||
| data = json.load(sys.stdin) | ||
| command = data.get("command", "") | ||
| # A PreToolUse hook matched on Bash receives the command nested at tool_input.command, not | ||
| # top-level — reading the top-level key always returned "", so this check silently no-op'd | ||
| # on every single commit since it was written. Confirmed against Claude Code's real | ||
| # PreToolUse hook input schema and caught by CodeRabbit on i-machine-arr/reaparr PR #2. | ||
| command = data.get("tool_input", {}).get("command", "") | ||
| except json.JSONDecodeError: | ||
| sys.exit(0) | ||
|
|
||
|
|
@@ -71,7 +111,8 @@ def main(): | |
| if not diff: | ||
| sys.exit(0) | ||
|
|
||
| added_lines = [line for line in diff.splitlines() if line.startswith("+") and not line.startswith("+++")] | ||
| added = _added_lines_by_file(diff) | ||
| added_lines = [line for _, line in added] # line-only view used by the checks below | ||
|
Comment on lines
111
to
+115
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: sed -n '84,164p' .claude/hooks/pre_commit_sp_check.py
sed -n '13,83p' .claude/hooks/test_pre_commit_sp_check.py
cat .claude/settings.jsonRepository: i-machine-things/thread Length of output: 6793 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- hook ---'
cat -n .claude/hooks/pre_commit_sp_check.py | sed -n '1,125p'
printf '%s\n' '--- focused diff ---'
git diff --no-ext-diff --unified=30 7408297e285fcfc45d7b26ef950e9d9854a49d46 ac4631a5b0a1cacd2838bd9e6d89c5178420e0e7 -- .claude/hooks/pre_commit_sp_check.py .claude/hooks/test_pre_commit_sp_check.py .claude/settings.json
printf '%s\n' '--- guidance references ---'
rg -n -S 'PreToolUse|pre_commit_sp_check|get_staged_diff|git diff --cached|git add.*git commit|CODING_NOTES' --glob '!*.pyc' .Repository: i-machine-things/thread Length of output: 16498 Inspect the diff that a compound
Inspect the unstaged diff for the paths targeted by the preceding 🤖 Prompt for AI Agents |
||
|
|
||
| warnings = [] | ||
|
|
||
|
|
@@ -80,8 +121,9 @@ def main(): | |
| # --------------------------------------------------------------- | ||
|
|
||
| # Broad except Exception | ||
| for line in added_lines: | ||
| if re.search(r"except\s+Exception\b", line): | ||
| # Python files only: docs that show the anti-pattern as an example must not trip it. | ||
| for path, line in added: | ||
| if path.endswith(".py") and re.search(r"except\s+Exception\b", line): | ||
| warnings.append( | ||
| "Broad 'except Exception' detected — use specific exceptions " | ||
| "(e.g. OSError, AttributeError)." | ||
|
|
@@ -100,14 +142,18 @@ def main(): | |
| # --------------------------------------------------------------- | ||
|
|
||
| if warnings: | ||
| print() | ||
| print(f"Coding Notes Pre-commit Check — {len(warnings)} issue(s) found:") | ||
| for w in warnings: | ||
| print(f" * {w}") | ||
| print() | ||
| print("Review .claude/CODING_NOTES.md before proceeding. Commit is NOT blocked — " | ||
| "fix on next commit if intentional.") | ||
| print() | ||
| message = ( | ||
| f"Coding Notes Pre-commit Check — {len(warnings)} issue(s) found:\n" | ||
| + "\n".join(f" * {w}" for w in warnings) | ||
| + "\n\nReview .claude/CODING_NOTES.md before proceeding. Commit is NOT blocked — " | ||
| "fix on next commit if intentional." | ||
| ) | ||
| # Plain stdout from a PreToolUse hook is only logged. systemMessage shows the warning to the user; | ||
| # additionalContext puts it in front of Claude. Neither blocks the tool call (no permissionDecision). | ||
| print(json.dumps({ | ||
| "systemMessage": message, | ||
| "hookSpecificOutput": {"hookEventName": "PreToolUse", "additionalContext": message}, | ||
| })) | ||
|
|
||
| sys.exit(0) | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,83 @@ | ||
| """Tests for the pre-commit hook helpers. Run from the repo root: python -m unittest discover -s .claude/hooks""" | ||
|
|
||
| import json | ||
| import os | ||
| import subprocess | ||
| import sys | ||
| import unittest | ||
|
|
||
| sys.path.insert(0, os.path.dirname(__file__)) | ||
| import pre_commit_sp_check as hook # noqa: E402 | ||
|
|
||
|
|
||
| class IsGitCommit(unittest.TestCase): | ||
| def test_plain_commit(self): | ||
| self.assertTrue(hook._is_git_commit_command('git commit -m "x"')) | ||
|
|
||
| def test_commit_after_add(self): | ||
| self.assertTrue(hook._is_git_commit_command('git add a.py && git commit -m "x"')) | ||
| self.assertTrue(hook._is_git_commit_command("git add -A; git commit -m x")) | ||
| self.assertTrue(hook._is_git_commit_command("git add a || git commit -m x")) | ||
| self.assertTrue(hook._is_git_commit_command("git add a\ngit commit -m x")) | ||
|
|
||
| def test_global_options_before_subcommand(self): | ||
| self.assertTrue(hook._is_git_commit_command("git -C /tmp/repo -c user.name=x commit -m x")) | ||
|
|
||
| def test_non_commit(self): | ||
| self.assertFalse(hook._is_git_commit_command("git status")) | ||
| self.assertFalse(hook._is_git_commit_command("git add a.py && git push")) | ||
| self.assertFalse(hook._is_git_commit_command("ls -la")) | ||
|
|
||
| def test_commit_word_inside_message_is_not_a_commit(self): | ||
| self.assertFalse(hook._is_git_commit_command('git log --grep "git commit"')) | ||
| self.assertFalse(hook._is_git_commit_command('echo "run git commit later"')) | ||
|
|
||
| def test_multiline_message_stays_one_command(self): | ||
| self.assertTrue(hook._is_git_commit_command('git commit -m "line one\nline two"')) | ||
| self.assertFalse(hook._is_git_commit_command('git log --grep "a\ngit commit b"')) | ||
|
|
||
|
|
||
| class AddedLines(unittest.TestCase): | ||
| DIFF = ( | ||
| "--- a/x.py\n+++ b/x.py\n@@\n+try:\n+ pass\n+except Exception as e:\n" | ||
| "--- a/notes.md\n+++ b/notes.md\n@@\n+ except Exception as e:\n" | ||
| ) | ||
|
|
||
| def test_pairs_lines_with_files(self): | ||
| pairs = hook._added_lines_by_file(self.DIFF) | ||
| self.assertIn(("x.py", "+except Exception as e:"), pairs) | ||
| self.assertIn(("notes.md", "+ except Exception as e:"), pairs) | ||
| self.assertFalse(any(line.startswith("+++") for _, line in pairs)) | ||
|
|
||
|
|
||
| class EndToEnd(unittest.TestCase): | ||
| def run_hook(self, command, cwd): | ||
| payload = json.dumps({"tool_input": {"command": command}}) | ||
| return subprocess.run([sys.executable, hook.__file__], input=payload, capture_output=True, | ||
| text=True, cwd=cwd) | ||
|
|
||
| def test_warning_is_json_system_message_and_python_only(self): | ||
| import tempfile | ||
| with tempfile.TemporaryDirectory() as tmp: | ||
| def git(*args): | ||
| subprocess.run(["git", *args], cwd=tmp, check=True, capture_output=True) | ||
| git("init", "-q") | ||
| with open(os.path.join(tmp, "notes.md"), "w") as f: | ||
| f.write(" except Exception as e:\n") | ||
| git("add", "notes.md") | ||
| out = self.run_hook("git commit -m x", tmp) | ||
| self.assertEqual(out.returncode, 0) | ||
| self.assertEqual(out.stdout.strip(), "") # markdown example must not warn | ||
|
|
||
| with open(os.path.join(tmp, "a.py"), "w") as f: | ||
| f.write("try:\n pass\nexcept Exception:\n pass\n") | ||
| git("add", "a.py") | ||
| out = self.run_hook("git add a.py && git commit -m x", tmp) | ||
| self.assertEqual(out.returncode, 0) | ||
| data = json.loads(out.stdout) | ||
| self.assertIn("except Exception", data["systemMessage"]) | ||
| self.assertEqual(data["hookSpecificOutput"]["hookEventName"], "PreToolUse") | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| unittest.main() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
sed -n '1,165p' .claude/hooks/pre_commit_sp_check.pyRepository: i-machine-things/thread
Length of output: 5900
🏁 Script executed:
Repository: i-machine-things/thread
Length of output: 8320
Preserve Bash line continuations before splitting commands.
When Bash receives
git \followed by a newline andcommit -m x, it executesgit commit. The current replacement runs first and produces separategitandcommitsegments. The fallback does not run because parsing succeeds, and it cannot match the raw backslash-newline form. The hook therefore skips its advisory staged-diff warnings for this command.Remove backslash-newline continuations before replacing command-separating newlines.
Suggested fix
def _command_segments(command: str) -> list: """Split a shell command line into simple commands at &&, ||, ;, |, & and newlines. @@ """ + command = command.replace("\\\n", "") lexer = shlex.shlex(command.replace("\n", " ; "), posix=True, punctuation_chars=True)📝 Committable suggestion
🤖 Prompt for AI Agents