You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Raised by #453, which measured the cost and found there is no automatic reclaim path for it.
The facts, measured on a real fetch of microsoft/vscode with the production command:
The fetch left 5286 loose ref files, 21,651,456 B (20.6 MiB), with packed-refs still at 103 B.
git pack-refs --all reclaims 21,225,472 B in 0.36s.
pack-refs --auto cannot be leaned on: it is a no-op on the files backend, declining both at 5000-loose-versus-1-packed and at 100-versus-5001.
gc.auto never looks at refs at all, so packing happens only incidentally, whenever some unrelated gc fires for object reasons.
So this is a straggler by principle 2's definition, of a modest and predictable size, with a cheap and completely safe remedy. Which makes the question less "is it worth reclaiming" than where the work belongs, and that is a maintainability call rather than a safety one:
On the detached background sweep, which already fetches and is already the thing that touches the bare. 0.36s on the worst case measured. Weigh what it means to add work to a path whose whole point is that the user is not waiting for it.
On --prune, alongside the other reclamation, so all the deliberate reclaiming lives in one command. Weigh that --prune currently never touches a bare cache at all, by design, and that this would be the first exception.
Nowhere. 20 MiB against the 104 GB this map started from is a rounding error, and every line of maintenance has a cost. Principle 3's simplicity clause is a real argument here, and "measured, named in the docs, deliberately not reclaimed" is a legitimate outcome that the contract table can carry.
Note that packing refs is pure: it changes representation and not content, cannot lose a ref, and needs no principle-1 argument. That is unusual on this map and should make the decision easy once the placement question is settled.
How the answer gets tested: if it lands anywhere, a test that the ref count in packed-refs rises and no ref is lost. If nowhere, the contract row states the figure.
agent-decided (long-term maintainability):yes, on the detached background sweep — one git pack-refs --all in the arm immediately after a successful fetch_all, which is the same lock scope, the same path, and the same shape as the git call already there. Principle 1 is silent, since packing is pure. Principle 2 turns out to be weakly engaged rather than strongly, which the scaling check below is what establishes — so this is decided on principle 3's maintainability clause, over its simplicity clause, and the reason is placement rather than bytes.
The check first, because it changes the frame
The ticket asked whether 20.6 MiB is the typical case or the outlier before deciding. It is the outlier, and the mechanism is worth stating precisely: the cost is one filesystem block per loose ref, so it tracks ref count and has essentially nothing to do with repository size. Measured now, cheaply, against real remotes with git ls-remote (no fetches, nothing from #453 re-measured):
remote
heads
tags
refs
loose refs at 4096 B
microsoft/vscode
4903
439
5342
20.9 MiB
torvalds/linux
1
1886
1887
7.4 MiB
numpy/numpy
34
469
503
2.0 MiB
blooop/bencher
199
196
395
1.5 MiB
prefix-dev/pixi
93
280
373
1.5 MiB
rust-lang/rust
10
324
334
1.3 MiB
loft-sh/devpod
8
236
244
1.0 MiB
ros2/ros2
50
120
170
0.7 MiB
blooop/wayfinder
64
49
113
0.4 MiB
blooop/devlaunch
30
57
87
0.3 MiB
torvalds/linux is the point. By history it is larger than everything else here and it carries 1887 refs, barely a third of vscode's — while rust-lang/rust sits at 334. What drives the figure is how many branches upstream leaves open, and vscode's 4903 heads is a hygiene outlier, not a size one. Median of the sample is ~370 refs, about 1.4 MiB; a 20-to-40-repo cache therefore carries something like 30 to 60 MB in total, a couple of percent of one bare's own size.
Two consequences. First, principle 2 barely bites: loose refs are bounded by the remote's ref count and do not accumulate (heads are pruned, per #453; the tags leak is #453's item 1 and a different ticket's problem), so this is a constant-factor representation cost, not straggler data piling up. Option 3's simplicity claim is genuinely live and I gave it a real hearing. Second, "reclaim 20 MiB" is the wrong headline for the change, so it cannot be what carries it.
What carries it instead: the work has a home where it costs one call
fetch_all has exactly one production caller — lazy_fetch, whose only production caller is sweep_repo_fetches. The launch path uses fetch_ref for a single branch and never runs the broad refspec. So "after a successful fetch_all" is the detached sweep today, by construction rather than by convention, and it is the code that creates the loose refs sitting next to the code that packs them. The sweep is already a bounded git invocation on the bare, under run_if_lock_free, with every failure an arm and the loop carrying on. A second bounded git invocation on the bare is not a new kind of thing there; it is the kind of thing that path is.
And the reclaim is not the only return. commits_beyond_every_ref is rev-list --count <rev> --not --allasked of the bare, and it is #446's reachability proof, run per worktree under a lock (#459 measured 593 ms over 37 clones). On vscode's bare, every one of those probes reads 5286 loose ref files instead of one 426 KB packed-refs. Packing makes this map's own safety machinery cheaper on precisely the repositories where it is slowest, which is a maintainability return that survives the 20-MiB headline collapsing.
The honest cost, stated rather than waved past. The ticket asks what it means to add work to a path nobody waits on, and the sweep's own doc comment already answers it: the lock this takes is the one a launch blocks on, so "nobody is waiting" is false in exactly the way that matters. The sweep never queues for a launch, but a launch queues for the sweep. So the real figure is 0.36 s worst case added to a scope that is bounded at BACKGROUND_FETCH_TIMEOUT = 300 s and that just spent 0.86 s steady-state (46 s cold) on the fetch. It is a rounding error against the wait it lengthens, and it only ever happens on a pass that actually fetched — a Fetched::Skipped pass adds nothing, and a repeat pack on an already-packed bare is trivial. No threshold and no --auto: pack-refs --all is idempotent, and a loose-ref threshold would be a second thing to maintain and mistune for no measured gain.
Why not --prune
Not for the stylistic reason the ticket offers. --prune's architecture is classify directories, print the plan, re-classify each one under the lock, remove: PrunePlan is Reclaimable and Kept, and its whole design point is that a plan and the act on it cannot disagree. Packing refs is not that operation. There is no directory to classify, nothing to weigh with exclusive_usage, nothing for a y/N to be about (packing needs no consent, because it removes nothing), and the bare it would touch is a thing --prune has already decided to keep. PrunePlan has no arm for "mutate something we keep", and adding one would put a verb of a different shape inside the exact structure this map exists to stop from acquiring exceptions. That the bare would be the first bare --prune touches is the symptom; the model mismatch is the reason.
Why not nowhere
Because "nowhere" is the right answer when the reclaim has no natural home, or when the safety argument cannot be made, or when the population is somebody else's (the 86.5 GB in #450, the 39 volumes in #451, buildkit's cache). None of that holds here. The remedy is pure, one call, in a path that already does this exact kind of thing under a lock it already holds, and it pays a second time in probe latency. Refusing that is not simplicity, it is leaving a known cost in place to avoid twelve lines.
Build work implied (not ticketed here, reported to the map)
Git::pack_refs(&self, bare: &Path) in clients/git.rs: git pack-refs --all, cwd the bare, with_timeout(ABOUT_ONE_REPO). Deliberately no --auto (no-op on the files backend, measured both directions in Confirm the bare object stores are bounded #453) and no --prune flag of git's own.
One call site in RepositoryManager::fetch_repo (flows/repo_manager.rs), after the fetch's refusal check and before the last_fetched stamp. A refusal from packing must not become a FetchRepoError: the fetch happened and the stamp has to land, so packing's outcome is its own arm reported as a CacheNotice and never as a failed fetch. An explicit arm, not let _ = — the module's own comment on RecordUpdate::Absent is the precedent.
Tests, as the ticket specifies: the ref set is identical before and after (nothing lost, which is the whole safety claim and is cheap to assert exactly), the packed-refs ref count rises, and the argv reaches the spawn as pack-refs --all with the bare as cwd. Plus one asserting a packing refusal leaves fetch_repo returning Ok with the stamp written.
docs/cleanup.md gains the contract row: loose refs in the bare, packed by the background sweep after each fetch, ~4096 B per ref against ~81 B packed, and the note that pack-refs --auto and gc.auto cannot be leaned on. No em or en dashes (test_docs_prose.py).
Independent of #453's item 1 (the --tags refspec), and either can land first.
Activity
blooop commentedon Aug 25, 2026
agent-decided (long-term maintainability): yes, on the detached background sweep — one
git pack-refs --allin the arm immediately after a successfulfetch_all, which is the same lock scope, the same path, and the same shape as the git call already there. Principle 1 is silent, since packing is pure. Principle 2 turns out to be weakly engaged rather than strongly, which the scaling check below is what establishes — so this is decided on principle 3's maintainability clause, over its simplicity clause, and the reason is placement rather than bytes.The check first, because it changes the frame
The ticket asked whether 20.6 MiB is the typical case or the outlier before deciding. It is the outlier, and the mechanism is worth stating precisely: the cost is one filesystem block per loose ref, so it tracks ref count and has essentially nothing to do with repository size. Measured now, cheaply, against real remotes with
git ls-remote(no fetches, nothing from #453 re-measured):torvalds/linux is the point. By history it is larger than everything else here and it carries 1887 refs, barely a third of vscode's — while
rust-lang/rustsits at 334. What drives the figure is how many branches upstream leaves open, and vscode's 4903 heads is a hygiene outlier, not a size one. Median of the sample is ~370 refs, about 1.4 MiB; a 20-to-40-repo cache therefore carries something like 30 to 60 MB in total, a couple of percent of one bare's own size.Two consequences. First, principle 2 barely bites: loose refs are bounded by the remote's ref count and do not accumulate (heads are pruned, per #453; the tags leak is #453's item 1 and a different ticket's problem), so this is a constant-factor representation cost, not straggler data piling up. Option 3's simplicity claim is genuinely live and I gave it a real hearing. Second, "reclaim 20 MiB" is the wrong headline for the change, so it cannot be what carries it.
What carries it instead: the work has a home where it costs one call
fetch_allhas exactly one production caller —lazy_fetch, whose only production caller issweep_repo_fetches. The launch path usesfetch_reffor a single branch and never runs the broad refspec. So "after a successfulfetch_all" is the detached sweep today, by construction rather than by convention, and it is the code that creates the loose refs sitting next to the code that packs them. The sweep is already a bounded git invocation on the bare, underrun_if_lock_free, with every failure an arm and the loop carrying on. A second bounded git invocation on the bare is not a new kind of thing there; it is the kind of thing that path is.And the reclaim is not the only return.
commits_beyond_every_refisrev-list --count <rev> --not --allasked of the bare, and it is #446's reachability proof, run per worktree under a lock (#459 measured 593 ms over 37 clones). On vscode's bare, every one of those probes reads 5286 loose ref files instead of one 426 KBpacked-refs. Packing makes this map's own safety machinery cheaper on precisely the repositories where it is slowest, which is a maintainability return that survives the 20-MiB headline collapsing.The honest cost, stated rather than waved past. The ticket asks what it means to add work to a path nobody waits on, and the sweep's own doc comment already answers it: the lock this takes is the one a launch blocks on, so "nobody is waiting" is false in exactly the way that matters. The sweep never queues for a launch, but a launch queues for the sweep. So the real figure is 0.36 s worst case added to a scope that is bounded at
BACKGROUND_FETCH_TIMEOUT= 300 s and that just spent 0.86 s steady-state (46 s cold) on the fetch. It is a rounding error against the wait it lengthens, and it only ever happens on a pass that actually fetched — aFetched::Skippedpass adds nothing, and a repeat pack on an already-packed bare is trivial. No threshold and no--auto:pack-refs --allis idempotent, and a loose-ref threshold would be a second thing to maintain and mistune for no measured gain.Why not
--pruneNot for the stylistic reason the ticket offers.
--prune's architecture is classify directories, print the plan, re-classify each one under the lock, remove:PrunePlanisReclaimableandKept, and its whole design point is that a plan and the act on it cannot disagree. Packing refs is not that operation. There is no directory to classify, nothing to weigh withexclusive_usage, nothing for ay/Nto be about (packing needs no consent, because it removes nothing), and the bare it would touch is a thing--prunehas already decided to keep.PrunePlanhas no arm for "mutate something we keep", and adding one would put a verb of a different shape inside the exact structure this map exists to stop from acquiring exceptions. That the bare would be the first bare--prunetouches is the symptom; the model mismatch is the reason.Why not nowhere
Because "nowhere" is the right answer when the reclaim has no natural home, or when the safety argument cannot be made, or when the population is somebody else's (the 86.5 GB in #450, the 39 volumes in #451, buildkit's cache). None of that holds here. The remedy is pure, one call, in a path that already does this exact kind of thing under a lock it already holds, and it pays a second time in probe latency. Refusing that is not simplicity, it is leaving a known cost in place to avoid twelve lines.
Build work implied (not ticketed here, reported to the map)
Git::pack_refs(&self, bare: &Path)inclients/git.rs:git pack-refs --all, cwd the bare,with_timeout(ABOUT_ONE_REPO). Deliberately no--auto(no-op on thefilesbackend, measured both directions in Confirm the bare object stores are bounded #453) and no--pruneflag of git's own.RepositoryManager::fetch_repo(flows/repo_manager.rs), after the fetch's refusal check and before thelast_fetchedstamp. A refusal from packing must not become aFetchRepoError: the fetch happened and the stamp has to land, so packing's outcome is its own arm reported as aCacheNoticeand never as a failed fetch. An explicit arm, notlet _ =— the module's own comment onRecordUpdate::Absentis the precedent.packed-refsref count rises, and the argv reaches the spawn aspack-refs --allwith the bare as cwd. Plus one asserting a packing refusal leavesfetch_reporeturningOkwith the stamp written.docs/cleanup.mdgains the contract row: loose refs in the bare, packed by the background sweep after each fetch, ~4096 B per ref against ~81 B packed, and the note thatpack-refs --autoandgc.autocannot be leaned on. No em or en dashes (test_docs_prose.py).Independent of #453's item 1 (the
--tagsrefspec), and either can land first.