Skip to main content

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-push pre-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-push and 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

git_a_grip-0.5.0.tar.gz (67.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

git_a_grip-0.5.0-py3-none-any.whl (29.4 kB view details)

Uploaded Python 3

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

Hashes for git_a_grip-0.5.0.tar.gz
Algorithm Hash digest
SHA256 a1eb9725a1b4faa29e21a837510c92a66d250cb4a427fd5f241ac2902dd49ad8
MD5 ea4e3ceb8a8612b60b4b996e48898989
BLAKE2b-256 949b8260d6a8a8a4f2e179ba679909d5c1dea64d26416ddbcd09d3355c3c4a88

See more details on using hashes here.

Provenance

The following attestation bundles were made for git_a_grip-0.5.0.tar.gz:

Publisher: publish.yml on dannybrown37/git-a-grip

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

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

Hashes for git_a_grip-0.5.0-py3-none-any.whl
Algorithm Hash digest
SHA256 5d8f5c129e8f19c24f4a5b015e8d5cea6aa3263f2a4e17666f416375be657b6b
MD5 7538f4b99251c8546a2c839df0db6151
BLAKE2b-256 cc0a9de1d8e82dffd2811aeeca358f02b2059cd98a173205b6e3faf10529a659

See more details on using hashes here.

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

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page