fix(lint): derive the release gate from the pushed ref, reject multi-token entries
The gate hardcoded HEAD as its diff tip, but pre-commit exports PRE_COMMIT_TO_REF for exactly this. Pushing "somebranch:main" from another checkout diffed the wrong tip — a false negative when HEAD is older, a false positive when newer. Fixing only the diff tip leaves a second bug: git describe took the tag baseline from HEAD too, so a tag reachable only from HEAD becomes a baseline the pushed ref never saw. Both now resolve from the pushed ref, and an all-zeros ref (branch deletion) short-circuits before any rev resolution rather than surfacing as "could not diff". PRE_COMMIT_FROM_REF is deliberately not used: it is the remote's current tip, so diffing from it would let an untagged release-relevant commit already on main excuse the next push from cutting a tag — the drift this gate exists to catch. The baseline must stay the last release tag. collect_release_paths took tokens[0] as a path unconditionally. ADR-0014 makes bare single-path entries a binding constraint, but nothing enforced it, and the sibling .pre-commit-config.yaml already ships "entry: bash <script>". Under that shape add_release_path takes "bash", git diff accepts the non-matching pathspec silently, bundle_root becomes "." and is skipped — the hook's whole surface leaves the gate with no error, the same shape as the --config regression in LESSONS.md. Multi-token entries now fail loudly naming the hook and the ADR, and tokens[0] must resolve at HEAD or at the tag (the union is load-bearing: a per-scope check would reject the deletion cases). Six mutations verified, each restored. One correction worth recording: the first multi-token test passed with its guard removed, because the existence guard caught "bash" and printed a similar message. It now requires the verbatim entry text that only the multi-token diagnostic emits. Refs: #85 ADR: 0014
This commit is contained in:
@@ -21,6 +21,27 @@ if [[ "${PRE_COMMIT_REMOTE_BRANCH:-}" != "$TARGET_BRANCH" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# What is actually being pushed, which is only HEAD for the common
|
||||
# `git push <remote> <current-branch>` case. pre-commit's pre-push hook-impl
|
||||
# exports the local sha of each pushed ref as PRE_COMMIT_TO_REF; a
|
||||
# `git push <remote> topic:main` from a different checkout would otherwise be
|
||||
# gated on the wrong tip — a false negative when HEAD is behind the pushed ref
|
||||
# (unreleased changes sail through), a false positive when it is ahead.
|
||||
# PRE_COMMIT_FROM_REF, the *remote's* current tip, is deliberately not used
|
||||
# anywhere here: the baseline is the last release tag, not what the remote
|
||||
# already has. Diffing from the remote tip would let an untagged
|
||||
# release-relevant commit already on main excuse the next push from cutting a
|
||||
# tag, which is precisely the drift this gate exists to catch.
|
||||
PUSHED_REF="${PRE_COMMIT_TO_REF:-HEAD}"
|
||||
|
||||
# pre-commit passes an all-zeros sha (40 hex zeros under sha1, 64 under sha256)
|
||||
# as the "to" ref when the push deletes a branch. Nothing is being shipped, and
|
||||
# every rev-taking command below would fail on an unresolvable sha, so bail out
|
||||
# rather than turning a branch deletion into a confusing "could not diff".
|
||||
if [[ "$PUSHED_REF" =~ ^0+$ ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
REPO_ROOT="$(git rev-parse --show-toplevel)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
@@ -31,8 +52,10 @@ if [[ ! -f "$HOOKS_MANIFEST" ]]; then
|
||||
fi
|
||||
|
||||
# Only vX.Y.Z release tags count as a baseline — an incidental checkpoint or
|
||||
# experiment tag reachable from HEAD must not shift the diff baseline.
|
||||
LAST_TAG="$(git describe --tags --abbrev=0 --match 'v[0-9]*.[0-9]*.[0-9]*' 2>/dev/null || true)"
|
||||
# experiment tag reachable from the pushed ref must not shift the diff baseline.
|
||||
# The tag is resolved from $PUSHED_REF, not HEAD, for the same reason the diff
|
||||
# is: a tag reachable only from HEAD is not part of the history being pushed.
|
||||
LAST_TAG="$(git describe --tags --abbrev=0 --match 'v[0-9]*.[0-9]*.[0-9]*' "$PUSHED_REF" 2>/dev/null || true)"
|
||||
|
||||
if [[ -z "$LAST_TAG" ]]; then
|
||||
echo "FAIL: no release tag exists yet, but .pre-commit-hooks.yaml already exposes hooks to external consumers." >&2
|
||||
@@ -68,16 +91,93 @@ add_release_path() {
|
||||
RELEASE_PATHS+=("$candidate")
|
||||
}
|
||||
|
||||
# Emits one "<hook id><TAB><entry value>" line per hook so a rejected entry can
|
||||
# name the hook a human has to go fix. The id sits on its own line above its
|
||||
# entry: in YAML, so it is carried forward and then cleared; a hook that somehow
|
||||
# has no id still reports something printable rather than an empty name. Kept in
|
||||
# bash rather than awk: matching `[[:space:]]` inside a bracket expression is
|
||||
# reliable in bash's own globs but not in the BWK awk macOS ships. `read -r` with
|
||||
# a single variable is the trimmer — it strips leading and trailing whitespace
|
||||
# while preserving anything in between, so a multi-token entry survives intact
|
||||
# for the error message to quote back.
|
||||
manifest_entries() {
|
||||
local line id="" value
|
||||
while IFS= read -r line; do
|
||||
# Drop the indentation and the optional list dash, so that `- id: x` and
|
||||
# ` entry: y` both reduce to the same bare "key: value" shape.
|
||||
line="${line#"${line%%[![:space:]]*}"}"
|
||||
if [[ "$line" == -* ]]; then
|
||||
line="${line#-}"
|
||||
line="${line#"${line%%[![:space:]]*}"}"
|
||||
fi
|
||||
case "$line" in
|
||||
id:*)
|
||||
read -r id <<< "${line#id:}"
|
||||
;;
|
||||
entry:*)
|
||||
read -r value <<< "${line#entry:}"
|
||||
printf '%s\t%s\n' "${id:-(unnamed hook)}" "$value"
|
||||
id=""
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# A hook's script is legitimate if it exists in the working tree *or* at
|
||||
# $LAST_TAG — the same union the pathspec itself spans. Checking per-scope
|
||||
# instead would reject exactly the case this gate exists to flag: a script
|
||||
# deleted since the tag while its entry survives (see the no -e filtering note
|
||||
# further down) is a real deletion to report, not a malformed manifest.
|
||||
entry_path_exists() {
|
||||
local candidate="$1"
|
||||
[[ -e "$candidate" ]] && return 0
|
||||
git cat-file -e "$LAST_TAG:$candidate" 2>/dev/null && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
# $1 selects where the "does this hook bundle an assets/ tree?" guard looks:
|
||||
# "worktree" probes the filesystem, anything else is a rev whose tree is probed
|
||||
# with git plumbing. Reading entry lines from stdin keeps one derivation for
|
||||
# both the tagged manifest and the current one.
|
||||
collect_release_paths() {
|
||||
local scope="$1" entry bundle_root
|
||||
local scope="$1" line hook_id entry bundle_root where
|
||||
local -a tokens
|
||||
while IFS= read -r entry; do
|
||||
if [[ "$scope" == "worktree" ]]; then
|
||||
where="the working tree's $HOOKS_MANIFEST"
|
||||
else
|
||||
where="$HOOKS_MANIFEST at $scope"
|
||||
fi
|
||||
while IFS= read -r line; do
|
||||
hook_id="${line%%$'\t'*}"
|
||||
entry="${line#*$'\t'}"
|
||||
read -ra tokens <<< "$entry"
|
||||
[[ ${#tokens[@]} -eq 0 ]] && continue
|
||||
# ADR-0014 binds every entry to a bare script path and nothing else, because
|
||||
# pre-commit rewrites only entry[0] into the hook-repo clone. That is a
|
||||
# constraint nothing else enforces, and the sibling .pre-commit-config.yaml
|
||||
# already ships the multi-token `bash <script>` shape one copy-paste away —
|
||||
# so an entry like `bash scripts/foo.sh` would add "bash" as a pathspec that
|
||||
# matches nothing and derive a bundle root of ".", dropping that hook's
|
||||
# entire surface out of the gate silently. Both malformed shapes below fail
|
||||
# loudly instead: silent degradation here is the same class of defect as the
|
||||
# --config token already recorded in LESSONS.md.
|
||||
if [[ ${#tokens[@]} -gt 1 ]]; then
|
||||
echo "FAIL: hook '$hook_id' in $where has a multi-token entry: $entry" >&2
|
||||
echo " Why: pre-commit rewrites only entry[0] into the hook-repo clone, so every later" >&2
|
||||
echo " token resolves against the *consuming* repo and can never name a file this" >&2
|
||||
echo " repo ships — and this gate would derive its release paths from '${tokens[0]}'." >&2
|
||||
echo " Fix: make the entry a bare script path and have the script self-locate anything" >&2
|
||||
echo " else from \${BASH_SOURCE[0]} (see ADR-0014, 'Consequences')." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! entry_path_exists "${tokens[0]}"; then
|
||||
echo "FAIL: hook '$hook_id' in $where names a path that exists neither in the working tree nor at $LAST_TAG: ${tokens[0]}" >&2
|
||||
echo " Why: this gate derives its release-relevant pathspec from that path, so a name" >&2
|
||||
echo " that resolves to no file silently drops the hook's whole surface from the diff." >&2
|
||||
echo " Fix: point the entry at a script path this repo actually ships (see ADR-0014," >&2
|
||||
echo " 'Consequences'); a bare command name is not a valid entry here." >&2
|
||||
exit 1
|
||||
fi
|
||||
add_release_path "${tokens[0]}"
|
||||
bundle_root="$(dirname "$(dirname "${tokens[0]}")")"
|
||||
[[ "$bundle_root" == "." ]] && continue
|
||||
@@ -91,7 +191,7 @@ collect_release_paths() {
|
||||
}
|
||||
|
||||
# The worktree alone is not enough: a path is release-relevant if it was part of
|
||||
# the contract at $LAST_TAG *or* is part of it at HEAD, so both trees have to be
|
||||
# the contract at $LAST_TAG *or* is part of it now, so both trees have to be
|
||||
# derived and unioned. Deriving only from the worktree meant that deleting a
|
||||
# hook's entire assets/ tree made the `-d` guard drop the path from the pathspec
|
||||
# altogether, and the deletion — which breaks every consumer at the next rev —
|
||||
@@ -102,7 +202,7 @@ collect_release_paths() {
|
||||
# surface they cannot reach without a new tag. The union never over-fires on its
|
||||
# own, either — any manifest edit that makes the two disagree already changes
|
||||
# $HOOKS_MANIFEST, which is itself a release-relevant path.
|
||||
collect_release_paths worktree < <(sed -n 's/^[[:space:]]*entry:[[:space:]]*//p' "$HOOKS_MANIFEST")
|
||||
collect_release_paths worktree < <(manifest_entries < "$HOOKS_MANIFEST")
|
||||
|
||||
# A missing manifest at the tag is legitimate (the manifest was added since) but
|
||||
# is indistinguishable from an unreadable tagged tree by its exit status alone,
|
||||
@@ -110,20 +210,21 @@ collect_release_paths worktree < <(sed -n 's/^[[:space:]]*entry:[[:space:]]*//p'
|
||||
# shallow clone, a truncated fetch — fails closed exactly like a `git diff`
|
||||
# failure does, rather than silently degrading to worktree-only derivation.
|
||||
if MANIFEST_AT_TAG="$(git cat-file -p "$LAST_TAG:$HOOKS_MANIFEST" 2>/dev/null)"; then
|
||||
collect_release_paths "$LAST_TAG" < <(printf '%s\n' "$MANIFEST_AT_TAG" | sed -n 's/^[[:space:]]*entry:[[:space:]]*//p')
|
||||
collect_release_paths "$LAST_TAG" < <(printf '%s\n' "$MANIFEST_AT_TAG" | manifest_entries)
|
||||
elif ! git cat-file -e "$LAST_TAG^{tree}" 2>/dev/null; then
|
||||
echo "FAIL: could not read the tree at $LAST_TAG to determine which paths that release exposed." >&2
|
||||
echo " Fix: ensure full tag history is available (e.g. git fetch --unshallow) and retry." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# No -e/existence filtering: a path deleted since $LAST_TAG is exactly the case
|
||||
# that must be caught (external consumers pinning the old tag would hit a
|
||||
# missing file), and `git diff` reports deletions fine without it existing at
|
||||
# HEAD. A git failure (e.g. a shallow clone missing $LAST_TAG's history) must
|
||||
# fail closed, not be swallowed into an empty, falsely-clean diff.
|
||||
if ! CHANGED="$(git diff --name-only "$LAST_TAG"..HEAD -- "${RELEASE_PATHS[@]}")"; then
|
||||
echo "FAIL: could not diff $LAST_TAG..HEAD to check for release-relevant changes (see git error above)." >&2
|
||||
# No -e/existence filtering on the pathspec: a path deleted since $LAST_TAG is
|
||||
# exactly the case that must be caught (external consumers pinning the old tag
|
||||
# would hit a missing file), and `git diff` reports deletions fine without it
|
||||
# existing at the pushed ref. A git failure (e.g. a shallow clone missing
|
||||
# $LAST_TAG's history) must fail closed, not be swallowed into an empty,
|
||||
# falsely-clean diff.
|
||||
if ! CHANGED="$(git diff --name-only "$LAST_TAG".."$PUSHED_REF" -- "${RELEASE_PATHS[@]}")"; then
|
||||
echo "FAIL: could not diff $LAST_TAG..$PUSHED_REF to check for release-relevant changes (see git error above)." >&2
|
||||
echo " Fix: ensure full tag history is available (e.g. git fetch --unshallow) and retry." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
Reference in New Issue
Block a user