I cloned a 397 MiB repository with --filter=blob:limit=1m and ended up with a 36 MiB .git. Then I did an afternoon of unremarkable work in it — ten git checkout of older commits, no fetches, no pulls — and measured again.
360 MiB.
The partial clone had lazily fetched back nearly everything it had declined to download at clone time, and nothing in git's housekeeping would remove it. git gc leaves those blobs alone, on purpose: they are promisor objects, they are reachable, and until recently git had no way to tell them apart from anything else you might want.
There is a way now, on git's master branch. git repack --drop-filtered was merged on 25 August 2026. It is not in 2.55.0 — I checked the tag — and it is on course for 2.56, which has not shipped. Everything below is from a build of b8242b093, which calls itself 2.55.0.806.gb8242b093.
The refill
My test repo is synthetic and deliberately hostile: 61 commits, twelve 3 MiB assets of incompressible random bytes, two of them rewritten in every commit, plus 200 small text files that churn constantly. That is roughly what a repo with build artefacts or design files in it looks like from git's point of view.
| stage | .git |
|---|---|
git clone --filter=blob:limit=1m |
36.2 MiB |
| after ten detached checkouts through history | 360.3 MiB |
after a plain git repack -a -d
|
360.3 MiB |
Three runs, identical to the tenth of a mebibyte each time. The 36 MiB at clone time is not the filter failing; it is the checkout of main pulling down the twelve assets it needs for the working tree. The other 324 MiB is history I asked for once and will probably never look at again.
Bare clones are starker, because nothing is checked out. A bare --filter=blob:limit=1m clone of the same repo is 195,141 bytes. I then ran one command in each of six fresh copies:
git log --oneline --all 195141 -> 195141
git rev-list --objects --all 195141 -> 415716093
git rev-list --all 195141 -> 195141
git fsck --no-progress 195141 -> 195141
git gc --quiet 195141 -> 200091
git cat-file --batch-all-objects --batch-check 195141 -> 195141
One git rev-list --objects --all — the sort of thing a mirroring script or a repo-size dashboard runs without thinking — pulled 396 MiB. fsck and gc did not.
The reclaim
With nothing large in the working tree, the new option is nearly free:
$ git repack -a -d --filter=blob:limit=1m --drop-filtered --dry-run | wc -l
120
$ time git repack -a -d --filter=blob:limit=1m --drop-filtered
real 0m0.036s
user 0m0.013s
sys 0m0.023s
$ du -sh .git
372K .git
120 blobs gone in 36 milliseconds. The documentation says it rebuilds the promisor pack without those objects and then deletes the now-redundant old packs, which would explain why there is nothing here to pay for. Switching back to main refetches the twelve assets the working tree needs and settles at 36.2 MiB, exactly where the clone started. git fsck is clean afterwards, and the three assets I compared byte for byte matched the server's copies.
The bit that will stop you
That transcript above was taken on a branch with no large files. On main, with the assets checked out:
$ git repack -a -d --filter=blob:limit=1m --drop-filtered
fatal: cannot drop 'assets/asset1.bin' (6ee0d39165f32c48d1236fb18eb1b2dd5c94f712): it is referenced by the current index
Exit 128. Not a warning, not a skip — the whole repack aborts on the first offending blob and drops nothing. --dry-run aborts too, so you cannot even find out what it would have removed.
The documentation describes this as refusing "to drop any blob that the current index references", which I read as meaning those blobs would be kept and the rest dropped. They are not. One large file in your index and the command does nothing at all.
So I tried the obvious escape. Sparse-checkout, cone mode, assets excluded:
working tree assets present? 0 files
index entries for assets: 12
skip-worktree flagged: 12
fatal: cannot drop 'assets/asset1.bin' (6ee0d391...): it is referenced by the current index
The files are not on disk. The index still lists them, skip-worktree bit and all, and that counts. Then I tried the sparse index, where assets/ collapses into a single tree entry and the individual blobs vanish from the index entirely — git ls-files --sparse assets returned zero .bin entries. Still refused, and on the way it did this:
hint: The sparse index is expanding to a full index, a slow operation.
...
fatal: cannot drop 'assets/asset1.bin' (6ee0d39165f32c48d1236fb18eb1b2dd5c94f712): it is referenced by the current index
It expands the sparse index to a full one and then refuses. Partial clone plus sparse-checkout is the standard large-monorepo setup, which is to say the people most likely to want this option are the ones it currently turns away. My workaround was to keep a branch with the large directories deleted and check that out before repacking, which is fine for a maintenance script and absurd as a suggestion to a colleague.
What it costs to have dropped them
Here is the number that argues against the whole idea. I built two identical clones, dropped the blobs from one of them, and sent each to visit five old commits.
| wall time | lazy fetches | data pulled | |
|---|---|---|---|
| everything local | 0.94–0.97s | 0 | 0 |
after --drop-filtered
|
3.29–3.42s | 5 | 180.1 MiB |
Then ten clones at once, because sooner or later somebody puts the repack into a cron job on a build fleet.
| wall time | |
|---|---|
| ten warm clients | 3.00–3.35s |
| ten dropped clients | 10.52–11.75s |
Between them those ten pulled 1,801 MiB. It all went over a file:// transport on local disk with no network anywhere in the path, so the seconds are a floor; the bytes are the part worth carrying across to a real remote. Whoever runs the next checkout pays them, not the person who ran the repack.
Every dropped blob you touch again costs an entire git fetch child process. I read one 200 KiB blob back and it left behind a 204,906-byte pack holding that single object.
Nothing does this for you
I threw the whole housekeeping toolbox at the fat clone:
start: 377827458
after git gc: 377832466
after gc --aggressive: 377843639
after maintenance gc: 377843639
after prune --expire=now:377843639
It grew by 16 KiB. There is no repack.dropFiltered config, no git maintenance task, nothing in git gc --auto. It is entirely opt-in and entirely manual, which given the refetch cost above is the right call, but it does mean nobody gets this by upgrading.
The filter you pass is not the filter you cloned with
I assumed --drop-filtered would only evict things the clone had originally filtered out. It does not check. I added twenty 200 KiB files to the server, which a blob:limit=1m clone happily delivers in the initial pack, then repacked with a tighter filter:
$ git repack -a -d --filter=blob:limit=100k --drop-filtered --dry-run | wc -l
32
Twelve assets plus those twenty documents. Objects that were deliberately kept local by my clone filter got evicted because I typed a different number, and every git log --stat or git blame that touches them now goes to the network. Nothing warns you.
Losing the remote afterwards is as bad as it sounds:
fatal: could not fetch 6ee0d39165f32c48d1236fb18eb1b2dd5c94f712 from promisor remote
Everything it refuses
Worth knowing before you script it, since all of these exit 128:
| invocation | message |
|---|---|
-d without -a
|
--drop-filtered requires -a |
no --filter
|
--drop-filtered requires --filter |
--filter=blob:none or tree:0
|
--drop-filtered only supports --filter=blob:limit=<n> for now |
with -b
|
options '--drop-filtered' and '--write-bitmap-index' cannot be used together |
with --filter-to
|
options '--drop-filtered' and '--filter-to' cannot be used together |
--dry-run alone |
--dry-run only takes effect with --drop-filtered |
| in a full clone | --drop-filtered requires a promisor remote |
| mid-bisect or mid-revert | --drop-filtered cannot be used while another operation (merge, rebase, am, cherry-pick, revert, or bisect) is in progress |
The in-progress check is real and it is checking state, not guessing: I triggered it with an active bisect and again with a git revert --no-commit that left REVERT_HEAD behind.
What I got wrong on the way
My first ten-client measurement said the dropped fleet took 3.01 seconds and pulled 0 MiB, which would have made a nonsense of the entire post. The re-drop step in my loop ran after the timed section rather than before it, so the "cold" clients had been warmed by an earlier run and were reading everything from local packs. The give-away was the 0 MiB, not the timing — if I had only been watching the clock I would have published "no measurable difference" and been wrong by a factor of three and a half. Every cold run in this post now re-drops immediately before the timer starts.
Run it yourself
Building master needs the curl headers, which the stock Ubuntu image does not have:
apt-get install -y libcurl4-openssl-dev zlib1g-dev libssl-dev
git clone --filter=blob:none --no-checkout https://github.com/git/git git-src
cd git-src && git checkout origin/master
make -j4 NO_TCLTK=1 NO_GETTEXT=1 prefix=/opt/git256 all install
/opt/git256/bin/git repack -h | grep drop-filtered
The script below builds the server, the client, the refill and both repacks. It took 21 and 23 seconds on two runs here, and printed 13 MiB, 61 MiB, the fatal, then 1 MiB and 13 MiB.
#!/bin/bash
set -e
GIT=${GIT:-git}
BASE=$(mktemp -d); echo "working in $BASE"
mkdir -p "$BASE/server"/{src,assets}; cd "$BASE/server"
$GIT init -q -b main .
$GIT config user.name lab; $GIT config user.email lab@example.com
$GIT config uploadpack.allowFilter true
$GIT config uploadpack.allowAnySHA1InWant true
for i in $(seq 1 6); do head -c 2097152 /dev/urandom > assets/a$i.bin; done
for i in $(seq 1 50); do echo "src $i" > src/f$i.txt; done
$GIT add -A; $GIT commit -q -m "commit 0"
for c in $(seq 1 20); do
echo "rev $c" > "src/f$(( c % 50 + 1 )).txt"
head -c 2097152 /dev/urandom > "assets/a$(( c % 6 + 1 )).bin"
head -c 2097152 /dev/urandom > "assets/a$(( (c+1) % 6 + 1 )).bin"
$GIT add -A; $GIT commit -q -m "commit $c"
done
$GIT checkout -q -b noassets main; $GIT rm -rq assets; $GIT commit -q -m "no assets"
$GIT checkout -q main; $GIT repack -a -d -q
cd "$BASE"
$GIT clone -q --filter=blob:limit=1m "file://$BASE/server" client
cd client
sz() { echo "$(du -sm .git | cut -f1) MiB"; }
echo "after clone: $(sz)"
for c in $($GIT rev-list main | awk 'NR%3==1'); do $GIT checkout -q --detach "$c"; done
$GIT checkout -q main; $GIT repack -a -d -q
echo "after some work: $(sz)"
$GIT repack -a -d -q --filter=blob:limit=1m --drop-filtered || true
$GIT checkout -q noassets
$GIT repack -a -d -q --filter=blob:limit=1m --drop-filtered
echo "after drop: $(sz)"
$GIT checkout -q main
echo "back on main: $(sz)"
What to do about it
Go and measure the partial clones you already have. du -sh .git against the size you expected at clone time is enough, and git count-objects -vH gives you size-pack for a dashboard. If the gap is large, that is disk you are paying for on every CI worker and every developer laptop, and it will keep growing whether or not 2.56 ships with this option.
When it does ship, do not put it in a cron job on developer machines. The refetch cost lands on whoever runs the next checkout, and ten of them at once turned three seconds into eleven. Run it on the machines that clone, work and get thrown away — CI images, ephemeral build agents, the mirror that only ever serves git log — where the disk is real and the refetch never happens. And check whether anything in your working tree is over the limit first, because otherwise it will refuse and you will not find out from --dry-run.
Top comments (0)