Packfiles and Garbage Collection: How Git Optimizes Storage
AI generated
git
HEAD
Git · Internals · Packfiles · Garbage Collection
Packfiles and Garbage Collection: How Git Optimizes Storage
From loose objects to delta-compressed packs

A bloated .git directory spanning several gigabytes is rarely an accident. It is the sum of every commit, every rebase, and every accidentally committed binary that Git never deletes on its own. This article explains how loose objects turn into delta-compressed packfiles, what git gc actually does under the hood, what role the reflog plays in preventing data loss, and how to diagnose and shrink a bloated repository.

13 min. read Packfiles · Delta Compression · git gc Git 2.x · CLI · Repository Maintenance

1. Why Git repositories bloat over time

Every commit, every amend, every rebase creates new objects in .git/objects, but Git never automatically deletes those objects during normal work. Even git commit --amend or an interactive rebase does not overwrite an old commit; it creates a new one and leaves the old commit untouched in the object store, still reachable via the reflog. Over months this adds up: hundreds of overwritten commits, discarded branches, and experimental merges technically remain, even though no branch points to them anymore.

Accidentally committed binaries carry particular weight: a ten-megabyte PDF or a database dump removed seconds later with git rm still stays part of the history forever, because every version of a file is stored as its own, immutable blob object. That explains why a .git directory is often many times larger than the current checkout, and why git clone takes surprisingly long in a project that has grown over the years.

2. Loose objects: one file per object

Every new object first lands as a loose object under .git/objects/xx/yyyy..., where xx is the first two characters of the SHA hash and the rest forms the filename. The content is zlib-deflated, but each object remains its own file on disk. For a small repository with a few hundred commits, this is completely fine and even convenient, since every object is instantly readable on its own.

With thousands of commits, the number of loose objects quickly becomes a problem, not because of the compression, but because of filesystem overhead: every file consumes at least one inode, many filesystems noticeably slow down with hundreds of thousands of small files in a handful of directories, and every read means a separate system call. The command below shows exactly how many loose objects actually exist before any git gc has even run.


# Count loose object files directly on disk
$ find .git/objects -type f -not -path "*/pack/*" | wc -l
14832

# Total size of the .git directory
$ du -sh .git
612M    .git

# Git's own summary: loose vs. packed objects and their sizes
$ git count-objects -v
count 14832
size 187344
in-pack 92150
packs 3
size-pack 421088
prune-packable 0
garbage 0
size-garbage 0

3. Packfiles: bundling many objects into a single .pack file

A packfile bundles many individual objects into exactly two files: a .pack file holding the actual, often delta-compressed object data, and an accompanying .idx file containing a sorted index of hash and byte offset, so Git can locate a single object without scanning the entire pack sequentially. Both files live together under .git/objects/pack/.

Thousands of loose files often collapse into just two, which noticeably speeds up checkout and clone, because network transfer now involves a single large, already compressed object instead of thousands of small SSH or HTTP requests. git gc creates packfiles automatically, but git repack -a -d can also be called manually at any time to consolidate all loose objects and existing packs into a single, optimized pack.

4. Delta compression: storing objects as differences

Inside a pack, Git stores most objects not as a full copy but as a delta against a similar base object, usually an earlier version of the same file. Two commits that differ in only a few lines of a large file then only need the difference plus a reference to the base object, instead of storing the full content twice.

Choosing delta candidates is heuristic: Git groups objects by similar size and similar filename, then tests which combination yields the best compression. pack.window (default 10) limits how many objects are compared as base candidates, and pack.depth (default 50) limits how many deltas can chain one after another before a full object is stored again, keeping reconstruction time on read bounded.

5. git gc in detail: what garbage collection actually does

git gc is not a single operation but a chain of maintenance steps: loose objects get consolidated into a new pack and delta-compressed, existing inefficient packs get repacked if needed, expired reflog entries get removed, and objects that are no longer reachable from any branch, tag, or reflog entry afterward get marked for deletion or removed outright.

Git already calls git gc --auto regularly after operations like git commit or git fetch, but only once thresholds are exceeded: gc.auto (default 6700 loose objects) and gc.autoPackLimit (default 50 packs) determine when automatic cleanup kicks in. A manual run with --aggressive forces a more thorough, but significantly slower delta search across the entire repository.


# Standard maintenance: repack loose objects, expire old reflog entries
$ git gc
Enumerating objects: 14832, done.
Counting objects: 100% (14832/14832), done.
Delta compression using up to 8 threads
Compressing objects: 100% (12904/12904), done.
Writing objects: 100% (14832/14832), done.
Total 14832 (delta 8210), reused 0 (delta 0), pack-reused 0

$ du -sh .git
198M    .git

# Aggressive repack with a wider delta search, plus immediate pruning
$ git gc --aggressive --prune=now
Enumerating objects: 106982, done.
Compressing objects: 100% (98410/98410), done.
Total 106982 (delta 61230), reused 92150 (delta 55870)

$ du -sh .git
164M    .git

6. Unreachable objects and the role of the reflog

A commit becomes unreachable as soon as no branch, no tag, and no other reachable commit points to it anymore, for example after git commit --amend, git rebase, or git branch -D. Git still does not delete such objects right away, because the reflog, a local history of every move of HEAD and branch pointers, keeps referencing them and thereby keeps them artificially alive.

By default, reflog entries for reachable commits expire after 90 days (gc.reflogExpire), and for already unreachable commits after 14 days (gc.reflogExpireUnreachable). This exact safety net is what makes git reflog the most important tool for effortlessly recovering an accidentally deleted commit, a lost branch, or a failed rebase within that time window, without depending on an external backup.

7. Pruning: when objects are actually deleted

An object only actually gets removed from disk once it is both unreachable and no longer protected by a reflog entry, and additionally older than the grace period configured in gc.pruneExpire, two weeks by default. git gc alone therefore rarely deletes anything immediately in practice; it mostly cleans up and repacks.

git prune without options respects that same two-week grace period, while git gc --prune=now lifts it entirely and removes everything no longer reachable right away. That is exactly what makes --prune=now risky: if a commit only just became unreachable through a mistake and is not yet recorded in the reflog, it disappears irrevocably, without the usual two-week rescue window.


# Full sequence to actually purge unreachable history immediately
# WARNING: destructive, only use when you are certain nothing is needed

# 1. Expire every reflog entry right now, removing the safety net
$ git reflog expire --expire=now --all

# 2. List what would be purged: unreachable objects not protected by reflog
$ git fsck --unreachable --no-reflog
unreachable commit a1b2c3d4e5f67890abcdef1234567890abcdef12
unreachable blob 9f8e7d6c5b4a3928170695847362514039281706

# 3. Actually remove the unreachable objects from disk
$ git prune -v
Removing a1b2c3d4e5f67890abcdef1234567890abcdef12
Removing 9f8e7d6c5b4a3928170695847362514039281706

$ git count-objects -v
count 0
size 0
in-pack 92150
packs 1
size-pack 164024

8. Diagnosing repository bloat and shrinking .git

git count-objects -v provides an overview of the count and size of loose objects plus existing packs within seconds, without altering the repository at all. du -sh .git shows the actual total size on disk, often surprisingly larger than the current working tree if the history contains many large files that were deleted long ago.

To find the biggest culprits inside a pack, git verify-pack -v combined with sorting by object size helps. Once the largest objects are identified and turn out to be accidentally committed binaries, git gc alone is not enough, since those objects remain part of the history. Only a history rewrite with git filter-repo removes them permanently from every commit, followed by an immediate prune of all stale references. For regular, unattended maintenance on central bare repositories, a scheduled workflow run fits better than manual intervention.


# Find the largest objects inside a packfile by uncompressed size
$ git verify-pack -v .git/objects/pack/pack-*.idx \
    | sort -k3 -n -r \
    | head -n 5
7c2b1a9f... blob   9484032 2103841 481920
3e8d4f21... blob   4210688  982104 512004
b1a0f7c3... blob   2094112  601022 918233
90fa2e11... blob   1887744  399102 105881
e4c5d6a7... blob   1024006  287441  22019

# Cross-reference the largest hash with the file path it belongs to
$ git rev-list --objects --all | grep 7c2b1a9f
7c2b1a9f... assets/legacy-export-2019.sql.gz

# .github/workflows/git-maintenance.yml
# Scheduled maintenance for a self-hosted bare mirror repository
name: Git repository maintenance

on:
  schedule:
    # Every night at 02:15 UTC
    - cron: "15 2 * * *"
  workflow_dispatch: {}

jobs:
  maintain:
    runs-on: self-hosted
    steps:
      - name: Run incremental git maintenance
        run: |
          cd /srv/git/mirrors/mironsoft.git
          git maintenance run --task=loose-objects
          git maintenance run --task=incremental-repack

      - name: Full weekly repack with aggressive delta search
        if: github.event.schedule == '15 2 * * 0'
        run: |
          cd /srv/git/mirrors/mironsoft.git
          git gc --aggressive --prune=now

      - name: Report repository size after maintenance
        run: |
          du -sh /srv/git/mirrors/mironsoft.git

9. Storage and maintenance strategies compared

The table below summarizes which symptom points to which root cause, which command diagnoses it, and which fix actually helps, instead of blindly running git gc and hoping for the best.

Situation Symptom Diagnostic command Fix
Many small commits without maintenance Thousands of loose files under .git/objects git count-objects -v git gc (repacks automatically)
Accidentally committed binary .git many times larger than the checkout git verify-pack -v | sort -k3 -nr git filter-repo + git gc --prune=now
History heavy on amends and rebases git gc visibly deletes nothing git reflog Wait 90 days or expire the reflog deliberately
Many small packs instead of one large one Clone and checkout keep getting slower ls .git/objects/pack | wc -l git repack -a -d or git maintenance run
Immediate deletion desired git prune seems to do nothing git count-objects -v git gc --prune=now (risky, no safety net)

For repositories to stay lean long-term, a regular, automated maintenance run beats occasional manual intervention, especially on central mirror or bare repositories that many CI jobs access every day.

Mironsoft

Repository maintenance, CI/CD performance, and Git infrastructure for PHP and Magento teams

Ready to shrink a bloated repository?

We analyze your .git history, find the biggest culprits, safely remove them from the past, and set up automated maintenance runs so your repository stays lean for good.

Repository audit

Identify the largest objects, unreachable commits, and root causes of bloat

History rewrite

Safely remove accidental binaries and secrets from history

Maintenance automation

Scheduled git maintenance and gc runs for mirrors and CI pipelines

10. Summary

Git initially stores every object as a loose file, but bundles them via git gc or git repack into packfiles, which through delta compression against similar base objects end up considerably smaller than the sum of their individual files. Unreachable commits do not disappear immediately; they stay reachable as a safety net via the reflog for 90 days by default, before they even become eligible for pruning.

Only once an object is neither reachable nor protected by the reflog nor younger than the two-week gc.pruneExpire grace period does Git actually remove it from disk, whether through the regular git gc cycle or explicitly via git prune. For structurally large objects like accidentally committed binaries, that alone is not enough: only a history rewrite with git filter-repo, followed by an immediate prune, removes them for good, and it makes diagnostic tools like git count-objects and git verify-pack a permanent part of regular repository maintenance.

Packfiles and Garbage Collection at a Glance

Loose objects vs. packfiles

One file per object under .git/objects/xx/ gets bundled into a single .pack and .idx file.

Delta compression

Similar objects are stored as a difference against a base object instead of fully duplicated.

Reflog & pruning

Unreachable objects stay protected via the reflog for 90 days; gc.pruneExpire delays deletion by 2 more weeks.

Diagnostics & maintenance

git count-objects -v, git verify-pack -v, and scheduled git maintenance runs keep repositories lean.

11. FAQ: Packfiles and Garbage Collection

1What is the difference between loose objects and a packfile?
Loose objects are individual zlib-compressed files under .git/objects/xx/. A packfile bundles many objects into one .pack plus .idx file with added delta compression.
2What does git gc actually do in one run?
Repacks loose objects with delta compression, repacks inefficient packs, removes expired reflog entries, and deletes unreachable, unprotected objects.
3How long do unreachable commits stay reachable via the reflog?
90 days for reachable commits (gc.reflogExpire), 14 days after becoming unreachable (gc.reflogExpireUnreachable). Both values are configurable.
4When does Git actually delete an unreachable object?
Once it is unreachable, no longer reflog-protected, and the gc.pruneExpire grace period of two weeks by default has passed.
5Is git prune dangerous?
Without options it is relatively safe due to the two-week grace period. git gc --prune=now or --expire=now are risky since they bypass it.
6What is the difference between git gc and git gc --aggressive?
Standard gc uses moderate pack.window/depth values and is fast. --aggressive searches more thoroughly for better deltas but takes much longer.
7How do I find the largest objects in my repository?
git verify-pack -v combined with sort -k3 -n -r and head shows the largest objects, whose path can be found via git rev-list --objects --all.
8Is git gc enough to get rid of an accidentally committed binary?
No, git gc only removes unreachable objects. A file still referenced anywhere in reachable history must be removed with git filter-repo.
9What is delta compression and how does Git choose the base objects?
Objects are stored as differences against a similar base object. Git groups by size/name and tests the best combination within the pack.window.
10How do I keep a central bare repository small over time?
With a scheduled maintenance job like git maintenance run or a cron-driven git gc in the CI/CD pipeline instead of manual intervention.