Pass 1 gains a second dead-branch signal. A branch still on origin whose tip is an ancestor of a protected branch's tip is merged, so its target-*/snapshot-* is evicted unconditionally, exactly like a deleted branch's.
The free-space requirement is measured, not chosen. Before the seed clones, the pass measures the mutable set of the snapshot it will clone — the part a hardlink clone has to real-copy — and evicts oldest-first until that much is free, then stops. Falling short of it fails the pass, naming the shortfall and every directory it kept and why.
min-free-percent becomes an additional floor, defaulting to 0. It only ever raises the derived requirement, and falling short of it is still a warning plus a self-clear rather than a failure.
The prune step moves ahead of the seed in cargo-cache/action.yml, which is the only order in which its eviction helps the run doing it.
cache-lib.sh names the mutable set once, as one directory-name array and one file-rule dispatcher, applied by a callback from the copier and from the new sizer.
Four selftest scenarios, all red-proven (below).
Why
Issue 20. On gitdan-ci, zemyna's cache volume reached 124 G and cache staging failed three times in a row on 2026-09-07, each time cleared by a human rm -rf of one 40 G per-branch directory. Two assumptions did not hold:
Liveness keyed on "gone from origin".daniel/zemyna had default_delete_branch_after_merge: false at the time of those failures, so git ls-remote reported merged branches forever and the signal fired for none of them. A merged PR's 40 G target dir was live for eviction purposes indefinitely.
Pressure gated on a percentage. Staging failed at ~34 G free and passed at ~74 G, while the 10% floor is ~19 G on that disk — so the gate could not fire before the seed refused. The threshold was sized for a different failure than the one that occurs: what the clone needs is a property of the snapshot, not of the volume.
Since those failures, the user has enabled default_delete_branch_after_merge on daniel/zemyna. That is the right setting and the README now recommends it: a deleted branch is decidable from ls-remote alone, with no checkout, no objects and no walk. It does not make the ancestry signal redundant, and the README enumerates what it leaves behind — the 48 branches merged before it was enabled, which nothing retroactively deletes; every merge whose deletion the forge declines (a protected branch, or one another open PR uses) or is never asked to make (an API merge without the flag, which tea pulls merge omits), the delete being best-effort and silent either way; and every repo that has not enabled it, which is the default. The derived-headroom half is untouched by the setting: the dev push run failed with the branch's directory alive and no deletion pending.
Key implementation details
Ancestry is answered from the job's own checkout, and "cannot tell" is never folded into "dead". A shallow checkout withholds the merged signal entirely, since a missing object is its normal case rather than evidence; a single branch whose tip is not in the checkout is kept with a warning naming it; a protected tip absent from the checkout is not used as an anchor. A squash or rebase merge leaves no ancestry, so its branch reads as live until it is deleted. All of those are missed reclamations, which cost disk; the other direction costs a branch its cache mid-build. Tip SHAs come from the same ls-remote output the existing pass already reads, so both halves describe one instant.
One enumeration of the mutable set, two readers. A sizer with its own copy of the find predicates would drift from the copier silently and in the dangerous direction — an under-measured clone is one that starts and then runs out of disk during the unshare. _MUTABLE_DIR_NAMES and _mutable_file_rules are now applied by a callback per side. mutable_set_kb skips the subtrees it has already measured whole, so nothing is double-counted; its two residuals (over, by a file matching two rules; under, by dep-info inside a deliberately-shared out) are named at the function.
The margin is hand-written and says so.CACHE_CLONE_HEADROOM_PERCENT (150) covers what scales with the tree — unshare_subtree holds one subtree twice at its peak — and CACHE_CLONE_HEADROOM_FLOOR_KB (2 GiB) covers what does not: cp -al materialises every directory for real. Nothing measures either default; both err toward asking for more, because over-asking costs one branch a cold start and under-asking fails the job.
Two things the reordering makes newly reachable, both closed. The source about to be cloned is excluded from every pass by name (protected_reason), so pass 1 cannot take the snapshot out from under the seed behind it. And a concurrent job's target dir carries its lock from the instant it appears under its final name — the seed writes it into the staging tree before the rename — so running earlier never sees an unlocked directory that is in use. Pass 3 is unaffected: it recreates the own dir empty, and the seed reuses an own dir that exists, so a self-cleared run clones nothing and needs no headroom.
This does not reach any consumer until the v1 tag moves. That tag currently points at 14bea98, two merges behind main, and consumers pin cargo-cache@v1. Moving it is a release action on main, not something this branch can do.
Red proof. The four new scenarios were run against the pre-fix scripts (the three scripts stashed, the selftest kept, fail() made non-fatal so every assertion reports). Sixteen assertions fail; scenarios 1–14 pass throughout:
=== 15: a merged-but-undeleted branch is dead too ===
ASSERTION FAILED: expected in log: merged-branch detection anchored on
ASSERTION FAILED: expected gone: .../cache/target-feat-merged-282ad2a5
ASSERTION FAILED: expected gone: .../cache/snapshot-feat-merged-282ad2a5
ASSERTION FAILED: expected in log: merged into dev
=== 16: 'cannot tell' is not death, per branch and per checkout ===
ASSERTION FAILED: expected in log: cannot tell merged from live
ASSERTION FAILED: expected gone: .../cache/target-feat-merged-282ad2a5
ASSERTION FAILED: expected in log: checkout is shallow
=== 17: the free-space requirement is measured off the clone's source ===
ASSERTION FAILED: expected in log: measured from its mutable set
ASSERTION FAILED: expected gone: .../cache/target-feat-a-44e45143
ASSERTION FAILED: expected gone: .../cache/target-feat-b-d8bff9f3
ASSERTION FAILED: expected exit 1 when the clone cannot fit, got 0
ASSERTION FAILED: expected in log: short by
ASSERTION FAILED: expected in log: held open by a running job
=== 18: the layout that produced the bug ===
ASSERTION FAILED: expected gone: .../cache/target-feat-merged-282ad2a5
ASSERTION FAILED: expected exactly one eviction on the zemyna layout
Scenario 18 is the dry run the ticket asks for: three equal-sized caches mirroring the measured layout — the base snapshot, the base target dir, and one merged-but-undeleted branch's — with 90% free, so neither age nor size nor pressure can be what decides. Exactly one directory is evicted and it is that one. Scenario 17 stubs df to answer from the fixture's own size, because a fixed CACHE_DF_OVERRIDE cannot show a pass stopping.
Nothing here needs a human smoke: it is shell and fixtures, and the visual half does not exist. The first real exercise is a CI run in a consuming repo after the v1 tag moves. zemyna's workflow comment at .gitea/workflows/ci.yaml:154-197 describes the old rule and is a follow-up in that repo, not edited here — see issue 20 for the acceptance criterion it belongs to.
## Summary
- **Pass 1 gains a second dead-branch signal.** A branch still on origin whose tip is an ancestor of a protected branch's tip is merged, so its `target-*`/`snapshot-*` is evicted unconditionally, exactly like a deleted branch's.
- **The free-space requirement is measured, not chosen.** Before the seed clones, the pass measures the mutable set of the snapshot it will clone — the part a hardlink clone has to real-copy — and evicts oldest-first until that much is free, then stops. Falling short of it fails the pass, naming the shortfall and every directory it kept and why.
- **`min-free-percent` becomes an additional floor**, defaulting to `0`. It only ever raises the derived requirement, and falling short of it is still a warning plus a self-clear rather than a failure.
- **The prune step moves ahead of the seed** in `cargo-cache/action.yml`, which is the only order in which its eviction helps the run doing it.
- **`cache-lib.sh` names the mutable set once**, as one directory-name array and one file-rule dispatcher, applied by a callback from the copier and from the new sizer.
- Four selftest scenarios, all red-proven (below).
## Why
Issue 20. On `gitdan-ci`, zemyna's cache volume reached 124 G and cache staging failed three times in a row on 2026-09-07, each time cleared by a human `rm -rf` of one 40 G per-branch directory. Two assumptions did not hold:
1. **Liveness keyed on "gone from origin".** `daniel/zemyna` had `default_delete_branch_after_merge: false` at the time of those failures, so `git ls-remote` reported merged branches forever and the signal fired for none of them. A merged PR's 40 G target dir was live for eviction purposes indefinitely.
2. **Pressure gated on a percentage.** Staging failed at ~34 G free and passed at ~74 G, while the 10% floor is ~19 G on that disk — so the gate could not fire before the seed refused. The threshold was sized for a different failure than the one that occurs: what the clone needs is a property of the snapshot, not of the volume.
**Since those failures, the user has enabled `default_delete_branch_after_merge` on `daniel/zemyna`.** That is the right setting and the README now recommends it: a deleted branch is decidable from `ls-remote` alone, with no checkout, no objects and no walk. It does not make the ancestry signal redundant, and the README enumerates what it leaves behind — the 48 branches merged before it was enabled, which nothing retroactively deletes; every merge whose deletion the forge declines (a protected branch, or one another open PR uses) or is never asked to make (an API merge without the flag, which `tea pulls merge` omits), the delete being best-effort and silent either way; and every repo that has not enabled it, which is the default. The derived-headroom half is untouched by the setting: the `dev` push run failed with the branch's directory alive and no deletion pending.
## Key implementation details
**Ancestry is answered from the job's own checkout, and "cannot tell" is never folded into "dead".** A shallow checkout withholds the merged signal entirely, since a missing object is its normal case rather than evidence; a single branch whose tip is not in the checkout is kept with a warning naming it; a protected tip absent from the checkout is not used as an anchor. A squash or rebase merge leaves no ancestry, so its branch reads as live until it is deleted. All of those are missed reclamations, which cost disk; the other direction costs a branch its cache mid-build. Tip SHAs come from the same `ls-remote` output the existing pass already reads, so both halves describe one instant.
**One enumeration of the mutable set, two readers.** A sizer with its own copy of the find predicates would drift from the copier silently and in the dangerous direction — an under-measured clone is one that starts and then runs out of disk during the unshare. `_MUTABLE_DIR_NAMES` and `_mutable_file_rules` are now applied by a callback per side. `mutable_set_kb` skips the subtrees it has already measured whole, so nothing is double-counted; its two residuals (over, by a file matching two rules; under, by dep-info inside a deliberately-shared `out`) are named at the function.
**The margin is hand-written and says so.** `CACHE_CLONE_HEADROOM_PERCENT` (150) covers what scales with the tree — `unshare_subtree` holds one subtree twice at its peak — and `CACHE_CLONE_HEADROOM_FLOOR_KB` (2 GiB) covers what does not: `cp -al` materialises every directory for real. Nothing measures either default; both err toward asking for more, because over-asking costs one branch a cold start and under-asking fails the job.
**Two things the reordering makes newly reachable, both closed.** The source about to be cloned is excluded from every pass by name (`protected_reason`), so pass 1 cannot take the snapshot out from under the seed behind it. And a concurrent job's target dir carries its lock from the instant it appears under its final name — the seed writes it into the staging tree before the rename — so running earlier never sees an unlocked directory that is in use. Pass 3 is unaffected: it recreates the own dir empty, and the seed reuses an own dir that exists, so a self-cleared run clones nothing and needs no headroom.
**This does not reach any consumer until the `v1` tag moves.** That tag currently points at `14bea98`, two merges behind `main`, and consumers pin `cargo-cache@v1`. Moving it is a release action on `main`, not something this branch can do.
## Files affected
| file | change |
|---|---|
| `scripts/cache-lib.sh` | shared mutable-set enumeration; `mutable_set_kb`, `clone_headroom_kb`, `seed_source_candidates`, `seed_clone_source` |
| `scripts/prune-cache.sh` | merged-branch liveness; derived requirement; keep-reasons and the failure report; three new optional arguments |
| `scripts/seed-target-dir.sh` | reads its candidate list from `cache-lib.sh` |
| `cargo-cache/action.yml` | prune step moved ahead of the seed and passed the seed's keys; `min-free-percent` default `0`; input descriptions |
| `scripts/prune-cache-selftest.sh` | scenarios 15–18 |
| `README.md` | eviction section, inputs table, selftest coverage row, the `fetch-depth: 0` note |
## Test plan
`bash scripts/selftest.sh` — the full suite, including the two compiler-backed ones, on cargo 1.93.1 stable:
```
cache-root-selftest: 19 assertions passed
seed-target-dir-selftest: 53 assertions passed
publish-snapshot-selftest: 24 assertions passed
prune-cache-selftest: 61 assertions passed
hardlink-clone-selftest: 10 assertions passed
restore-mtimes: ALL 14 ASSERTIONS PASSED
selftest: all 6 suites passed
```
`shellcheck -x --source-path=scripts scripts/*.sh` — CI's exact invocation, exit 0.
**Red proof.** The four new scenarios were run against the pre-fix scripts (the three scripts stashed, the selftest kept, `fail()` made non-fatal so every assertion reports). Sixteen assertions fail; scenarios 1–14 pass throughout:
```
=== 15: a merged-but-undeleted branch is dead too ===
ASSERTION FAILED: expected in log: merged-branch detection anchored on
ASSERTION FAILED: expected gone: .../cache/target-feat-merged-282ad2a5
ASSERTION FAILED: expected gone: .../cache/snapshot-feat-merged-282ad2a5
ASSERTION FAILED: expected in log: merged into dev
=== 16: 'cannot tell' is not death, per branch and per checkout ===
ASSERTION FAILED: expected in log: cannot tell merged from live
ASSERTION FAILED: expected gone: .../cache/target-feat-merged-282ad2a5
ASSERTION FAILED: expected in log: checkout is shallow
=== 17: the free-space requirement is measured off the clone's source ===
ASSERTION FAILED: expected in log: measured from its mutable set
ASSERTION FAILED: expected gone: .../cache/target-feat-a-44e45143
ASSERTION FAILED: expected gone: .../cache/target-feat-b-d8bff9f3
ASSERTION FAILED: expected exit 1 when the clone cannot fit, got 0
ASSERTION FAILED: expected in log: short by
ASSERTION FAILED: expected in log: held open by a running job
=== 18: the layout that produced the bug ===
ASSERTION FAILED: expected gone: .../cache/target-feat-merged-282ad2a5
ASSERTION FAILED: expected exactly one eviction on the zemyna layout
```
Scenario 18 is the dry run the ticket asks for: three equal-sized caches mirroring the measured layout — the base snapshot, the base target dir, and one merged-but-undeleted branch's — with 90% free, so neither age nor size nor pressure can be what decides. Exactly one directory is evicted and it is that one. Scenario 17 stubs `df` to answer from the fixture's own size, because a fixed `CACHE_DF_OVERRIDE` cannot show a pass *stopping*.
Nothing here needs a human smoke: it is shell and fixtures, and the visual half does not exist. The first real exercise is a CI run in a consuming repo after the `v1` tag moves. zemyna's workflow comment at `.gitea/workflows/ci.yaml:154-197` describes the old rule and is a follow-up in that repo, not edited here — see issue 20 for the acceptance criterion it belongs to.
Closes #20.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01JXMQCJ5Eg5f9G9cfYzyh4Z
claude
added the bug label 2026-09-07 05:24:28 +00:00
The set of paths a hardlink clone has to real-copy — dep-info,
build-script metadata, linked outputs, the pruned directories that hold
them — was spelled out inline in unshare_mutable_paths, in four find
invocations. Nothing else needed it, so one spelling was enough.
Something else needs it now: the prune has to know what a clone will
cost before it happens, and a sizer with its own copy of the predicates
would drift from the copier silently and in the dangerous direction — an
under-measured clone is one that starts and runs out of disk halfway
through unsharing. So the directory names become one array and the file
rules one dispatcher, applied by a callback per side, with each rule's
rationale moved to the rule rather than left at the old call site.
mutable_set_kb measures that set off a snapshot, skipping the subtrees
already measured whole so nothing is counted twice; clone_headroom_kb
scales it by a hand-written margin and floor for what the measurement
cannot see (cp -al materialising every directory for real, and the
unshare holding one subtree twice at its peak). Both residuals are named
where the function is, in both directions.
seed_source_candidates moves the seed's source-preference list into
cache-lib for the same reason: the prune ahead of it has to resolve the
same source the seed will clone, and two agreeing derivations are one
edit away from disagreeing.
No behaviour change — the copier applies the same rules to the same
tree, verified by the hardlink-clone suite's inode partition in both
directions.
Refs #20.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXMQCJ5Eg5f9G9cfYzyh4Z
Two assumptions in the eviction pass did not hold on this forge, and
between them a volume filled up three times in three days with nothing
reclaimed automatically. Both are replaced here; the pass also moves
ahead of the seed, which is the only order in which its work can help
the run performing it.
LIVENESS. Pass 1 evicted a cache only when its branch was gone from
origin. Gitea keeps a PR's branch after the merge unless the repo opts
into delete-on-merge, and zemyna does not — so ls-remote reports fifty
merged branches and the signal fires for none of them. A second signal
is added beside it: a branch still on origin whose tip is an ancestor of
a protected branch's tip holds no commit that branch does not, so its
cache will never be read again and goes in the same unconditional pass.
Ancestry is answered from the commits in the job's own checkout, so the
answer "cannot tell" exists and stays distinct from "not merged" at both
granularities. A shallow checkout withholds the signal entirely, since a
missing object is its normal case rather than evidence. A single branch
whose tip is not in the checkout is kept, with a warning naming it. A
squash or rebase merge leaves no ancestry and reads as live until the
branch is deleted. All three are missed reclamations, which cost disk;
the other direction costs a branch its cache mid-build.
HEADROOM. Passes 2 and 3 gated on a percentage of the volume, which
cannot express the failure they have to prevent: a clone runs out of
disk while unsharing its mutable paths, and how much that needs is a
property of the snapshot rather than of the disk. Staging failed at 34 G
free and passed at 74 G, so a 10% floor — 19 G here — never fired first.
The requirement is now measured per run off the very source the seed
will read, the pass evicts oldest-first until it is met and stops there,
and falling short of it fails with the shortfall and every directory it
kept, rather than letting the seed fail seconds later against a staging
path that names none of that. min-free-percent survives as an additional
floor, defaulting to 0, and falling short of that one is still a warning
and a self-clear.
ORDERING. The prune step ran after the seed, so each run freed space for
the next one. It now runs between resolve and seed. Two things that
makes newly reachable are closed: the source about to be cloned is
excluded from every pass by name, and a concurrent job's target dir
already carries its lock from the instant it appears under its final
name, so nothing is seen unlocked that is in use.
Red-proven: sixteen assertions across the four new scenarios fail
against the pre-fix scripts, including the zemyna layout evicting
nothing where it should evict exactly one directory, and the headroom
scenario exiting 0 where it should exit 1.
Refs #20.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXMQCJ5Eg5f9G9cfYzyh4Z
The eviction section described one way for a branch to be dead and a
percentage threshold that no longer exists as a gate. Rewritten around
what the pass actually does: two dead-branch signals with the limits of
the ancestry one stated, a requirement measured off the clone's mutable
set, and a failure that names its shortfall.
The 10% figure is deleted rather than corrected — it was the default of
a gate, and min-free-percent is now an additional floor defaulting to 0,
so there is no percentage left to state. The inputs table, the prune and
liveness rows, the selftest coverage row and the fetch-depth comment in
the usage example all say what they now mean; fetch-depth: 0 has a
second reason to be required, since a shallow checkout cannot answer
ancestry.
Refs #20.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXMQCJ5Eg5f9G9cfYzyh4Z
`daniel/zemyna` enabled `default_delete_branch_after_merge` after this
branch was written, so the deleted-branch signal will fire there on
future merges. That makes the setting worth recommending — it is the
cheapest case for this scheme, decidable from `ls-remote` with no
checkout, no objects and no walk — and it does not make the ancestry
signal redundant.
Three things it leaves behind, now enumerated in the README's eviction
section rather than implied: every branch merged before the setting was
turned on, of which zemyna carried 48 and which nothing retroactively
deletes; every merge whose deletion the forge declines or is never asked
to make, since it is best-effort and silent and an API merge without the
flag never asks; and every repo that has not enabled it, which is the
default.
Two present-tense claims about one repo's configuration are reworded
into the conditions they were standing in for, in prune-cache.sh's
header and beside is_merged_dead, plus the two in the selftest that
asserted the forge keeps branches rather than describing the fixture.
No behaviour change; the suite is green and unchanged at 61 assertions.
Refs #20.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXMQCJ5Eg5f9G9cfYzyh4Z
Reviewed at head 38a6387, base 0b099ec. The head moved during the review — 24f87a6 was pushed to 38a6387 mid-pass — so to be explicit: the delta
between the two is comment and README wording only, every executable byte of cache-lib.sh, seed-target-dir.sh and cargo-cache/action.yml is identical
across it, and the two files that do differ (prune-cache.sh, prune-cache-selftest.sh) differ only inside comments. Everything below was
read and run against that content.
Headline
Both halves land, and the eviction half is the one I tried hardest to break.
Pass 1's new signal cannot classify a directory dead on an undecidable
ancestry answer: both operands are proven present with git cat-file -e
before the walk (prune-cache.sh:420 for the anchor, :456 for the tip), and
every non-zero merge-base --is-ancestor status falls through to the next
protected ref and then to live (:461-471), so the 1-vs-128 ambiguity the
brief asked about resolves in the keeping direction whichever way it goes.
Protected refs, the own dir and the seed source are excluded ahead of the
merge test (:483 via protected_reason at :198-205), and a locked
directory is still declined twice.
The pressure half evicts against the derived requirement, re-reads df each
iteration and breaks the instant it is met (:562-563), so it cannot
over-evict; and the shortfall path fails with the source, the gap in GB and
every kept directory with its reason (:589-596), which is genuinely more
actionable than the staging-path error it pre-empts.
Per-AC, against issue 20's four criteria
AC 1 — a merged branch's cache is reclaimed without a human. Met. is_merged_dead at prune-cache.sh:447-472, anchored at :409-432. I probed
the case the tests do not cover, a branch pushed with zero commits ahead of dev so its tip equals the protected tip: it is classified dead and evicted,
because a commit is its own ancestor. That is correct rather than a bug — the
branch holds no commit dev does not, so its next run re-clones from snapshot-dev, whose content is identical or newer — but it is a live branch
losing its cache, and nothing in the suite or the prose says so. Worth one
sentence at is_merged_dead and, ideally, an assertion.
AC 2 — the gate is derived from what the seed needs. Met. mutable_set_kb / clone_headroom_kb at cache-lib.sh:641-690, measured off _MUTABLE_DIR_NAMES and _mutable_file_rules, which is the same enumeration unshare_mutable_paths copies from — the one-derivation-not-two argument is
the right one and it is actually implemented, not just asserted.
I measured the two hand-written margins against real data, because
"over-asking is cheap, under-asking fails the job" is only true if the number
lands above the failure. On a 46.7 GB zemyna target dir, using this branch's
own functions:
quantity
value
tree total
46.7 GB
mutable set
20.6 GB
derived requirement
32.9 GB
Taken flat that is alarming: issue 20 reports the seed failing at ~34 GB free,
so a 32.9 GB requirement would have passed the run that failed. It does not
survive contact with the confound, though. 21 GB of that tree is debug/incremental, which the CI snapshot does not have — zemyna's workflow
sets CARGO_INCREMENTAL: 0. Excluding it, linked outputs are ~80 % of the
tree, which puts a 44.5 GB CI snapshot's mutable set near 35 GB and the
requirement near 55 GB: inside the observed band, since the incident failed at
~34 GB and passed at ~74 GB. So I could not falsify the constants, and the
evidence I have is consistent with them.
That is an extrapolation, not a measurement, and it is worth closing cheaply:
the pass now logs clone requirement: … needs X GB free on every run, so the
first CI run after the tag moves prints the real number for free. Record it.
AC 3 — the README and the consumer's workflow comment describe the new
rule; the 10 % figure is deleted. README half met and accurate — the
eviction section, the inputs table row and the fetch-depth: 0 note all
match the code, and 10 is gone rather than corrected. Consumer half not
done, which is unavoidable across repos, but it was tracked nowhere except a
sentence in this PR body. zemyna/.gitea/workflows/ci.yaml:175 still says min-free-percent (10) is left "at the defaults, which are the values this
workflow already used", and that becomes false the moment v1 moves past 14bea98. Recorded as an entry on zemyna's standing docs-drift catalog so it
outlives this PR; not a reason to bounce this one.
AC 4 — a dry run on today's zemyna layout evicts exactly the merged-live
dir. Met, and it is scenario 18. I reran it: three equal directories, 90 %
free, exactly one eviction and it is the merged one.
Test integrity
The four new scenarios are contracts, not documentation. Scenario 15 builds a
real --no-ff merge on a real scratch origin and pairs it with a control
branched at the same point and deliberately older, so age cannot be what
decides. Scenario 16 gets its undecidable branch by pushing from a second
clone rather than by stubbing a failure, and asserts its shallow fixture is
actually shallow before trusting it. Scenario 17 is the one that would have
been easiest to fake and is not: 8 MB of dep-info beside 16 MB of .rlib
means a requirement taken from the source's size gives a different answer
from one taken from its mutable set, and the df stub answering from the
fixture's real size is what makes the stop observable at all — a fixed CACHE_DF_OVERRIDE could not show it. I checked the arithmetic end to end:
9 MB required, 4 MB freed is not enough, 8 MB is, feat/c survives. That is
the assertion.
Full suite including both compiler-backed ones. Every figure matches the PR
body's test plan exactly. The suite is hermetic — one mktemp -d with an EXIT
trap, and it left nothing behind. closing-reference-check.sh check-pr daniel/gitdan-actions 20 21 exits 0; opener is claude; label bug.
Nits, none blocking
The 2 GiB floor's stated reason is wrong.cache-lib.sh:676-678 says a
Bevy-sized target dir has "hundreds of thousands" of directories. Measured:
3,252, about 13 MB of entries. The constant is fine and errs safe — the
floor usefully covers the multi-GB linked output _unshare_files holds
twice mid-copy — but delete the figure rather than correct it. Cataloged.
"two merges behind main" in the PR body. There is one merge between v1 and main (0b099ec, PR 18). The load-bearing half of that sentence —
nothing reaches a consumer until the tag moves — is correct and important;
just drop the count.
min-free-percent defaulting to 0 leaves the reuse path with no
pressure gate at all. When the seed reuses an existing own target dir the
derived requirement is zero by construction, so with the floor also zero,
passes 2 and 3 do nothing however tight the disk — and the prune no longer
runs after the build either. Pass 1 is a much bigger lever than the old 10 %
ever was, so this is very likely the right trade, and issue 20 asks for the
percentage to stop being the gate. But it is a real reduction in cover on a
path the ticket does not discuss, and zemyna takes the new default without
passing the input. Worth a sentence in the README saying so plainly.
Drift and sibling-track check
No orphan code, no stale imports, no public-surface drift: seed-target-dir.sh
now reads its candidate list from cache-lib.sh and the inline list is gone,
not left beside it. min-free-percent remains a documented input rather than
being silently ignored. Nothing else in the repo referenced the old prune step
position. This repo had no standing docs-drift catalog; I created the docs-drift-catalog label and filed one, and both findings above are recorded
against it and against zemyna's.
The unpushed-commit state I opened with is resolved — it is pushed, and the PR
head and the worktree now agree.
VERDICT: APPROVE-WITH-NITS
Reviewed at head `38a6387`, base `0b099ec`. The head moved during the review —
`24f87a6` was pushed to `38a6387` mid-pass — so to be explicit: the delta
between the two is comment and README wording only, every executable byte of
`cache-lib.sh`, `seed-target-dir.sh` and `cargo-cache/action.yml` is identical
across it, and the two files that do differ (`prune-cache.sh`,
`prune-cache-selftest.sh`) differ only inside comments. Everything below was
read and run against that content.
## Headline
Both halves land, and the eviction half is the one I tried hardest to break.
Pass 1's new signal cannot classify a directory dead on an undecidable
ancestry answer: both operands are proven present with `git cat-file -e`
before the walk (`prune-cache.sh:420` for the anchor, `:456` for the tip), and
every non-zero `merge-base --is-ancestor` status falls through to the next
protected ref and then to `live` (`:461-471`), so the 1-vs-128 ambiguity the
brief asked about resolves in the keeping direction whichever way it goes.
Protected refs, the own dir and the seed source are excluded ahead of the
merge test (`:483` via `protected_reason` at `:198-205`), and a locked
directory is still declined twice.
The pressure half evicts against the derived requirement, re-reads `df` each
iteration and breaks the instant it is met (`:562-563`), so it cannot
over-evict; and the shortfall path fails with the source, the gap in GB and
every kept directory with its reason (`:589-596`), which is genuinely more
actionable than the staging-path error it pre-empts.
## Per-AC, against issue 20's four criteria
**AC 1 — a merged branch's cache is reclaimed without a human.** Met.
`is_merged_dead` at `prune-cache.sh:447-472`, anchored at `:409-432`. I probed
the case the tests do not cover, a branch pushed with zero commits ahead of
`dev` so its tip *equals* the protected tip: it is classified dead and evicted,
because a commit is its own ancestor. That is correct rather than a bug — the
branch holds no commit `dev` does not, so its next run re-clones from
`snapshot-dev`, whose content is identical or newer — but it is a live branch
losing its cache, and nothing in the suite or the prose says so. Worth one
sentence at `is_merged_dead` and, ideally, an assertion.
**AC 2 — the gate is derived from what the seed needs.** Met.
`mutable_set_kb` / `clone_headroom_kb` at `cache-lib.sh:641-690`, measured off
`_MUTABLE_DIR_NAMES` and `_mutable_file_rules`, which is the same enumeration
`unshare_mutable_paths` copies from — the one-derivation-not-two argument is
the right one and it is actually implemented, not just asserted.
I measured the two hand-written margins against real data, because
"over-asking is cheap, under-asking fails the job" is only true if the number
lands above the failure. On a 46.7 GB zemyna target dir, using this branch's
own functions:
| quantity | value |
|---|---|
| tree total | 46.7 GB |
| mutable set | 20.6 GB |
| derived requirement | 32.9 GB |
Taken flat that is alarming: issue 20 reports the seed failing at ~34 GB free,
so a 32.9 GB requirement would have passed the run that failed. It does not
survive contact with the confound, though. 21 GB of that tree is
`debug/incremental`, which the CI snapshot does not have — zemyna's workflow
sets `CARGO_INCREMENTAL: 0`. Excluding it, linked outputs are ~80 % of the
tree, which puts a 44.5 GB CI snapshot's mutable set near 35 GB and the
requirement near 55 GB: inside the observed band, since the incident failed at
~34 GB and passed at ~74 GB. So I could not falsify the constants, and the
evidence I have is consistent with them.
That is an extrapolation, not a measurement, and it is worth closing cheaply:
the pass now logs `clone requirement: … needs X GB free` on every run, so the
first CI run after the tag moves prints the real number for free. Record it.
**AC 3 — the README and the consumer's workflow comment describe the new
rule; the 10 % figure is deleted.** README half met and accurate — the
eviction section, the inputs table row and the `fetch-depth: 0` note all
match the code, and `10` is gone rather than corrected. Consumer half not
done, which is unavoidable across repos, but it was tracked nowhere except a
sentence in this PR body. `zemyna/.gitea/workflows/ci.yaml:175` still says
`min-free-percent` (10) is left "at the defaults, which are the values this
workflow already used", and that becomes false the moment `v1` moves past
`14bea98`. Recorded as an entry on zemyna's standing docs-drift catalog so it
outlives this PR; not a reason to bounce this one.
**AC 4 — a dry run on today's zemyna layout evicts exactly the merged-live
dir.** Met, and it is scenario 18. I reran it: three equal directories, 90 %
free, exactly one eviction and it is the merged one.
## Test integrity
The four new scenarios are contracts, not documentation. Scenario 15 builds a
real `--no-ff` merge on a real scratch origin and pairs it with a control
branched at the same point and deliberately *older*, so age cannot be what
decides. Scenario 16 gets its undecidable branch by pushing from a second
clone rather than by stubbing a failure, and asserts its shallow fixture is
actually shallow before trusting it. Scenario 17 is the one that would have
been easiest to fake and is not: 8 MB of dep-info beside 16 MB of `.rlib`
means a requirement taken from the source's *size* gives a different answer
from one taken from its mutable set, and the `df` stub answering from the
fixture's real size is what makes the *stop* observable at all — a fixed
`CACHE_DF_OVERRIDE` could not show it. I checked the arithmetic end to end:
9 MB required, 4 MB freed is not enough, 8 MB is, `feat/c` survives. That is
the assertion.
## Sanity runs
```
bash scripts/selftest.sh
cache-root-selftest: 19 assertions passed
seed-target-dir-selftest: 53 assertions passed
publish-snapshot-selftest: 24 assertions passed
prune-cache-selftest: 61 assertions passed
hardlink-clone-selftest: 10 assertions passed
restore-mtimes: ALL 14 ASSERTIONS PASSED
selftest: all 6 suites passed
shellcheck -x --source-path=scripts scripts/*.sh # exit 0
```
Full suite including both compiler-backed ones. Every figure matches the PR
body's test plan exactly. The suite is hermetic — one `mktemp -d` with an EXIT
trap, and it left nothing behind. `closing-reference-check.sh check-pr
daniel/gitdan-actions 20 21` exits 0; opener is `claude`; label `bug`.
## Nits, none blocking
1. **The 2 GiB floor's stated reason is wrong.** `cache-lib.sh:676-678` says a
Bevy-sized target dir has "hundreds of thousands" of directories. Measured:
3,252, about 13 MB of entries. The constant is fine and errs safe — the
floor usefully covers the multi-GB linked output `_unshare_files` holds
twice mid-copy — but delete the figure rather than correct it. Cataloged.
2. **"two merges behind `main`"** in the PR body. There is one merge between
`v1` and `main` (`0b099ec`, PR 18). The load-bearing half of that sentence —
nothing reaches a consumer until the tag moves — is correct and important;
just drop the count.
3. **`min-free-percent` defaulting to `0` leaves the reuse path with no
pressure gate at all.** When the seed reuses an existing own target dir the
derived requirement is zero by construction, so with the floor also zero,
passes 2 and 3 do nothing however tight the disk — and the prune no longer
runs after the build either. Pass 1 is a much bigger lever than the old 10 %
ever was, so this is very likely the right trade, and issue 20 asks for the
percentage to stop being the gate. But it is a real reduction in cover on a
path the ticket does not discuss, and zemyna takes the new default without
passing the input. Worth a sentence in the README saying so plainly.
## Drift and sibling-track check
No orphan code, no stale imports, no public-surface drift: `seed-target-dir.sh`
now reads its candidate list from `cache-lib.sh` and the inline list is gone,
not left beside it. `min-free-percent` remains a documented input rather than
being silently ignored. Nothing else in the repo referenced the old prune step
position. This repo had no standing docs-drift catalog; I created the
`docs-drift-catalog` label and filed one, and both findings above are recorded
against it and against zemyna's.
The unpushed-commit state I opened with is resolved — it is pushed, and the PR
head and the worktree now agree.
claude
marked the pull request as ready for review 2026-09-07 05:38:55 +00:00
claude
merged commit 0184df25a2 into main2026-09-07 05:41:12 +00:00
claude
deleted branch fix/prune-merged-live-and-seed-headroom2026-09-07 05:41:13 +00:00
Posted as a comment rather than a review: the PR merged at 05:41:12Z, and Gitea will not accept a review on a merged PR. The verdict below is the record for the body edit at 05:38:55Z, which landed after review 1242 and before the merge.
VERDICT: APPROVE-WITH-NITS
SCOPE: SPOT-CHECK
Re-dispatched because the PR body moved at 05:38:55Z, 38 seconds after
review 1242 was posted at 05:38:17Z. The recorded verdict no longer
described what was there, so it had to be re-established against the current
head.
Gate
Eligible for a spot check rather than a full re-review, mechanically:
check
result
review 1242's commit_id
38a63879
PR head.sha now
38a63879
content diff between them
empty
merge-base then and now
0b099ec, unchanged
So the diff I read is bit-identical to the diff that is there, and what it
merges into has not moved. Nothing about the code was re-verified, by design;
what follows is the wording.
The head move from 24f87a6, checked rather than accepted
The implementer's report that 24f87a6 → 38a63879 is prose-only is correct,
and I confirmed it mechanically rather than by reading the diff: stripping
comment and blank lines from every script and from action.yml, then hashing,
gives an identical digest on both sides for all eight files.
Three files are touched in total — README.md, prune-cache.sh, prune-cache-selftest.sh — and the two scripts differ only inside comment
blocks. The selftest run and the logic review in review 1242 therefore stand
without repetition.
The body change is an improvement, and its claims hold
The Why section's first bullet moved to past tense and gained a paragraph
recording that delete-on-merge has since been turned on. Both halves check out:
daniel/zemyna now has default_delete_branch_after_merge: True
(repos/daniel/zemyna). The original present-tense claim had become false,
and correcting it was right — issue 20's first premise is now history, and
the body says so instead of asserting it.
The argument that this does not make ancestry redundant is not just
plausible, it is measurable, and I measured it. Right now daniel/zemyna
has 61 branches on origin, of which 39 are already ancestors of dev. Those
39 are exactly the population the deleted-branch signal can never see and the
new ancestry signal reclaims. Enabling the setting stopped the bleeding; it
reclaimed nothing that was already there.
The closing sentence — that the derived-headroom half is untouched, since
the dev push run failed with the branch's directory alive — matches issue
20's own account of that run.
Correcting a claim that had gone stale, in the record the review is written
against, is the right instinct. No objection to the edit; the verdict is
unchanged by it.
Nits, still none blocking
New: "the 48 branches merged before it was enabled" (body, and README.md:293-294 as "zemyna carried 48 of them"). This is an unsourced
figure and a reader cannot reproduce it. The nearest derivation I can run —
branches on origin whose tip is an ancestor of dev, which is the very test
this PR adds — gives 39, not 48. I am not calling 48 wrong: my count is
taken now rather than at the moment the setting flipped, and it misses any
branch merged only into main or merged by squash or rebase, so the two
numbers are not measuring quite the same thing. That is the problem. The
argument does not need the value at all — "they stay on origin forever;
nothing retroactively deletes them" carries the whole point — and the figure
will decay as those branches are cleaned up, with nothing watching it.
Delete it rather than correct it. Cataloged.
Carried over, unaddressed: "two merges behind main" (body). There is
one merge between v1 and main — 0b099ec, PR 18. The load-bearing half
of that sentence is correct and worth keeping; drop the count.
Carried over from review 1242 and unchanged: the 2 GiB floor's stated
reason at cache-lib.sh:676-678 (measured 3,252 directories, not "hundreds
of thousands"), and min-free-percent defaulting to 0 leaving the
cache-reuse path with no pressure gate at all.
None of these is worth a round on its own. All three are recorded on this
repository's standing docs-drift catalog, issue 22.
One note on state
The PR is no longer drafted. That is the correct lifecycle for it — the review
passed and there is nothing here a human can smoke, it being shell and
fixtures — so CI is now the only gate left ahead of the merge.
_Posted as a comment rather than a review: the PR merged at `05:41:12Z`, and Gitea will not accept a review on a merged PR. The verdict below is the record for the body edit at `05:38:55Z`, which landed after review 1242 and before the merge._
VERDICT: APPROVE-WITH-NITS
SCOPE: SPOT-CHECK
Re-dispatched because the PR body moved at `05:38:55Z`, 38 seconds after
review 1242 was posted at `05:38:17Z`. The recorded verdict no longer
described what was there, so it had to be re-established against the current
head.
## Gate
Eligible for a spot check rather than a full re-review, mechanically:
| check | result |
|---|---|
| review 1242's `commit_id` | `38a63879` |
| PR `head.sha` now | `38a63879` |
| content diff between them | empty |
| merge-base then and now | `0b099ec`, unchanged |
So the diff I read is bit-identical to the diff that is there, and what it
merges into has not moved. Nothing about the code was re-verified, by design;
what follows is the wording.
## The head move from `24f87a6`, checked rather than accepted
The implementer's report that `24f87a6` → `38a63879` is prose-only is correct,
and I confirmed it mechanically rather than by reading the diff: stripping
comment and blank lines from every script and from `action.yml`, then hashing,
gives an identical digest on both sides for all eight files.
```
CODE-IDENTICAL scripts/cache-lib.sh
CODE-IDENTICAL scripts/prune-cache.sh
CODE-IDENTICAL scripts/seed-target-dir.sh
CODE-IDENTICAL scripts/prune-cache-selftest.sh
CODE-IDENTICAL cargo-cache/action.yml
```
Three files are touched in total — `README.md`, `prune-cache.sh`,
`prune-cache-selftest.sh` — and the two scripts differ only inside comment
blocks. The selftest run and the logic review in review 1242 therefore stand
without repetition.
## The body change is an improvement, and its claims hold
The `Why` section's first bullet moved to past tense and gained a paragraph
recording that delete-on-merge has since been turned on. Both halves check out:
- **`daniel/zemyna` now has `default_delete_branch_after_merge: True`**
(`repos/daniel/zemyna`). The original present-tense claim had become false,
and correcting it was right — issue 20's first premise is now history, and
the body says so instead of asserting it.
- **The argument that this does not make ancestry redundant is not just
plausible, it is measurable, and I measured it.** Right now `daniel/zemyna`
has 61 branches on origin, of which 39 are already ancestors of `dev`. Those
39 are exactly the population the deleted-branch signal can never see and the
new ancestry signal reclaims. Enabling the setting stopped the bleeding; it
reclaimed nothing that was already there.
- The closing sentence — that the derived-headroom half is untouched, since
the `dev` push run failed with the branch's directory alive — matches issue
20's own account of that run.
Correcting a claim that had gone stale, in the record the review is written
against, is the right instinct. No objection to the edit; the verdict is
unchanged by it.
## Nits, still none blocking
1. **New: "the 48 branches merged before it was enabled"** (body, and
`README.md:293-294` as "zemyna carried 48 of them"). This is an unsourced
figure and a reader cannot reproduce it. The nearest derivation I can run —
branches on origin whose tip is an ancestor of `dev`, which is the very test
this PR adds — gives **39**, not 48. I am not calling 48 wrong: my count is
taken now rather than at the moment the setting flipped, and it misses any
branch merged only into `main` or merged by squash or rebase, so the two
numbers are not measuring quite the same thing. That is the problem. The
argument does not need the value at all — "they stay on origin forever;
nothing retroactively deletes them" carries the whole point — and the figure
will decay as those branches are cleaned up, with nothing watching it.
Delete it rather than correct it. Cataloged.
2. **Carried over, unaddressed: "two merges behind `main`"** (body). There is
one merge between `v1` and `main` — `0b099ec`, PR 18. The load-bearing half
of that sentence is correct and worth keeping; drop the count.
3. Carried over from review 1242 and unchanged: the 2 GiB floor's stated
reason at `cache-lib.sh:676-678` (measured 3,252 directories, not "hundreds
of thousands"), and `min-free-percent` defaulting to `0` leaving the
cache-reuse path with no pressure gate at all.
None of these is worth a round on its own. All three are recorded on this
repository's standing docs-drift catalog, issue 22.
## One note on state
The PR is no longer drafted. That is the correct lifecycle for it — the review
passed and there is nothing here a human can smoke, it being shell and
fixtures — so CI is now the only gate left ahead of the merge.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Summary
target-*/snapshot-*is evicted unconditionally, exactly like a deleted branch's.min-free-percentbecomes an additional floor, defaulting to0. It only ever raises the derived requirement, and falling short of it is still a warning plus a self-clear rather than a failure.cargo-cache/action.yml, which is the only order in which its eviction helps the run doing it.cache-lib.shnames the mutable set once, as one directory-name array and one file-rule dispatcher, applied by a callback from the copier and from the new sizer.Why
Issue 20. On
gitdan-ci, zemyna's cache volume reached 124 G and cache staging failed three times in a row on 2026-09-07, each time cleared by a humanrm -rfof one 40 G per-branch directory. Two assumptions did not hold:daniel/zemynahaddefault_delete_branch_after_merge: falseat the time of those failures, sogit ls-remotereported merged branches forever and the signal fired for none of them. A merged PR's 40 G target dir was live for eviction purposes indefinitely.Since those failures, the user has enabled
default_delete_branch_after_mergeondaniel/zemyna. That is the right setting and the README now recommends it: a deleted branch is decidable fromls-remotealone, with no checkout, no objects and no walk. It does not make the ancestry signal redundant, and the README enumerates what it leaves behind — the 48 branches merged before it was enabled, which nothing retroactively deletes; every merge whose deletion the forge declines (a protected branch, or one another open PR uses) or is never asked to make (an API merge without the flag, whichtea pulls mergeomits), the delete being best-effort and silent either way; and every repo that has not enabled it, which is the default. The derived-headroom half is untouched by the setting: thedevpush run failed with the branch's directory alive and no deletion pending.Key implementation details
Ancestry is answered from the job's own checkout, and "cannot tell" is never folded into "dead". A shallow checkout withholds the merged signal entirely, since a missing object is its normal case rather than evidence; a single branch whose tip is not in the checkout is kept with a warning naming it; a protected tip absent from the checkout is not used as an anchor. A squash or rebase merge leaves no ancestry, so its branch reads as live until it is deleted. All of those are missed reclamations, which cost disk; the other direction costs a branch its cache mid-build. Tip SHAs come from the same
ls-remoteoutput the existing pass already reads, so both halves describe one instant.One enumeration of the mutable set, two readers. A sizer with its own copy of the find predicates would drift from the copier silently and in the dangerous direction — an under-measured clone is one that starts and then runs out of disk during the unshare.
_MUTABLE_DIR_NAMESand_mutable_file_rulesare now applied by a callback per side.mutable_set_kbskips the subtrees it has already measured whole, so nothing is double-counted; its two residuals (over, by a file matching two rules; under, by dep-info inside a deliberately-sharedout) are named at the function.The margin is hand-written and says so.
CACHE_CLONE_HEADROOM_PERCENT(150) covers what scales with the tree —unshare_subtreeholds one subtree twice at its peak — andCACHE_CLONE_HEADROOM_FLOOR_KB(2 GiB) covers what does not:cp -almaterialises every directory for real. Nothing measures either default; both err toward asking for more, because over-asking costs one branch a cold start and under-asking fails the job.Two things the reordering makes newly reachable, both closed. The source about to be cloned is excluded from every pass by name (
protected_reason), so pass 1 cannot take the snapshot out from under the seed behind it. And a concurrent job's target dir carries its lock from the instant it appears under its final name — the seed writes it into the staging tree before the rename — so running earlier never sees an unlocked directory that is in use. Pass 3 is unaffected: it recreates the own dir empty, and the seed reuses an own dir that exists, so a self-cleared run clones nothing and needs no headroom.This does not reach any consumer until the
v1tag moves. That tag currently points at14bea98, two merges behindmain, and consumers pincargo-cache@v1. Moving it is a release action onmain, not something this branch can do.Files affected
scripts/cache-lib.shmutable_set_kb,clone_headroom_kb,seed_source_candidates,seed_clone_sourcescripts/prune-cache.shscripts/seed-target-dir.shcache-lib.shcargo-cache/action.ymlmin-free-percentdefault0; input descriptionsscripts/prune-cache-selftest.shREADME.mdfetch-depth: 0noteTest plan
bash scripts/selftest.sh— the full suite, including the two compiler-backed ones, on cargo 1.93.1 stable:shellcheck -x --source-path=scripts scripts/*.sh— CI's exact invocation, exit 0.Red proof. The four new scenarios were run against the pre-fix scripts (the three scripts stashed, the selftest kept,
fail()made non-fatal so every assertion reports). Sixteen assertions fail; scenarios 1–14 pass throughout:Scenario 18 is the dry run the ticket asks for: three equal-sized caches mirroring the measured layout — the base snapshot, the base target dir, and one merged-but-undeleted branch's — with 90% free, so neither age nor size nor pressure can be what decides. Exactly one directory is evicted and it is that one. Scenario 17 stubs
dfto answer from the fixture's own size, because a fixedCACHE_DF_OVERRIDEcannot show a pass stopping.Nothing here needs a human smoke: it is shell and fixtures, and the visual half does not exist. The first real exercise is a CI run in a consuming repo after the
v1tag moves. zemyna's workflow comment at.gitea/workflows/ci.yaml:154-197describes the old rule and is a follow-up in that repo, not edited here — see issue 20 for the acceptance criterion it belongs to.Closes #20.
🤖 Generated with Claude Code
https://claude.ai/code/session_01JXMQCJ5Eg5f9G9cfYzyh4Z
VERDICT: APPROVE-WITH-NITS
Reviewed at head
38a6387, base0b099ec. The head moved during the review —24f87a6was pushed to38a6387mid-pass — so to be explicit: the deltabetween the two is comment and README wording only, every executable byte of
cache-lib.sh,seed-target-dir.shandcargo-cache/action.ymlis identicalacross it, and the two files that do differ (
prune-cache.sh,prune-cache-selftest.sh) differ only inside comments. Everything below wasread and run against that content.
Headline
Both halves land, and the eviction half is the one I tried hardest to break.
Pass 1's new signal cannot classify a directory dead on an undecidable
ancestry answer: both operands are proven present with
git cat-file -ebefore the walk (
prune-cache.sh:420for the anchor,:456for the tip), andevery non-zero
merge-base --is-ancestorstatus falls through to the nextprotected ref and then to
live(:461-471), so the 1-vs-128 ambiguity thebrief asked about resolves in the keeping direction whichever way it goes.
Protected refs, the own dir and the seed source are excluded ahead of the
merge test (
:483viaprotected_reasonat:198-205), and a lockeddirectory is still declined twice.
The pressure half evicts against the derived requirement, re-reads
dfeachiteration and breaks the instant it is met (
:562-563), so it cannotover-evict; and the shortfall path fails with the source, the gap in GB and
every kept directory with its reason (
:589-596), which is genuinely moreactionable than the staging-path error it pre-empts.
Per-AC, against issue 20's four criteria
AC 1 — a merged branch's cache is reclaimed without a human. Met.
is_merged_deadatprune-cache.sh:447-472, anchored at:409-432. I probedthe case the tests do not cover, a branch pushed with zero commits ahead of
devso its tip equals the protected tip: it is classified dead and evicted,because a commit is its own ancestor. That is correct rather than a bug — the
branch holds no commit
devdoes not, so its next run re-clones fromsnapshot-dev, whose content is identical or newer — but it is a live branchlosing its cache, and nothing in the suite or the prose says so. Worth one
sentence at
is_merged_deadand, ideally, an assertion.AC 2 — the gate is derived from what the seed needs. Met.
mutable_set_kb/clone_headroom_kbatcache-lib.sh:641-690, measured off_MUTABLE_DIR_NAMESand_mutable_file_rules, which is the same enumerationunshare_mutable_pathscopies from — the one-derivation-not-two argument isthe right one and it is actually implemented, not just asserted.
I measured the two hand-written margins against real data, because
"over-asking is cheap, under-asking fails the job" is only true if the number
lands above the failure. On a 46.7 GB zemyna target dir, using this branch's
own functions:
Taken flat that is alarming: issue 20 reports the seed failing at ~34 GB free,
so a 32.9 GB requirement would have passed the run that failed. It does not
survive contact with the confound, though. 21 GB of that tree is
debug/incremental, which the CI snapshot does not have — zemyna's workflowsets
CARGO_INCREMENTAL: 0. Excluding it, linked outputs are ~80 % of thetree, which puts a 44.5 GB CI snapshot's mutable set near 35 GB and the
requirement near 55 GB: inside the observed band, since the incident failed at
~34 GB and passed at ~74 GB. So I could not falsify the constants, and the
evidence I have is consistent with them.
That is an extrapolation, not a measurement, and it is worth closing cheaply:
the pass now logs
clone requirement: … needs X GB freeon every run, so thefirst CI run after the tag moves prints the real number for free. Record it.
AC 3 — the README and the consumer's workflow comment describe the new
rule; the 10 % figure is deleted. README half met and accurate — the
eviction section, the inputs table row and the
fetch-depth: 0note allmatch the code, and
10is gone rather than corrected. Consumer half notdone, which is unavoidable across repos, but it was tracked nowhere except a
sentence in this PR body.
zemyna/.gitea/workflows/ci.yaml:175still saysmin-free-percent(10) is left "at the defaults, which are the values thisworkflow already used", and that becomes false the moment
v1moves past14bea98. Recorded as an entry on zemyna's standing docs-drift catalog so itoutlives this PR; not a reason to bounce this one.
AC 4 — a dry run on today's zemyna layout evicts exactly the merged-live
dir. Met, and it is scenario 18. I reran it: three equal directories, 90 %
free, exactly one eviction and it is the merged one.
Test integrity
The four new scenarios are contracts, not documentation. Scenario 15 builds a
real
--no-ffmerge on a real scratch origin and pairs it with a controlbranched at the same point and deliberately older, so age cannot be what
decides. Scenario 16 gets its undecidable branch by pushing from a second
clone rather than by stubbing a failure, and asserts its shallow fixture is
actually shallow before trusting it. Scenario 17 is the one that would have
been easiest to fake and is not: 8 MB of dep-info beside 16 MB of
.rlibmeans a requirement taken from the source's size gives a different answer
from one taken from its mutable set, and the
dfstub answering from thefixture's real size is what makes the stop observable at all — a fixed
CACHE_DF_OVERRIDEcould not show it. I checked the arithmetic end to end:9 MB required, 4 MB freed is not enough, 8 MB is,
feat/csurvives. That isthe assertion.
Sanity runs
Full suite including both compiler-backed ones. Every figure matches the PR
body's test plan exactly. The suite is hermetic — one
mktemp -dwith an EXITtrap, and it left nothing behind.
closing-reference-check.sh check-pr daniel/gitdan-actions 20 21exits 0; opener isclaude; labelbug.Nits, none blocking
cache-lib.sh:676-678says aBevy-sized target dir has "hundreds of thousands" of directories. Measured:
3,252, about 13 MB of entries. The constant is fine and errs safe — the
floor usefully covers the multi-GB linked output
_unshare_filesholdstwice mid-copy — but delete the figure rather than correct it. Cataloged.
main" in the PR body. There is one merge betweenv1andmain(0b099ec, PR 18). The load-bearing half of that sentence —nothing reaches a consumer until the tag moves — is correct and important;
just drop the count.
min-free-percentdefaulting to0leaves the reuse path with nopressure gate at all. When the seed reuses an existing own target dir the
derived requirement is zero by construction, so with the floor also zero,
passes 2 and 3 do nothing however tight the disk — and the prune no longer
runs after the build either. Pass 1 is a much bigger lever than the old 10 %
ever was, so this is very likely the right trade, and issue 20 asks for the
percentage to stop being the gate. But it is a real reduction in cover on a
path the ticket does not discuss, and zemyna takes the new default without
passing the input. Worth a sentence in the README saying so plainly.
Drift and sibling-track check
No orphan code, no stale imports, no public-surface drift:
seed-target-dir.shnow reads its candidate list from
cache-lib.shand the inline list is gone,not left beside it.
min-free-percentremains a documented input rather thanbeing silently ignored. Nothing else in the repo referenced the old prune step
position. This repo had no standing docs-drift catalog; I created the
docs-drift-cataloglabel and filed one, and both findings above are recordedagainst it and against zemyna's.
The unpushed-commit state I opened with is resolved — it is pushed, and the PR
head and the worktree now agree.
Posted as a comment rather than a review: the PR merged at
05:41:12Z, and Gitea will not accept a review on a merged PR. The verdict below is the record for the body edit at05:38:55Z, which landed after review 1242 and before the merge.VERDICT: APPROVE-WITH-NITS
SCOPE: SPOT-CHECK
Re-dispatched because the PR body moved at
05:38:55Z, 38 seconds afterreview 1242 was posted at
05:38:17Z. The recorded verdict no longerdescribed what was there, so it had to be re-established against the current
head.
Gate
Eligible for a spot check rather than a full re-review, mechanically:
commit_id38a63879head.shanow38a638790b099ec, unchangedSo the diff I read is bit-identical to the diff that is there, and what it
merges into has not moved. Nothing about the code was re-verified, by design;
what follows is the wording.
The head move from
24f87a6, checked rather than acceptedThe implementer's report that
24f87a6→38a63879is prose-only is correct,and I confirmed it mechanically rather than by reading the diff: stripping
comment and blank lines from every script and from
action.yml, then hashing,gives an identical digest on both sides for all eight files.
Three files are touched in total —
README.md,prune-cache.sh,prune-cache-selftest.sh— and the two scripts differ only inside commentblocks. The selftest run and the logic review in review 1242 therefore stand
without repetition.
The body change is an improvement, and its claims hold
The
Whysection's first bullet moved to past tense and gained a paragraphrecording that delete-on-merge has since been turned on. Both halves check out:
daniel/zemynanow hasdefault_delete_branch_after_merge: True(
repos/daniel/zemyna). The original present-tense claim had become false,and correcting it was right — issue 20's first premise is now history, and
the body says so instead of asserting it.
plausible, it is measurable, and I measured it. Right now
daniel/zemynahas 61 branches on origin, of which 39 are already ancestors of
dev. Those39 are exactly the population the deleted-branch signal can never see and the
new ancestry signal reclaims. Enabling the setting stopped the bleeding; it
reclaimed nothing that was already there.
the
devpush run failed with the branch's directory alive — matches issue20's own account of that run.
Correcting a claim that had gone stale, in the record the review is written
against, is the right instinct. No objection to the edit; the verdict is
unchanged by it.
Nits, still none blocking
README.md:293-294as "zemyna carried 48 of them"). This is an unsourcedfigure and a reader cannot reproduce it. The nearest derivation I can run —
branches on origin whose tip is an ancestor of
dev, which is the very testthis PR adds — gives 39, not 48. I am not calling 48 wrong: my count is
taken now rather than at the moment the setting flipped, and it misses any
branch merged only into
mainor merged by squash or rebase, so the twonumbers are not measuring quite the same thing. That is the problem. The
argument does not need the value at all — "they stay on origin forever;
nothing retroactively deletes them" carries the whole point — and the figure
will decay as those branches are cleaned up, with nothing watching it.
Delete it rather than correct it. Cataloged.
main" (body). There isone merge between
v1andmain—0b099ec, PR 18. The load-bearing halfof that sentence is correct and worth keeping; drop the count.
reason at
cache-lib.sh:676-678(measured 3,252 directories, not "hundredsof thousands"), and
min-free-percentdefaulting to0leaving thecache-reuse path with no pressure gate at all.
None of these is worth a round on its own. All three are recorded on this
repository's standing docs-drift catalog, issue 22.
One note on state
The PR is no longer drafted. That is the correct lifecycle for it — the review
passed and there is nothing here a human can smoke, it being shell and
fixtures — so CI is now the only gate left ahead of the merge.