todo-app/scripts/install_hooks.sh
Krzysztof kuhy Rudnicki 34b2c1c87c Fix three gate defects found reviewing the cap refactor
Moving bump_patch_version.sh into .pre-commit-config.yaml introduced three
bugs, all reproduced in clean clones of HEAD before fixing and re-verified
after:

1. DATA LOSS. pre-commit stashes unstaged changes and restores them with
   `git apply`. The bump rewrites pubspec.yaml inside that window, so an
   uncommitted edit near the `version:` line made the restore conflict:
   the commit aborted AND the developer's edit vanished from the working
   tree, recoverable only from $PRE_COMMIT_HOME. Only non-mutating checks
   belong in that file.

2. A commit rejected by the file-length gate still had its version bumped
   and staged, riding into the next successful commit. `always_run: true`
   with no `fail_fast` meant "ordered last" did not mean "runs last only
   on success" -- which is exactly what the comment above it claimed.

3. `files: ^pubspec\.yaml$` narrowed the crdt_sync override check from
   unconditional to staged-only, so a poisoned pubspec arriving by pull or
   merge was invisible to every later commit that did not touch pubspec.
   The hand-written hook this replaced checked it every time; that
   narrowing quietly widened the hole the check exists to close.

The fix keeps the framework for the read-only gates and puts the mutation
back outside it: install_hooks.sh now writes a wrapper that runs
pre-commit's generated hook first and bump_patch_version.sh only if it
passed. Plus fail_fast, and always_run on the crdt check.

Also fixes three stale references this refactor created: a load-bearing
layout comment citing the renamed _buildRaw, bin/todo_desktop.dart naming
two split-away test files, and CLAUDE.md giving two different suite
runtimes.

Verified: unstaged pubspec edit preserved through a commit; a rejected
commit leaves nothing staged; a merged-in override is caught on an
unrelated commit. 313 tests, 100% coverage, gate exits 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-14 17:13:31 +02:00

88 lines
3.0 KiB
Bash
Executable File

#!/bin/bash
# ============================================================================
# Install this repo's git hooks.
#
# .git/hooks/ is not tracked, so a fresh clone has no hooks. Run this once
# after cloning.
#
# Two layers, deliberately:
#
# 1. pre-commit (the framework) runs the read-only gates in
# .pre-commit-config.yaml -- the crdt_sync override check and the
# 250-line file-length cap.
# 2. A thin wrapper hook installed here runs pre-commit first and, only if
# it passed, runs scripts/bump_patch_version.sh.
#
# The bump cannot live in .pre-commit-config.yaml. pre-commit stashes
# unstaged changes for the duration of its run and restores them with
# `git apply`; the bump rewrites pubspec.yaml inside that window, so an
# uncommitted edit anywhere near the `version:` line makes the restore
# conflict -- which aborts the commit and DROPS the developer's edit from the
# working tree. Running it after pre-commit has exited keeps the mutation
# outside the stash cycle, and gating it on pre-commit's exit status stops a
# rejected commit from leaving a stray bump staged for the next one.
# ============================================================================
set -euo pipefail
REPO_ROOT="$(git rev-parse --show-toplevel)"
readonly REPO_ROOT
readonly HOOK="$REPO_ROOT/.git/hooks/pre-commit"
readonly LEGACY_MARKER='bump_patch_version.sh'
# The old hand-written hook has to go before pre-commit will install over it;
# pre-commit refuses to clobber a foreign hook.
remove_legacy_hook() {
if [[ -e "$HOOK" ]] && grep -q "$LEGACY_MARKER" "$HOOK" \
&& ! grep -q 'pre-commit' "$HOOK"; then
rm -f "$HOOK"
echo "Removed the legacy hand-written pre-commit hook."
fi
}
ensure_pre_commit() {
if command -v pre-commit >/dev/null 2>&1; then
return
fi
echo "Installing pre-commit..."
if command -v pipx >/dev/null 2>&1; then
pipx install pre-commit
else
python3 -m pip install --user pre-commit
fi
}
# Wraps pre-commit's own generated hook so the version bump runs after it,
# and only on success. pre-commit install --hook-type pre-commit writes the
# generated script; we move it aside and call it from ours.
install_wrapper() {
local generated="$REPO_ROOT/.git/hooks/pre-commit.pre-commit-generated"
mv "$HOOK" "$generated"
cat > "$HOOK" <<'HOOK_BODY'
#!/bin/bash
# Installed by scripts/install_hooks.sh — see that file for why the version
# bump runs here rather than as a pre-commit hook.
set -euo pipefail
ROOT="$(git rev-parse --show-toplevel)"
readonly ROOT
# The read-only gates. A non-zero exit here rejects the commit, and the bump
# below never runs, so a rejected attempt leaves no stray version change.
"$ROOT/.git/hooks/pre-commit.pre-commit-generated" "$@"
exec bash "$ROOT/scripts/bump_patch_version.sh"
HOOK_BODY
chmod 755 "$HOOK"
}
main() {
ensure_pre_commit
remove_legacy_hook
pre-commit install
install_wrapper
echo "Installed pre-commit hooks: $HOOK"
}
main "$@"