skip to content

After removing a huge blob from Git history, why does .git stay large, and what shrinks it?

level: seniorimportance: should knowfreq 45%

answer

  1. rewriting adds commits, deletes none
  2. something still points at the old tip
  3. backup refs and tracking refs count
  4. the ref history file is a root
  5. age cutoffs delay the deletion

basics

~20 s

The blob is still reachable from something you forgot: leftover backup refs, tags, remote-tracking refs, other worktrees, or the reflog. Until every path to it is gone and gc prunes past its grace period, the object stays on disk.

solid answer

~50 s

Rewriting history creates *new* commits; it never deletes the old ones. The old commits — and therefore the fat blob — remain reachable from whatever still points at them: `refs/original/*` left behind by `git filter-branch`, unrewritten tags, `refs/remotes/origin/*` that still reflect the old tips, the `HEAD` of another worktree, `refs/stash`, and above all the reflog entries recording where each branch used to be. Even after every path is gone, `git gc` keeps unreachable loose objects for `gc.pruneExpire` (two weeks by default), and an unreachable object still sitting inside an old packfile survives until that pack is rewritten. The sequence that actually reclaims space is: delete the stale refs, expire the reflogs with `git reflog expire --expire=now --expire-unreachable=now --all`, then `git gc --prune=now`. Verify with `git count-objects -v` before and after — and remember other clones are separate repositories with their own reachability.

code

bash · 4 lines
bash
git for-each-ref refs/original
git reflog expire --expire=now --expire-unreachable=now --all
git gc --prune=now
git count-objects -v

go deeper

for a junior

Recall that Git never deletes an object that something still points at, so removing a file from newer commits does not remove it from the repository.

for a middle

Explain the specific roots that survive a rewrite — backup refs, tags, remote-tracking refs, reflog entries — and the two-week prune grace that delays deletion even after those are gone.

for a senior

Show the diagnostic order: find what still reaches the object, clear those refs, expire reflogs, prune with no grace, and measure with count-objects. Be explicit that expiring the reflog is the point of no return.

for a principal

Own the coordination: every clone and every copy is an independent object store, so reclaiming space is a fleet-wide operation with a re-clone plan, plus a policy that prevents the next large binary from landing at all.

## The rewrite did not delete anything Git objects are immutable and content-addressed. "Rewriting history" means computing a new chain of commit objects whose trees no longer mention the offending blob, then pointing a branch at the new tip. The original commits, their trees, and the blob itself are all still in `.git/objects`. Whether they are removable depends entirely on reachability, and reachability has more roots than people remember. ## The usual survivors, in the order they catch people out **Backup refs.** `git filter-branch` deliberately saves the pre-rewrite tips under `refs/original/`. That namespace is a perfectly ordinary set of refs, so every old commit remains reachable until you delete them. **Reflogs.** Every ref update — including the one the rewrite performed — appends an entry to `.git/logs/refs/heads/<branch>` and `.git/logs/HEAD` recording the previous object ID. Those IDs are reachability roots. Reflogs are per-ref and per-repository, so the rewrite of `main` leaves a reflog trail on `main`, and any other branch you also rewrote leaves its own. **Tags.** A rewrite that only walks branches leaves annotated and lightweight tags pointing into the old graph. One unrewritten release tag is enough to hold the entire old history. **Remote-tracking refs.** `refs/remotes/origin/*` are ordinary refs in your repository. Until they are updated or deleted, they anchor the old commits locally, and a subsequent `git fetch` can re-anchor them if the other side still has them. **Other worktrees and the stash.** Each linked worktree has its own `HEAD` and index, both of which are roots; `refs/stash` and its reflog are roots as well. ## Then the grace periods Once nothing points at the old graph, the objects are unreachable but not yet gone: - `git gc` expires reflog entries only past `gc.reflogExpire` (90 days) or, for entries already pointing at unreachable objects, `gc.reflogExpireUnreachable` (30 days). - `git prune`, as invoked by gc, deletes loose unreachable objects only if their file mtime is older than `gc.pruneExpire` (two weeks). - An unreachable object inside an existing packfile is not touched by prune at all. It disappears only when that pack is rewritten. And `git repack -A` deliberately explodes such objects back to loose form so that the mtime grace still applies, which can even make `.git` grow briefly. That combination is why "I ran gc and nothing happened" is the normal experience rather than a bug. ## The sequence that actually reclaims the space 1. Confirm what is holding the object. `git fsck --unreachable --no-reflogs` shows what would already be garbage if reflogs were gone. To find live paths, list the refs that still reach it — `git for-each-ref` plus a reachability check tells you which namespace to clean. 2. Delete the stale refs: the `refs/original/` backups, any tags still pointing into the old graph, and remote-tracking refs that mirror the old tips. 3. Expire the reflogs: `git reflog expire --expire=now --expire-unreachable=now --all`. This is the step that converts "recoverable" into "garbage", and it is the step that makes the operation irreversible. 4. Prune without grace and repack: `git gc --prune=now`. Objects still packed get dropped as the pack is rewritten; loose ones are deleted regardless of age. 5. Measure. `git count-objects -v` before and after gives `size-pack` and the loose `size` in KiB; `git verify-pack -v` on the new `.idx` shows the largest remaining objects if the number is still not what you expected. ## What this does not do It shrinks *this* repository. Every clone is an independent object store with its own refs and its own reflog and will keep the blob until it performs the same cleanup — and a fetch from any copy that still has it can bring it back if a ref there still reaches it. Likewise, the copy living on whatever service hosts the repository is a separate repository with its own reachability and its own maintenance schedule; pushing rewritten branches does not by itself reclaim space there. The honest senior framing in an interview is therefore two-part: the local recipe (drop refs, expire reflog, prune now) and the coordination problem (everyone re-clones, or every copy repeats the cleanup), plus the observation that the cheapest fix is to never let the blob in.

  • Why is expiring the reflog the irreversible step?
    Because until then the old commits are still reachable, so a mistake is one `git reset --hard <old-oid>` away from being undone. `git reflog expire --expire-unreachable=now --all` removes the last pointers; after the following prune the objects are genuinely gone from this repository, recoverable only from another copy that has not been cleaned.
  • Why can .git briefly get bigger during this cleanup?
    `git repack -A` writes unreachable objects that were living inside an old pack back out as loose objects, so the mtime-based grace period can apply to them. Delta compression is lost in the process, so the same content temporarily costs more on disk until the following prune removes it.
  • Does pushing the rewritten branch reclaim space in other clones?
    No. Every clone is an independent object database with its own refs and reflog and keeps the blob until it performs the same cleanup or is re-cloned. A fetch from any copy whose refs still reach the old graph can also reintroduce the objects, which is why coordinated re-cloning is the usual answer.

saying these in an interview costs you the question

  • Thinks rewriting history deletes the old objects
  • Runs gc and concludes the blob was never removed
  • Forgets tags and remote-tracking refs point into old history
  • Believes prune removes objects still inside a packfile
  • Assumes other clones shrink once you push

context