Skip to content

Git Maintenance Across a Developer Fleet

Lesson 8 of 10Advanced15 min readGit at Scale & Enterprise Engineering · Large RepositoriesVerified: git 2.43.0 (git-maintenance manual page)

A large repository that is never maintained gets slower every week, and nobody can point at the day it happened.

Loose objects accumulate. Pack files multiply. Refs pile up as individual files. Each is individually harmless; together they turn a two-hundred-millisecond git status into a three-second one, and the developer’s conclusion is that the repository is too big rather than that it is untended.

git maintenance is the modern replacement for scheduling git gc. It runs a set of small, background-safe tasks on a schedule instead of one large disruptive one.

git maintenance register enables the incremental strategy, whose schedule the manual page states explicitly:

  • gc: disabled
  • commit-graph: hourly
  • prefetch: hourly
  • loose-objects: daily
  • incremental-repack: daily

gc is disabled deliberately. The manual describes it as expensive on large repositories because it repacks all objects into a single pack file, and disruptive because it deletes stale data. The incremental tasks achieve most of the benefit without either property.

At fleet scale the deployment is the problem, not the command. Getting maintenance onto a thousand developer machines and keeping it running is an endpoint management exercise.

Terminal window
git maintenance register
git config --get maintenance.strategy
incremental

It adds the repository path to the global maintenance.repo list, so a single scheduled process iterates over every registered repository.

It sets maintenance.strategy = incremental if not already set, which selects the schedule above.

It sets maintenance.auto = false in the repository. This disables foreground maintenance — the automatic housekeeping Git otherwise performs during ordinary commands. The manual notes this setting remains after git maintenance unregister, which is a genuine trap: unregistering a repository leaves it with no background maintenance and no foreground maintenance.

git maintenance start does everything register does and additionally installs a scheduler entry to run git maintenance run --scheduled hourly. That is the command to deploy; register alone assumes you are scheduling it yourself.

Seven tasks, each solving a specific accumulation.

commit-graph — writes the commit graph incrementally and verifies it. The manual states the incremental write is safe alongside concurrent Git processes because it does not expire .graph files referenced by the previous chain. This is the task with the largest effect on history operations; see commit-graph and multi-pack-index.

prefetch — runs a fetch against each registered remote, placing refs under refs/prefetch/ rather than updating remote-tracking branches, and not updating tags. The user’s next real fetch becomes near-instant because the objects are already local. This is the task developers notice and cannot explain.

loose-objects — packs loose objects in batches, capped at 50,000 objects per run to bound the runtime. It first deletes loose objects already present in a pack, then packs a batch.

incremental-repack — repacks using the multi-pack-index. It expires packs the index no longer references, then combines several small packs into a larger one. The default batch size of zero attempts to repack everything into one pack file.

gc — the traditional full garbage collection. Disabled under the incremental strategy for the reasons above.

pack-refs — collapses loose reference files into a single file, speeding up anything that iterates refs. Matters enormously on repositories with tens of thousands of branches and tags.

worktree-prune and reflog-expire exist as additional tasks in recent versions; check git help maintenance for the set your Git provides.

Terminal window
git maintenance run --task=commit-graph
ls .git/objects/info/
commit-graph-chain
graph-60af76ddbc0ac153634af85e34080a4fdb29317d.graph

--task runs the named tasks in the order given, regardless of schedule. Useful for a one-off, for CI, and for testing what a task does before deploying it.

--auto runs tasks only when thresholds are met — the loose-object count exceeding gc.auto, or the pack count exceeding gc.autoPackLimit. Not compatible with --schedule.

--schedule=hourly|daily|weekly runs the tasks due at that frequency, which is what the background scheduler invokes.

Terminal window
git maintenance start --scheduler=systemd-timer

The manual lists auto, crontab, systemd-timer, launchctl and schtasks. On Linux, auto selects systemd-timer when available and falls back to crontab; macOS uses launchctl; Windows uses schtasks.

For a managed fleet, be explicit. auto is fine for an individual, but a deployment script that specifies the scheduler produces a configuration you can verify and audit. A fleet where half the machines use crontab and half use systemd timers is a fleet with two failure modes.

Verify it took. git maintenance start succeeding does not prove the scheduler entry runs. Check for the entry, and check maintenance.<task>.lastRun values after a day.

The lock and why maintenance silently skips

Section titled “The lock and why maintenance silently skips”

The behaviour that explains most “maintenance is not running” reports.

Each git maintenance run takes a lock on the repository’s object database. Concurrent runs on the same repository cannot proceed — one of them simply does not run.

The scheduled process iterates over every registered repository. With many registered repositories, or large ones, the manual notes that a single hourly pass may take longer than an hour, at which point the next hourly run starts while the previous is still going and they collide on the lock. One of the two tasks does not run.

The symptom is maintenance appearing to run less often than configured, with no error anywhere.

The manual’s remedy is to reduce complexity — move expensive tasks to a lower frequency, and prefer incremental-repack over gc, accepting a slightly larger object database in exchange.

At fleet scale this means watching how many repositories each developer has registered. Somebody with forty registered repositories, several of them large, has an hourly window that cannot complete.

The prefetch task deserves special attention

Section titled “The prefetch task deserves special attention”

Of the five default tasks, prefetch is the one whose value is least obvious and most felt.

What it does: every hour, it runs a fetch against each registered remote, but places the results under refs/prefetch/ instead of the usual remote-tracking refs, and does not update tags.

Why the separate namespace matters. The manual is explicit about the reasoning: users expect remote-tracking branches to stay where they are unless the user fetches. A background process silently moving origin/main would be surprising and would break workflows that compare against it. Prefetch therefore gets the objects without moving the refs.

The effect on a real fetch. When the developer runs git fetch, the objects are already in the local object database. In the ideal case the manual describes, the fetch becomes an update to a set of remote-tracking branches with no object transfer at all.

This is the single most noticeable improvement for developers on large repositories. A morning git fetch that used to take ninety seconds takes two, and the reason is invisible.

The cost is background network traffic, hourly, per registered repository, per developer. On a large fleet that is a real load on the origin server, and it is worth being aware of before enabling it for ten thousand people. It is also a genuine consideration for developers on metered or constrained connections.

Consider reducing its frequency rather than disabling it. Even a daily prefetch removes most of the pain, at a fraction of the traffic.

Terminal window
git config maintenance.prefetch.schedule daily

What accumulates, and what each task addresses

Section titled “What accumulates, and what each task addresses”

Understanding the mapping makes it possible to diagnose a slow repository rather than running everything and hoping.

SymptomAccumulationTask
Slow git log, git merge-base, git branch --containsNo or stale commit graphcommit-graph
Slow git fetchNothing local; every fetch transfersprefetch
Slow git status, growing .gitLoose objectsloose-objects
Slow object lookup, many .pack filesPack sprawlincremental-repack
Slow branch listing, slow anything touching refsLoose ref filespack-refs
.git far larger than the content justifiesUnreachable objects never expiredgc (deliberately)

Diagnose before deploying. A repository with three packs and no loose objects does not need incremental-repack; one with 90,000 refs in individual files needs pack-refs far more than anything else.

Terminal window
# The four numbers that tell you which task matters
git count-objects -v | grep -E 'count:|in-pack:'
ls .git/objects/pack/*.pack 2>/dev/null | wc -l
ls .git/objects/info/commit-graph* 2>/dev/null | wc -l
git for-each-ref | wc -l

The strategy is a starting point, not a mandate.

Terminal window
git config maintenance.pack-refs.enabled true
git config maintenance.pack-refs.schedule weekly
git config maintenance.prefetch.enabled false

maintenance.<task>.enabled controls whether a task is considered at all when no --task argument is given.

maintenance.<task>.schedule sets its frequency — hourly, daily or weekly.

maintenance.<task>.lastRun records when it last ran, and is what --schedule compares against.

pack-refs is the task most worth adding explicitly on repositories with very large numbers of refs, since it is not part of the default incremental set on every version.

Configure per repository, not globally, unless you genuinely want the same schedule everywhere. A monorepo and a small service repository have different needs.

The command is trivial; getting it onto a thousand machines and keeping it there is the work.

  1. Decide the Git version floor. Task availability and behaviour vary by version. Maintenance deployed against an old client does less than you think.

  2. Put it in the clone wrapper. If developers clone through an internal script, that script runs git maintenance start and the fleet is configured by construction rather than by campaign.

  3. Or ship it via endpoint management. A configuration management tool that ensures maintenance.repo contains the repositories that matter, and that the scheduler entry exists.

  4. Constrain what gets registered. Registering every repository a developer has ever cloned makes the hourly window unmanageable. Register the large, actively used ones.

  5. Verify remotely. git config --global --get-all maintenance.repo and the maintenance.<task>.lastRun values tell you whether it is working. Collect them if your tooling allows.

  6. Publish the manual command. Someone will need to run it on a machine your tooling did not reach, and one documented line is the difference between them doing it and filing a ticket.

Do not mandate maintenance without measuring first. If the fleet’s repositories are small, this is effort spent for no gain and it will be resented.

The decision that most distinguishes git maintenance from what came before, and the one worth understanding rather than accepting.

git gc repacks all objects into a single pack file. On a 20 GB repository that is a long operation with substantial memory and disk requirements, and it produces one enormous pack that must be rewritten in full the next time.

It deletes stale data, which is the disruptive half. Unreachable objects past their expiry are removed. That is usually correct and occasionally is the reason a commit somebody was about to recover is gone.

It is not safe to run alongside foreground work in the way the incremental tasks are. The incremental tasks were designed with concurrency in mind — the commit-graph writer does not expire graph files the current chain references; the loose-objects task deletes only objects already packed; incremental-repack expires only packs the multi-pack-index no longer needs.

The trade is a slightly larger object database. The manual states it directly: prefer incremental-repack over gc, at the cost of some extra size. On a large repository, a few percent of extra disk is trivially cheaper than an hour-long disruptive operation.

When gc is still right: a one-off cleanup after a history rewrite, a mirror being prepared for archival, or a repository that has genuinely accumulated large amounts of unreachable data. Run it deliberately, out of hours, with the repository quiet — and via git maintenance run --task=gc so the lock is taken correctly.

Different problem, different answer.

Ephemeral runners need no maintenance. The clone is created and destroyed. Scheduling background tasks is pure overhead.

Persistent runners with reused clones need it badly. A working directory that has fetched every commit for six months accumulates exactly the loose objects and pack sprawl maintenance exists to control, and nothing is running on a schedule.

Run it explicitly rather than scheduling it:

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

Between jobs, not during one. Maintenance takes the object database lock, and a build waiting on it is a build that got slower.

Or on a timer on the runner host, which is cleaner if you control the host.

Bare mirrors and caches need maintenance most of all, because they receive constant pushes and nothing else ever runs there. A cache server whose packs have never been consolidated is slower to serve than the origin it was meant to accelerate.

Maintenance on a partial clone is subtler.

Promisor packs are treated differently. Objects in packs marked .promisor came from a promisor remote, and repacking must preserve that distinction — otherwise Git loses track of which absent objects are legitimately absent.

Git handles this, and the tasks are safe. But it is a reason to prefer the maintenance tasks over hand-rolled git repack invocations, which are much easier to get wrong on a partial clone.

prefetch on a partial clone fetches according to the recorded filter, so it does not undo the partial clone by pulling every blob.

Expect .git to grow anyway, as on-demand fetches accumulate. That is normal; maintenance keeps it organised rather than small.

Server-side maintenance is somebody else’s job — until it is not

Section titled “Server-side maintenance is somebody else’s job — until it is not”

A distinction worth being clear about, because the terminology overlaps.

On GitHub, the server-side repository is maintained by GitHub. Repacking, ref packing, garbage collection on the hosted copy — none of that is your responsibility, there is no knob for it, and it is not the cause of a slow local repository.

On GitHub Enterprise Server, the appliance handles its own maintenance, and it is one of the reasons the appliance has sizing requirements. Not something to script yourself.

What you do own is every non-origin copy your organisation runs:

  • Bare mirrors used for backup or for read replicas
  • CI cache servers that serve clones to runners
  • Local mirrors near a remote office
  • Any bare repository a script pushes to

These receive constant writes and nobody ever runs a command in them interactively, which is exactly the condition under which they degrade unnoticed. A mirror that has taken six months of pushes with no maintenance can have thousands of pack files, and it is measurably slower to serve than the origin it was built to offload.

For a bare repository, run maintenance on a timer on the host:

Terminal window
git --git-dir=/srv/mirrors/monorepo.git maintenance run \
--task=pack-refs --task=incremental-repack --task=commit-graph

prefetch is meaningless on a bare mirror that is pushed to rather than fetching, so omit it.

Watch the disk. Maintenance needs working space to write new packs before removing old ones, and a mirror host sized exactly to the repository size will fail at the worst moment.

git status wall time on a representative large repository, sampled over weeks. This is the developer-visible number and the one that degrades.

Pack count. ls .git/objects/pack/*.pack | wc -l. A number that climbs steadily means incremental-repack is not running.

Loose object count. git count-objects -v. Should stay low.

maintenance.<task>.lastRun timestamps. The direct answer to “is it running”, and the first thing to check.

Repository count per developer. The input to the hourly-window problem.

Terminal window
git count-objects -v
ls .git/objects/pack/*.pack 2>/dev/null | wc -l
git config --get-regexp 'maintenance\..*\.lastRun'

Scheduling git gc on large repositories. Expensive and disruptive; the incremental strategy exists for this reason.

Enabling loose-objects and gc together. The manual advises against it explicitly.

Running git gc on a repository under git maintenance. It does not take the lock the same way.

Unregistering without unsetting maintenance.auto. The repository ends up with no maintenance at all.

Registering every repository a developer has. The hourly window cannot complete and tasks silently skip.

Assuming git maintenance start means it is running. Verify the scheduler entry and the lastRun values.

Leaving persistent CI runners unmaintained. They accumulate the fastest and are checked the least.

Hand-rolling git repack on partial clones. Promisor pack handling is easy to get wrong.

Deploying it without measuring first. You will not be able to show it helped.

Maintenance is unusual among the techniques in this cluster in that it is entirely invisible when it works, which makes it hard to advocate for and easy to have quietly disabled.

Lead with the prefetch number. “Your morning fetch will take two seconds instead of ninety” is a claim people can verify in a day. Repository health metrics are not.

Ship it in the setup path rather than announcing it. New joiners get it by default from the clone wrapper; nobody has to opt in.

Do not force it on machines you have not tested. A developer whose laptop battery drains from an hourly prefetch over a slow VPN will disable it and tell their team to as well.

Give a documented off switch. git maintenance stop halts the schedule without unregistering. Somebody travelling, or on a constrained connection, should be able to pause it and turn it back on.

Check back after a month. Collect lastRun values if you can. The number that matters is not how many machines you deployed to — it is how many are still running it.

And be willing to conclude it is not worth it. For an organisation whose largest repository is 200 MB, maintenance changes nothing measurable, and deploying it anyway spends credibility you will want later for something that does matter.

Maintenance is the difference between a repository that is large and a repository that is large and slow. The traditional gc is one big disruptive operation; git maintenance is a set of small ones that are safe to run alongside work. At fleet scale, the command is not the problem — deployment, verification and the hourly lock window are.

  • git maintenance replaces scheduled git gc, with tasks designed to be background-safe
  • The incremental strategy disables gc and runs commit-graph and prefetch hourly, loose-objects and incremental-repack daily
  • register sets maintenance.auto = false, and that setting survives unregister
  • prefetch populates refs/prefetch/ without touching remote-tracking branches, making later fetches near-instant
  • loose-objects and gc should not both be enabled — the manual says so directly
  • Each run takes the object database lock, and an over-long hourly pass causes silent skipping
  • git gc must not be combined with git maintenance run; use --task=gc instead
  • Ephemeral CI runners need no maintenance; persistent ones and mirrors need it most
  • Partial clones require care with promisor packs, which is a reason to use the tasks rather than raw repack
  • maintenance.<task>.lastRun is the direct evidence that it is working

Use a disposable clone of a repository you own.

  1. Run git maintenance register. Check maintenance.strategy and maintenance.auto. Predict: what are they?

  2. Check the global list: git config --global --get-all maintenance.repo. Confirm your repository is there.

  3. Run git maintenance run --task=commit-graph. List .git/objects/info/. Predict: what appears?

  4. Run git maintenance unregister. Check maintenance.auto again. Predict: did it change?

  5. Read git help maintenance and list the tasks your Git version provides. Compare with the list in this article.

  6. Create fifty loose objects (fifty tiny commits). Run git count-objects -v, then git maintenance run --task=loose-objects, then count again.

  7. Count the repositories on your machine that would be worth registering. Estimate how long an hourly pass over all of them would take.

  8. Clean up: unregister and unset maintenance.auto.

Engineering Team Onboarding SystemA 30-day Git and GitHub programme with standards templates, assessments and governance checklists.