Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion .github/workflows/goreleaser.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ name: Release
on:
push:
tags:
- "v*"
# Full versions only. The moving major alias (v4) is a tag too, and matching
# "v*" would make every alias update kick off a second, duplicate release.
- "v[0-9]+.[0-9]+.[0-9]+*"
workflow_dispatch:
inputs:
tag:
Expand Down Expand Up @@ -67,3 +69,20 @@ jobs:
MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }}
MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }}
MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }}
# `uses: blairham/go-pre-commit@v4` is the documented way to consume the
# composite action, and a documented path that does not resolve is worse
# than no documentation. Move the alias with every release rather than
# remembering to do it by hand.
- name: Move the major-version alias tag
env:
TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}
run: |
major="${TAG%%.*}" # v4.6.7 -> v4
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git tag "$major" "$TAG^{}" --force
# Retire the old alias, then publish the new one. Deleting first keeps
# this a plain create rather than a rewrite of a ref someone may be
# resolving right now.
git push origin ":refs/tags/$major" || true
git push origin "refs/tags/$major"
59 changes: 51 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,50 @@
[![GoDoc](https://pkg.go.dev/badge/github.com/blairham/go-pre-commit/v4)](https://pkg.go.dev/github.com/blairham/go-pre-commit/v4)
[![License](https://img.shields.io/github/license/blairham/go-pre-commit)](https://github.com/blairham/go-pre-commit/blob/main/LICENSE)

A Go reimplementation of [pre-commit](https://github.com/pre-commit/pre-commit) — a framework for managing and maintaining multi-language pre-commit hooks.
**Run your existing pre-commit hooks without installing Python.**

## Features
Same `.pre-commit-config.yaml`, same hook repositories, same commands — as a
single binary. An independent Go reimplementation of
[pre-commit](https://github.com/pre-commit/pre-commit), not a fork and not
affiliated with it.

- **Drop-in replacement** — identical CLI interface to the Python pre-commit tool
- **22 supported languages**: Python, Node, Go, Ruby, Rust, Docker, Docker Image, Conda, Coursier, Dart, Dotnet, Haskell, Julia, Lua, Perl, R, Swift, Fail, Pygrep, System, Script, Python venv
- **All hook types**: pre-commit, pre-merge-commit, pre-push, commit-msg, post-checkout, post-commit, post-merge, post-rewrite, prepare-commit-msg, pre-rebase
Measured against Python pre-commit 4.6.2 on every pull request:
**78 of 78 differential checks pass** ([how that is measured](docs/parity.md)).

## Is this for you?

Probably not, and that is worth two minutes of your time:
**[should you use this instead of Python pre-commit?](docs/comparison.md)**

The short version — if you already have Python and pre-commit working, keep
them. The case for this tool is a repo whose *only* reason to install a Python
toolchain is to run its hooks.

## What it does

- **Drop-in** — the same CLI, config format, hook repositories and cache
location as the Python tool
- **One binary** — no interpreter, no virtualenv, no `pip`
- **All hook types**: pre-commit, pre-merge-commit, pre-push, commit-msg,
post-checkout, post-commit, post-merge, post-rewrite, prepare-commit-msg,
pre-rebase
- **22 languages** implemented — though not equally proven; the
[parity grading](docs/parity.md#language-support-graded) says which are
well-trodden and which you would be the first to try
- **File type identification** by extension, filename, and shebang
- **Parallel hook execution** with xargs-style batching
- **Automatic caching** of hook repositories

## Installation

> **It installs a binary called `pre-commit`, and that is deliberate.**
> `pre-commit install` writes a git hook that invokes `pre-commit` by name, so a
> drop-in has to answer to that name. On Homebrew it shadows
> `homebrew/core/pre-commit`, and `brew` will say so. If you keep both tools,
> know which one you are getting: `pre-commit --version` prints a `(build …)`
> suffix here and nothing of the sort upstream. The cache directory is shared
> with the Python tool too — details, including what `clean` removes, are in
> [docs/parity.md](docs/parity.md#deliberate-behaviors-that-surprise-people).

### Homebrew

```bash
Expand Down Expand Up @@ -62,13 +93,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: blairham/go-pre-commit@main
- uses: blairham/go-pre-commit@v4
```

Inputs:

```yaml
- uses: blairham/go-pre-commit@main
- uses: blairham/go-pre-commit@v4
with:
version: latest # release to install, e.g. "v4.6.6"
extra_args: --all-files # passed to `pre-commit run`
Expand Down Expand Up @@ -204,8 +235,20 @@ git tag v4.6.7
git push origin v4.6.7
```

The release workflow also moves the `v4` alias tag to the new release, which is
what `uses: blairham/go-pre-commit@v4` resolves to. Its tag trigger deliberately
matches full versions only, so moving the alias does not start a second release.

CI builds, signs, and notarizes cross-platform binaries, publishes a GitHub release, and updates the Homebrew formula in [blairham/homebrew-tap](https://github.com/blairham/homebrew-tap) automatically. Versions track upstream parity: `v4.6.x` means feature parity with Python pre-commit 4.6.

## Documentation

| | |
|---|---|
| [Should you use this?](docs/comparison.md) | The case for staying on Python pre-commit, and the narrow case against it |
| [Parity](docs/parity.md) | What is measured, what is not, and which languages are actually proven |
| [Stability](docs/stability.md) | What the version number means, and what is frozen |

## Attribution

This is an independent reimplementation, not a fork and not an official
Expand Down
65 changes: 65 additions & 0 deletions docs/comparison.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Should you use this instead of Python pre-commit?

Often, no. This page exists to make that easy to determine, because a
reimplementation that oversells itself wastes your afternoon and earns nothing.

## Keep using Python pre-commit if

- **It is working for you.** There is no problem here that you have and it
solves; the tool you already have is the reference implementation, maintained
by the people who designed the thing.
- **Your team already has Python everywhere.** The install cost this removes is
a cost you are not paying.
- **You rely on a language backend outside the well-trodden set.** Python,
system, script, pygrep and fail are proven here. Julia, Swift, R, Haskell and
friends are implemented but effectively unexercised — see
[parity.md](parity.md) for the grading.
- **You need Windows.** It builds. Nobody has run it there.
- **You want the guarantee that the tool matches the docs at pre-commit.com.**
Only one implementation can promise that, and it is not this one.

## Consider this if

- **CI spends real time installing Python to run three whitespace hooks.** The
common case for adopting this is a repo with no other reason to have a Python
toolchain — a Go, Rust, or Node service whose lint hooks are the only Python
in the build.
- **You want one binary.** No interpreter, no virtualenv, no `pip`, no
`python_version` skew between a laptop and a runner.
- **Your hooks are already mostly `system`, `script` or Go tools.** That path is
both the best-tested here and the one where the interpreter is pure overhead.

## What it is not

- **Not a fork.** It shares no code with upstream. It is an independent
implementation of the same behavior, and it can be wrong in ways upstream is
not. See [NOTICE](../NOTICE).
- **Not affiliated with the pre-commit project.** Do not file its bugs on their
tracker. Do not ask them about it.
- **Not a different design.** There is deliberately no feature here that
upstream lacks. New behavior would be a compatibility break with extra steps;
divergence is a bug. If you want something pre-commit does not do, the useful
place to ask for it is upstream.
- **Not faster at running your hooks.** It is faster at *starting*, and faster
per-hook when the hook itself is trivial. If your slow hook is `golangci-lint`
or `eslint`, the tool doing the work is the same tool, and the number will not
move. The README's benchmark shows exactly that: 4× on `trailing-whitespace`,
1.0× on `golangci-lint`.

## Against other options

| Option | When it is the better answer |
|---|---|
| **Python pre-commit** | Almost always, if you already have it. The reference implementation of the thing this copies. |
| **`pre-commit.ci`** | You want hooks fixed and pushed automatically on PRs. That is a hosted service; this is a binary and does not compete with it. |
| **`lefthook`, `husky`, `overcommit`** | You want a git-hook runner on its own terms and you are not attached to the pre-commit config format or its hook ecosystem. They are not drop-ins — different config, different hook repos. |
| **A shell script in `.git/hooks`** | Your needs are one or two checks and you do not want a framework. This is a fine answer and you should not feel talked out of it. |
| **This** | You want the pre-commit config format and its hook ecosystem, without an interpreter in the way. |

## The honest summary

The pitch is narrow on purpose: **the same tool, the same config, the same hook
repositories, without needing Python installed to run them.** Everything else —
the startup time, the single binary — follows from that one thing. If the
Python dependency is not costing you anything, this project has nothing to sell
you, and that is a fine outcome.
135 changes: 135 additions & 0 deletions docs/parity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
# Parity

This project's whole reason to exist is that it behaves like Python
[pre-commit](https://github.com/pre-commit/pre-commit). Divergence is a bug,
not a feature. This page says how much of that is *measured* rather than
intended, because those are different numbers and only one of them is
evidence.

## What is measured

`test/integration/parity_test.go` runs the same command against both binaries in
the same scratch repository and compares exit codes, output and the resulting
files on disk. It runs on every pull request.

| | |
|---|---|
| Checks | **78** |
| Passing | **78** (100.0%) |
| Measured against | Python pre-commit **4.6.2**, pinned in CI |
| Report | [`test/integration/parity_report.json`](../test/integration/parity_report.json), regenerated by the suite |

The report records the version it measured against, because a parity percentage
without that version is not a claim anyone can check. If it reads
`Measured against: NOTHING`, no comparison happened and the number is void.

Run it yourself:

```bash
pip install 'pre-commit==4.6.2'
PARITY_REQUIRE=1 go test -tags=integration -timeout=600s ./test/integration/
```

The harness refuses to measure against the wrong thing, and this is not
theoretical — it is a bug that already happened here. Because this project
installs a binary *called* `pre-commit`, the obvious `LookPath("pre-commit")`
finds **this tool** on any machine that has it, and the suite spent some time
diffing the tool against an older build of itself while reporting the result as
parity with Python (#45). Candidates now have to prove they are Python by their
`--version` output, an off-target Python minor line is rejected, and under
`PARITY_REQUIRE` a run that compares nothing fails instead of passing green.

## What the 78 checks cover

Every command's surface: `run`, `install`, `uninstall`, `install-hooks`,
`autoupdate`, `clean`, `gc`, `try-repo`, `init-templatedir`, `migrate-config`,
`sample-config`, `validate-config`, `validate-manifest`, `help`, `--version` —
their exit codes, their output, and the files they leave behind.

What they do **not** cover is the part underneath: building an environment for
each language and running a real hook in it. The suite's own configs use
`language: system` and `language: python` only.

## Language support, graded

All 22 languages are implemented and registered. They are not equally proven,
and the README's flat list does not tell you which is which. This table does.

| Language | Differential vs Python | Unit tests | Exercised in CI |
|---|---|---|---|
| `python`, `python_venv` | ✅ | ✅ 12 | ✅ |
| `system` | ✅ | ✅ | ✅ |
| `script`, `fail`, `pygrep` | — | ✅ 11 (shared file) | — |
| `golang` | — | ✅ 2 | — |
| `docker`, `docker_image` | — | ✅ 3 | — |
| `node` | — | ❌ none | — |
| `ruby` | — | ❌ none | — |
| `rust` | — | ❌ none | — |
| `conda`, `coursier`, `dart`, `dotnet`, `haskell`, `lua`, `perl`, `r` | — | ❌ none of their own¹ | — |
| `julia`, `swift` | — | ❌ none | — |

¹ These are instances of the shared `SimpleLanguage` machinery in
`internal/languages/configlang.go`, which does have 17 tests. Those tests cover
the machinery, not any individual language's install or health-check commands.

Read that table as: **if your hooks are Python, system, script, pygrep or fail,
this is well-trodden.** If they are Julia or Swift, you are likely the first
person to run that path, and an issue is genuinely useful. Nothing in the lower
rows is known broken — it is untested, which is a different and more honest
claim than "supported".

## Platform support

CI is `ubuntu-latest` only. Releases ship macOS and Windows archives, and macOS
is used daily by the maintainer. **Windows has never been exercised by anyone**;
it builds, and that is the extent of what is known.

## Deliberate behaviors that surprise people

These are not bugs and not oversights. They are parity decisions, and they are
here so you meet them in the docs rather than in your terminal.

**The binary is named `pre-commit`, and it shadows the Python one.** That is the
point: `pre-commit install` writes a git hook that invokes `pre-commit` by name,
so a drop-in replacement has to answer to that name. The Homebrew formula
shadows `homebrew/core/pre-commit` and `brew` will tell you so. If you want both
tools on one machine, install this one somewhere earlier or later on `PATH`
deliberately and know which one you are getting — `pre-commit --version` prints
a `(build …)` suffix here and nothing of the sort upstream.

**The cache is shared with Python pre-commit, and the formats are not
compatible.** Both default to `~/.cache/pre-commit` and both honor
`PRE_COMMIT_HOME`. This one indexes into `db.json` with content-addressed
directory names; Python indexes into a SQLite `db.db` with its own. Running both
against one store leaves the two sets of environments side by side:

```
$PRE_COMMIT_HOME/
db.db <- Python's index
db.json <- this tool's index
repo3_4fef0c/ <- the same hook repo, Python's copy
repocf9afd75442a4da1/ <- and this tool's
.lock
```

Neither tool disturbs the other's entries — verified by alternating runs of both
against one store — and they take turns properly, because both take an exclusive
`flock` on the same `.lock` file. What you pay is disk: the same hook repository
gets an environment per tool.

**`clean` removes the entire store, including environments Python pre-commit
built.** This matches upstream, whose `clean` also removes the store — but
because the store is shared, it reaches further than you may expect. Running
`pre-commit clean` with this tool deletes Python's `db.db` and its environment
directories along with its own; the next Python run rebuilds them from scratch
(`[INFO] This may take a few minutes...`). Nothing is lost permanently, but
nothing is free either. Point `PRE_COMMIT_HOME` somewhere separate if you want
the two kept apart.

## Reporting a divergence

Any behavior difference from Python pre-commit 4.6.x is a bug here. File it
against [this repo](https://github.com/blairham/go-pre-commit/issues), never
upstream — their maintainers did not write this and should not field its
defects. The most useful report is the pair of commands and the two outputs; if
you can express it as a check in `parity_test.go`, better still.
65 changes: 65 additions & 0 deletions docs/stability.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Stability and versioning

## The version number is not about this codebase

`v4.6.6` does **not** mean "the sixth patch of the sixth minor of this
project". It means: *this behaves like Python pre-commit 4.6.x*.

| Part | Meaning |
|---|---|
| `4.6` | The Python pre-commit line whose behavior is targeted. |
| `.6` | Releases of this implementation against that line — fixes, coverage, packaging. |

So `v4.6.6` and `v4.6.7` both target upstream 4.6. When upstream releases 4.7,
this project's next version is `v4.7.0`, and it means *parity with 4.7*, not
"new features from us".

This is unusual, and it has one consequence worth stating plainly: **a bump in
the minor version is not a promise about this project's maturity.** `v4` here is
inherited from upstream's numbering, not a claim to four major versions of
stability. Judge maturity from [parity.md](parity.md), which is evidence.

The CI parity harness pins the Python version it measures against to the
declared line and refuses any other, so this number cannot quietly drift away
from what it claims.

## What is frozen

These are the contracts. Breaking any of them requires a release that says so
in its notes, prominently.

- **The CLI.** Command names, flags, exit codes, and the meaning of each. They
are upstream's, and they change when upstream changes them.
- **The config format.** `.pre-commit-config.yaml` and hook manifests are
upstream's schemas. This project does not add keys to them.
- **The cache contract.** `PRE_COMMIT_HOME`, `XDG_CACHE_HOME`, and the default
`~/.cache/pre-commit`.
- **The binary name.** `pre-commit`. Installed git hooks invoke it by that name,
so renaming it would break every repo that ran `install`.
- **The composite action's inputs.** `version`, `extra_args`, `cache`,
`install-only`.

## What is not frozen

- Anything under `internal/`. It is not an API; import paths there can change in
any release.
- Log and progress output that is not part of upstream's documented output.
- The set of platforms with published binaries.
- Which languages are *proven*, in the sense of [parity.md](parity.md) — that
table should only ever improve, but it is a status report, not a promise.

## Upstream changes win

When Python pre-commit changes behavior in a way this project has copied, this
project follows — even if the old behavior was nicer. That is the whole
proposition. If you need behavior upstream does not have, this is the wrong tool
to ask, and asking upstream is more likely to help everyone.

## Support

Single maintainer, personal project, no company behind it, no SLA. What that
buys you if it stops being maintained: your `.pre-commit-config.yaml` is
upstream's format and your hook repositories are upstream's, so the exit is
`pip install pre-commit` and deleting one line from a workflow. That is
deliberate — there is no state, no service, and nothing proprietary anywhere in
the path.
Loading