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:
And toggles the repo-wide extension:
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:
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:
- Client sends
filter blob:nonewith its fetch request. - Server sends commits and trees, omits all blob objects.
- When a later command needs
deadbeef..., the client issues a fetch request forwant deadbeefto the promisor remote. - 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:
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:
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 rebasereplays 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:
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:
- Hourly: prefetch (
git fetch --prefetch) to bulk-update the promisor cache for active branches - Daily: commit-graph write to accelerate
git logand 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 fsck warns about missing objects"¶
git fsck in a partial clone reports missing objects as 'error' by default. Tell it about the promisor:
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 |