The runner already downloaded a repository once and cloned every job from that
copy, but nothing bounded the copies and nothing reclaimed what they no longer
needed. They grew with every tag, every branch, and every commit a job pinned by
id, and a force-push left the old history reachable through the
refs/remotes/origin/* reflogs for 90 days. A long-lived runner eventually filled
its disk and then could not check anything out.
This is the second half of the same idea: the cache that saves a download now has a budget.
What changed
- A cap,
--git-cache-size(10G by default;0keeps every copy). Here a missing flag means the default, unlike--cache-size, because deleting a saved checkout only costs the next job a download. - Repack before delete. A copy holding more than its share of the cap, or any
copy when the cache is over the cap, is repacked: leftover
refs/vole/jobs/*from a killed runner are removed, the cache’s reflogs are made expendable, andgit gc --prune=nowdrops everything no branch or tag can reach. Branch tips and tags stay, so a job for a cached revision is still a delta fetch. - Then delete the least recently used copy, whole. The repository this runner is about to build is never the one deleted, and a copy another process holds the lock on is passed over.
- Bounded at startup and between jobs. Startup matches how the job cache is
trimmed; the between-jobs pass skips
--once, which is about to exit anyway. A repository under its share is repacked at most every six hours. - A per-repository lock (
flock, under<dir>/.locks) around every read and write of a saved checkout:prepareWorktree(fetch, temporary ref, local clone), the repack, the deletion, and the site and release pushes that are written from the same cache. Without it, a--prune=nowcould delete objects another process was writing or cloning, and a deletion could land under a running job. The lock is per repository, so runners sharing a--dirstill work in parallel on different repositories. git clone --no-checkoutfor the saved copy. Nothing reads its working tree: records, sites and releases are written with plumbing, and each job gets a clone of its own, so the checkout only spent disk.
Two deliberate omissions
- The branch fetch on a cache hit stays. Skipping it would make a cache hit fully
offline, but a job’s checkout carries the cache’s tracking refs as
origin/*, and a step comparing againstorigin/mainwould silently see an old branch. The fetch transfers no history; the download is what the cache already removes. - The repack does not run on a background goroutine. It is bounded, at most twice
a day per repository once warm, and running it inline keeps the lock ordering
honest: a background repack would just make the next job block inside
prepareWorktreeinstead.
Testing
- New tests in
ci/agentcover the LRU deletion order, keeping the active repository’s copy, declining while another process holds the lock, and a repack dropping both a deleted branch’s commits and a leftover job ref while keeping the branch tip. The existing trusted-cache test now also asserts the saved copy has no working tree, must not be rebuilt when it is reused, and costs exactly one branch fetch on a hit. vole workflow run verifypasses (format, vet, build, licenses, govulncheck, packages, links,go test -race -count=1 ./...).
No issue is linked: this came from a direct request about runner bandwidth rather than from a tracked issue.