# 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.

- Source: https://oharu121.com/blog/git-gc-loose-objects-vs-filter-repo-history-rewrite/
- Published: 2026-08-14T00:04:22+09:00
- Tags: Git, WebP

---
## 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:

```text title="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:

```ts title="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:

```bash
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:

```bash
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`.

*Figure — BlastRadius: 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:

```bash
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:

*Figure — RepoWeight: 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:

```bash
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:

```bash
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.

*Figure — SquashOrphans: 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:

```bash
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:

```bash
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
```

*Figure — ObjectStates: 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:

```bash
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:

*Figure — CodecSplit: 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

- [git-gc documentation, including the two-week `gc.pruneExpire` default that keeps unreachable objects around](https://git-scm.com/docs/git-gc)
- [git-count-objects, whose `-v` output is what distinguishes loose objects from a packfile](https://git-scm.com/docs/git-count-objects)
- [Git internals: packfiles and delta compression, which is why text collapses and images do not](https://git-scm.com/book/en/v2/Git-Internals-Packfiles)
- [git-filter-repo, the tool for the problem `git gc` cannot touch](https://github.com/newren/git-filter-repo)
- [git-fsck, whose `--unreachable` listing treats reflogs as roots unless you pass `--no-reflogs`](https://git-scm.com/docs/git-fsck)
- [The cruft pack design document, on why unreachable objects get a packfile and an `.mtimes` file instead of being exploded back to loose](https://github.com/git/git/blob/master/Documentation/technical/cruft-packs.adoc)
