Tags
The Git diamond logo in red and the git wordmark in dark brown on a white card

git gc recovered 8.6 MB while I was investigating a 0.8 MB problem in git history

Chasing 1.6 MB of PNG blobs in git history found a repo that had never been packed. git gc recovered 8.6 MB; the rewrite I skipped was worth 0.8 MB.

On this page

Introduction

The cover images on this blog were PNG. The in-body figures had been WebP since a conversion script landed for them, so the registry looked like an oversight, and I asked whether converting it would be a good idea for the sake of format consistency. The answer turned out to be no, for a reason I did not expect: the repository had never been garbage collected, and git gc recovered 8.6 MB while the conversion I was asking about was worth 0.8 MB. This article walks through the measurements that turned a format question into a packing question, why I decided against rewriting history to get that 0.8 MB, and the one thing git gc does not do that a reader with a real blob problem needs to know.

The consistency I was asking for already existed

The premise died first. Astro’s image pipeline emits WebP derivatives at build time regardless of what the source file is, so the built site already served both formats identically. The proof was sitting in dist/, where a thumbnail derivative and a figure derivative are the same format and neither is a PNG:

dist/_astro/
astro-generic.CZGwia_K_2sMF92.webp ← source is PNG
one-component-two-locales.CwF4yp8J.webp ← source is WebP

One PNG does survive to a reader, and it is deliberate. The Open Graph card is pinned to PNG:

src/layouts/ArticleLayout.astro
const ogAsset = await getImage({ src: cover.src, width: 1200, height: 630, format: 'png' });

That line does not care about the source format either, because sharp decodes WebP perfectly well. Converting the sources would have changed zero bytes over the wire. Whatever the conversion was worth, it was not worth anything to a reader.

The only thing left was git weight, and it was small

That left the repository itself. Summing every thumbnail blob that has ever existed gives a number small enough to end most arguments:

Terminal window
git rev-list --objects --all -- src/assets/thumbnails |
git cat-file --batch-check='%(objecttype) %(objectname) %(objectsize) %(rest)' |
grep '^blob' | awk '{s+=$3; n++} END {printf "%d blobs, %.1f MB\n", n, s/1048576}'
# 24 blobs, 1.6 MB

Twenty-four blobs for eighteen live files, because six had been replaced at some point. Re-encoding them as WebP would save roughly half of that.

There was already a rule in this repo covering exactly this situation, written for article figures. scripts/optimize-figures.ts refuses to convert anything git tracks, and says why in its own comment: rewriting a file that is already committed does not reclaim anything, because the old blob stays in history for good. Every one of the eighteen thumbnails was committed. Converting them in place would have added a second copy of each rather than replacing the first.

So the only version of this that saves anything is the version that removes the old blobs from history, and I asked how hard that would be.

What the rewrite would have touched

Not hard, mechanically. git-filter-repo was already installed, and the repo is small. The cost is not the run, it is what the run invalidates, and that depends on when the offending files first appear:

Terminal window
git log --format='%h %ad %s' --date=short -- src/assets/thumbnails | tail -1
# 999d7e5 2026-08-04 feat: trilingual Astro blog with translation pipeline (#1)

The thumbnails landed in the first commit, which is the worst possible answer. A rewrite that has to start at commit one re-hashes every commit after it, so at the time that meant all 45 commits, all 25 tags re-pointed, and the merge commits behind 34 merged pull requests left pointing at objects no longer on main.

What each option costs, and what it returnsTwo columns compared. A git filter-repo history rewrite would re-hash all 45 commits, re-point all 25 tags, orphan the merge commits behind 34 merged pull requests, and recover 0.8 MB, and it is the only option that removes the 1.6 MB of PNG blobs. git gc re-hashes no commits, re-points no tags, orphans no pull requests, recovers 8.6 MB, and removes no PNG blobs at all: all 24 are kept.What each option costs, and what it returnsMeasured on this repository at the time: 45 commits, 25 tags, 34 merged pull requestsgit filter-repogit gccommits re-hashed45nonetags re-pointed25nonemerged PRs orphaned34nonePNG blobs removedyes, 1.6 MBno, all 24 keptrecovered0.8 MB8.6 MB
The 1.6 MB of PNG blobs is the one thing only a rewrite can remove. Everything else in the table is a reason not to run one.

One check came back reassuring and did not change the verdict. Grepping the published articles for links to this repository found eleven, and every one of them points at a separate demo repository rather than at a commit here, so no published content would have broken. That mattered less than it sounds, because the numbers on the other side of the ledger had already collapsed.

The repository had never been packed

While measuring how much a rewrite could return, the agent ran git count-objects and found a different problem:

Terminal window
du -sh .git
# 17M
git count-objects -vH
# count: 2081
# size: 16.46 MiB
# in-pack: 240
# size-pack: 437.45 KiB

2081 loose objects, and a packfile holding 240. This repository had been committed to for months and never once garbage collected, so almost everything in it was sitting on disk as an individually zlib-compressed file with no delta compression against anything.

Packing it in a throwaway mirror clone, so nothing was at risk while the number was still a guess, took 17.0 MB to 7.8 MB. That is eleven times what the history rewrite was offering, for a command that rewrites nothing.

The split of what shrank is the part worth keeping:

The same repository, before and after packingTwo horizontal bars drawn to scale. Before garbage collection the repository is 17.0 MB, of which 6.7 MB is image blobs and 10.3 MB is everything else, so images are 39 percent. After garbage collection it is 8.4 MB, of which the same 6.7 MB is image blobs and only 1.7 MB is everything else, so images are 80 percent. The image segment is unchanged; only the non-image segment shrank.The same repository, before and after packingImage blob sizes summed across all history; they barely change when packedBefore gc17.0 MBAfter gc8.4 MBimage blobseverything else
Nothing was added or removed between these two bars. The non-image half compressed; the image half could not, because it was already compressed when it arrived.

Summed across all of history, image blobs come to 6.7 MB. That number is nearly identical before and after packing, because PNG and WebP are already-compressed formats and a packfile has almost nothing left to take out of them. The MDX and TypeScript around them compress enormously. So images went from a minority of the loose repository to most of the packed one without a single byte being added, which is why the loose-object view had made them look like a smaller problem than they were, and the packed view makes them look bigger than any rewrite could fix.

That reframing is what settled it. I decided not to rewrite history: trading every commit SHA, 25 tags, and the commit links on 34 merged pull requests for 0.8 MB is a bad trade on a private single-author repository where clone time was never a complaint. Running git gc was free.

Where 2081 loose objects and 483 orphaned commits came from

Two words get used as if they were the same thing, and the difference is what decides whether git gc can help.

Loose describes how an object is stored: one zlib-compressed file at .git/objects/ab/cdef…, with no delta compression against anything. Unreachable describes whether anything points at it. They are independent axes, not two names for the same condition.

Term What it describes The case that surprises people
Loose How the object sits on disk The commit you just made is loose, and perfectly current
Unreachable Whether a ref or reflog leads to it A packed object can be unreachable garbage

Of those 2081 loose objects, 1269 were reachable and perfectly current. Nothing was wrong with them. They were simply the objects git writes as it goes: a blob per added file, a commit object per commit, and a tree per directory the commit touched. Trees dominate, because a nested path writes one per level on every commit that touches it.

They accumulated because nothing ever triggered a pack. Git packs automatically when the loose object count crosses gc.auto, which defaults to 6700:

Terminal window
git config --get gc.auto # unset, so the default 6700 applies

At 2081 the threshold was never crossed, and git push does not pack the local repository either. GitHub packs its own copy on receipt, which is why the remote reported 7964 KB while the local copy sat at 17 MB.

The unreachable objects have a different and much more interesting source:

Terminal window
git cat-file --batch-all-objects --batch-check='%(objecttype)' | grep -c '^commit'
# 532
git rev-list --all --count
# 49

532 commit objects exist for a 49-commit history. The other 483 are orphans, and the largest producer of them is this blog’s own release process. A squash merge does not merge the branch’s commits; it writes one new commit carrying their combined diff, under a new hash. Deleting the branch afterwards leaves the originals with nothing pointing at them.

What one squash merge leaves behindTwo rows. Before the merge, the main branch has a line of commits and a feature branch of three commits hangs off it, all connected. After the merge, main has gained one new commit carrying the whole diff under a new hash, and the three feature-branch commits are drawn detached with nothing pointing at them, because a squash merge replays the diff rather than merging the commits and the branch was then deleted.What one squash merge leaves behindOnce per release, which is where 483 orphaned commits came fromBefore the mergemainfeature branch, then deletedAfter the mergemainone new commit, new SHAnothing points here any more
The three feature-branch commits still exist as objects. After the squash and the branch delete, nothing references them.

The release that shipped this article’s tooling demonstrated it mid-investigation: the unreachable count went from 812 to 826, and the fourteen new ones are that branch’s three commits plus their trees and blobs.

What git gc actually did, and what it did not

On this repository:

Terminal window
git gc
du -sh .git
# 8.4M
git count-objects -vH
# count: 0
# in-pack: 2331
# size-pack: 8.12 MiB

8.6 MB recovered, zero objects rewritten. The result lands slightly above the 7.8 MB the test clone reported, and the gap is the 812 unreachable objects, which a plain git gc keeps rather than deletes.

It is worth being precise about what the command did, because it is three operations and only one of them deletes anything:

  1. Repack. Every reachable object goes into one packfile, delta compressed against its neighbours. That pack ended up holding 1519 objects, ten of which were written after the loose count above was taken, and it removes nothing.
  2. Prune. Unreachable objects are deleted once they are older than gc.pruneExpire, which defaults to 2.weeks.ago. All 812 were younger, so none qualified.
  3. Cruft pack. Objects that survived step 2 cannot stay loose without defeating step 1, so git writes them into a second packfile alongside a .mtimes file recording each object’s age, which is what lets the grace period outlive a repack.

That last one is visible on disk, and the object count in it is exact:

Terminal window
ls .git/objects/pack/
# pack-1f33b91c….pack the reachable objects
# pack-2bd20ff4….pack the cruft pack
# pack-2bd20ff4….mtimes its per-object ages
git verify-pack -v .git/objects/pack/pack-2bd20ff4….idx | grep -cE '^[0-9a-f]{40}'
# 812
Two independent axes, four different outcomesA two by two grid. The columns are reachable and unreachable; the rows are loose and packed. Loose and reachable objects are packed into the main packfile: 1269 of the 2081 loose objects, which is where the 8.6 MB came from. Loose and unreachable objects are swept into a cruft pack: 812 of them, kept for two weeks. Packed and reachable objects are re-delta compressed and kept: 240 were already there. Packed and unreachable objects are deleted once two weeks old, and none had aged out yet. The 24 PNG blobs are in the reachable column, which is why garbage collection never removes them.Two independent axes, four different outcomesWhat git gc does to an object depends on both, not eitherReachablea ref or reflog leads to itUnreachablenothing leads to itLooseone zlib file per objectPackedinside a packfilePacked into the main packfile1269 of the 2081 loose. This is the 8.6 MBSwept into a cruft pack812 objects, kept for two weeksRe-delta compressed, kept240 objects were already hereDeleted, once two weeks oldnone had aged out yetThe 24 PNG blobs sit in the reachable column. That is why no amount of gc removes them.
Only the bottom-right cell deletes anything, and it was empty. Everything recovered came from the top-left.

The tool that reports all of this is git fsck, whose actual job is integrity rather than cleanup: it recomputes every object’s hash and checks that everything referenced exists. It is read-only. Its --unreachable listing is a side effect, and it treats reflogs as roots by default, which is why git reset --hard stays recoverable. On this repository that default hides 352 objects: 828 unreachable normally, 1180 with --no-reflogs.

Now the part that matters most, and the reason the honest version of this story is not “git gc fixed it”. After the collection:

Terminal window
git rev-list --objects --all -- src/assets/thumbnails | ... # same command as above
# 24 blobs, 1.58 MB

Every PNG blob is still there. git gc only deletes objects that nothing references, and these are reachable from nine commits, so it packed them and kept all twenty-four. The problem I set out to investigate is exactly as unsolved as it was before, and that is the correct outcome rather than a loose end. Its fix costs 45 rewritten commits and returns 0.8 MB.

This is the distinction worth carrying away. git gc and git filter-repo do not address the same bytes:

Symptom What is actually wrong The tool
.git far larger than the working tree, thousands of loose objects Never packed. No delta compression, no packfile git gc
A large file reachable from history that nobody wants any more The blob is referenced by a commit, so nothing can drop it git filter-repo
A large file that has never been committed Nothing is wrong yet Convert or delete it before committing

Anyone carrying a genuine 500 MB binary in history still needs the rewrite. git gc will pack it neatly and give back nothing.

The rule that shipped instead

Since the saving only ever exists before the first commit, the rule was written to bind at that moment and nowhere else. pnpm thumbnails:fix now converts any PNG that git does not yet track, and leaves the eighteen committed ones alone. That is the same grandfathering the figures pipeline already used, so both scripts now answer the same question the same way.

The encoder setting was the one place a plausible assumption turned out to be wrong. The agent’s first recommendation was lossless for everything, on the reasoning that these are flat logo cards where lossy ringing would show on the wordmark edges. Measuring every card both ways showed that reasoning applies to only half the registry:

One setting cannot serve both halves of the registryFour cover images, each encoded as WebP lossless and as WebP quality 90, drawn to one scale. For the flat marks, lossless is smaller: git-generic is 5 KB lossless against 12 KB at q90, and vercel-generic is 4 KB against 7 KB. For the textured cards the result inverts by an order of magnitude: expressive-code-generic is 90 KB lossless against 15 KB at q90, and llm-generic is 197 KB against 20 KB.One setting cannot serve both halves of the registryEvery card is 1200 × 630; the bars are the same two encodersFlat marks on a solid cardgit-generic5 KB lossless12 KB q90vercel-generic4 KB lossless7 KB q90Gradient or photographic textureexpressive-code-generic90 KB lossless15 KB q90llm-generic197 KB lossless20 KB q90smaller of the two
Same two encoders, same chart. The flat marks and the textured cards disagree about which one is smaller by an order of magnitude.

What shipped was neither fixed setting. The agent proposed encoding each image both ways and keeping whichever file came out smaller, and that is what the script does. It reads as a size hack and works as a content classifier: lossless output balloons exactly when an image has the gradient or photographic texture that makes lossy imperceptible, so the smaller file is reliably the codec that suits the art. Across the eighteen cards the totals come out at 1216 KB as PNG, 602 KB all-lossless, 210 KB all-q90, and 193 KB picking per image.

One defect survived into code review. The directory scan had been widened to accept .webp as well as .png, but the write branch still chose its encoder from the conversion flag, which is only true for an untracked PNG. An off-spec WebP therefore took the PNG path and was written back out as PNG bytes under its own .webp filename, where it measured 1200x630 and passed every later check silently. I chose to fix that and the two smaller findings alongside it rather than ship the known-good path only.

Summary

  • The format consistency I asked about was already true at delivery. Astro emits WebP for both source formats, and the surviving PNG is a deliberately pinned Open Graph card.
  • git gc recovered 8.6 MB; the history rewrite would have returned 0.8 MB. Measuring the free option first is what made the expensive one obviously not worth running.
  • Loose and unreachable are independent. Loose is a storage format and most of these were current; unreachable is a lifecycle state. git gc packs the first and quarantines the second, and it deleted nothing at all here.
  • Loose objects piled up because gc.auto defaults to 6700 and this repository only ever reached 2081, so the automatic pack never fired. Pushing does not pack the local copy.
  • The two tools solve different problems. git gc packs loose objects and deletes only unreachable ones. It removed none of the 1.6 MB of PNG blobs, because all twenty-four are reachable from nine commits.
  • Already-compressed files are why a packed repository looks different from a loose one. Text delta-compresses; images do not, so images went from 39% to 80% of this repository without changing at all.
  • A rewrite’s cost is set by when the file first appears. These thumbnails were in commit one, so the blast radius was the entire history rather than a recent slice of it.
  • Encoding both ways and keeping the smaller file beat both fixed settings, because file size turned out to be a usable proxy for whether an image has texture.

References

Share this article