Accelerate CI/CD: Where to Spend Git Performance Budget

Concrete patterns to cut Git wall-clock time in CI/CD — partial clone, oneshot fsmonitor, sparse checkout, and the surprising cost of shallow clones.

  • #ci-cd
  • #performance
  • #git
  • #monorepo

CI pipelines spend a surprising amount of time on Git. A monorepo build might fetch 800 MB, walk a 250k-file tree to compute status, and run git log --since=... to figure out what changed — all before the actual build starts. None of that work is useful; it’s just plumbing. Here’s where to cut.

Step 1: Partial clone, not shallow clone

The common reflex is git clone --depth=1. It feels right — only the latest commit — but it has real costs:

  • Disables commit-graph optimizations.
  • Breaks anything that needs history (e.g., git log to figure out which packages changed).
  • Often doesn’t significantly reduce clone size for large monorepos, because most of the bytes are working-tree blobs, not history.

Better:

git clone --filter=blob:none https://github.com/org/monorepo.git

--filter=blob:none clones the full history (commits, trees) but skips file contents. Blobs are fetched lazily when something actually reads them. For a typical monorepo, this cuts clone size by 70–90% while preserving history queries.

If you want to go further:

git clone --filter=tree:0 https://github.com/org/monorepo.git

tree:0 skips trees too. The tradeoff: commands that walk history (like git log --follow path) become a series of synchronous remote round-trips. Use only when you’re certain the job won’t walk history.

Step 2: Cache .git/ between runs

Most CI providers let you cache directories. Caching .git/ is one of the biggest wins for monorepo CI:

# GitHub Actions example
- uses: actions/cache@v4
  with:
    path: .git
    key: git-${{ github.repository }}

On the next run, Git only fetches new objects since last time — typically a few MB instead of hundreds.

The interaction with partial clone matters: if you use --filter=blob:none on the first run, subsequent fetches honour the same filter. Cache stays compact across runs.

Step 3: Sparse checkout for monorepo jobs

If a CI job only builds one app in a monorepo, sparse checkout cuts working-tree materialization time:

git sparse-checkout init --cone
git sparse-checkout set apps/payments libs/shared
git checkout main

Combined with partial clone, this is brutally efficient: blobs only download for files that exist in the sparse cone, which is itself a small subset of the repo.

For multi-app CI matrices, set the sparse cone per matrix entry. Each job materializes only its slice.

Step 4: fsmonitor in CI — yes, with oneshot mode

You’d think fsmonitor is irrelevant in CI (“the runner is fresh; there’s nothing to monitor”). But many CI jobs run multiple Git commands on the same checkout:

git fetch origin main
git status            # used to compute changed-files for incremental builds
git diff --name-only main...HEAD
git log --since=1d

Each of those without fsmonitor walks the working tree. With gity daemon oneshot:

gity daemon oneshot .   # spawns daemon, services this shell session, exits at the end
git fetch origin main
git status             # accelerated
git diff --name-only main...HEAD  # accelerated

The daemon shuts down automatically when the shell exits, so there’s no resource cleanup to manage.

In our measurements, this saves 4–8 seconds per CI job on monorepos over 100k files — and aggregated across thousands of runs per day, that’s real money.

Step 5: Use commit-graph for history queries

If your pipeline does any git log to compute changed paths or package boundaries, write a commit-graph after each commit on the canonical branch:

git commit-graph write --reachable --changed-paths

--changed-paths enables fast git log -- path/ queries. For incremental-build tools that ask “which packages changed since last green?”, this can cut a 30-second history walk to under a second.

GitHub and other forges write commit-graphs server-side; you get the benefit on every clone. For self-hosted Git, schedule it.

Step 6: Avoid git gc in CI

git gc is a foreground operation that locks the repo. If your CI cache grew over time and you’re tempted to gc, do it as a separate scheduled job, not inline with build runs. Better: run git maintenance run --task=incremental-repack periodically. It’s safe to run in parallel with normal operations.

Step 7: Match repo settings to the work

A few config flags worth setting in CI:

git config gc.auto 0                 # disable accidental gc during build
git config core.fsync none           # CI runners are ephemeral — fsync is wasted I/O
git config feature.manyFiles true    # the bundle from our monorepo guide
git config protocol.version 2        # smaller fetch protocol

core.fsync none is a big one. On a CI runner where nothing persists, the fsyncs Git does for index writes and object writes are pure overhead. Setting it to none cuts checkout time meaningfully — particularly on cloud disks where each fsync is several milliseconds of network round-trip.

Putting it together

A monorepo CI job that follows all the above looks like:

# Clone (or restore cache)
git clone --filter=blob:none https://github.com/org/monorepo.git
cd monorepo
git config feature.manyFiles true
git config gc.auto 0
git config core.fsync none
git sparse-checkout init --cone
git sparse-checkout set apps/payments libs/shared
git checkout main

# Start the daemon for the rest of this job
gity daemon oneshot .

# Now every Git command is accelerated
git fetch origin
git status
git log --since=1d

On a 250k-file monorepo, this typically cuts the Git portion of a CI job from 40+ seconds to under 10. Multiply across thousands of pipelines and the savings add up to entire days of compute per month.

If you want one place to start, install gity in your runners and add gity daemon oneshot . to your build script. It’s a one-line change that almost always pays back.

Frequently asked questions

Are shallow clones (`--depth=1`) actually faster in CI?

For small repos, yes. For large monorepos, often surprisingly not — shallow clones disable a lot of Git's optimizations and prevent commit-graph use. Partial clone (`--filter=blob:none`) is usually faster and more correct.

Should I run an fsmonitor daemon in CI?

If your CI job runs more than one Git command on the same repo, yes — use `gity daemon oneshot <repo>` to spin up a daemon for the duration of one job and let it exit. Each subsequent `git status` or `git diff` will be near-instant.

How do I keep CI Git caches warm?

Cache the `.git/` directory between runs and use partial clone with the same filter spec every time. The shared object store stays warm; the working tree gets re-materialized cheaply.