Give the artifact cap a --prune escape hatch

A gate that blocks a commit with no tooling to clear it just moves the
problem to whoever hits it. --prune stages the removal of the oldest
artifacts, keeping the newest 20 per directory plus the template, and
leaves the commit to the operator.

Running it caught a bug that reading it would not: sorting on %cs (date
only) ties every artifact committed the same day, so sort fell back to the
path and the prune deleted alphabetically - it picked one of today's
newest files instead of the oldest. Sorts on %ct with -rn now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PJrqKERAo5QUeEHWS5Asgp
This commit is contained in:
Krzysztof Rudnicki 2026-08-23 19:41:11 +02:00
parent 17d6b36776
commit 77f2eb9f11
2 changed files with 91 additions and 1 deletions

View File

@ -0,0 +1,36 @@
{
"intent": "Give the artifact cap a one-command fix, so the gate can never block a commit with no way out except reconstructing today's selection logic by hand.",
"scope": [
"meta/scripts/check_repo_size.sh - adds --prune",
"Non-goal: pruning anything now. The tree is at 21/21, well under the 60 cap."
],
"changes": [
"Added --prune: stages the removal of the oldest artifacts, keeping the newest KEEP_ARTIFACTS=20 per directory plus template.json, and leaves the commit to the operator.",
"Fixed a same-day ordering bug found by running it: the first version sorted on %cs (date only), so every artifact committed on the same day tied, sort fell back to comparing the path, and the prune deleted alphabetically. It removed cap-artifact-growth-2026-08-23.json - one of the newest files - instead of the oldest. Now sorts on %ct (unix seconds) with sort -rn."
],
"verification": [
{
"command": "./meta/scripts/check_repo_size.sh --prune (in a detached worktree)",
"result": "pass",
"evidence": "First run staged 2 deletions but they were WRONG: cap-artifact-growth-2026-08-23.json and flatten-linux-configuration-2026-08-23.json, both same-day files chosen alphabetically. This is what exposed the %cs tie."
},
{
"command": "./meta/scripts/check_repo_size.sh --prune after the %ct fix (fresh worktree)",
"result": "pass",
"evidence": "Now stages quarantine-arch-sysctl-cases-2026-08-22.json and thorium-repairs-tests-2026-08-23.json - the genuinely oldest. Templates survive; contracts correctly reports 'nothing to prune' at 20 + template."
},
{
"command": "./meta/scripts/check_repo_size.sh; pre-commit run --files meta/scripts/check_repo_size.sh",
"result": "pass",
"evidence": "'1050 source files (ceiling 1200)' EXIT=0; shfmt and shellcheck clean. Tested in a throwaway worktree so the real tree was never modified."
}
],
"risks": [
"--prune stages deletions rather than committing them, so an operator who runs it and forgets to review still has git diff --cached as the checkpoint.",
"KEEP_ARTIFACTS=20 is below MAX_ARTIFACTS_PER_DIR=60 on purpose; a prune therefore buys headroom instead of landing back on the limit."
],
"rollback": [
"git revert the commit. --prune is additive and off by default; the enforcing path is unchanged.",
"After rollback, confirm ./meta/scripts/check_repo_size.sh still exits 0 and prints the count."
]
}

View File

@ -31,6 +31,11 @@
#
# check_repo_size.sh # count and enforce
# check_repo_size.sh --count # print the number only, always exit 0
# check_repo_size.sh --prune # stage the removal of the oldest artifacts
#
# --prune is the fix when the artifact cap fails: it stages the deletions and
# leaves committing to you, so the gate never blocks a commit without a
# one-command way out.
#
# Exits 0 when at or under every ceiling, 1 when over any of them.
# ============================================================================
@ -60,18 +65,58 @@ readonly ARTIFACT_DIRS=(
)
readonly MAX_ARTIFACTS_PER_DIR=60
# What --prune keeps per directory. Below the cap on purpose, so a prune
# buys real headroom instead of landing back on the limit.
readonly KEEP_ARTIFACTS=20
COUNT_ONLY=0
PRUNE=0
usage() {
echo "Usage: $SCRIPT_NAME [--count]"
echo "Usage: $SCRIPT_NAME [--count | --prune]"
echo "Options:"
echo " --count Print the source-file count and exit 0"
echo " --prune git rm the oldest artifacts, keeping the newest"
echo " $KEEP_ARTIFACTS per directory plus template.json"
echo " -h, --help Show this help"
echo
echo "Fails when tracked source files exceed $MAX_SOURCE_FILES."
exit 0
}
# Remove the oldest artifacts, keeping the newest KEEP_ARTIFACTS per
# directory plus template.json. Ordered by git commit date rather than by
# filename: the corpus mixes -2026-05, -20260604 and undated slugs, so a
# filename sort silently mis-orders a chunk of it. Deleted files remain
# available in git history.
#
# The sort key is %ct (unix seconds), NOT %cs (date only). With %cs every
# artifact committed on the same day ties, sort falls back to comparing the
# path, and the prune deletes alphabetically - which cost the newest file in
# a same-day batch when this was first written.
prune_artifacts() {
local dir removed=0
for dir in "${ARTIFACT_DIRS[@]}"; do
local -a doomed=()
mapfile -t doomed < <(
git ls-files "$dir" |
grep -v '/template\.json$' |
while read -r f; do
printf '%s\t%s\n' "$(git log -1 --format=%ct -- "$f")" "$f"
done |
sort -rn | tail -n "+$((KEEP_ARTIFACTS + 1))" | cut -f2
)
if ((${#doomed[@]} == 0)); then
echo "$dir: nothing to prune"
continue
fi
git rm -q -- "${doomed[@]}"
echo "$dir: pruned ${#doomed[@]}, kept $KEEP_ARTIFACTS + template"
removed=$((removed + ${#doomed[@]}))
done
echo "Pruned $removed file(s). Review with 'git diff --cached --stat', then commit."
}
# Tracked files minus the generated and vendored trees. Printed one per line.
source_files() {
local -a filter=()
@ -102,6 +147,11 @@ check_artifact_dirs() {
}
main() {
if ((PRUNE)); then
prune_artifacts
return 0
fi
local count
count="$(source_files | wc -l)"
@ -135,6 +185,10 @@ while [[ $# -gt 0 ]]; do
COUNT_ONLY=1
shift
;;
--prune)
PRUNE=1
shift
;;
*)
echo "Unknown option: $1" >&2
exit 1