This blog's repository got fat: 3,538 commits, 46,219 objects, a 276 MiB pack. The weight is not in the history, it is in the present — every article also has a cover PNG, and PNGs do not compress. Once cloning started to annoy me, my reflex was everybody's reflex: --depth 1. Shallow clone. Drop the history, take the current state, get on with it.
The reflex was wrong. --depth 1 fetched 204.3 MiB; --filter=blob:none fetched 9.2 MiB. A twenty-two-fold difference, and against the shallow clone. The real lesson came afterwards: those 9 MiB were not a saving, they were a loan. The first git checkout showed up at the door and collected 204.1 MiB in one payment. A saving that was 30-fold at clone time came down to 1.3-fold once the repository was workable.
Three tables, three traps, and at the end the one combination that actually stays small. Plus a constraint in my own CI that I do not like.
The ground the measurements stand on
Environment: macOS (arm64), Homebrew git 2.48.1. Everything was taken against a single-branch bare copy derived from the repository's origin/main tip — df0de9d4, 22 October 2026:
$ git init --bare srv.git
$ git push srv.git origin/main:refs/heads/main
$ git -C srv.git config uploadpack.allowFilter true
$ git -C srv.git rev-list --count HEAD # 3538
$ git -C srv.git count-objects -v | grep in-pack # in-pack: 46219
The single branch matters: rev-list --count HEAD and --all give the same number, and all 46,219 objects in the pack are reachable. That removes the question "which ref set does this number come from".
There is also the matter of the unit. The first round used du -s, and APFS told a lie: two pack files of 289,923,968 bytes with identical md5 sums occupied 566,264 and 590,824 512-byte blocks in two different clones. Same content, 4.3 percent apart. So every number below is not du but the byte sum of the pack files — unchanged by whatever the filesystem decides to do, and the quantity we actually care about.
The first two traps: the filter drops out quietly
The first attempt at a filtered clone gave a local path:
$ LC_ALL=C git clone --filter=blob:none --no-checkout /tmp/lab/srv.git a1
Cloning into 'a1'...
warning: --filter is ignored in local clones; use file:// instead.
done.
$ echo $?
0
Adding the file:// prefix fixed that. Then the second road: right prefix, but the server setting off.
$ git -C srv.git config --unset uploadpack.allowFilter
$ LC_ALL=C git clone --filter=blob:none --no-checkout "file:///tmp/lab/srv.git" a2
Cloning into 'a2'...
warning: filtering not recognized by server, ignoring
$ echo $?
0
Both warn, both return zero, and both produce a full clone — 21,213 blobs, 276 MiB. Filtering has two separate gates, and with either one shut the command still counts as a success. The server-side gate is uploadpack.allowFilter; its documentation says what the setting does ("if this option is set, upload-pack will support partial clone and partial fetch object filtering") but not what happens when it is unset. The measurement says: the request is not refused, it falls back to a full clone without a sound.
The genuinely unpleasant part is common to both. The filter was not applied, yet Git still wrote this into the config:
$ git -C a2 config --local --list | grep -E 'promisor|partialclone'
remote.origin.promisor=true
remote.origin.partialclonefilter=blob:none
The repository is complete but labelled a partial clone, and later fetches honour the label. In the file://-with-the-setting-off case Git also left a .promisor file behind. That file is not a symptom — every partial clone has one. The oddity here is that the pack behind it is complete.
So how do you spot it? The two indicators that come to mind first both turn out to be useless: git config remote.origin.promisor returns true in a successful partial clone too, and the warning: This repository uses promisor remotes. Some objects may not be loaded. line that appears on commands touching the object store shows up in that clone too. Both tell you the repository is labelled partial; your question is whether the filter was applied. The one thing that separates them is counting blobs:
$ git -C a2 cat-file --batch-all-objects --batch-check | awk '{print $2}' | sort | uniq -c
21213 blob
3538 commit
21468 tree
If you were expecting zero blobs, that table answers the question: 21,213 in the degraded clone, 0 in the successful one.
The clone ladder
All taken with --no-checkout, so the only thing compared is the set of downloaded objects. The server's own pack is 289,908,372 bytes.
| clone | time | pack fetched | objects |
|---|---|---|---|
| full | 2.9 s | 289,923,968 B — 276.5 MiB | 21,213 blobs · 3,538 commits · 21,468 trees |
--depth 1 |
2.0 s | 214,235,359 B — 204.3 MiB | 6,321 blobs · 1 commit · 84 trees |
--filter=blob:limit=10k |
0.5 s | 14,034,161 B — 13.4 MiB | 8,371 blobs · 3,538 commits · 21,468 trees |
--filter=blob:none |
0.4 s | 9,656,017 B — 9.2 MiB | 0 blobs · 3,538 commits · 21,468 trees |
--filter=tree:0 |
0.1 s | 1,086,548 B — 1.0 MiB | 3,538 commits only |
Why the shallow clone is so large sits in the blob column: 6,321 blobs. --depth 1 cuts the history but brings the complete photograph of HEAD — every file as it stands today, cover PNGs included. Dropping the history takes 276.5 down to 204.3, twenty-six percent. Dropping the blobs takes it to 9.2.
The tree:0 row is instructive too. The local man page (2.48.1) says of tree:<depth>: it omits all blobs and trees whose depth from the root tree is greater than or equal to <depth>, and <depth>=0 will not include any trees or blobs unless given explicitly on the command line. That is exactly the table — 3,538 commits, nothing else, 1 MiB.
blob:limit=10k is there because its documentation says it omits blobs of size at least <n> bytes, meaning it keeps the small ones. Your text files are local, the PNGs are remote: 13.4 MiB. A reasonable middle road for anyone who wants the code but not the assets.
The loan: the first checkout
Starting work in the blobless clone changes the table. Pack bytes were re-totalled after each command:
| command | time | fetched |
|---|---|---|
| (clone) | 0.4 s | 9,656,017 B — 9.2 MiB |
git checkout |
3.6 s | +214,046,263 B — 204.1 MiB |
git log --oneline (3,538 lines) |
0.0 s | 0 B |
git log -p -1 |
0.1 s | +15,379 B |
git blame README.md |
0.2 s | +12,571 B |
git log -p -20 |
0.9 s | +1,653,754 B — 1.6 MiB |
Everything after the first row is good news: walking the history is free. Listing 3,538 commits fetches not one byte, because the commits and the trees are already here. Ask for a patch and Git goes for that commit's blobs; twenty commits' worth of patches costs 1.6 MiB. For someone who says "I want to look at the past, but not at every version of every file", the model fits.
The bad news is the second row. git checkout alone fetched 204.1 MiB. The clone was 9.2 MiB, the working repository is 213.3 MiB, a full clone is 276.5 MiB. The saving that was 30-fold at clone time is now 1.3-fold.
Why is in the partial clone documentation, and the mechanism was fixed by a patch: checkout (and any other command using unpack-trees) has been taught to bulk pre-fetch all required missing blobs in a single batch. Without it things would be worse; the same document says dynamic object fetching tends to be slow as objects are fetched one at a time, and among the known limitations it lists that dynamic fetching invokes fetch-pack once for each item, which may incur significant overhead — and multiple authentication requests — if many objects are needed. The batch pre-fetch is why it finished in 3.6 seconds.
That one-at-a-time fetching leaves a trace on disk. After the five commands above the repository held 27 pack files, every one of them marked promisor: each lazy fetch leaves its own pack. I was about to write something here, and the measurement stopped me. The partial clone design document says gc does "not touch promisor packfiles at all"; but the same document's "Current Limitations" section says repack treats promisor and non-promisor packfiles as two distinct partitions and does not mix them. The document contradicts itself: the first sentence has gone stale, the second matches both git-repack's 2.48.1 man page and today's behaviour. The measurement backs the second — running git gc took those 27 packs down to a single promisor pack. That consolidation came from the gc I ran by hand; at 27, below gc.autoPackLimit's default of 50, automatic maintenance would not have fired yet. In a long-lived copy, once the threshold is crossed, it tidies up on its own. For the record: that one sentence is the stale part. The batch pre-fetch, the per-object fetch-pack and the online requirement I take from the same document all agree with the man pages and with the measurements.
In the treeless clone the ratio is wilder still: a 1,086,548-byte repository climbs to 215,334,800 bytes after checkout — 198-fold. In the blobless clone that multiplier is 23. The absolute figure is smaller than the blobless clone's 213.3 MiB (205.4), but it shows well how little the number you see at clone time tells you.
The rule that falls out: --filter has deferred the download, not reduced it. If you are going to open the whole working tree, you pay the loan at the first checkout.
The shallow clone's bill is not in size, it is in correctness
Size is not the shallow clone's only problem:
$ git -C shallow rev-list --count HEAD
1
$ git -C blobless rev-list --count HEAD
3538
The number dropping was expected. What is uncomfortable is the same line of the same file across the two clones:
$ git -C shallow blame README.md | head -1
^df0de9d (Social Bot 2026-10-22 07:15:19 +0000 1) # Mustafa Erbay — Blog & Teknoloji
$ git -C blobless blame README.md | head -1
d263086f6 (mustafaerbay 2026-06-09 20:55:09 +0300 1) # Mustafa Erbay — Blog & Teknoloji
git blame does not fail. It runs, it produces output, and it attributes that line to the commit a social-posting bot pushed that same morning. The true answer is 9 June, a different hand. The leading ^ marks a boundary commit, but to an eye not trained on that table this is simply a wrong date and a wrong name. A slow tool is liveable; a wrong one is not.
One more side effect is in the documentation: --depth implies --single-branch unless --no-single-branch is given. For CI that is usually right; but the answer to "why does git fetch not see that branch" lands here months later.
The combination that actually stays small
The only way not to pay the loan is never to open the whole working tree. git clone carries --sparse for this, and its documentation says word for word what it does: it employs a sparse-checkout, with only files in the toplevel directory initially being present.
| setup | pack | working tree | files |
|---|---|---|---|
| full clone + checkout | 276.5 MiB | 232.8 MiB | 6,336 |
full clone + sparse-checkout set
|
276.5 MiB | 15.9 MiB | 1,112 |
--filter=blob:none --sparse (at clone) |
9.3 MiB | 0.5 MiB | 14 |
+ sparse-checkout set (3 dirs) |
16.7 MiB | 15.9 MiB | 1,112 |
The second row explains why sparse-checkout does not deliver what people expect. The working tree went from 232.8 MiB to 15.9 — but the pack stayed at 276.5 MiB, not a byte moved. sparse-checkout does not shrink the download, and the command's own tagline promises no more than that: it reduces your working tree to a subset of tracked files. The mechanism is described in the documentation's INTERNALS section and has nothing to do with the object store: the skip-worktree bit goes into the index, Git avoids populating those files' contents, and their absence is ignored. The index entry stays put — as in the lab: in a working tree with 14 files, git ls-files still counts 6,336 paths.
The last row is the whole point: a 32.6 MiB setup holding all the articles of one category plus src/lib and scripts. The full setup is 509.3 MiB; less than one fifteenth. And the history is still there. The recipe is three lines:
$ git clone --filter=blob:none --sparse "file://$SRV" work
$ cd work
$ git sparse-checkout set src/content/blog/tutorials src/lib scripts
After the first line you will have 14 files; until the cone widens, the repository is just its root directory. The "it came up empty" panic is normal.
The sparse index does not come unless you ask
Switching to cone mode was supposed to shrink the index too. It did not:
$ git sparse-checkout set src/lib
$ git config index.sparse ; stat -f%z .git/index
# no line printed
938361
The documentation says so explicitly: the --[no-]sparse-index option can be used to get a sparse index, and the default is not to use it. With the flag, 938,361 bytes drop to 5,839. In a wider three-directory cone, 936,257 → 162,430 bytes, a factor of 5.8.
On the speed side there was nothing, and that goes down as it is. git status --porcelain ran five times each; best values: 0.040 s with the sparse index, 0.041 s with the full one. The difference sits inside the noise. The documentation says this can have significant performance advantages for commands such as git status or git add; in a repository with 6,336 paths it does not show. This setting's promise is for monorepos with tens of thousands of files. The same page says the feature is still experimental and that some commands might be slower with a sparse index, and the capitalised "this command is experimental" warning sits at the end of the DESCRIPTION section.
One more trap: if files are left outside the cone, Git warns during git status that the sparse index is expanding to a full index, a slow operation — and that run's numbers are garbage. My first attempt landed exactly there.
The constraint in my CI that I do not like
All of this was for my own publishing pipeline. Of 17 workflow files, 7 use actions/checkout; 2 of those declare fetch-depth (one 1, one 0) and the remaining 5 take the default. The pinned version's action.yml gives 1 as that default, described as "Number of commits to fetch. 0 indicates all history for all branches and tags."
So my pipeline implements my reflex: a shallow clone. Per table 1 that is a 204 MiB photograph per run — with the caveat that the 204 MiB was taken on the local file:// side; GitHub's upload-pack packs differently and actions/checkout does not run git clone, it fetches a single ref. It gives the order of magnitude, not the bill. The workflow that says fetch-depth: 0 pays at least 276 MiB — the server in the table had a single branch, while fetch-depth: 0 asks for all branches and tags.
The action also has filter and sparse-checkout inputs — precisely the winning combination above. But the filter input's description is two sentences, and the second is this: it overrides sparse-checkout if set. The two cannot be used together in this action. The smallest setup in this article — blobless clone and a narrow cone — cannot be expressed with actions/checkout's inputs. Either take the 9 MiB clone and pay the loan by opening the full tree, or ask for the narrow tree and download the full object set.
There is a way out, and it is not cheap: use actions/checkout only to place credentials (persist-credentials), then run git clone --filter=blob:none --sparse yourself. The price is that credential handling becomes your problem — and since every later log -p/blame in a partial clone is a network request, that credential has to outlive the checkout step. I have not done it in my pipeline yet; the measurement is on the table, the constraint is named, the decision is pending.
Before you apply any of this
-
Is history needed?
git log,git blame,git bisect. If yes,--depth 1is off the table: it gives a wrong answer without erroring. If no, it is the simplest road — provided your repository's weight is in its history. -
Where is the weight? Of my repository's 276 MiB, 204 is today's photograph. Measure once: clone with
--depth 1 --no-checkoutand look at the pack bytes. If the photograph is large, cutting history will not help. -
Is the whole working tree needed? If yes,
--filterdefers the download, it does not reduce it. If no,--filter=blob:none --sparseis the only real win. -
Will you work offline? The documentation is explicit: use of partial clone requires that the user be online and the origin remote or other promisor remotes be available. When the promisor is unreachable a partial clone does not get slow: commands that make do with local objects (
status,commit,log --oneline) keep working, while every command needing a missing object fails — and you cannot repair that offline. -
Does the server allow it?
uploadpack.allowFilteris not the only gate:uploadpackfilter.<filter>.allowcan ban individual filters anduploadpackfilter.tree.maxDepthcapstree:<n>.blob:nonemay work whiletree:0is refused. On your own Git server this is the first place to look.
Three things to avoid: asking for a filter without file://, asking for one while allowFilter is off on the server (both quietly give you a full clone), and assuming cone mode brings the sparse index along. None of the three breaks anything; they just do not do what you asked, and the first two exit zero while doing it. A two-line assert in a CI job catches those two — but not via the promisor setting, which is true in a successful clone as well. After the clone, count the blobs, or test the pack bytes against a threshold.
A deferred cost is not a reduced cost
What made me think most this round was the delight at first seeing that 9 MiB clone. The number was real, the measurement was correct, it said thirty-fold. The only wrong thing was the conclusion drawn from it: "the repository got smaller". It had not; its bill had been postponed, and it came back the moment I typed git checkout.
The second lesson was in the measuring itself. The first round used du, and showed a 4 percent gap between two copies of the same pack — until it became clear the filesystem was reporting on its own block accounting, not on the repository. In a measurement article, picking the unit is as much work as taking the measurement.
Other instances of the same reflex: the article where Git wrote this repo half a commit-graph, where a correct and fresh cache was not speeding up the question actually being asked, and the one where git status walked 36,811 entries for three lines.
A five-minute exercise on your own repository: take a blobless clone, note the pack bytes, run git checkout, note them again. The gap between those two numbers is the loan you deferred.
Official Sources
- technical/partial-clone.adoc — promisor remotes, batch pre-fetch and known limitations
- git-sparse-checkout — cone mode, skip-worktree and the sparse index
- git-clone — the
--filter,--sparseand--depthoptions - config/uploadpack.adoc —
allowFilterand theuploadpackfilter.*permissions - rev-list-options.adoc — the definition of filter specifications
- actions/checkout action.yml — the
filter,sparse-checkoutandfetch-depthinputs
Top comments (1)
The
git blame README.mdpair is the result I would pin in CI documentation. The shallow clone does not fail. It credits line 1 to the commit a bot pushed that morning (^df0de9d), so any "who owns this" figure computed in a depth-1 checkout collapses onto the tip author. Your blobless numbers show blame costing +12,571 B on README.md. That is one file. On the most-edited file in the repository, does blame in a blobless clone batch the missing blobs, or does it run one fetch-pack per revision, as the limitations section you quote warns? Counting promisor packs aftergit blameon that file would show which.