git-maintenance — Background Repo Hygiene for DevOps¶
A guide for DevOps engineers running long-lived Git repositories on CI runners,
build agents, and shared infrastructure where .git directories persist across
hundreds or thousands of builds.
git maintenance (added in Git 2.30) is Git's built-in task scheduler. It runs
background housekeeping jobs (gc, commit-graph, pack-refs, loose-object,
incremental-repack, pack-everything) at sensible intervals so repos don't
degrade over time. For DevOps this is critical: a CI runner that has cloned
the same monorepo 10,000 times will accumulate tens of thousands of loose
objects, a slow commit-graph, and fragmented packs — and your build times will
creep upward invisibly until a hotfix pipeline is suddenly 3× slower.
Why it matters for DevOps¶
| Problem (without maintenance) | Symptom | Fix via git maintenance |
|---|---|---|
Thousands of loose objects in .git/objects/ after many small commits |
git log and git status slow on warm cache |
loose-objects task packs them up |
| No commit-graph for a repo with 500k commits | git log --graph, git merge-base, range scans are O(n) |
commit-graph task writes CHANGED and CHANGED-parent files |
| Packfile fragmentation | Large fetch/push deltas, wasted disk | incremental-repack and pack-everything |
Outdated pack-refs after many branches created/deleted |
git for-each-ref slow |
pack-refs task |
| Stale unreachable objects | Disk bloat | gc task prunes (with 2-week grace by default) |
Quick start¶
# One-time per repo (run as the user that owns the repo on the runner)
cd /var/lib/ci-runner/repos/monorepo
git maintenance start
# Verify the schedule is active
git maintenance run --task=gc --quiet
git maintenance run --task=commit-graph --quiet
# Inspect what tasks ran
git maintenance run --task=gc --verbose
git maintenance start registers hourly/daily/weekly cron-like timers with
crontab on Linux, launchd on macOS, and Task Scheduler on Windows. The
default schedule is:
- hourly —
loose-objects,incremental-repack - daily —
pack-refs,gc - weekly —
commit-graph,pack-everything
Recommended DevOps config¶
The default schedule is conservative. On a busy CI runner, tighten it.
Create a maintenance.repo-config file or set repo-local config:
# .git/config or a config include shared via /etc/gitconfig
[maintenance]
strategy = incremental
[maintenance.window]
daily = 03:00
weekly = 03:30
[maintenance.commit-graph]
writeCommitGraph = true
readCommitGraph = true
[maintenance.gc]
autoDetach = true
detachAutoMaintenance = true
If you run on shared infrastructure, prefer a dedicated maintenance user so the crontab entries don't compete with workload.
Network optimisation: pre-fetch refs from origin¶
prefetch is the secret weapon for monorepo CI. It refreshes the remote
tracking refs in the background so a CI run doesn't pay the cost of a full
fetch on the critical path.
# Enable prefetch on the upstream
git config remote.origin.maintenance "true"
# Disable auto-fetch on the local repo so it does not race the prefetch
git config fetch.writeCommitGraph false
# Manually trigger (or let hourly schedule do it)
git maintenance run --task=prefetch --quiet
Combine with remote.origin.partialCloneFilter=blob:none for very large
monorepos — the prefetch job only needs trees and commits, not blobs.
Pruning unreachable objects safely¶
By default, gc keeps unreachable objects for 2 weeks (gc.pruneExpire).
On CI runners that's a feature, not a bug: a misconfigured pipeline can create
unreachable objects that are still wanted, and the grace window lets you
recover them from the reflog. If your runners are ephemeral (fresh clone per
build), shrink the grace to 24 hours to keep the runner image small.
# Ephemeral runners — keep grace short
git config gc.pruneExpire "24.hours.ago"
# Persistent runners (Jenkins agents, self-hosted runners) — keep default 2 weeks
git config --unset gc.pruneExpire
Locking and concurrent access¶
git maintenance run takes a lock on $GIT_DIR/maintenance.lock. If a build
is already mid-gc, the maintenance run will skip that task and log a notice.
This is safe but means you should not run git gc --aggressive manually
while the scheduler is active — let the scheduler pick the cadence.
# Check whether a maintenance lock is currently held
test -f .git/maintenance.lock && echo "maintenance in progress"
Programmatic API for scripts¶
# List enabled tasks for the current repo
git maintenance run --task=prefetch --schedule=hourly --dry-run --verbose
# Run a single task now (for scripts, cron alternatives, or fleet-wide scripts)
git maintenance run --task=gc --quiet
git maintenance run --task=commit-graph --quiet
git maintenance run --task=pack-everything --quiet
The exit code is 0 on success, non-zero on any task failure. Pair with a monitoring probe to alert on stale maintenance windows:
# /usr/local/bin/check-maintenance.sh
repo=$1
last=$("$repo"/.git/logs/maintenance 2>/dev/null | tail -1)
age=$(( $(date +%s) - $(date -d "${last%% *}" +%s 2>/dev/null || echo 0) ))
[[ $age -gt 86400 ]] && { echo "stale maintenance: $age seconds"; exit 1; }
Fleet-wide rollout (Ansible example)¶
- name: Enable git maintenance on CI runners
ansible.builtin.cron:
name: "git-maintenance-{{ repo.name }}"
user: ci-runner
minute: "{{ 59 | random(seed=repo.name) }}"
job: "/usr/bin/git -C {{ repo.path }} maintenance run --task=gc --quiet"
loop: "{{ ci_runner_repos }}"
Spread the minute across repos to avoid stampedes.
Troubleshooting¶
| Symptom | Cause | Fix |
|---|---|---|
fatal: cannot lock maintenance.lock |
another gc is running |
wait or kill stale git gc |
| Tasks silently no-op | gc.auto set too high |
lower gc.auto or run --task=gc --force |
| Disk usage not shrinking | grace period not elapsed | lower gc.pruneExpire for ephemeral runners |
| Prefetch fails behind proxy | HTTPS_PROXY not set for the scheduler process | set in /etc/environment or the cron entry |
See also¶
git-maintenance(1)— official manualgit-config(1)— search formaintenance.*docs/troubleshooting/reflog-rescue.md— recovering pruned objects within the grace window