Repository navigation
ci: align ruff gates and fix claude hooks - #869
Merged
Merged
Conversation
Claude Code passes hook input as JSON on stdin and never sets CLAUDE_FILE_PATHS, so the edit hooks (format, mypy, and the block on editing main) never saw a file. They now read tool_input.file_path. The format hook leaves unused imports alone, so an import added before its first use survives the edit. The mypy hook exits 2 so its errors reach Claude. The Stop hook read stdin twice and looked for a field that doesn't exist; it now reads stdin once and checks last_assistant_message. The commit hook only matched commands that start with "git commit" and resolved .venv against the current directory; it now matches compound and "git -C" commits and runs from the repo root.
quality.yml runs `ruff check .` and `ruff format --check .`, but just lint, fmt-check and fix, the pre-commit hook, the /check and /bump skills and CONTRIBUTING.md only covered src/ and tests/. Anything else CI checks, like scripts/ and Python code blocks in Markdown, could pass locally and still fail CI, and just fix couldn't repair it. All of them now run ruff on the whole repo. The format-on-edit hook also formats Markdown files, and it skips gitignored files, which ruff's --force-exclude still formats when they're passed by path.
The main-branch guard took the branch from Claude's current directory, so an edit to a checkout on main went through from a worktree on a feature branch, and gitignored local files such as .claude/settings.local.json were blocked. It now checks the branch of the checkout the edited file is in, limited to this repo's checkouts, and skips gitignored files. The commit hook also matches "/usr/bin/git commit" and a commit followed by ";" or ")". Its message says to re-add the files after just fix, since it checks the working tree while git commits the index. tests/test_gates/test_claude_hooks_gate.py runs each hook with the JSON Claude Code sends, inside throwaway repos.
The main-branch guard placed the edited file by string prefix, so a path spelled through a symlink or in another case slipped past it, and it let edits under .git/ through on main. It now asks git where the file sits (rev-parse --show-prefix) and blocks anything it can't place: an edit outside a work tree, or a project dir git can't read. The mypy hook keeps mypy's "Found N errors" line, so a cut-off list still says how many errors there were. The commit hook only lets the git options that take a value (-C, -c, --git-dir, --work-tree, --namespace, --config-env) take one, so `git --no-pager log --grep commit` isn't mistaken for a commit, and `git commit>file` is caught. AGENTS.md notes that the hooks need jq and that CI skips .claude/**. The hook tests cover the new cases.
The commit hook also lets --attr-source take a separate value, so `git --attr-source HEAD commit` is checked. The hook tests now cover a nested AGENTS.md on main, which stays blocked, and a _build/ file, which only ruff's own excludes keep away from the format hook. AGENTS.md says CI's path filters also skip Markdown-only and scripts/marketing/-only PRs, so those get no ruff check in CI.
quality.yml and tests.yml only ran when a PR touched something outside docs/, Markdown, website/, .claude/ and scripts/marketing/, and ci-skip.yml stubbed their checks otherwise. Ruff checks every .py and .md file and the tests read docs/api/, scripts/marketing/ and .claude/settings.json, so a PR touching only those merged without the checks that cover them. Both workflows now run on every PR, and ci-skip.yml only stubs the four workflows that keep their filters. The push guard only matched --force, -f or "origin main" right after "git push". It now finds git push after git's global options and blocks force pushes (--force, -f, --force-with-lease, --mirror, --all, +refspecs) and any main or master target. On main, a push that names no branch, or names a ref that resolves to main, is blocked too; the branch is read in the checkout -C points at. The format hook also leaves unused except bindings alone (F841).
The push guard matched raw command text, so a quoted or expanded branch or flag slipped a push to main or a force push past it, and it only followed -C to find the checkout, so --git-dir, --work-tree or GIT_DIR pointed the push at a branch it never read. It now reads the push with quotes removed and across line breaks, and refuses one it cannot read: $, backticks, backslashes, quoted text with spaces, the repo overrides above, or a -C directory that is not a repo.
Some spellings still slipped a push to main or a force push past the guard: --branches and abbreviated options, heads/main targets, ':' and '*' refspecs, braces, quotes or backslashes inside the git or push words, xargs, -c and GIT_CONFIG overrides, and a second push glued on with ';' and no space. The guard now reads each push the way the shell would and refuses the forms it still cannot read. Its loop runs in an explicit subshell, so an exit inside it means the same under zsh, where the old code exited 3 with no message and let the push through.
The push guard matched shell text with sed and grep, so an operator glued to a word could hide a flag or a target: git push --force; git log, (git push origin HEAD) from main, and git push origin 2>&1 from main all went through. It now splits each line into simple commands the way the shell does, at operators, parentheses and substitutions with or without spaces around them, drops redirections and comments, and checks every push on its own. A command substitution is checked as a command of its own, and quoted text stays one word, so a commit message that mentions a push is no longer read as one. The guard also stops reading -ofoo as -f, stops refusing a push because another command on the line ends a pattern in $, sees GIT and --shallow-file, and refuses a push with a quoted or escaped shell operator in it.
Quoted text on a line is never read as a push, but the guard reads one line at a time, so the later lines of a multi-line quoted message can still be.
The guard read shell syntax line by line, so a push that followed a multi-line quoted string got through, such as a heredoc commit message chained with && git push. A checkout earlier in the same command also made the on-main check read the wrong branch. Patching the parser kept opening gaps like these. Any command that mentions git push is now refused unless the whole command is one plain git push line, which keeps the existing checks. The refusal message says how to run the push, or to pass text that spells out git push from a file. The release steps and /bump show the tag push as its own command.
The push guard looked for git, its global options and then push, so an
option value built from a variable, such as git -C ${HOME}/repo push,
hid the push and it ran without any check. Any command with the word
git followed later by the word push is now treated as a push, and
unless it is one plain git push line it is refused.
The format and mypy hooks now resolve the edited path before matching
it against the repo root, so an edit through a symlink is checked. The
branch hook resolves .. the way the Write tool does, so a path through
a missing directory can no longer land in a checkout on main unseen.
A push word built from a shell expansion, such as pu$(:)sh, or a space held in a variable gets past the push guard, and so do push settings stored with git config. The hook notes now list them and no longer say the guard reads through $ and braces. The format hook test used fixtures that only ruff format changes, so it passed with --force-exclude dropped from the ruff check step. They now need a lint fix as well.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The local ruff gates checked less than CI, CI skipped its ruff and test jobs for files those jobs cover, and most of the Claude Code hooks in
.claude/settings.jsonnever ran.just lint,just fmt-checkandjust fixonly coveredsrc/andtests/, so the two Markdown code blocks ruff 0.17 reformatted in #865 passed locally and failed the Format check. The edit hooks looped over$CLAUDE_FILE_PATHS, which Claude Code doesn't set, so nothing was formatted on edit, mypy never ran, and edits onmainwent through.Closes #867
Closes #868
Changes
just lint,just fmt-checkandjust fixrun ruff on., the same filesquality.ymlchecks. The/checkand/bumpskills and CONTRIBUTING.md list the same commands.quality.ymlandtests.ymlrun on every PR, andci-skip.ymlno longer posts stubs for their checks. Ruff checks every.pyand.mdfile and the tests readdocs/api/,scripts/marketing/and.claude/settings.json, so a Markdown-only, marketing-only or hook-only PR used to merge without the checks that cover it. The security, dependency review, docker and smoke test workflows keep their path filters.tool_input.file_path) and find the repo root withgit rev-parse, so they also work from a subdirectory such aswebsite/and for a path that goes through a symlink.ruff check --fix --unfixable F401,F841andruff formaton.pyfiles andruff formaton.mdfiles. Unused imports andexcept ... as ebindings are left forjust fix, so code added before its first use survives the edit. Gitignored files and ruff's excludes are skipped; ruff's--force-excludestill formats a gitignored file passed by path, so the hook asksgit check-ignorefirst.main. It asks git which checkout the edited file is in, after resolving..the way the Write tool does, so an edit to a checkout onmainis caught from another worktree or through a symlink too, and anything it can't place, such as a file under.git/, is blocked. Gitignored local files such as.claude/settings.local.jsonstay editable.git commitinside compound commands too, includingcd <dir> && git commitandgit -C <dir> commit, runsruff format --check .from the repo root, and lists the files when it blocks. It used to block every commit made from a subdirectory. It checks the working tree, so its message says to re-add the fixed files.gitfollowed later by the wordpushas a push, and only accepts one written as a plain command of its own: one line starting withgit, with nothing chained, piped or redirected and no quotes,$, backslashes, braces, globs or comments. Anything else with git and then push in it is refused with a message that says how to run it. That covers pushes inside&&chains, subshells,$(...)and multi-line commands, or with option values built from variables, where shell syntax can hide where the push goes, and commit or PR text with git and push in it, which goes in a file instead. In a plain push it reads git's global options (git -C <dir> push) and blocks force pushes,--all,--branchesand--mirror(abbreviations too),:refspecs, and any target namingmainormaster, includingHEAD:mainandheads/main. Onmainormaster, a push that names no branch is blocked too; tag pushes still go through. A push using-c,--git-diror--work-tree, or a-Cdirectory that isn't a repo, is refused. Sogit -C . push --force,git push -u origin mainand, frommain,git pushno longer get through. It's there to catch mistakes, so a push through a git alias, a shell alias or a script isn't seen.last_assistant_message.tests/test_gates/test_claude_hooks_gate.pyruns each hook with the JSON Claude Code sends, inside throwaway repos, so a hook that stops reading its input fails the suite.jq, which every hook needs to read its input. The release steps and the/bumpskill show each push as its own command.Testing
The new gate test covers each hook. It runs the push guard under every shell it finds (bash, sh, zsh) and checks that pushes inside chains, subshells, command substitutions, pipes, redirects and multi-line commands are refused. The push guard was also checked against what bash actually runs, with a stand-in
gitthat only logs its arguments, on macOS and on Ubuntu 24.04 with GNU grep, GNU sed, mawk and gawk, and no disallowed push got past it. The hooks ran against sample input under bash 5.3, macOS's bash 3.2, dash and zsh, and the edit, mypy, branch, commit and push hooks were exercised in a live Claude Code session.just fmt-checknow fails on an unformatted Markdown code block the way CI does, andjust fixformats it. All checks pass.Type of Change
fix:)feat:)refactor:)docs:)ci:)chore:)Checklist
Closes #Ntype:*and onepriority:*labelphase:*label (or none when not roadmap work)feat/<slug>,fix/<slug>, etc.)mypy src/)ruff check .)ruff format --check .)