Skip to content

build: add format targets and a pre-commit format hook - #32

Merged
nehalkpatel merged 1 commit into
mainfrom
format-tooling
Aug 27, 2026
Merged

build: add format targets and a pre-commit format hook#32
nehalkpatel merged 1 commit into
mainfrom
format-tooling

Conversation

@nehalkpatel

Copy link
Copy Markdown
Owner

Why

CI rejects unformatted C++ and Python, but nothing caught it before a push — the first signal was a red PR. That is exactly how #31 failed: scripted edits produced valid C++ that clang-format disagreed with, and it took a CI round trip plus a history rewrite to find out.

One implementation, three callers

tools/format.sh is the single source of truth. CI calls it, the CMake format/format-check targets call it, and the pre-commit hook calls it.

Sharing one script is the entire point. A local check that has drifted from CI is worse than no local check, because it is trusted — you stop looking at CI, and CI is the one that's right. CI's inline clang-format and ruff blocks are replaced by a call to the script.

tools/format.sh --check     # whole tree, what CI runs
tools/format.sh --fix       # reformat in place + safe lint fixes
tools/format.sh --staged    # staged content only, what the hook runs

mypy deliberately stays inline in CI: type checking is not a formatting concern and is too slow to belong in a commit hook.

The hook checks the index, not the working tree

git show :file, not the file on disk. A file can be staged in one state and edited further before committing; checking the working tree would let a bad commit through, or block a good one. Verified in both directions:

index working tree hook
clean dirty passes — ignores the unstaged mess
dirty clean fails — catches what would actually be committed

Installation

The hook lives in .githooks rather than .git/hooks, so it is version controlled and arrives with a clone. The CMake configure step points core.hooksPath at it.

That writes to the developer's local git config, so it is announced in the configure output rather than done silently:

-- Set git core.hooksPath to .githooks (pre-commit format check)
--   Opt out with -DINSTALL_GIT_HOOKS=OFF; bypass once with git commit --no-verify

-DINSTALL_GIT_HOOKS=OFF opts out (verified: sets nothing, prints nothing). git commit --no-verify bypasses a single commit. The hook exits 0 if tools/format.sh is missing, so a branch predating this change does not become uncommittable.

Version pinning

clang-format is pinned to 18, matching CI and the dev container's clang-format -> clang-format-18 alias, and warns if a different major version is found. An unpinned binary reintroduces exactly the local/CI split this is meant to prevent.

Verification

Every path was tested against deliberately broken input rather than assumed:

  • --check rejects a bad file (rc=1) and passes a clean tree (rc=0)
  • The hook blocks an actual git commit — no commit object was created
  • --fix repairs the file and --check then passes
  • Python is covered too: formatting violations and lint violations (unused import) both rejected; clean file accepted
  • format and format-check both exist as ninja targets and behave identically to the script
  • Hook installs from scratch, is idempotent when already set, and skips under -DINSTALL_GIT_HOOKS=OFF
  • ci.yml parses as valid YAML; both scripts pass bash -n
  • Full host-debug workflow still green, 29/29

The commit adding the hook was itself checked by the hook.

Note on naming

cmake/format.cmake is lowercase to match the existing cmake/python_venv.cmake. CamelCase is CMake's convention for its own upstream and find-modules (FetchContent, GNUInstallDirs), not for a project's own modules — I had it CamelCased initially and corrected it.

🤖 Generated with Claude Code

CI rejects unformatted C++ and Python, but nothing caught it before a push, so
the first signal was a red PR. Three entry points now check the same things.

tools/format.sh is the single implementation. CI calls it, the CMake
format/format-check targets call it, and the pre-commit hook calls it. Sharing
one script is the entire point: a local check that has drifted from CI is worse
than no local check, because it is trusted. CI's inline clang-format and ruff
blocks are replaced by a call to it; mypy stays inline, since type checking is
not a formatting concern and is too slow for a commit hook.

The hook checks staged content via `git show :file`, not the file on disk. A
file can be staged in one state and edited further before committing, and
checking the working tree would let a bad commit through -- or block a good one.
Both directions are covered.

It lives in .githooks rather than .git/hooks so it is version controlled, and
the CMake configure step points core.hooksPath at it. That writes to the
developer's local git config, so it is announced in the configure output rather
than done silently, and -DINSTALL_GIT_HOOKS=OFF opts out. `git commit
--no-verify` bypasses a single commit.

clang-format is pinned to 18 to match CI and the dev container, with a warning
if a different major version is found -- an unpinned binary reintroduces exactly
the local/CI split this is meant to prevent.

cmake/format.cmake is lowercase to match cmake/python_venv.cmake. CamelCase is
the convention for CMake's own upstream and find-modules, not for a project's
own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@nehalkpatel
nehalkpatel merged commit a658d68 into main Aug 27, 2026
1 check passed
@nehalkpatel
nehalkpatel deleted the format-tooling branch August 27, 2026 13:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant