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.
Table of Contents
- 1. Why Git repositories bloat over time
- 2. Loose objects: one file per object
- 3. Packfiles: bundling many objects into a single .pack file
- 4. Delta compression: storing objects as differences
- 5. git gc in detail: what garbage collection actually does
- 6. Unreachable objects and the role of the reflog
- 7. Pruning: when objects are actually deleted
- 8. Diagnosing repository bloat and shrinking .git
- 9. Storage and maintenance strategies compared
- 10. Summary
- 11. FAQ
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.