Coding agent tools, like Cursor and OpenCode, have started embracing worktrees. They make it easy to distribute changesets for an application in parallel. One agent works on the backend, another on the frontend, without having to share a branch.
Some tools, like Cursor, are OK about cleaning up after themselves once they're done. But I often find myself with hundreds of un-pruned trees sitting around.
And for projects that use NPM, that means their node_modules are sitting around too.
I've been using my little tree-buddy tool to manage this mess, but when it came time to delet the worktrees I ran into a problem.
Deletions were taking hours.
I had hundreds of worktrees, each with thousands of files. Deleting them all should be fast, these are just directories I want gone. But hitting "delete all" meant waiting forever.
Finding the culprit
I threw some timing logs around the delete operations to find the culprit. Turns out, git worktree remove --force is inherently slow because git walks the entire directory tree and deletes files one by one, sequentially.
Not in parallel. Not in batches. One. By. One.
When you run git worktree remove, Git calls delete_git_work_tree() which in turn calls remove_dir_recursively(). Looking at the source code in dir.c, the remove_dir_recurse() function works like this:
static int remove_dir_recurse(struct strbuf *path, int flag, int *kept_up)
{
DIR *dir;
struct dirent *e;
dir = opendir(path->buf);
// ...
while ((e = readdir_skip_dot_and_dotdot(dir)) != NULL) {
// For each entry:
if (S_ISDIR(st.st_mode)) {
// Recursively call remove_dir_recurse() for subdirectories
remove_dir_recurse(path, flag, &kept_down);
} else if (!only_empty) {
// Delete individual files one by one
unlink(path->buf);
}
}
closedir(dir);
rmdir(path->buf); // Finally remove the now-empty directory
}
Every file deletion is a separate system call (unlink()), processed one after another in a single-threaded loop. There's no parallelization.
For nested directory structures, Git must open each directory, read each entry, stat each entry, delete each file, recurse into subdirectories, then delete the directory itself.
Each unlink() and rmdir() is a synchronous system call that requires a context switch to kernel mode, updates filesystem metadata, may trigger disk I/O, and waits for the operation to complete before proceeding.
For a worktree with 10,000 files and 500 directories, Git performs approximately 10,000 unlink() calls, 500 rmdir() calls, 10,500+ lstat() calls, and 500+ opendir()/closedir() calls.
With hundreds of worktrees, this multiplies quickly. 100 worktrees with 10,000 files each means 1,000,000 sequential unlink operations.
The --force flag only skips the git status --porcelain check for uncommitted changes. It does not change the deletion mechanism at all:
if (file_exists(wt->path)) {
if (!force)
check_clean_worktree(wt, av[0]); // <-- --force skips this
ret |= delete_git_work_tree(wt); // <-- Still sequential deletion
}
So --force is actually faster than without it (since it skips the status check), but the deletion itself remains the bottleneck.
Big-brain solution
This was lunacy, so naturally I opted to solve the problem with idiocy: just rm -rf the directories, sort of...
While rm -rf also uses sequential deletion under the hood, it's a highly optimized C program specifically for this purpose, and some systems have parallel rm implementations.
Here's how I solved it:
- Move to trash using the system's native trash mechanism (
shell.trashItem()in Electron, which uses the same mechanism as your osascript Finder command) - Run
git worktree pruneonce at the end to clean up git's metadata about the now-missing worktrees
// Fast path - ["bumblebee"](https://news.ycombinator.com/item?id=23054506) approach
if (item.useTrash) {
await shell.trashItem(item.path); // Instant - just moves directory
// prune happens once at the end for all roots
} else {
await removeWorktreeAsync(item.root, item.path, !!item.force); // Slow - walks every file
}
// After all deletions, clean up git metadata
for (const root of uniqueRoots) {
await pruneWorktreesAsync(root); // git worktree prune
}
Same result, but seconds instead of hours.
