# Deduplicating git worktrees on EXT4 with jdupes

> Source: <https://ma.ttias.be/deduplicating-git-worktrees-ext4-jdupes/>
> Published: 2026-07-24 00:00:00+00:00

A git worktree workflow eats a lot more disk than you’d expect. On the [remote box I run my coding agents on](/remote-coding-environment-vps/)
, 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[#](#ten-worktrees-99-gb)

Every agent gets its own worktree, which is the whole point: two agents editing the same working directory trip over each other instantly. What I hadn’t thought through is what a worktree *costs*.

``` bash
$ 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[#](#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.

``` bash
$ 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[#](#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](https://codeberg.org/jbruchon/jdupes)
finds byte-identical files and replaces the duplicates with hardlinks.

The whole idea, with two 8 MB files:

``` bash
$ 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[#](#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:

``` bash
$ 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-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. I’m keeping ext4 on this box and running jdupes instead, because reformatting a root filesystem is a much bigger job than a nightly cron.
