fix(lint): flag release-relevant paths retired since the last tag
Coverage was derived from the worktree alone, so the -d guard on a hook's bundled assets/ tree meant deleting the whole tree removed it from the pathspec instead of flagging it — the gate stayed silent about a change that breaks every consumer at the next rev:. The path set is now derived twice, from the worktree manifest and from the manifest at $LAST_TAG, then unioned. A path the tag exposed but HEAD no longer does is a removal pinned consumers must be told about; a path only HEAD exposes is new contract surface. Both need flagging. Fails closed on an unreadable tagged tree (shallow clone), and treats a readable root tree with no manifest as "added since the tag". tokens[0] needed no exit-code fix — it carries no existence guard, so both deletion cases already exited non-zero. What was wrong was the reporting: a fully retired hook could no longer be named in the failure message. The tagged manifest fixes that. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MCQ648fLSFXPHGZdQ8gn58
This commit is contained in:
@@ -30,6 +30,16 @@ if [[ ! -f "$HOOKS_MANIFEST" ]]; then
|
||||
exit 0
|
||||
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)"
|
||||
|
||||
if [[ -z "$LAST_TAG" ]]; then
|
||||
echo "FAIL: no release tag exists yet, but .pre-commit-hooks.yaml already exposes hooks to external consumers." >&2
|
||||
echo " Fix: cut the first release tag (e.g. v1.0.0) before this lands on main." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Derive release-relevant paths from .pre-commit-hooks.yaml's own entry: lines
|
||||
# instead of hand-maintaining a parallel list — the manifest is the single
|
||||
# source of truth for what external consumers actually pull at a pinned rev,
|
||||
@@ -46,26 +56,64 @@ fi
|
||||
# from inventing paths: a bundle root of "." is skipped, because a script in a
|
||||
# top-level directory (scripts/skill-size-check.sh) would derive the repo's own
|
||||
# shared assets/, which no hook owns and whose churn must not demand a release;
|
||||
# and the directory is added only when it exists, since a hook that bundles
|
||||
# nothing must not contribute a pathspec matching nothing.
|
||||
# and the assets/ directory is added only where it is known to exist, since a
|
||||
# hook that bundles nothing must not contribute a pathspec matching nothing.
|
||||
RELEASE_PATHS=("$HOOKS_MANIFEST")
|
||||
while IFS= read -r entry; do
|
||||
read -ra tokens <<< "$entry"
|
||||
[[ ${#tokens[@]} -eq 0 ]] && continue
|
||||
RELEASE_PATHS+=("${tokens[0]}")
|
||||
bundle_root="$(dirname "$(dirname "${tokens[0]}")")"
|
||||
if [[ "$bundle_root" != "." && -d "$bundle_root/assets" ]]; then
|
||||
RELEASE_PATHS+=("$bundle_root/assets")
|
||||
fi
|
||||
done < <(sed -n 's/^[[:space:]]*entry:[[:space:]]*//p' "$HOOKS_MANIFEST")
|
||||
|
||||
# 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)"
|
||||
add_release_path() {
|
||||
local candidate="$1" existing
|
||||
for existing in "${RELEASE_PATHS[@]}"; do
|
||||
[[ "$existing" == "$candidate" ]] && return 0
|
||||
done
|
||||
RELEASE_PATHS+=("$candidate")
|
||||
}
|
||||
|
||||
if [[ -z "$LAST_TAG" ]]; then
|
||||
echo "FAIL: no release tag exists yet, but .pre-commit-hooks.yaml already exposes hooks to external consumers." >&2
|
||||
echo " Fix: cut the first release tag (e.g. v1.0.0) before this lands on main." >&2
|
||||
# $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 -a tokens
|
||||
while IFS= read -r entry; do
|
||||
read -ra tokens <<< "$entry"
|
||||
[[ ${#tokens[@]} -eq 0 ]] && continue
|
||||
add_release_path "${tokens[0]}"
|
||||
bundle_root="$(dirname "$(dirname "${tokens[0]}")")"
|
||||
[[ "$bundle_root" == "." ]] && continue
|
||||
if [[ "$scope" == "worktree" ]]; then
|
||||
[[ -d "$bundle_root/assets" ]] && add_release_path "$bundle_root/assets"
|
||||
else
|
||||
git cat-file -e "$scope:$bundle_root/assets" 2>/dev/null && add_release_path "$bundle_root/assets"
|
||||
fi
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
# 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
|
||||
# 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 —
|
||||
# diffed clean. The two manifests can genuinely disagree (an entry added,
|
||||
# removed, or renamed since the tag), and the union is the conservative side of
|
||||
# that disagreement: a path the tag exposed and HEAD no longer does is a removal
|
||||
# consumers must be told about, and a path only HEAD exposes is new contract
|
||||
# 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")
|
||||
|
||||
# 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,
|
||||
# so the tag's root tree is verified separately. An absent tree object — a
|
||||
# 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')
|
||||
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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user