Skip to content

Git Partial Clone and Promisor Remote — Deferred Object Fetching for DevOps

Git partial clone lets you clone a repository without downloading every object. Blobs (file contents) or even trees (directory listings) stay on the remote. Git fetches them on demand, lazily, through a promisor remote.

This changes the economics of CI checkouts. A full clone of a 5 GB monorepo is wasteful when a pipeline touches 20 MB of files. Partial clone pushes that 5 GB down to 100 MB or less.

1. What a promisor remote is

When you run git clone --filter=blob:none <url>, Git inserts two config keys:

remote.origin.promisor = true
remote.origin.partialclonefilter = blob:none

And toggles the repo-wide extension:

extensions.partialClone = origin

These three settings form a contract: Git's object database is allowed to be incomplete. When a command needs a missing blob, Git looks at the promisor remotes (list them with git config --no-type --list | grep promisor), connects to that remote, and fetches the missing objects by their hash.

This is an implicit fetch — no git fetch command required. Git calls this a promisor fetch. It happens transparently during checkout, merge, rebase, diff, blame — any command that must inspect object content.

The word "promisor" comes from legal terminology. The remote promised to supply the objects. Git trusts that promise and prunes conservatively around it.

2. Filter levels and their trade-offs

Filter Command Fetched Initially Best For
Full git clone <url> All commits, trees, blobs Complete offline access, full history traversal
Blobless git clone --filter=blob:none <url> All commits and trees. Blobs fetched on demand Full git log and git blame, general development
Treeless git clone --filter=tree:0 <url> Commits only. Trees and blobs on demand Automated CI needing only the current commit
Blob limit git clone --filter=blob:limit=1m <url> Blobs under 1 MB; larger blobs on demand Repos with mixed small source files and large assets

--filter=blob:limit=<n> was removed from the client CLI in Git 2.37 but still works if set directly with -c or in config. Stick to blob:none and tree:0 for reliable use.

--filter=sparse:path=<file> is a server-defined sparse set. The server hosts a .gitignore-style file that lists paths the client should fetch. Rarely used in practice — cone sparse-checkout is more explicit.

Standard practice

Use case Filter
Developer workstation --filter=blob:none — full git log, git blame, instant file diff
CI pipeline (single service) --filter=tree:0 — only the current commit matters
CI pipeline (full build) --filter=blob:none — needs tree objects for most build steps
Air-gapped backup Full clone — no promisor dependency

3. Protocol v2 and fetch negotiation

Partial clone requires Git protocol v2. The server must advertise protocol=2, and the client enables it with:

git -c protocol.version=2 clone --filter=blob:none <url>

Most modern Git hosts (GitHub, GitLab, Forgejo, Azure Repos, self-hosted Gitea) support protocol v2 by default. Your client Git version must be >= 2.25 (v2.29 for tree:0).

The negotiation protocol works like this:

  1. Client sends filter blob:none with its fetch request.
  2. Server sends commits and trees, omits all blob objects.
  3. When a later command needs deadbeef..., the client issues a fetch request for want deadbeef to the promisor remote.
  4. Server sends the single blob object — no other objects, no ref negotiation.

This single-object fetch is extremely fast: one network round trip, one packfile with one object.

Skipping negotiation

Git can skip the full HAVE negotiation on subsequent fetches in a partial clone by enabling:

git config fetch.negotiationAlgorithm skipping

This sends only the latest few local commits as HAVE lines, skipping the expensive enumeration of all local ref tips. Works well with partial clones where the local repo already has most of the commit graph. Saves seconds per fetch on large repos.

4. Inspecting missing objects

git rev-list --missing=<action> tells you what the promisor hasn't fetched yet.

# List every missing object's hash
git rev-list --missing=print --all --objects

# Count missing objects per type
git rev-list --missing=print --all --objects | wc -l

# Check if HEAD's tree is fully materialized
git rev-list --missing=error HEAD -- src/main.ts

Three modes:

  • --missing=print — print missing hashes; useful for auditing promisor health
  • --missing=allow — proceed normally, skipping missing objects (default)
  • --missing=error — abort with an error if any object is missing; enforces materialized state

Combine with --objects to see the path associated with each blob hash:

git rev-list --missing=print --objects --all | head -20

5. Forcing object fetch

Normally Git fetches missing objects implicitly during a command that needs them. You can force materialization explicitly.

Pre-fetch critical paths

Before a CI build that needs specific files, materialize them eagerly instead of taking promisor latency during the build:

# Fetch specific paths by checking them out
git checkout HEAD -- src/services/auth/
git reset HEAD src/services/auth/      # unstage

Or use git rev-list + git fetch with hash negotiation:

# Collect needed blob hashes for a directory
git rev-list --objects HEAD -- src/services/auth/ | \
  awk '{print $1}' > /tmp/needed-hashes.txt

# Batch fetch them through the promisor
xargs -a /tmp/needed-hashes.txt git cat-file --batch-check='%(objectname)' > /dev/null

The git cat-file --batch-check call forces Git's promisor machinery to fetch each missing hash.

Full re-materialization

# Force the promisor to re-fetch all objects (not a re-clone)
git fetch --refetch origin

# Or compact: re-fetch ALL current ref objects from the promisor
git fetch --refetch --all

--refetch tells Git to ignore the local object store and re-download every object reachable from the fetched refs. It uses the filter so you don't get full history if you have blob:none. Use this when:

  • The promisor remote changed its object store (squash merge replaced blobs with new hashes)
  • The local repo has corrupt or missing promisor-tagged objects
  • You want to rebuild a clean state without re-cloning

6. Merging, rebasing, and cherry-picking in partial clones

Merge and rebase operations work transparently in partial clones, but promisor fetches happen:

  • During merge, Git needs the tree objects of both sides to find the merge base. If those trees are missing, the promisor fetches them.
  • During conflict resolution, Git needs the three ancestor blobs (%O, %A, %B). Missing blobs are fetched on demand.
  • git rebase replays commits, each needing the tree and blob at that commit.

This works. The only cost is the serialized promisor fetch latency during each step.

CI merge gate in a partial clone

git clone --filter=tree:0 --branch main --single-branch <url> repo
cd repo
git fetch origin feature-branch
git merge-tree --write-tree HEAD FETCH_HEAD

git merge-tree succeeds without materializing the working tree, but it still needs tree objects from both sides. Those come through the promisor. The --missing=error flag on git rev-list can test preconditions.

Rebase simulation

git clone --filter=blob:none <url>
cd repo
git fetch origin feature-branch
git rebase main --onto base-sha   # promisor fetches objects for each replayed commit

The promisor handles this correctly — each commit's tree is fetched when that commit is processed.

7. GC and maintenance in promisor repos

Git's garbage collector behaves differently with promisor remotes.

What gc will not do

git gc will never prune objects that came from a promisor remote, even if they are unreachable. This protects the contract: if the remote ceased to exist, a gc that pruned promisor objects would corrupt the repo.

To see unreachable promisor objects:

git fsck --unreachable --no-dangling

What gc does do

gc packs promisor objects into the same packfile structure as local objects. It still knows they are promisor objects (metadata in the pack .idx and in git cat-file --batch-check shows promisor).

Pruning promisor objects

If you want to evict promisor objects and re-fetch later, rewrite the config:

# Turn off promisor
git config --unset extensions.partialClone

# Prune unreachable objects
git reflog expire --all --expire=now
git gc --prune=now --aggressive

# Turn promisor back on
git config extensions.partialClone origin

Warning: this removes objects that Git might need later. After re-enabling, missing objects trigger promisor fetches on demand. Test in a throwaway checkout first.

Maintenance schedule

Partial clones benefit from git maintenance start which runs background jobs to prefetch objects, update commit-graph files, and repack data regularly. With promisor repos, the prefetch step is particularly valuable:

git maintenance start
git config set maintenance.gc.experimental true   # Git >= 2.36
  • Hourly: prefetch (git fetch --prefetch) to bulk-update the promisor cache for active branches
  • Daily: commit-graph write to accelerate git log and reachability checks
  • Weekly: loose-object repacking

8. CI pipeline patterns for promisor clones

Pattern A: Minimal checkout, fast signal

# Treeless clone — takes seconds regardless of repo size
git clone --filter=tree:0 --depth=1 --single-branch \
  "https://token@server/repo.git" .

# Full object materialization happens only for changed files
git diff-tree --no-commit-id --name-only -r HEAD | \
  while read path; do git checkout HEAD -- "$path"; done

Benefit: git clone finishes in < 3 seconds on a 5 GB monorepo. Only touched files are materialized.

Pattern B: Pre-fetch for predictable build

# Start with blobless — fast clone but no deferred latency during build
git clone --filter=blob:none --single-branch --branch main \
  "https://token@server/repo.git" .

# Materialize the build directory before invoking the build tool
git checkout HEAD -- services/auth/
git checkout HEAD -- libs/shared/
git commit --allow-empty -m "materialized commit"  # no-op, forces index population

Benefit: the build tool never waits for a promisor fetch because files are already on disk.

Pattern C: Graceful fallback on promisor failure

# Attempt promisor clone
git clone --filter=blob:none <url> repo 2>/tmp/clone-status.txt

# If clone failed (promisor protocol negotiation, network issue), fall back
if [ $? -ne 0 ] && grep -qi "promisor\|filter\|git-upload-pack" /tmp/clone-status.txt; then
  echo "Promisor clone failed, falling back to full clone"
  rm -rf repo
  git clone <url> repo
fi

Benefit: pipeline doesn't fail on promisor-unfriendly servers. Full clone is the safety net.

Pattern D: Multi-stage CI with shared promisor cache

# Stage 1: shared base clone (runs once, cached)
git clone --filter=blob:none --single-branch --branch main <url> /build-cache/base
cd /build-cache/base
git maintenance start
git fetch --refetch origin

# Stage 2: per-job worktree (runs in seconds)
git worktree add /build/workspace feature-branch
cd /build/workspace
git checkout HEAD -- services/my-service/

Benefit: the base clone's promisor cache is shared across parallel worktrees. The --refetch on the base ensures the promisor objects are fresh.

9. Troubleshooting

"fatal: remote error: upload-pack: not our ref"

The server rejected a want line for an object it does not have. This happens when: - The promisor remote was garbage-collected or the repo was re-created - You fetched from one remote, then switched the promisor to a different remote that doesn't have those objects

Fix: run git fetch --refetch origin to re-negotiate and download objects from the new state.

"fatal: bad object deadbeef"

The promisor remote is unreachable, and the needed object was never fetched. Check network connectivity, then:

git config remote.origin.promisor false
git fetch --refetch origin
git config remote.origin.promisor true

The temporary promisor false prevents Git from trying the promisor fetch on already-known-missing objects.

"error: could not fetch "

Git's promisor fetch failed for a specific hash. This usually means the object is in the promisor remote's advertised refs but not in the fetched pack. Run git fetch origin (normal fetch, not --refetch) to bring in new objects, then retry the operation.

"The remote doesn't support the advertised filter"

The git server doesn't speak protocol v2 or the admin disabled partial clones. Fix the clone command to remove --filter or configure an SSH/config override:

git clone <url> repo
# no --filter flag — full clone is the fallback

"git fsck warns about missing objects"

git fsck in a partial clone reports missing objects as 'error' by default. Tell it about the promisor:

git fsck --no-dangling --unreachable --connectivity-only

Or check that the object is truly corrupt vs just deferred:

git config fsck.missingMailmapMap "warn"    # optional relax
git cat-file -e <sha>                       # triggers promisor fetch if missing

10. Config reference

Config key Purpose
remote.<name>.promisor = true Marks remote as a promisor source
remote.<name>.partialclonefilter = blob:none Filter applied on promisor fetches
extensions.partialClone = <name> Enables partial clone repo-wide
fetch.negotiationAlgorithm = skipping Speeds up ref negotiation in partial clones
protocol.version = 2 Protocol version required by partial clone
maintenance.gc.experimental = true GC that respects promisor boundaries
gc.partial = true Prevents gc from attempting full repack in partial clone
index.version = 4 Path-based index for faster sparse operations
core.repositoryFormatVersion = 1 Required for extensions like partialClone