Skip to content

Git Maintenance: Keeping Repositories Fast

Lesson 8 of 11Intermediate → Advanced10 min readModern Git Workflows · Modern Git ProductivityVerified: Git 2.43.0 on Ubuntu 24.04; every command in this lesson was run

Git repositories accumulate: loose objects from every commit, refs from every branch, reflog entries from every operation. Left alone, a busy repository gets slower.

Git handles most of this automatically. git maintenance gives you control over when and which tasks run, which matters on large repositories where the automatic behaviour is either too infrequent or badly timed.

The single most useful thing to know is when not to intervene: for most repositories, Git’s defaults are correct and manual optimisation is at best a waste of time.

Loose objects. Every git add and git commit writes new objects as individual compressed files. A day’s work can produce thousands.

Packfiles. Repacking consolidates loose objects, but repeated repacks leave several packfiles, and Git must search each one.

Unreachable objects. Amended commits, deleted branches, abandoned rebases. They remain until pruned.

Refs. Thousands of branches and tags as individual files makes any ref-scanning operation slow.

Reflog entries. Every ref movement, retained for 90 days by default.

None of this is a problem at small scale. On a repository with a million commits and tens of thousands of refs, each of them costs measurable time on ordinary commands.

Git already runs maintenance for you. Commands that create objects — commit, merge, rebase, am — occasionally trigger git gc --auto, which does work only if thresholds are exceeded:

SettingDefaultTriggers
gc.auto6700Loose objects before repacking
gc.autoPackLimit50Packfiles before consolidating
gc.autoDetachtrueRun in the background so you are not blocked
Terminal window
git count-objects -v
count: 0
size: 0
in-pack: 11
packs: 2
size-pack: 3
prune-packable: 0
garbage: 0
size-garbage: 0

count is loose objects; in-pack is packed ones. If count is in the low thousands, automatic maintenance has not run recently and does not need to.

The traditional command. It repacks loose objects, consolidates packfiles, packs refs, expires reflog entries and prunes unreachable objects.

Terminal window
git gc
Terminal window
git count-objects -v | grep -E 'count|in-pack'
count: 0
in-pack: 11

Loose objects have moved into a packfile. Refs are consolidated too:

Terminal window
cat .git/packed-refs
# pack-refs with: peeled fully-peeled sorted
4ff2767a422691863b00b07ee6e51de7a65b1919 refs/heads/main

git gc --aggressive recomputes delta compression from scratch. It is slow — hours on a large repository — and the benefit is usually small. Reach for it at most once after an unusual event such as a large import, never routinely.

The modern interface. Rather than one monolithic gc, it exposes individual tasks that can be run or scheduled separately.

Terminal window
git maintenance run --task=commit-graph

What it doesRuns one named maintenance task immediately in the current repository.

Why we run itIndividual tasks are far cheaper than a full gc, so they can run frequently without disruption.

Expected resultUsually no output. Effects are visible in .git — for example a new file under objects/info/.

Terminal window
ls .git/objects/info/
commit-graphs

The available tasks:

TaskDoesCost
commit-graphBuilds a cache of commit metadata and ancestryLow
prefetchFetches from remotes in the backgroundLow, network
loose-objectsPacks a batch of loose objectsLow
incremental-repackConsolidates packfiles graduallyModerate
pack-refsPacks refs into a single fileLow
gcThe full traditional collectionHigh

An invalid name lists nothing helpful, so keep this table handy:

Terminal window
git maintenance run --task=nope
error: 'nope' is not a valid task

The highest-value task for most large repositories. It caches each commit’s parents, generation numbers and other metadata in a single file, so operations that walk history do not have to read and decompress every commit object.

git log --graph, git merge-base, git branch --contains and anything computing reachability all get substantially faster. On a repository with hundreds of thousands of commits the difference is dramatic.

Fetches from remotes in the background into a private ref namespace, without touching your remote-tracking branches. When you later run git fetch, most of the objects are already local, so it completes quickly.

Because it does not update origin/*, it never changes what your commands report — it only makes the eventual fetch cheap.

Consolidates packfiles gradually rather than rewriting everything at once. This is the key difference from gc: it bounds the work per run, so it can be scheduled hourly without ever blocking you for minutes.

git maintenance start registers the repository and installs a schedule using the platform’s own mechanism — systemd timers, launchd, or Task Scheduler depending on the operating system.

Terminal window
git maintenance start

Thereafter Git runs the appropriate tasks on an hourly, daily and weekly cadence without you doing anything.

To register a repository without installing the scheduler — useful when you manage scheduling yourself:

Terminal window
git maintenance register
git config --global --get-all maintenance.repo
/home/you/project

And to undo either:

Terminal window
git maintenance unregister
git maintenance stop

Do intervene when:

  • Everyday commands have become noticeably slow in a large repository.
  • git count-objects -v shows tens of thousands of loose objects or dozens of packfiles.
  • You have just imported a large history or completed a major rewrite.
  • You are on a very large repository and want commit-graph and prefetch scheduled.
  • You need to reclaim disk after deliberately removing large objects from history.

Do not intervene when:

  • Git feels fast. There is nothing to fix.
  • You read that --aggressive makes things faster. Usually it does not, and it is expensive.
  • You want to “clean up” a repository. Automatic maintenance already does this.
  • You are tempted by --prune=now for tidiness. It destroys recoverable work.

The features that make a very large repository usable are mostly not gc. In rough order of impact:

1. The commit-graph. Turns history traversal from “read and decompress every commit object” into a lookup in a purpose-built file. Enable it and keep it current:

Terminal window
git config --global fetch.writeCommitGraph true
git maintenance run --task=commit-graph

2. A filesystem monitor. git status on a huge working tree spends its time asking the operating system about files. core.fsmonitor makes Git subscribe to change notifications instead:

Terminal window
git config core.fsmonitor true

Git includes a built-in monitor daemon on supported platforms. This is the single biggest improvement for git status on a working tree with hundreds of thousands of files.

3. The untracked cache, which avoids re-scanning directories for untracked files:

Terminal window
git config core.untrackedCache true

4. A sparse index, if you also use sparse checkout — it shrinks the index itself rather than only the working tree. See Sparse Checkout.

5. Scheduled incremental maintenance, so repacking happens gradually rather than as an occasional long pause.

Slowness usually has a specific cause, and maintenance only fixes some of them.

SymptomLikely causeFix
git status is slowVery large working treeSparse checkout with --sparse-index, or core.fsmonitor
git log --graph is slowNo commit-graphgit maintenance run --task=commit-graph
git branch is slowThousands of loose refsgit maintenance run --task=pack-refs
git fetch is slowLarge transfersprefetch task, or partial clone
Everything is slowMany loose objects or packfilesgit gc, or incremental-repack
Clone is slowRepository sizePartial or shallow clone

Note that the first row is not a maintenance problem at all. A slow git status on a huge working tree is about the number of files on disk, and no amount of repacking will help.

Knowing what a task changes on disk makes it much easier to tell whether it helped.

commit-graph writes .git/objects/info/commit-graphs/. It stores each commit’s parents, root tree, commit date and a generation number — a precomputed value that lets Git answer “is A an ancestor of B?” without walking the graph. That single optimisation is why history-traversal commands speed up so dramatically on large repositories.

pack-refs consolidates .git/refs/** into .git/packed-refs. After it runs, listing files under refs/heads/ may show nothing while the branches all still exist — Git checks both locations. This is why git show-ref is the correct way to enumerate refs.

loose-objects takes a bounded batch of loose objects and packs them. Because the batch is bounded, it never blocks you for long, unlike gc.

incremental-repack combines small packfiles into larger ones a few at a time, converging on a good layout without ever rewriting everything at once.

prefetch fetches into refs/prefetch/, a private namespace. Your origin/* refs are untouched, so nothing you see changes — but the objects are already local when you next fetch.

gc does all of the above plus pruning, in one potentially long operation.

Repacking reduces size, but there are limits worth understanding before you spend an afternoon on it.

Terminal window
du -sh .git
git count-objects -vH

-H prints human-readable sizes. If size-pack is large, the objects themselves are large — repacking will not change that materially.

The usual causes of a large .git:

CauseFix
Many loose objectsgit gc — genuinely helps
Large binaries in historyRewriting history, or Git LFS going forward
Long history of a large codebasePartial or shallow clone
Unreachable objects from rewritesgc after the reflog expires

Maintenance also expires reflog entries, which is worth understanding because it determines your recovery window:

SettingDefaultApplies to
gc.reflogExpire90 daysEntries for reachable commits
gc.reflogExpireUnreachable30 daysEntries for unreachable commits

Those defaults are generous, and lengthening them is rarely necessary. Shortening them narrows the window in which recovery from a bad rewrite is possible, which is a poor trade for a small amount of disk.

Running git gc --aggressive routinely. Expensive, and usually achieves nothing measurable.

Using --prune=now as a cleanup habit. Destroys objects the reflog could still recover.

Optimising a repository that is not slow.

Expecting maintenance to fix a slow working tree. That is a file-count problem.

Deleting .git/objects contents by hand. Never do this. Use Git’s commands.

Assuming git gc shrinks a repository containing large files in history. Repacking helps a little; the objects are still there. Removing them requires rewriting history, with all the consequences that entails.

Enabling scheduled maintenance on a machine where background jobs are unwelcome.

Git maintenance is tidying a workshop.

Offcuts accumulate as you work. Git sweeps up automatically when there is enough to be worth sweeping, and for most workshops that is sufficient.

git maintenance lets you schedule the sweeping for a convenient time and choose which jobs to do — which matters in a very large workshop where sweeping everything at once would stop work for an hour.

--prune=now is emptying the bins before checking whether you threw something away by mistake.

  • Git runs maintenance automatically via gc --auto, triggered by thresholds on loose objects and packs.
  • git count-objects -v shows whether anything needs doing.
  • git maintenance run --task=<name> runs individual tasks: commit-graph, prefetch, loose-objects, incremental-repack, pack-refs, gc.
  • commit-graph is the highest-value task on large repositories.
  • incremental-repack bounds the work per run, unlike gc.
  • git maintenance start installs a platform-native schedule; register records the repository only.
  • --prune=now destroys unreachable objects the reflog might still recover.
  • --aggressive is slow and rarely worth it.
  • Most repositories need no manual maintenance at all.
  1. Create a repository with some churn:

    Terminal window
    mkdir ~/maint-lab && cd ~/maint-lab && git init
    for i in $(seq 1 30); do echo "line $i" >> f.txt; git add . && git commit -qm "commit $i"; done
  2. Look at the object counts: git count-objects -v. Note count and in-pack.

  3. Check what is in the objects directory: find .git/objects -type f | wc -l.

  4. Build a commit-graph:

    Terminal window
    git maintenance run --task=commit-graph
    ls .git/objects/info/
  5. Pack the refs:

    Terminal window
    git maintenance run --task=pack-refs
    cat .git/packed-refs
    ls .git/refs/heads/ 2>/dev/null

    Predict: will refs/heads/ still contain a file for main?

  6. Run a full gc and compare:

    Terminal window
    git gc
    git count-objects -v
  7. Confirm nothing was lost: git log --oneline | wc -l should still be 30.

  8. Register and unregister, without installing a scheduler:

    Terminal window
    git maintenance register
    git config --global --get-all maintenance.repo
    git maintenance unregister

Step 5 demonstrates why listing .git/refs/heads/ is an unreliable way to find branches — after packing, the files are gone and the branches are not. Use git show-ref or git branch.

Maintenance settings, hook paths, aliases and everything else live in Git configuration. The next lesson is the full reference.