Skip to content

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:

  • hourlyloose-objects, incremental-repack
  • dailypack-refs, gc
  • weeklycommit-graph, pack-everything

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 manual
  • git-config(1) — search for maintenance.*
  • docs/troubleshooting/reflog-rescue.md — recovering pruned objects within the grace window