Skip to content

Pack the bare caches' refs on the sweep - #478

Merged
blooop merged 5 commits into
mainfrom
wayfinder/devlaunch-470
Aug 25, 2026
Merged

Pack the bare caches' refs on the sweep#478
blooop merged 5 commits into
mainfrom
wayfinder/devlaunch-470

Conversation

@blooop

@blooop blooop commented Aug 25, 2026

Copy link
Copy Markdown
Owner

Closes #470. Implements the decision on #465.

One git pack-refs --all on the detached freshness sweep, after a fetch that succeeded.

The red test

real_git_the_sweep_packs_the_loose_refs_its_own_fetch_just_wrote, against a real bare cut from a real fixture remote. Clone (which arrives packed), move main on and push a new tag, run fetch_repo, assert nothing is loose:

assertion `left == right` failed: the sweep left its own loose refs behind
  left: ["/tmp/.tmp4Rpo1B/repos/test/repo/.bare/refs/heads/main",
         "/tmp/.tmp4Rpo1B/repos/test/repo/.bare/refs/tags/v1"]
 right: []

The two files are the fetch's own output, which is the whole point of the placement. The test's second half is what stops it being vacuous: packed-refs must hold the post-fetch sha for main and a line for v1, so a run that packed nothing would leave both refs as files with a clone-time packed-refs beside them.

Re-measured, because #464 moved the population

#464 made the tags refspec forced, so the sweep now prunes tags as well as heads and #453's 5286 loose refs describe a bare that no longer exists. Measured fresh on git 2.51.1, and the more useful correction is about where loose refs come from at all:

  • A git clone --bare arrives packed. 601 refs, zero loose files, a 33787 B packed-refs. The loose refs are made by fetching, one file per ref the fetch updates, and they shadow a packed-refs that keeps the pre-fetch shas.
  • One sweep fetch that moved all 601 refs: 601 loose files, 2404 KiB of blocks, against the same 33787 B packed. pack-refs --all took it back to zero loose with no ref lost.
  • Probe cost, rev-list --count HEAD --not --all on the bare, 30 runs each: 5.3 ms to 5.5 ms loose, 2.7 ms to 2.9 ms packed over 551 refs with 301 of them loose. The pack itself: 23 ms.
  • Ref sets before and after packing are byte-identical (for-each-ref over name plus objectname).

The disk headline stays where #465 left it: one block per ref, tracking how many branches a remote leaves open rather than repository size, so a whole cache is 30 MB to 60 MB. That is not what carries the change.

  • --all is not a nicety. Against a bare holding 301 loose heads and 101 loose tags, a bare pack-refs took the tags to zero and left all 301 heads untouched.

Packed versus loose deletion

Checked because packing and pruning meet on the same refs, and it is a no-difference finding rather than a hazard:

  • 100 refs deleted upstream, all of them packed: --prune removed all 100, rewrote packed-refs (33787 B to 28255 B), created no loose files, and the survivors were untouched.
  • A ref that was loose over a stale packed line, then deleted upstream: the prune removed both the file and the packed entry. Nothing was resurrected at the old sha, which is the failure this was looking for.

So packing changes nothing about what a later prune may delete or keep. The only difference is cost, and it falls on the prune: removing a packed ref rewrites the whole file where removing a loose one unlinks one path.

A pack that refuses

The fetch is the point of this function and the packing is the optional half, so a refusal is a CacheNotice::RefsNotPacked carrying the repository and git's own words, not a FetchRepoError, and last_fetched still lands. Principle 1 decides it and it is not close: the fetch happened, the cache is fresh, nothing is at risk. Withholding the stamp would be the actual harm, since every later sweep would re-fetch the whole repository forever on account of a representation change that did not come off. Its own arm rather than let _ =, which is the discipline RecordUpdate::Absent already gets three lines below. Pinned by a_pack_that_refused_is_reported_and_the_fetch_still_counts_as_done.

Argv pins

pack-refs --all, cwd the bare, bounded at 30 s, is pinned at the client seam; the flow seam pins that the pack follows the fetch and precedes the stamp. Three existing pins moved with the second spawn: two only_call() reads became the fetch call, and the lazy-fetch count went 1 to 2, with the skipped pass still spawning nothing.

Out of scope

Not on --prune, per #465: PrunePlan is classify-and-remove-directories with no arm for "mutate something we keep". No --auto and no loose-ref threshold. The refs/heads and refs/tags directories git leaves empty behind a pack are left alone. And the "second payment" in probe latency is measured here but prospective in the tree: the shipped unpushed probe is log --all --not --remotes on the clone (#471); #446's rev-list --not --all against the bare is decided and not yet built.

🤖 Generated with Claude Code

Summary by Sourcery

Pack loose refs in bare caches after successful sweep fetches while preserving fetch success semantics and reporting packing refusals.

New Features:

  • Pack all loose refs in bare caches after successful freshness-sweep fetches to reduce ref storage overhead.

Bug Fixes:

  • Ensure refs written by a fetch are consolidated without changing the repository's ref set or interfering with later pruning.
  • Treat pack-refs failures as reported notices while still recording the successful fetch as complete.

Enhancements:

  • Add bounded Git client support for pack-refs --all and integrate it between fetching and freshness bookkeeping.
  • Expose repository-specific packing failure messages in cache notices.

Documentation:

  • Document the rationale, placement, performance characteristics, failure behavior, and pruning implications of packing bare-cache refs.

Tests:

  • Add client, flow-ordering, failure-handling, rendering, and real-Git coverage for packing loose refs after a sweep fetch.

Every ref a fetch updates is written as a loose file costing a whole
filesystem block, and nothing in devlaunch ever collapsed them: pack-refs
--auto is a no-op on git's files backend and no gc runs on a bare.

The packing goes where the loose refs are made. fetch_all has exactly one
production caller, so "after the fetch" is the detached background sweep by
construction, in a repo-lock scope it already holds, and the pass that skips
the fetch spawns nothing.

Not justified by disk: the cost is one block per ref, so it tracks how many
branches a remote leaves open rather than repository size, and a whole cache
is 30 MB to 60 MB. What carries it is placement plus a second payment in probe
latency. Measured on git 2.51.1 over a bare of 551 refs with 301 loose, the
rev-list probe fell from 5.3 ms to 2.8 ms, the loose files held 1204 KiB of
blocks against a 30 KiB packed-refs, and the pack took 23 ms.

A pack that refuses is not a fetch that failed. It is a CacheNotice naming the
repository and git's own words, and the freshness stamp still lands: the fetch
happened, and withholding the stamp would re-fetch the whole repository every
interval forever over a representation change that did not come off.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry @blooop, you've used your own review budget of 250,000 diff characters for the last 7 days.

You can request another review in 3 days and 3 hours by commenting @sourcery-ai review. Upgrade to get a review now.

@sourcery-ai

sourcery-ai Bot commented Aug 25, 2026

Copy link
Copy Markdown

Reviewer's Guide

After each successful all-refs freshness fetch, the detached sweep now runs a bounded git pack-refs --all in the bare cache to collapse fetch-created loose refs and improve ref traversal, while preserving fetch success and freshness semantics when packing refuses; the behavior is surfaced as a notice, tested at command, flow, real-Git, and rendering seams, and documented.

Sequence diagram for packing refs after a freshness fetch

sequenceDiagram
    participant Sweep as Detached freshness sweep
    participant Manager as RepositoryManager
    participant Git as Git client
    participant Bare as Bare cache
    participant Record as Cache record

    Sweep->>Manager: fetch_repo(owner, repo)
    Manager->>Git: fetch_all()
    Git->>Bare: Write updated refs as loose files
    Git-->>Manager: Fetch succeeded
    Manager->>Git: pack_refs(bare)
    Git->>Bare: git pack-refs --all
    Bare-->>Git: Packed refs
    Git-->>Manager: Pack result
    Manager->>Record: Update last_fetched
    Manager-->>Sweep: FetchedUpdates
Loading

Flow diagram for a refused ref pack

flowchart TD
    A[Successful fetch_all] --> B["pack_refs(bare)"]
    B --> C{Pack succeeded?}
    C -->|Yes| D[Update last_fetched]
    C -->|No| E[Emit RefsNotPacked notice]
    E --> D
    D --> F[Fetch still counts as complete]
Loading

File-Level Changes

Change Details Files
Add a bounded Git client operation to pack all loose refs in a bare cache.
  • Invoke git pack-refs --all in the bare repository with a 30-second timeout.
  • Cover command arguments, working directory, timeout, and spawn-count behavior with unit tests.
rust/devlaunch-core/src/clients/git.rs
rust/devlaunch-core/src/clients/git/tests.rs
Pack refs as part of the successful background freshness sweep without changing fetch success semantics.
  • Run packing after the all-refs fetch and before recording freshness.
  • Treat pack refusal as a non-fatal repository-specific notice while still recording last_fetched.
  • Add flow tests for ordering, refusal reporting, freshness stamping, and real Git verification that updated refs are packed.
rust/devlaunch-core/src/flows/repo_manager.rs
Expose pack failures through the existing cache-notice rendering path.
  • Render the repository and Git-provided reason for RefsNotPacked.
  • Add output coverage for the resulting notice.
rust/dl/src/render.rs
Document the rationale, placement, performance characteristics, and prune implications of packing bare-cache refs.
  • Explain why fetch-created loose refs accumulate and why the sweep is the appropriate packing point.
  • Document that packing changes representation rather than ref membership and does not alter later pruning semantics.
  • Record measured disk, probe-latency, and packing costs plus the non-fatal failure policy.
docs/cleanup.md

Assessment against linked issues

Issue Objective Addressed Explanation
#470 Add a bounded Git::pack_refs operation that runs git pack-refs --all with the bare repository as its working directory, and invoke it after a successful fetch in RepositoryManager::fetch_repo before recording last_fetched.
#470 Treat a packing refusal as a reported CacheNotice rather than a fetch failure, while still writing the fetch timestamp and returning Ok.
#470 Provide the requested regression and seam tests, plus a cleanup contract row documenting the behavior and its constraints. The contract row and most tests are present, including ref preservation, command arguments, working directory, ordering, and refusal handling. However, the real Git test does not explicitly capture and compare the complete ref set before and after packing, nor does it assert that the packed-refs count increases as required. It checks the final refs and selected packed entries instead.

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@codecov

codecov Bot commented Aug 25, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.85714% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 95.37%. Comparing base (a5c0049) to head (e4b5622).
⚠️ Report is 6 commits behind head on main.

Files with missing lines Patch % Lines
rust/devlaunch-core/src/flows/repo_manager.rs 97.43% 3 Missing ⚠️
Additional details and impacted files
Flag Coverage Δ
python 42.98% <ø> (ø)
rust 95.71% <97.85%> (+0.04%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Components Coverage Δ
shipped code (rust) 95.71% <97.85%> (+0.04%) ⬆️
harness and tooling (python) 42.98% <ø> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@blooop blooop left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was generated by AI during review.

Reviewed at ec9619aa9be0231fdeae2d045a73cd5e3beddd07 in a fresh context, against #470 and the decision on #465. Two axes, run independently and not merged. In-container: cargo fmt --check clean, cargo clippy --locked --all-targets -- -D warnings clean, cargo test --workspace all suites green, pytest test/ 412 passed.

The code is right. Both blocking findings are single sentences of prose that state a prospective benefit and an unreachable reporting channel in the present tense. No defect in the pack, the placement, the notice arm, or the tests.

Standards

Nothing blocks.

Checked and found not to be smells: reason: String on RefsNotPacked (repo_manager.rs:250-255) matches every other arm that carries a git refusal verbatim (LfsCacheNotFilled, TrackedFilesNotListed, LfsFilesNotListed), where NotRefreshed is the fetch vocabulary and three of its four arms are meaningless for a pack, so reusing it would model unreachable states. The { owner, repo } clump is pre-existing in nine arms. cache_notice (dl/src/render.rs:890) is the only exhaustive match over CacheNotice in the tree and has no wildcard, so the arm required exactly the compiler-forced set and no site was missed. swap_remove(0) is correct: calls() returns an owned clone and swap_remove(0) returns index 0, the fetch.

Non-blocking, ranked:

  1. repo_manager.rs:2516 and :2734 use swap_remove(0) where the file's own idiom is indexing. repo_manager.rs:2574, added by this same PR 58 lines later, and workspace_clone.rs:2096 both write calls()[n]. A reader has to recall that swap_remove returns the removed element rather than the one swapped into its place before they can see the assertion is about the fetch. Cost is one confused reader per visit.
  2. The measured figures now live in four unguarded copies. 1204 KiB / 30 KiB / 23 ms at clients/git.rs:596-598 and docs/cleanup.md:374-377; 5.3 ms / 2.8 ms at repo_manager.rs:1419-1420 and docs/cleanup.md:374-375; the pack-refs --auto no-op claim at git.rs:594, docs/cleanup.md:355 and repo_manager.rs:3756. Re-measuring on a later git is four edits and nothing fails if one is missed. In keeping with the repo's stated position that quoted numbers are not guarded, just at a higher copy count than elsewhere.
  3. repo_manager.rs:1430 has no timing::span where its sibling fetch_all at :1397 does. DEVLAUNCH_TIMING on a sweep shows the fetch and not the 23 ms pack beside it, so a future regression in pack cost is invisible to the tool this repo measures with.

Spec

#470 items 1, 2 and 4: met. clients/git.rs:621 spawns exactly ["pack-refs", "--all"], cwd the bare, .with_timeout(ABOUT_ONE_REPO) where git.rs:70 defines that as 30s; no --auto anywhere. The call at repo_manager.rs:1428 sits after the fetched.refusal() early return (ending :1410) and before update_repository (:1440), as its own CacheNotice arm and not a let _ =. docs/cleanup.md:349-351 carries the contract row; U+2014/U+2013 across docs/ and README.md hit only rust-port-scope.md, the documented exclusion.

#470 item 3: met. Sourcery's assessment is refuted on the merits, by construction. Sourcery marked item 3 unaddressed because "the real Git test does not explicitly capture and compare the complete ref set before and after packing, nor does it assert that the packed-refs count increases". Right on the letter, wrong on the entailment: the fixture is closed, so the hardcoded assert_eq!(refs_of(&bare), [...]) at repo_manager.rs:3799 is the before-set. I built the counterexample it alleges rather than arguing it. In a scratch worktree I replaced pack_refs with a call that packs correctly and silently drops one ref:

.with_args(["-c", "git update-ref -d refs/heads/feature/test && git pack-refs --all"])
panicked at repo_manager.rs:3799: packing changes the representation and must lose no ref
  left: ["refs/heads/main", "refs/tags/v1"]
 right: ["refs/heads/feature/test", "refs/heads/main", "refs/tags/v1"]

A pack that packs nothing is caught twice over, at :3781 (loose set non-empty) and at :3792, and that second assert is stronger than "the count rises": it proves the main line was rewritten to the post-fetch sha, not merely that a line was added. No counterexample survives. Non-blocking nit: refs_of is name-only, so feature/test's objectname is unpinned, but that is the one ref with no stale packed line, so the failure mode cannot arise there.

BLOCKING S1 — docs/cleanup.md:372-375 and repo_manager.rs:1417-1420 state a prospective benefit in the present tense.

docs/cleanup.md:372 — "the guards that decide whether a clone is safe to remove ask the bare a reachability question that walks every ref; with the refs packed that walk reads one file instead of thousands."

repo_manager.rs:1417 — "packing pays a second time in probe latency, since the reachability question asked of the bare walks every ref and today reads one file per ref to do it".

commits_beyond_every_ref does not exist in the tree. The shipped probe is git log --oneline --all --not --remotes (clients/git.rs:514), run via about(clone, ...) against the clone, called only from domain/workspace_state.rs:469. No shipped code asks the bare a reachability question, so packing the bare buys the guards zero today, not less. #446's rev-list --not --all on the bare is decided and unbuilt.

Concrete failure: docs/cleanup.md is #444's contract page, the artefact the map's destination is defined as. A reader weighing whether to keep, tune or remove this pack reads a measured 5.3 ms to 2.8 ms saving that no shipped code collects. The PR body concedes both are prospective under "Out of scope"; the page and the call site do not, and a PR body is not where a reader meets this. This map corrected exactly this class of false present-tense doc claim before merge on #464. Fix is tense plus a pointer to #446. Note the doc comment on Git::pack_refs (git.rs:589-620) is clean, makes no probe claim, and is the right shape.

BLOCKING S2 — docs/cleanup.md:380 promises a warning on a channel that is /dev/null in the only path that can produce it.

"so a refusal is a line on stderr naming the repository and git's own words, the record's freshness stamp still lands, and the next sweep tries again."

Traced: RefsNotPacked reaches SweepReport.notices, say() at dl/src/commands.rs:1191, eprintln!. But sweep_repo_fetches has exactly one production caller (dl/src/commands.rs:334, inside render_update_cache), reachable only as dl --update-cache, which is spawned only at lifecycle.rs:352 via runner.detach(...), and ProcessRunner::detach sets command.stderr(Stdio::null()) at devlaunch-runner/src/lib.rs:637. ProcessRunner is the only production Runner. So the line is written into a null descriptor on every production path, and --update-cache is hide = true.

Concrete failure: someone whose bare keeps loose refs is told by the contract page to look for a named warning on stderr. There is never one. The render.rs:3086 test comment compounds it, calling a line that did not name the repository "unactionable", which asserts an actionability that no user can reach.

In fairness, and this is why the fix is prose and not code: the invisibility is pre-existing and systemic. FetchingUpdates and FetchedUpdates are equally nulled, because fetch_repo is sweep-only. This PR did not introduce it. It is the first place that promises a user-visible warning for the sweep, and that promise is not kept. Fixing the sweep's reporting channel is a different ticket; correcting the sentence is this one.

#444 principles: honoured, and principle 2 holds on measurement rather than on argument. I tested whether a permanently refusing pack accumulates. It does not, it plateaus. Eight consecutive sweeps, each moving all 50 heads, no pack ever run:

sweep | refs | loose files | bytes in loose refs (blocks)
  1   |  51  |     50      |  204800
  ...
  8   |  51  |     50      |  204800

Flat. Loose refs are one file per ref, rewritten in place by the next fetch, never appended to. So a persistently failing pack degrades to exactly the pre-PR steady state, bounded by the remote's ref count, which is the state #465 already classified as "principle 2 barely bites". Warn-forever is the accepted trade, not a new straggler. Principle 1 is untouched: packing removes no ref, and the refused path keeps both the stamp and the fresh cache.

What I verified by running it, not by reading it

Concurrent readers during pack-refs --all (the container/lock question): safe, 636 iterations, zero anomalies. git 2.51.1, a 401-ref bare, writer making all 300 heads loose then pack-refs --all, 60 rounds, against a reader looping git clone from the bare plus fsck, for-each-ref and rev-parse --verify. Every clone exit 0, every clone got exactly 401 refs, every clone fsck-clean, zero missing refs, zero wrong shas across ~2000 resolutions. (Two earlier runs reported anomalies that were my harness miscounting HEAD and origin/HEAD, not git.) The architecture makes the window narrower than it looks anyway: clone_from_cache hardlinks and then set_remote_url repoints the clone at the forge, so a container's clone has its own refs and never reads .bare/refs at all, and the one devlaunch reader that does read them runs on the launch path under the same per-repo lock the sweep takes via run_if_lock_free.

The deletion finding: re-run, confirmed, and extended to tags. Case A, 100 packed heads plus 100 packed tags retracted upstream, fetched with devlaunch's real refspec +refs/heads/*:refs/heads/* +refs/tags/*:refs/tags/* --prune: 401 refs to 201, packed-refs 22487 B to 11403 B, zero loose files created, survivors intact. Case B, the resurrection case, built for a head and a tag: a loose file at the new sha over a stale packed line at the old sha, then retracted upstream. Run twice, once on a bare left unpacked and once on the same bare after pack-refs --all:

=== bare-nopack ===              === bare-packed ===
refs/heads/shadowhead -> GONE    refs/heads/shadowhead -> GONE
refs/tags/shadowtag   -> GONE    refs/tags/shadowtag   -> GONE
loose files left: 0              loose files left: 0
packed-refs mentions shadow*: 0  packed-refs mentions shadow*: 0

Nothing resurrected at the old sha in either arm, survivors intact, outcomes identical. Also confirmed the pack itself resolves the shadow correctly: after pack-refs --all the packed line carried the new sha, not the stale one. The author's claim holds, including for the tags #464 added an hour before this branch.

--all is pinned by tests, not only by prose: three of them, proved by mutation. Removing --all in a scratch worktree fails clients/git/tests.rs:516 (left: ["git", "pack-refs"]), repo_manager.rs:2559 (the flow argv sequence), and behaviourally repo_manager.rs:3781, which leaves refs/heads/main loose. That third one is the 301-untouched-heads observation reproduced in miniature by the suite itself.

The branch was left on wayfinder/devlaunch-470 at ec9619a, working tree clean; the mutation worktree was removed.

Verdict

Request changes (posted as a comment: GitHub refuses --request-changes on a same-account PR, so this written verdict is the gate).

Two blocking findings, both in the Spec axis, both single sentences, both accuracy rather than design:

  • S1docs/cleanup.md:372-375 and rust/devlaunch-core/src/flows/repo_manager.rs:1417-1420 state #446's bare-side reachability probe as present tense. It is unbuilt; the shipped probe runs on the clone. Fix the tense and point at #446.
  • S2docs/cleanup.md:380 promises a stderr line for a refused pack. The sweep's only production path is a detached child with stderr(Stdio::null()), so no user ever sees it. Say what actually happens, or say the channel is not reachable today.

Standards passes with no blocking findings. The pack, its placement, the CacheNotice arm keeping last_fetched, and the test set are all correct, and the two claims the ticket flagged for hard scrutiny both survived independent re-measurement.

…reads

The probe saving is real and nothing collects it: the bare-side
reachability guard is decided and unbuilt, and the probe that ships asks
the clone. Placement is what carries this call today, so say that in the
contract page and at the call site rather than only in the PR.

The refusal notice is not a line on stderr. The sweep runs detached with
its output discarded, so every notice it raises goes to a null
descriptor. Name that, and name what a failing pack costs while it goes
unread: loose refs are rewritten in place, so the count stays flat at one
sweep's worth rather than growing.
@blooop
blooop merged commit 5cb909a into main Aug 25, 2026
15 checks passed
@blooop
blooop deleted the wayfinder/devlaunch-470 branch August 25, 2026 18:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Pack the bare caches' refs on the sweep

1 participant