Skip to main content

git-a-grip

Two things, from one package:

  1. A set of pre-commit hooks you point your .pre-commit-config.yaml at.
  2. gag, a CLI for managing pre-commit across all your repos — see what every project pins, and pin them all to the same rev.

1. The hooks

repos:
  - repo: https://github.com/dannybrown37/git-a-grip
    rev: v0.10.0
    hooks:
      - id: commitizen-early
      - id: ruff-check
      - id: ruff-format
      - id: mypy
      - id: embed-tree
      - id: embed-command
        args: ['--marker=help', '--command=mytool --help']
      - id: regen-file
        args: ['--command=python scripts/build_docs.py', '--file=docs/api.md']
      - id: pytest
        args: [tests/, -q]
      - id: vitest

Nothing to install. Your project needs no ruff, cz or uv on PATH — pre-commit builds the env. (pytest, mypy, eslint, tsc and vitest are the exceptions: they must run inside your project's environment, so they shell out to uv run / your package manager.)

Hook What it does for you
commitizen-early Rejects a bad commit message in ~0.3s, instead of after the whole hook suite has run. Pair with the upstream commitizen hook for the cases it can't see (editor, merge, rebase).
ruff-check ruff check --fix, with fixes re-staged — no dirty tree to git add and amend.
ruff-format ruff format, likewise re-staged.
mypy Type-checks the project, in your project's env so mypy can import your dependencies instead of reporting on the imports. Names no files, so what it checks is what your mypy config says — not whichever files you happened to touch. --runner=uv run ty check swaps the engine.
pytest Your test suite, from the repo root, in your project's env. args: ['--runner=uv run --extra api pytest', ...] to change the runner.
embed-tree Keeps a file tree in your README true. Drop <!-- tree:start --> / <!-- tree:end --> in, and it regenerates and re-stages on every commit. Contents come from git ls-files, so it's exactly what's committed. (Was readme-tree; the old id still works.)
embed-command Keeps a command's output — mytool --help, make help — true in your README. Name the command in args; nothing is discovered and run on its own.
regen-file Replaces bash -c 'run-the-script && git add the-file'. Runs the generator you name and re-stages the files you name — only the ones whose contents actually moved, and it fails the commit if the script quietly stopped writing one. Use embed-command instead when the script owns only a marked block.
eslint eslint --fix, re-staged, --max-warnings=0 by default so warnings can't pile up forever. --dir=web for a monorepo.
tsc Type-checks the project, never bare filenames — given filenames, tsc silently ignores your tsconfig.json. --dir=web for a monorepo.
vitest vitest run, never the watch mode a bare vitest starts — that hangs the commit with no clue why. Sets CI=true, so a missing snapshot fails instead of being written and committed.

Anything in args is passed through to the underlying tool. Pin your own tool version with additional_dependencies: [ruff==0.16.1].

eslint, tsc and vitest run from the repo root unless you pass --dir=. If your JS lives in a subdirectory, its eslint.config.mjs, tsconfig.json and node_modules resolve from there, so name it — and scope the hooks to match, since files outside that subdirectory are skipped anyway:

      - id: eslint
        args: [--dir=web]
        files: ^web/
      - id: tsc
        args: [--dir=web]
        files: ^web/
      - id: vitest
        args: [--dir=web]
        files: ^web/

--runner= means different things either side of that list. For eslint and tsc it names only the package manager the tool is run through; for vitest, pytest and mypy it replaces the command outright, because npm test chooses its own test tool and appending vitest run to it would be wrong — write --runner=npm test -- --run in full.

The embed-* hooks and regen-file are the exception: their args configure the hook itself (--marker=, --command=, --file=, --depth=, --exit=). One entry per block, each with its own --marker; regen-file takes --file= once per file its script writes. In all three the command is only ever the one you wrote down — nothing is discovered and run on its own, and there is no shell, so && is an argument rather than a second command. Scope them with files: so a block that changes twice a year isn't regenerated on every commit.

Full details on any hook, including the paste-ready config block:

gag hooks           # one line per hook
gag hooks pytest    # that one, in full
gag hooks --all

2. The gag CLI

uv tool install git-a-grip    # `gag` on PATH
uvx --from git-a-grip gag    # or one-off

gag --help lists everything; gag <command> --help its options.

gag audit — what does every repo pin?

Walks your project directories, stops at each git working tree, and reports which hooks each one uses and at what rev — labelled (behind), (ahead), (unpinned) against the version you have installed. Third-party hooks and repo: local one-offs are listed too.

gag audit
gag audit ~/projects ~/work
gag audit --json | jq '.repos[] | select(.hooks == [])'

gag sync — pin them all to one rev

The other half. Dry run by default, one line per repo (old -> new).

gag sync                    # dry run against the installed version
gag sync --write            # apply it
gag sync --to v0.5.0
gag sync --latest --write
gag sync --repo https://github.com/gitleaks/gitleaks --latest

The rewrite is textual and touches only the rev: line, so your comments and formatting survive. Nothing is committed — changes land in each repo's working tree for you to review.

Without --to or --latest the target is the version you have installed, which is only a stand-in for the current release. So that run also asks the source for its newest tag and warns on stderr when your install is behind it:

sync: installed v0.7.0, but v0.8.0 is the newest tag at <repo>.
      Re-run with --latest to pin v0.8.0 instead, or upgrade git-a-grip.

The plan is still printed — the warning says the target may not be the one you want, not that the answer is useless. An unreachable remote skips the check silently, so gag sync still works offline.

gag hooks — the hook reference

The table above, in full, from your installed version. Colorized for the terminal, plain when piped. The block below is this repo's own embed-command hook keeping gag hooks output honest:

git-a-grip -- pre-commit hooks

Turn one on in .pre-commit-config.yaml:

repos:
  - repo: https://github.com/dannybrown37/git-a-grip
    rev: v0.10.0
    hooks:
      - id: <one of the below>
      - id: <...any number of others>

  commitizen-early  Reject a bad commit message before the slow hooks run.
  embed-command     Keep a command's output (`--help`) true in the README.
  embed-tree        Regenerate the README file tree, and re-stage it.
  eslint            Lint with the project's own eslint, re-staging fixes.
  mypy              Type-check the project in its own environment.
  pytest            Run the repo's tests through its own environment.
  regen-file        Run a generator script, and re-stage what it rewrote.
  ruff-check        Lint with `ruff check --fix`, re-staging what it fixed.
  ruff-format       Format with `ruff format`, re-staging what it rewrote.
  tsc               Type-check the project with the project's own tsc.
  vitest            Run the project's vitest suite once, never in watch mode.

gag hooks <id> for one in full, gag hooks --all for all of them.

Running a hook by hand

The hooks aren't scripts on your PATH — gag is the only thing this package installs. To run one outside pre-commit (debugging):

uv tool install 'git-a-grip[hooks]'
python -m git_a_grip.hooks ruff-check path/to/file.py

Development

uv sync
uv run pytest

Releases are cut by CI: merge to main, and if lint, tests and the install proofs pass, cz bump tags it and publish.yml uploads to PyPI. Nothing is tagged before the checks pass.

This repo eats its own dog food — its hooks run on itself, and the tree below is maintained by embed-tree.

git-a-grip/
|-- .github/
|   `-- workflows/
|       |-- ci.yml
|       `-- publish.yml
|-- src/
|   `-- git_a_grip/
|       |-- __init__.py
|       |-- audit.py
|       |-- cli.py
|       |-- commitizen_early.py
|       |-- cz.py
|       |-- doc_block.py
|       |-- embed_command.py
|       |-- embed_tree.py
|       |-- hook_docs.py
|       |-- hooks.py
|       |-- mypy_hook.py
|       |-- node_hooks.py
|       |-- project_env.py
|       |-- pytest_hook.py
|       |-- regen_file.py
|       |-- restage.py
|       |-- ruff_hooks.py
|       |-- sync.py
|       `-- version.py
|-- tests/
|   |-- test_audit.py
|   |-- test_cli.py
|   |-- test_commitizen_early.py
|   |-- test_doc_block.py
|   |-- test_embed_command.py
|   |-- test_embed_tree.py
|   |-- test_hook_docs.py
|   |-- test_hooks.py
|   |-- test_mypy_hook.py
|   |-- test_node_hooks.py
|   |-- test_packaging.py
|   |-- test_project_env.py
|   |-- test_pytest_hook.py
|   |-- test_regen_file.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
|-- CLAUDE.md
|-- LICENSE
|-- pyproject.toml
|-- README.md
`-- uv.lock

Release files for git-a-grip 0.10.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for git-a-grip 0.10.0
File Size Uploaded
git_a_grip-0.10.0.tar.gz 112.2 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for git-a-grip 0.10.0
File Interpreter ABI Platform
git_a_grip-0.10.0-py3-none-any.whl Python 3 none any Details

Total release size: 156.9 kB

Release files / git_a_grip-0.10.0.tar.gz

Download URL git_a_grip-0.10.0.tar.gz
Size 112.2 kB
Tags Source
SHA-256 checksum
How to use checksums
ab3c13f7daec8d47134a09ec1fd638e98953da28891c9599e98884ea1284edcb
BLAKE2b-256 checksum
How to use checksums
7b5b38fb89b522e53db8e39593c2ebf7a0c4126227a75e76be46f7a47f5a0262
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 11, 2026.

Transparency log

Release files / git_a_grip-0.10.0-py3-none-any.whl

Download URL git_a_grip-0.10.0-py3-none-any.whl
Size 44.7 kB
Tags Python 3
SHA-256 checksum
How to use checksums
57eec08b9d1139bb57b42fbfd9f8e9e48ac5ca1121b8db8b429ea9efcf205aef
BLAKE2b-256 checksum
How to use checksums
2aeed9d974057b66beb012c4e4bac0ab282592b9293dba99087773338a4c71bb
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 11, 2026.

Transparency log

Release history Release notifications | RSS feed

0.13.0

2 release files

0.12.1

2 release files

0.12.0

2 release files

0.11.1

2 release files

0.11.0

2 release files

This release

0.10.0 This release

2 release files

0.9.0

2 release files

0.8.1

2 release files

0.8.0

2 release files

0.7.0

2 release files

0.6.0

2 release files

0.5.1

2 release files

0.5.0

2 release files

0.4.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page