Git partial clone missing files: how to fix sparse checkout and promisor remote issues
When files appear to be missing after a Git partial clone, Git usually has not lost your source tree. The fix is to separate three cases: files hidden by sparse checkout, blobs intentionally missing because of --filter=blob:none, and a broken or unreachable promisor remote. Here’s what was actually wrong and how I fixed it.
I hit this while cloning a large monorepo into a Linux dev container with Git 2.43. The clone looked successful, the branch was correct, and git status was clean. Then half the files I expected were not in the working tree, and a build step failed as if someone had deleted a package directory.
The confusing part is that partial clone and sparse checkout both make files “missing” on purpose, but for different reasons. Sparse checkout controls what appears in the working tree. Partial clone controls which Git objects are downloaded up front. When those two features overlap, the first job is not to reclone harder, but to prove which layer is hiding the file or failing to fetch it.
The setup I was using
The repository was cloned like this:
git clone --filter=blob:none --sparse [email protected]:example/big-monorepo.git repo
cd repo
git sparse-checkout set services/api packages/shared
That command creates a blobless partial clone and a sparse working tree.
In plain English:
--filter=blob:nonedownloads commits and trees, but not file contents until Git needs them.--sparsestarts with a reduced working tree instead of checking out everything.git sparse-checkout set ...tells Git which paths should appear locally.
This setup works well for a monorepo. It can also trick you at 1:12 a.m., because “file not on disk” can mean any of these:
The path is outside the sparse-checkout rules.
The path is inside the sparse rules, but its blob has not been fetched yet.
The blob should be fetched on demand, but the promisor remote is not configured or reachable.
The local object cache is damaged.
The server does not support the filter request the way the client expects.
Git’s own partial clone docs describe this model as Git functioning without a complete copy of the repository. A promisor remote is the remote that promises to provide missing objects later. That is technically clear. It is less helpful when your build says No such file or directory.
Git partial clone missing files: first check sparse checkout
The first wrong path I took was trying to fetch more history:
git fetch --all --prune
git pull
It felt reasonable: files are missing, so fetch more of the repository. Classic developer superstition.
It did not help because my missing path was not included in sparse checkout. Fetching updates the object database and refs. It does not automatically widen the working tree.
Check sparse mode first:
git sparse-checkout listExample output:
services/api
packages/shared
If the file you need lives under tools/build-scripts, Git is doing exactly what you asked. It is excluding that directory from the working tree.
Fix it by adding the missing path:
git sparse-checkout add tools/build-scriptsThis works because sparse checkout is a working-tree filter. Adding the path tells Git to materialize matching tracked files locally. In a partial clone, this may also trigger lazy fetching of the blobs needed for those files.
If you want to remove sparse checkout completely:
git sparse-checkout disableThis restores the full working tree for the current checkout. On a huge repository, expect Git to download a lot of blobs. Do not run this in a tiny CI container unless you enjoy watching disk space turn into smoke.
You can also confirm whether Git knows about the path at all:
git ls-tree -r --name-only HEAD -- tools/build-scripts | headIf this prints paths, the files exist in the commit. They are just not necessarily present in your working tree.
Then check whether the missing file is an expected lazy blob
Once the sparse rules include the path, test whether Git can read the file from the object database or fetch it on demand:
git cat-file -e HEAD:tools/build-scripts/build.shNo output means success. That is Git’s charming way of saying, “Yes, I can resolve this object.”
If the blob is missing and Git cannot fetch it, you may see errors like:
fatal: could not fetch 9f3c0a... from promisor remoteor:
fatal: unable to read 9f3c0a...That points away from sparse checkout and toward the partial clone promise being broken.
Now inspect the partial clone config:
git config --local --get extensions.partialClone
git config --local --get remote.origin.promisor
git config --local --get remote.origin.partialclonefilter
For a healthy blobless clone from origin, I expect something like:
origin
true
blob:none
The exact remote name can vary, but this is the part that matters: Git must know which remote is allowed to provide promised objects, and that remote must still exist.
I have seen this break after people rewrite .git/config, rename remotes, copy .git directories into Docker layers, or use a CI cache restored from a different repository URL. The working tree still looks like a Git repo, but the “go ask origin for that missing blob” path is gone.
Fix the promisor remote config
If extensions.partialClone points to origin, make sure origin exists:
git remote -vIf origin is missing or has the wrong URL, repair it:
git remote set-url origin [email protected]:example/big-monorepo.gitIf the promisor flags are missing, put them back:
git config remote.origin.promisor true
git config remote.origin.partialclonefilter blob:none
git config extensions.partialClone origin
A partial clone can have missing objects only because Git knows a promisor remote can provide them later. Without that config, Git no longer treats the missing blob as an intentional deferred download. It starts treating it like corruption.
Then fetch with the same filter:
git fetch origin --filter=blob:noneThis keeps the repository in blobless mode while refreshing refs and making sure future object negotiation still uses the partial clone filter.
Now force Git to reapply sparse checkout:
git sparse-checkout reapplyAfter config or fetch repairs, reapply makes Git revisit the sparse working tree and materialize files that should be present under the current rules.
If the file is inside the sparse set but still not on disk, check it out directly:
git checkout HEAD -- tools/build-scripts/build.shCheckout asks Git to populate that path from HEAD. In a partial clone, that read can trigger the missing blob fetch.
If the cache is poisoned, reclone smaller instead of debugging forever
At some point, fixing the local repository costs more time than replacing it. My rule: if promisor config is correct, the remote is reachable, sparse rules include the path, and git cat-file still fails on multiple files, I stop trying to massage the cache.
Save local work first:
git status --short
git diff > /tmp/partial-clone-recovery.patch
Then make a fresh clone:
cd ..
mv repo repo.broken
git clone --filter=blob:none --sparse [email protected]:example/big-monorepo.git repo
cd repo
git sparse-checkout set services/api packages/shared tools/build-scripts
This rebuilds .git/objects, pack metadata, sparse checkout files, and promisor config from a clean negotiation with the server.
Apply your patch only after the checkout is healthy:
git apply /tmp/partial-clone-recovery.patchI prefer this over running random garbage collection commands on a partial clone. git gc, git repack, and multi-pack-index maintenance are valid Git tools, but they are not the first lever to pull when the problem is “my promisor remote cannot supply a missing blob.” Recent Git versions have improved promisor pack handling, but an old CI image plus a restored cache can still create a very weird afternoon.
Verification: prove the fix held
After the repair, I run these checks:
git sparse-checkout list
git config --local --get remote.origin.promisor
git config --local --get remote.origin.partialclonefilter
git ls-files tools/build-scripts/build.sh
test -f tools/build-scripts/build.sh && echo "file is in the working tree"
git fsck --connectivity-only
Expected output should look like:
services/api
packages/shared
tools/build-scripts
true
blob:none
tools/build-scripts/build.sh
file is in the working tree
For git fsck --connectivity-only, a partial clone may still know about promised objects it has not downloaded yet. What I care about here is no fatal missing-object error for the paths I need. If fsck reports promised objects in a way your Git version treats as normal, do not panic. If checkout or cat-file cannot fetch a required blob from the promisor remote, that is the real failure.
One more useful debugging command:
GIT_TRACE_PACKET=1 GIT_TRACE=1 git checkout HEAD -- tools/build-scripts/build.shTracing shows whether Git is contacting the remote during the lazy fetch. If there is no network attempt, your config or path classification is probably wrong. If there is a network attempt and the server rejects the filter or object request, you are looking at a remote capability or auth problem.
CI and Docker gotchas
- Do not cache partial clone
.gitdirectories across unrelated remotes. A promisor cache is tied to the remote that promised the missing objects. Reusing it with a different URL can fail in deeply unfun ways. - Do not assume
git fetch --unshallowfixes partial clone issues. Shallow clone limits commit history. Partial clone filters objects. They are different axes. - If your CI job runs offline after checkout, partial clone can break later build steps. Either prefetch the needed paths before going offline or avoid
--filter=blob:nonefor that job. - If sparse checkout is in non-cone mode, path patterns can be less obvious. Run
git sparse-checkout listand keep the rules boring unless you truly need pattern matching. - If the host does not support partial clone filters, use a normal clone or a sparse checkout without
--filter. Sparse checkout alone still reduces the working tree, but it does not reduce the object database in the same way.
The mental model that finally fixed this for me was simple: missing from disk is not the same as missing from Git. First check whether sparse checkout excluded the path. Then check whether the object is intentionally deferred. Only after that should you blame the promisor remote.
TL;DR fix
git sparse-checkout add && git config remote.origin.promisor true && git config remote.origin.partialclonefilter blob:none && git fetch origin --filter=blob:none && git sparse-checkout reapply