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.