A git worktree workflow eats a lot more disk than you’d expect. On the remote box I run my coding agents on , ten worktrees took a 28 GB volume down to 687 MB free in about a day. Here’s one way to limit that.
Ten worktrees, 9.9 GB#
Every agent gets its own worktree: two agents editing the same working directory trip over each other instantly. What I hadn’t thought through is what a worktree costs.
$ du -xh -d2 ~/.t3/worktrees | sort -rh | head -4
9.7G /home/dev/.t3/worktrees
5.9G /home/dev/.t3/worktrees/ohdear.app
3.7G /home/dev/.t3/worktrees/ma.ttias.be
1.9G /home/dev/.t3/worktrees/ma.ttias.be/t3code-c207f00a
The checkout itself is cheap. Git shares one .git across all worktrees, so the source files are a rounding error. The expensive part is what my setup script does after the checkout: composer install and npm ci.
On the Oh Dear app that’s about 530 MB of node_modules and 350 MB of vendor. Call it 880 MB per worktree. Ten worktrees, nine gigabytes, and almost all of it byte-for-byte identical because most of those branches share a lockfile.
No reflinks on ext4#
The right answer to “I need ten near-identical copies of a directory” is a reflink. On a copy-on-write filesystem, cp --reflink makes a clone that shares the underlying blocks with the original: nothing gets copied up front, and when one side writes, only the changed blocks get their own copy. That’s the exact shape of this problem.
So I tried it.
$ findmnt -no FSTYPE,SOURCE /
ext4 /dev/vda1
$ cp --reflink=always a.bin b.bin
cp: failed to clone 'b.bin' from 'a.bin': Operation not supported
Both files are on the same filesystem. There’s no flag to fix this. ext4 has no copy-on-write, so it has no reflinks, and a standard Ubuntu cloud image gives you ext4 on the root volume. Reflinks need btrfs, XFS formatted with reflink=1, or ZFS.
I could reformat the root filesystem of a live box for a dev-convenience win. I’m not going to.
Hardlinks, the ext4-shaped answer#
What ext4 does have is hardlinks. Two names pointing at one inode, one copy of the data on disk. jdupes finds byte-identical files and replaces the duplicates with hardlinks.
The whole idea, with two 8 MB files:
$ ls -li a.bin b.bin
761293 -rw-r--r-- 1 dev dev 8388608 Jul 24 08:08 a.bin
761296 -rw-r--r-- 1 dev dev 8388608 Jul 24 08:08 b.bin
$ du -sh .
17M .
$ jdupes -L -r .
$ ls -li a.bin b.bin
761293 -rw-r--r-- 2 dev dev 8388608 Jul 24 08:08 a.bin
761293 -rw-r--r-- 2 dev dev 8388608 Jul 24 08:08 b.bin
$ du -sh .
8.1M .
Same inode, link count 2, half the disk. -L does the linking, -r recurses. jdupes compares candidates byte for byte rather than trusting a hash, so it only links on real identity.
This isn’t copy-on-write. Hardlinked files don’t diverge on write: write into one and you’ve written into all of them. That would be a disaster for source files.
It’s fine for dependencies because of how package managers write. npm and Composer don’t edit files in place, they write a new file and rename it over the old one. A rename gives you a fresh inode, which breaks the link for that one file and leaves every other worktree’s copy untouched. This is the same trick pnpm uses on purpose with its global store. I’m just applying it after the fact.
Point it at node_modules and vendor#
For a Laravel app those two directories are the disk usage, so there’s nothing clever to do. Name the paths and let jdupes walk them:
$ jdupes -r -L ~/projects/*/node_modules ~/projects/*/vendor \
~/.t3/worktrees/*/*/node_modules ~/.t3/worktrees/*/*/vendor
Across ten worktrees that takes 25 seconds on this box. I run it nightly and again at the end of worktree setup, right after npm ci finishes, because the duplicate exists from the moment the install does and waiting until 04:20 wastes it for a day.
Name the paths, don’t match on directory name anywhere in the tree. Laravel also ships public/vendor and resources/views/vendor, and both of those are committed to the repo. Hardlinking a tracked file across worktrees means editing a Blade view on one branch silently edits it on every other. A top-level node_modules and vendor are gitignored build artifacts, which is what makes this safe.
The disk space saved#
The first sweep reclaimed 2833 MB. Combined with deleting the worktrees whose branches were already merged, the disk went from 98% to 57%.
If you’re provisioning a box like this from scratch, put the worktrees on a filesystem that can do reflinks and skip all of this. Real copy-on-write beats dedup-after-the-fact, because it never writes the duplicate blocks at all.
Which is what I did, a day after writing this. Reformatting the root disk is still a bigger job than a nightly cron, so I hung a second disk off the box instead: there’s now a 100 GB btrfs volume holding /home/dev
, worktrees included, so cp --reflink works there. jdupes keeps running on top of it.