git-a-grip
Personal pre-commit hooks.
repos:
- repo: https://github.com/dannybrown37/git-a-grip
rev: v0.3.1
hooks:
- id: commitizen-early
- id: ruff-check
- id: ruff-format
- id: pytest
args: [tests/, -q]
The commitizen and ruff hooks reach their tool through
sys.executable -m <tool> inside the env pre-commit builds for this repo, so
a consuming project needs no cz or ruff on PATH, no venv and no
uv/uvx of its own. (pytest is the exception — see below.)
Each hook pays only for what it uses: commitizen is a dependency of the
package, while ruff is declared by the two ruff hooks themselves, through
additional_dependencies in .pre-commit-hooks.yaml. You still pass
nothing. Pin your own ruff by setting additional_dependencies: [ruff==x.y.z] on the hook.
commitizen-early (pre-commit stage)
Rejects a non-conventional commit message in about a third of a second, instead of after the whole slow hook suite has run.
Git runs pre-commit -> prepare-commit-msg -> editor -> commit-msg as
separate invocations, so a stages: [commit-msg] commitizen hook can only
ever fail after your tests. Nothing in .pre-commit-config.yaml reorders
that. This hook instead recovers the message from the git commit process's
own argv while the pre-commit stage is still running, and checks it first.
Pair it with the upstream commitizen hook, which still catches the cases
argv cannot reach (interactive editor, merge, rebase) — this one exits 0 and
defers whenever it finds no message:
- repo: https://github.com/dannybrown37/git-a-grip
rev: v0.3.1
hooks:
- id: commitizen-early
- repo: https://github.com/commitizen-tools/commitizen
rev: v4.17.0
hooks:
- id: commitizen
stages: [commit-msg]
Put it first and give it fail_fast: true if you want it to short-circuit
the rest of the stage.
git-release (command, not a hook)
Bump, tag and push, in that order, exiting 0. For repos that release from a
laptop rather than from CI. Give it an alias that says what it does — not
gp, which reads as git push right up until it publishes something:
alias release='uvx --from git-a-grip git-release'
This repo itself no longer uses it: releases here are cut by CI once the
checks on main pass (see below). The command remains for projects with no
such pipeline, where the alternative is remembering the four commands by
hand.
On main it bumps and pushes; on any other branch it just pushes, so it can
replace git push outright. Refuses to run against a dirty tree, and pushes
anyway when there are no bumpable commits.
This exists because a pre-push hook cannot do this cleanly. Git chooses
which sha to push before hooks run, so a commit created afterwards leaves two
options: cancel the push, or let git push the now-superseded sha and have it
rejected as a non-fast-forward. Both end in error: failed to push some refs
on top of a release that worked. Running as a command puts the bump before
the push and the problem disappears.
Configure what the bump rewrites via [tool.commitizen] in the consuming
repo (version_provider, version_files).
A
bump-on-pushpre-push hook did this up to v0.2.1 and was removed in v0.3.0 for the reason above. If you pin an older rev, that hook still exists there; on upgrading, drop- id: bump-on-pushand use this command.
ruff-check and ruff-format (pre-commit stage)
ruff check --fix and ruff format, with the fixes re-staged so they are
part of the commit you just made rather than a dirty working tree you have to
git add and amend. Only the violations ruff could not fix stop the commit,
via ruff's own exit code.
Pass ruff's flags through args:
- id: ruff-check
args: [--config, .ruff.toml]
--force-exclude is always passed, so the exclude in your ruff config still
applies to the paths pre-commit hands over explicitly. The re-staged set is
narrowed by content digest — a file ruff did not change is never touched, and
because pre-commit stashes unstaged changes while a hook runs, re-adding a
file cannot sweep in an edit you deliberately left unstaged.
The ruff version is this repo's pinned dependency. To hold a repo at a different one:
- id: ruff-format
additional_dependencies: [ruff==0.16.1]
pytest (pre-commit stage)
Runs the test suite from the repo root. This hook can't use the isolated env
pre-commit builds here — a test suite needs the consuming project's
dependencies — so it shells out to a runner that resolves that environment,
uv run pytest by default. Everything else in args goes to pytest:
- id: pytest
args: [tests/, -q]
- id: pytest
args: ['--runner=uv run --extra api pytest', tests/, -q]
It runs from the repo root regardless of where git was invoked, and drops the
VIRTUAL_ENV/PYTHONPATH that pre-commit exports for its own hook env —
which would otherwise point the runner at an environment holding none of your
project's dependencies. Narrow when it runs with files: (default
^(src/|tests/).*).
readme-tree (pre-commit stage)
Keeps a file tree in your README true. Mark the spot once:
<!-- tree:start -->
<!-- tree:end -->
and the hook regenerates the block on every commit, re-staging the README so the commit that renamed the directory is the commit that fixed the docs. A hand-written tree is accurate exactly once; this one cannot be stale in a commit that passed.
- id: readme-tree
args: [--file=docs/layout.md, --depth=2, --marker=tree]
The contents come from git ls-files, so the tree is exactly what is
committed — no .venv, no build output, and no second copy of your
.gitignore rules to drift. --depth=N truncates below N levels
(default: unlimited).
eslint and tsc (pre-commit stage)
For the JS/TS repos. Both run through the project's package manager
(detected from the lockfile: pnpm, bun, yarn, else npx --no-install;
override with --runner=...), because lint rules and compiler plugins live
in your node_modules and a second isolated copy of the tool would resolve
none of them.
- id: eslint
- id: tsc
args: [-p, tsconfig.build.json]
eslint runs with --fix and re-stages what it rewrote, like the ruff
hooks. It also defaults to --max-warnings=0: eslint exits 0 on warnings, so
a rule set with warnings in it otherwise passes forever while the warnings
pile up. Pass --max-warnings=N to loosen that on purpose.
tsc never passes filenames — that is the trap. Given file arguments, tsc
ignores tsconfig.json entirely and type-checks with default options, so the
obvious entry: tsc --noEmit hook quietly checks something other than your
project. This one type-checks the project (-p . unless you name another).
pre-commit-audit (command, not a hook)
Audit every local repo's pre-commit setup at once, so a hook that drifted or never got installed shows up as a line rather than a surprise:
uvx --from git-a-grip pre-commit-audit
It walks the given trees (default: this repo's sibling directories), stops at
each git working tree, and reports four things: which of this repo's hooks
each project uses and the rev it pins, third-party hooks grouped by source
repo and rev, one-off repo: local hooks with their entry, and repos with no
usable config at all.
The report opens with the installed version of this package, and every pin
below it is labelled against that version — (behind), (ahead),
(unpinned) — so the list answers "who is stale" rather than leaving you to
diff revs by eye:
git-a-grip 0.3.1 (installed)
12 repos scanned, 9 with pre-commit hooks.
git-a-grip hooks in use
=======================
ruff-check
api v0.3.1
dotfiles v0.1.0 (behind)
--json emits the same data as {"version": ..., "repos": [...]}:
pre-commit-audit ~/projects ~/work
pre-commit-audit --json | jq '.repos[] | select(.hooks == [])'
hook-sync (command, not a hook)
The other half of the audit: having found five repos on four different revs, pin them all to one.
hook-sync # dry run against the installed version
hook-sync --write # apply it
hook-sync --to v0.4.0 # some other target
hook-sync --latest --write # whatever the source's newest tag is
hook-sync --repo https://github.com/gitleaks/gitleaks --latest
It is a dry run by default and prints one line per repo (old -> new). The
rewrite is textual and touches only the rev: line of the matching repo:
block: a YAML round-trip would hand your config back reformatted and stripped
of its comments, which is a much worse trade than the one line you asked to
change. Trailing comments on the rev: line survive, and ssh/https/.git
spellings of the same source all match.
Nothing is committed — the changes land in the working tree of each repo for
you to review, git add and commit yourself.
Installing the commands
The hooks need no installation — pre-commit builds this repo an isolated env
from the rev you pin. The three commands (git-release, pre-commit-audit,
hook-sync) are ordinary console scripts, published to PyPI:
uvx --from git-a-grip pre-commit-audit # one-off
uv tool install git-a-grip # both commands, on PATH
That install carries only what the commands import — commitizen and pyyaml — not the ruff the hooks use. To run the hook entry points by hand as well, ask for the extra:
uv tool install 'git-a-grip[hooks]'
Straight from a tag works too, and is the way to run something not yet released:
uvx --from git+https://github.com/dannybrown37/git-a-grip@v0.3.1 git-release
Releasing
Merge to main. That is the whole gesture.
ci.yml runs lint, tests and the install proofs on the merged commit; only
if they all pass does its bump job run cz bump, which writes the version
and changelog, commits, and tags. Pushing that tag triggers publish.yml,
which builds and uploads to PyPI via trusted publishing. A push with no
bumpable commits (docs, chores) ends after the checks and releases nothing.
Nothing is tagged before the checks pass, so a red build cannot leave a version number stranded on a release that never shipped.
Development
uv sync
uv run pytest
This repo eats its own dog food: its hooks are wired into its own
.pre-commit-config.yaml, and the tree below is maintained by readme-tree.
git-a-grip/
|-- .github/
| `-- workflows/
| |-- ci.yml
| `-- publish.yml
|-- src/
| `-- git_a_grip/
| |-- __init__.py
| |-- audit.py
| |-- commitizen_early.py
| |-- cz.py
| |-- node_hooks.py
| |-- pytest_hook.py
| |-- readme_tree.py
| |-- release.py
| |-- restage.py
| |-- ruff_hooks.py
| |-- sync.py
| `-- version.py
|-- tests/
| |-- test_audit.py
| |-- test_commitizen_early.py
| |-- test_node_hooks.py
| |-- test_packaging.py
| |-- test_pytest_hook.py
| |-- test_readme_tree.py
| |-- test_release.py
| |-- test_restage.py
| |-- test_ruff_hooks.py
| |-- test_sync.py
| `-- test_version.py
|-- .gitignore
|-- .pre-commit-config.yaml
|-- .pre-commit-hooks.yaml
|-- .ruff.toml
|-- CHANGELOG.md
|-- LICENSE
|-- pyproject.toml
|-- README.md
`-- uv.lock
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file git_a_grip-0.5.0.tar.gz.
File metadata
- Download URL: git_a_grip-0.5.0.tar.gz
- Upload date:
- Size: 67.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a1eb9725a1b4faa29e21a837510c92a66d250cb4a427fd5f241ac2902dd49ad8
|
|
| MD5 |
ea4e3ceb8a8612b60b4b996e48898989
|
|
| BLAKE2b-256 |
949b8260d6a8a8a4f2e179ba679909d5c1dea64d26416ddbcd09d3355c3c4a88
|
Provenance
The following attestation bundles were made for git_a_grip-0.5.0.tar.gz:
Publisher:
publish.yml on dannybrown37/git-a-grip
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
git_a_grip-0.5.0.tar.gz -
Subject digest:
a1eb9725a1b4faa29e21a837510c92a66d250cb4a427fd5f241ac2902dd49ad8 - Sigstore transparency entry: 2331904001
- Sigstore integration time:
-
Permalink:
dannybrown37/git-a-grip@070ef3eef7d981cd5d0ac67781fcf272ee33a26a -
Branch / Tag:
refs/tags/v0.5.0 - Owner: https://github.com/dannybrown37
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@070ef3eef7d981cd5d0ac67781fcf272ee33a26a -
Trigger Event:
push
-
Statement type:
File details
Details for the file git_a_grip-0.5.0-py3-none-any.whl.
File metadata
- Download URL: git_a_grip-0.5.0-py3-none-any.whl
- Upload date:
- Size: 29.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
5d8f5c129e8f19c24f4a5b015e8d5cea6aa3263f2a4e17666f416375be657b6b
|
|
| MD5 |
7538f4b99251c8546a2c839df0db6151
|
|
| BLAKE2b-256 |
cc0a9de1d8e82dffd2811aeeca358f02b2059cd98a173205b6e3faf10529a659
|
Provenance
The following attestation bundles were made for git_a_grip-0.5.0-py3-none-any.whl:
Publisher:
publish.yml on dannybrown37/git-a-grip
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
git_a_grip-0.5.0-py3-none-any.whl -
Subject digest:
5d8f5c129e8f19c24f4a5b015e8d5cea6aa3263f2a4e17666f416375be657b6b - Sigstore transparency entry: 2331904141
- Sigstore integration time:
-
Permalink:
dannybrown37/git-a-grip@070ef3eef7d981cd5d0ac67781fcf272ee33a26a -
Branch / Tag:
refs/tags/v0.5.0 - Owner: https://github.com/dannybrown37
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@070ef3eef7d981cd5d0ac67781fcf272ee33a26a -
Trigger Event:
push
-
Statement type: