From 1c73442a64e4c9b2e83b18332068e46dba42e0c6 Mon Sep 17 00:00:00 2001 From: Blair Hamilton Date: Mon, 17 Aug 2026 21:26:37 -0400 Subject: [PATCH] docs: say what is proven, not what is implemented MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README asked strangers to trust a flat list. "22 supported languages" is true in the sense that 22 backends are registered, and misleading in the sense a reader takes it: ten of them live in one file with no tests of their own and have never been run by CI or by anyone. Someone whose hooks are Julia finds that out at the worst moment. docs/parity.md grades every language by the evidence that exists for it — differential vs Python, unit tests, exercised in CI — and says plainly that the untested rows are untested rather than broken. It also records the two behaviors people meet by surprise: the binary shadows the Python one by design, and the cache is shared, so `clean` removes environments Python pre-commit built. Both are verified here, not assumed: two tools alternating against one store leave db.db and db.json side by side and interlock on the same .lock, and `clean` does take the lot with it. docs/comparison.md leads with the reasons to keep using Python pre-commit, because a reimplementation that oversells itself wastes the reader's afternoon. docs/stability.md explains that v4.6.6 means parity with upstream 4.6 and is not a claim to four majors of maturity. The README now leads with the one sentence that is actually the pitch — run your existing hooks without installing Python — and links these rather than restating them. Its Action example said @main, which pins consumers to an unreleased branch; it now says @v4, and the release workflow moves that alias on every release so the documented path resolves. The tag trigger narrows to full versions, otherwise moving the alias would start a second release of itself. Refs #43 --- .github/workflows/goreleaser.yml | 21 ++++- README.md | 59 ++++++++++++-- docs/comparison.md | 65 +++++++++++++++ docs/parity.md | 135 +++++++++++++++++++++++++++++++ docs/stability.md | 65 +++++++++++++++ 5 files changed, 336 insertions(+), 9 deletions(-) create mode 100644 docs/comparison.md create mode 100644 docs/parity.md create mode 100644 docs/stability.md diff --git a/.github/workflows/goreleaser.yml b/.github/workflows/goreleaser.yml index 1c4b3bc..e0885bb 100644 --- a/.github/workflows/goreleaser.yml +++ b/.github/workflows/goreleaser.yml @@ -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: @@ -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" diff --git a/README.md b/README.md index e7a3559..a053edc 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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` @@ -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 diff --git a/docs/comparison.md b/docs/comparison.md new file mode 100644 index 0000000..8a96d9c --- /dev/null +++ b/docs/comparison.md @@ -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. diff --git a/docs/parity.md b/docs/parity.md new file mode 100644 index 0000000..e1e6204 --- /dev/null +++ b/docs/parity.md @@ -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. diff --git a/docs/stability.md b/docs/stability.md new file mode 100644 index 0000000..4709560 --- /dev/null +++ b/docs/stability.md @@ -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.