Install-agnostic pre-commit hook fallback + spec-kitty migrate repin-hooks
Filename: 2026-08-27-1-precommit-hook-stable-entrypoint-and-repin-migration.md
Status: Accepted
Date: 2026-08-27
Deciders: Robert (Human-in-Control), ratified 2026-08-27 on the issue
Technical Story: spec-kitty/EXPERIMENTAL-spec-kitty#254
Context and Problem Statement
src/specify_cli/policy/hook_installer.py writes a .git/hooks/pre-commit shell script that
pins sys.executable — an absolute interpreter path — at install time, then execs it
directly. This works until the install method changes: migrating a repo's spec-kitty install
from pipx to uv tool install moves (or removes) the pinned interpreter, and the existing
hook keeps pointing at a path that no longer exists. The next commit then fails at the shell
level with a bare No such file or directory naming a stale path — not a diagnosis, and no
command re-pins it.
Two prior regressions constrain the fix: #105 (the hook must not rely on a bare
python/python3/py resolved fresh off PATH — that lookup is exactly as fragile as the
absolute-path pin, just deferred) and #669 (the installer must preserve symlinks in
sys.executable, not resolve them — resolving strips a venv/pipx shim's sys.prefix
context).
Decision Drivers
- No install method (pipx, uv, or anything else) should be able to leave a dead hook with no recovery path.
- The already-broken case (existing repos with a stale pin) needs a one-time repair, not just a fix for newly-installed hooks.
- Must not reintroduce #105 (bare
pythononPATH) or regress #669 (symlink preservation). - The failure mode, when nothing is reachable, must name a concrete remedy — not surface a bare shell error.
Considered Options
- A. Re-pin automatically on every commit (detect staleness, self-heal silently).
- B. Make the hook invoke a stable, install-agnostic entrypoint (
spec-kittyresolved offPATH) as a fallback when the pinned interpreter is gone, and add a one-timespec-kitty migrate repin-hookscommand for repos already broken. (chosen) - C. Drop the absolute-path pin entirely; always resolve
spec-kittyoffPATH.
Decision Outcome
Chosen option: "B", matching the issue's ratified decision.
Concretely, as shipped:
hook_installer.HOOK_TEMPLATEnow guards the primaryexecwithif [ -x "{interpreter}" ](pinned interpreter, preserved as a symlink per #669) and adds a second guarded branch:if command -v {fallback_command}execsspec-kitty commit-guard-hook "$@"resolved fresh offPATHat commit time. Neither branch is a bare, unguardedpython/python3lookup, so #105 is not reintroduced.- If neither the pinned interpreter nor
spec-kittyonPATHis available, the hook exits 1 with a message naming the concrete remedy:spec-kitty migrate repin-hooks. spec-kitty commit-guard-hook(cli/commands/commit_guard_hook_cmd.py, a hidden Typer command) is the install-agnostic fallback entrypoint — a thintyper.Exitwrapper aroundspecify_cli.policy.commit_guard_hook.main()'s existing int return code.spec-kitty migrate repin-hooks(cli/commands/migrate_cmd.py+cli/commands/migrate/repin_hooks.py) re-runs the same install theimplementlane already calls internally, re-pinning.git/hooks/pre-committo the current interpreter. It takes no--project-rootoption (resolves the current repo vialocate_project_root(), matchingbackfill-identity/backfill-topology), is idempotent, and does not depend on the hook it is repairing.
Consequences
Positive
- An install-method migration (pipx -> uv, or any future method) can no longer leave a hook that hard-fails with an undiagnosable shell error — there is always a fallback path or a named remedy.
- Already-broken repos have a one-time, explicit repair command instead of requiring a manual hook reinstall.
Negative
- The hook script is slightly larger (two guarded
execbranches instead of one) and takes a process-spawn cost forcommand -von the (rare) fallback path. - A second discovery mechanism (
PATH-resolvedspec-kitty) now exists alongside the pinned interpreter, which must both be kept working.
Neutral
spec-kitty migrate repin-hooksmirrors the existingmigratesubgroup's shape and adds no new CLI surface concepts.
Confirmation
Success signals, verified at ship time: (1) with the pinned interpreter gone and spec-kitty
on PATH, a commit succeeds via the fallback branch; (2) with neither available, a commit is
blocked with the named remedy in stderr, not a bare shell path/126/127 error; (3)
spec-kitty migrate repin-hooks re-pins a hook to the current interpreter regardless of what
it was previously pinned to, and is idempotent.
Pros and Cons of the Options
A. Automatic silent self-heal on every commit
Pros: no user-visible repair step. Cons: silently rewriting a git hook on every commit is a surprising side effect for something that should fail loudly and rarely; also does not by itself cover the "neither entrypoint reachable" case, which still needs a named remedy.
B. Install-agnostic fallback entrypoint + one-time repin command (chosen)
Pros: covers both the "hook still runs, just via a different path" case and the already-broken repo case explicitly, with a named remedy when both are exhausted. Cons: two code paths (pin + fallback) to maintain.
C. Drop the pin, always resolve off PATH
Pros: simplest hook body. Cons: reintroduces #105's exact failure mode as the only path (no absolute-path fast path), and loses the #669 symlink-preservation benefit of pinning the exact interpreter the hook was installed under.
More Information
- Issue: spec-kitty/EXPERIMENTAL-spec-kitty#254
- Related regressions: #105 (no bare
pythononPATH), #669 (preservesys.executablesymlinks, don't resolve). - Standing distribution model (context, not directly actionable by this ADR): uv tool install
from private GitHub Releases +
uv tool upgrade.