Git Worktrees for Parallel Development
Master git worktrees for parallel development workflows including hotfixes, PR reviews, and multi-branch testing without stashing or switching
Git Worktrees for Parallel Development
Overview
Git worktrees let you check out multiple branches of the same repository into separate directories simultaneously, without cloning the repo again. They eliminate the constant git stash / git checkout / git stash pop dance that kills your flow when you need to fix a production bug while halfway through a feature. If you have ever lost a stash, forgotten which branch you were on, or waited for node_modules to rebuild after switching branches, worktrees solve all of that permanently.
Prerequisites
- Git 2.15 or later (worktrees existed since 2.5 but 2.15 fixed critical bugs)
- A working understanding of Git branches, remotes, and the staging area
- Node.js 18+ for the JavaScript tooling examples
- Familiarity with shell scripting (bash or PowerShell)
- At least one repository with multiple active branches
What Worktrees Are and How They Differ from Branches
A branch in Git is just a pointer to a commit. When you switch branches with git checkout or git switch, Git rewrites your working directory to match that commit. You have one working directory, one index (staging area), and one HEAD at any given time.
A worktree changes this model entirely. It gives you a separate working directory with its own index and its own HEAD, but all worktrees share the same .git object database. There is no duplication of commit history, no second clone consuming disk space. Think of it as having multiple checkout windows into the same repository.
Traditional workflow (one working directory):
main-repo/
.git/
src/
package.json
(can only have ONE branch checked out)
Worktree workflow (multiple working directories):
main-repo/ <-- main branch
.git/
src/
package.json
../hotfix-auth/ <-- hotfix/auth-bypass branch
src/
package.json
../review-pr-42/ <-- feature/new-dashboard branch
src/
package.json
Each directory is a fully functional checkout. You can run npm install, start a dev server, run tests -- all independently, all at the same time. The .git directory only exists in the main worktree. Linked worktrees have a .git file (not directory) that points back to the main repo.
# Inside a linked worktree, .git is a file:
cat ../hotfix-auth/.git
# gitdir: /home/dev/main-repo/.git/worktrees/hotfix-auth
This is fundamentally different from cloning the repo multiple times. Clones are completely independent -- they have separate object databases, separate remotes, separate reflog. Worktrees share everything except the working directory, the index, and HEAD. A commit made in any worktree is immediately visible in all other worktrees.
Creating and Managing Worktrees
Creating a Worktree
The basic command is git worktree add <path> <branch>:
# Create a worktree for an existing branch
git worktree add ../hotfix-auth hotfix/auth-bypass
# Create a worktree with a new branch (like git checkout -b)
git worktree add -b feature/new-api ../new-api main
# Create a worktree for a remote tracking branch
git worktree add ../review-pr-42 origin/feature/dashboard
Output when creating a worktree:
Preparing worktree (checking out 'hotfix/auth-bypass')
HEAD is now at a3f9c21 Fix token expiration logic
The path can be absolute or relative. I keep worktrees as sibling directories to the main repo. Some teams use a subdirectory pattern like worktrees/ inside the repo, but that gets messy with .gitignore and IDE indexing.
Listing Worktrees
git worktree list
Output:
/home/dev/main-repo a1b2c3d [main]
/home/dev/hotfix-auth a3f9c21 [hotfix/auth-bypass]
/home/dev/review-pr-42 f7e8d9c [feature/dashboard]
The --porcelain flag gives machine-readable output for scripting:
git worktree list --porcelain
worktree /home/dev/main-repo
HEAD a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0
branch refs/heads/main
worktree /home/dev/hotfix-auth
HEAD a3f9c21b4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a
branch refs/heads/hotfix/auth-bypass
worktree /home/dev/review-pr-42
HEAD f7e8d9c0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6
branch refs/heads/feature/dashboard
Removing Worktrees
# Remove a worktree (directory must be clean -- no uncommitted changes)
git worktree remove ../hotfix-auth
# Force remove (discards uncommitted changes)
git worktree remove --force ../hotfix-auth
If you manually delete a worktree directory (e.g., rm -rf ../hotfix-auth), Git keeps stale metadata. Clean it up with:
git worktree prune
This is important. Stale worktree references prevent you from checking out that branch in a new worktree.
Practical Use Cases
Hotfixes While Feature Work Continues
This is the killer use case. You are deep into a feature branch, node_modules is installed, your dev server is running, you have files open in your editor. A production bug comes in.
Without worktrees:
# The painful way
git stash
git checkout main
git pull
git checkout -b hotfix/critical-bug
# fix the bug
git commit -m "Fix critical auth bypass"
git push origin hotfix/critical-bug
git checkout feature/my-feature
git stash pop
# hope nothing broke, restart dev server, reopen files...
With worktrees:
# The fast way -- your current work is untouched
git worktree add -b hotfix/critical-bug ../hotfix main
cd ../hotfix
npm install
# fix the bug
git commit -am "Fix critical auth bypass"
git push origin hotfix/critical-bug
cd ../main-repo
git worktree remove ../hotfix
# your feature branch dev server never stopped running
Your feature branch's node_modules, running processes, open editor tabs -- none of it is disturbed. The hotfix gets its own isolated environment.
Reviewing Pull Requests
Code review is better when you can actually run the code. Worktrees make this trivial:
# Fetch the PR branch and create a review worktree
git fetch origin pull/42/head:pr-42
git worktree add ../review-pr-42 pr-42
cd ../review-pr-42
npm install
npm test
npm start
# test in browser, check output, review with the app running
When you are done reviewing:
cd ../main-repo
git worktree remove ../review-pr-42
git branch -D pr-42
Comparing Implementations Side by Side
Sometimes you need to compare how code behaves across branches -- maybe you are benchmarking an optimization or verifying a refactor did not change behavior.
# Set up both versions
git worktree add ../v1-implementation release/v1.0
git worktree add ../v2-implementation release/v2.0
# Run benchmarks against both simultaneously
cd ../v1-implementation && npm install && node benchmark.js > ../v1-results.txt &
cd ../v2-implementation && npm install && node benchmark.js > ../v2-results.txt &
wait
diff ../v1-results.txt ../v2-results.txt
Shared vs. Independent State
Understanding what is shared and what is isolated between worktrees is critical. Get this wrong and you will lose work.
Shared Across All Worktrees
- Object database (
.git/objects) -- all commits, blobs, trees - Refs -- branches, tags, remote tracking branches
- Config (
.git/config) -- remotes, user settings, aliases - Hooks (
.git/hooks) -- pre-commit, pre-push, etc. - Reflog for shared refs -- branch tips, remote refs
Independent Per Worktree
- Working directory -- the actual files on disk
- Index (staging area) -- what is staged for the next commit
- HEAD -- which commit/branch is currently checked out
- Stash -- this one surprises people
- Bisect state -- each worktree can run its own bisect
- Rebase/merge state -- in-progress operations are isolated
Wait -- stash is actually shared. Let me correct that. git stash is stored in the reflog at refs/stash, which is a shared ref. If you stash in one worktree and pop in another, it works. This can be confusing. As of Git 2.38, there is no per-worktree stash. Be aware of this.
# In worktree A
echo "work in progress" > temp.txt
git stash
# In worktree B -- this will pop the stash from worktree A!
git stash pop
My recommendation: avoid git stash when using worktrees. The whole point of worktrees is that you do not need to stash. If you have uncommitted work in a worktree, just leave it there.
The Lock Mechanism
Git prevents you from checking out the same branch in two worktrees simultaneously. This is a safety feature to prevent conflicting changes.
# This will fail if main is already checked out in the main worktree
git worktree add ../second-main main
# fatal: 'main' is already checked out at '/home/dev/main-repo'
You can override this with --force, but do not do that unless you understand exactly what you are doing.
You can also lock a worktree to prevent accidental removal:
git worktree lock ../hotfix-auth
git worktree lock ../hotfix-auth --reason "Deploy in progress, do not remove"
# Unlock when done
git worktree unlock ../hotfix-auth
Integrating Worktrees with IDE Workflows
Most modern editors handle worktrees well because each worktree is just a directory. You open the worktree directory as a project.
VS Code
VS Code treats each worktree as an independent workspace. Open a worktree with:
code ../hotfix-auth
VS Code detects the .git file and correctly traces it back to the main repository. Git operations (commit, push, pull) work normally. The Source Control panel shows the correct branch.
For power users, create a multi-root workspace that includes multiple worktrees:
{
"folders": [
{ "path": "/home/dev/main-repo", "name": "main" },
{ "path": "/home/dev/hotfix-auth", "name": "hotfix" },
{ "path": "/home/dev/review-pr-42", "name": "PR #42" }
],
"settings": {
"files.exclude": {
"**/node_modules": true
}
}
}
JetBrains IDEs (WebStorm, IntelliJ)
JetBrains IDEs have native worktree support since 2023.2. Use Git > Manage Worktrees from the menu. They will display linked worktrees in the Git tool window and let you switch between them.
For older versions, just open each worktree as a separate project window.
Worktrees and CI/CD Local Testing
Worktrees are excellent for testing CI pipeline changes locally before pushing. If your CI config (GitHub Actions, Azure Pipelines, etc.) is in the repo, you can test pipeline changes in a worktree without disrupting your main development.
# Create a worktree for CI testing
git worktree add -b ci/fix-pipeline ../ci-test main
cd ../ci-test
# Edit .github/workflows/ci.yml or azure-pipelines.yml
# Test with act (GitHub Actions local runner)
act -j build
# Or test Docker builds
docker build -t myapp:ci-test .
docker run --rm myapp:ci-test npm test
You can also use worktrees to test your application against different Node.js versions simultaneously:
git worktree add ../test-node18 main
git worktree add ../test-node20 main --detach # detached HEAD, same commit
# In separate terminals:
cd ../test-node18 && nvm use 18 && npm install && npm test
cd ../test-node20 && nvm use 20 && npm install && npm test
Note the --detach flag. Since you cannot check out the same branch in two worktrees, --detach checks out the commit without associating it with a branch.
Worktree-Aware Scripts and Automation
Once you adopt worktrees, you will want scripts that understand them. Here is a Node.js utility for common worktree operations:
// worktree-utils.js
var childProcess = require("child_process");
var path = require("path");
var fs = require("fs");
function execGit(args, cwd) {
var result = childProcess.execSync("git " + args, {
cwd: cwd || process.cwd(),
encoding: "utf8",
stdio: ["pipe", "pipe", "pipe"]
});
return result.trim();
}
function listWorktrees(repoPath) {
var output = execGit("worktree list --porcelain", repoPath);
var worktrees = [];
var current = {};
output.split("\n").forEach(function (line) {
if (line.startsWith("worktree ")) {
current = { path: line.replace("worktree ", "") };
} else if (line.startsWith("HEAD ")) {
current.head = line.replace("HEAD ", "");
} else if (line.startsWith("branch ")) {
current.branch = line.replace("branch refs/heads/", "");
} else if (line === "detached") {
current.detached = true;
} else if (line === "") {
if (current.path) {
worktrees.push(current);
current = {};
}
}
});
if (current.path) {
worktrees.push(current);
}
return worktrees;
}
function findStaleWorktrees(repoPath) {
var worktrees = listWorktrees(repoPath);
var stale = [];
worktrees.forEach(function (wt) {
if (!fs.existsSync(wt.path)) {
stale.push(wt);
}
});
return stale;
}
module.exports = {
execGit: execGit,
listWorktrees: listWorktrees,
findStaleWorktrees: findStaleWorktrees
};
A Shell Function for Quick Worktree Creation
Add this to your .bashrc or .zshrc:
# Quick worktree creation with npm install
wt() {
local branch="$1"
local dir="${2:-../$branch}"
if [ -z "$branch" ]; then
echo "Usage: wt <branch> [directory]"
echo " Creates worktree and runs npm install"
return 1
fi
# Check if branch exists remotely
if git ls-remote --heads origin "$branch" | grep -q "$branch"; then
git fetch origin "$branch"
git worktree add "$dir" "origin/$branch"
else
echo "Branch '$branch' not found on remote. Creating new branch from HEAD."
git worktree add -b "$branch" "$dir"
fi
if [ -f "$dir/package.json" ]; then
echo "Running npm install in $dir..."
(cd "$dir" && npm install)
fi
echo ""
echo "Worktree ready at: $dir"
echo " cd $dir"
}
# Quick worktree cleanup
wt-rm() {
local dir="$1"
if [ -z "$dir" ]; then
echo "Usage: wt-rm <directory>"
return 1
fi
git worktree remove "$dir"
echo "Removed worktree at $dir"
}
# List all worktrees with status
wt-ls() {
git worktree list
}
Usage:
wt feature/new-dashboard
# Preparing worktree (checking out 'origin/feature/new-dashboard')
# Running npm install in ../feature/new-dashboard...
# added 847 packages in 12.4s
#
# Worktree ready at: ../feature/new-dashboard
# cd ../feature/new-dashboard
Performance Considerations and Disk Usage
Worktrees are lightweight in terms of Git metadata -- they share the object database. But the working directory files are real copies on disk. For a Node.js project, the main disk cost is node_modules.
# Check disk usage of worktrees
du -sh ../main-repo ../hotfix-auth ../review-pr-42
# Typical output for a medium Node.js project:
# 487M ../main-repo
# 412M ../hotfix-auth
# 412M ../review-pr-42
Most of that is node_modules. The source code itself is usually tiny (10-50 MB). There are strategies to reduce this:
Shared node_modules with Symlinks (Use with Caution)
You can symlink node_modules across worktrees if they share the same package-lock.json. I do not recommend this in general -- different branches often have different dependencies. But for short-lived review worktrees, it can save time:
# Only do this if package-lock.json is identical
cd ../review-pr-42
ln -s ../main-repo/node_modules node_modules
npm Cache Optimization
The npm cache is shared across all worktrees (it is a user-level cache, not per-project). So even without symlinks, subsequent npm install runs are faster because packages are already cached:
# First worktree install (cache miss): ~45s
# Second worktree install (cache hit): ~12s
npm install --prefer-offline
Disk Space Monitoring Script
// check-worktree-size.js
var childProcess = require("child_process");
var path = require("path");
function getWorktreeInfo() {
var output = childProcess.execSync("git worktree list --porcelain", {
encoding: "utf8"
});
var worktrees = [];
var current = {};
output.split("\n").forEach(function (line) {
if (line.startsWith("worktree ")) {
current = { path: line.replace("worktree ", "") };
} else if (line.startsWith("branch ")) {
current.branch = line.replace("branch refs/heads/", "");
} else if (line === "") {
if (current.path) {
try {
var size = childProcess.execSync(
'du -sh "' + current.path + '" 2>/dev/null',
{ encoding: "utf8" }
).split("\t")[0];
current.size = size;
} catch (e) {
current.size = "N/A";
}
worktrees.push(current);
current = {};
}
}
});
return worktrees;
}
var worktrees = getWorktreeInfo();
console.log("Worktree Disk Usage:");
console.log("====================");
worktrees.forEach(function (wt) {
var branch = wt.branch || "(detached)";
console.log(wt.size + "\t" + branch + "\t" + wt.path);
});
node check-worktree-size.js
# Worktree Disk Usage:
# ====================
# 487M main /home/dev/main-repo
# 412M hotfix/auth-bypass /home/dev/hotfix-auth
# 412M feature/dashboard /home/dev/review-pr-42
The Bare Repository Pattern with Worktrees
This is the pattern I recommend for teams that use worktrees heavily. Instead of having a "main" worktree that also serves as a regular checkout, you clone the repo as bare and create all working directories as worktrees.
# Clone as bare repository
git clone --bare [email protected]:myorg/myapp.git myapp.git
cd myapp.git
# Now create worktrees for each branch you need
git worktree add ../myapp-main main
git worktree add ../myapp-develop develop
git worktree add -b feature/api-v2 ../myapp-api-v2 main
The directory structure looks like this:
projects/
myapp.git/ <-- bare repo (no working files, just .git internals)
myapp-main/ <-- worktree for main
myapp-develop/ <-- worktree for develop
myapp-api-v2/ <-- worktree for feature branch
Why use this pattern?
- No "primary" worktree -- all worktrees are equal. You can remove any of them without affecting the others.
- Cleaner mental model -- the bare repo is the source of truth, worktrees are disposable checkouts.
- Prevents accidental work in the main repo directory -- there is no working directory in the bare repo.
The downside: some Git GUIs and IDE integrations do not handle bare repos gracefully. If your tools choke on it, stick with the normal worktree model.
Fetching in Bare Repos
One gotcha: bare repos cloned with --bare do not set up remote tracking branches the usual way. You need to configure the fetch refspec:
cd myapp.git
git config remote.origin.fetch "+refs/heads/*:refs/remotes/origin/*"
git fetch origin
Without this, git fetch will not update origin/main, origin/develop, etc.
Combining Worktrees with Git Bisect
Git bisect is already powerful on its own (see my article on git bisect for a deep dive). Combined with worktrees, you can bisect a bug without leaving your current branch.
# You are on feature/new-api and suspect a regression in main
# Create a dedicated bisect worktree
git worktree add ../bisect-debug main
cd ../bisect-debug
git bisect start
git bisect bad HEAD
git bisect good v2.1.0
# Run automated bisect with a test script
git bisect run npm test -- --grep "auth token validation"
# When done, record the result
git bisect log > ../bisect-results.txt
git bisect reset
# Clean up
cd ../main-repo
git worktree remove ../bisect-debug
The beauty here is that your feature branch work continues undisturbed. You do not have to stash anything, you do not lose your place. The bisect runs in a completely separate directory.
For automated bisect scripts in a worktree context:
#!/bin/bash
# bisect-test.sh -- used with git bisect run
npm install --prefer-offline 2>/dev/null
# Run the specific test that catches the regression
npm test -- --grep "should validate token expiry" 2>&1
exit $?
git bisect run ./bisect-test.sh
# Bisecting: 64 revisions left to test after this (roughly 6 steps)
# ...
# a3f9c21 is the first bad commit
Complete Working Example: Worktree Manager Tool
Here is a comprehensive Node.js tool that manages worktrees for a team workflow. It handles creating review worktrees from GitHub PRs, cleaning up stale worktrees, and automating common patterns.
#!/usr/bin/env node
// wt-manager.js -- Git Worktree Manager
// Usage: node wt-manager.js <command> [options]
var childProcess = require("child_process");
var path = require("path");
var fs = require("fs");
// ─── Helpers ────────────────────────────────────────────────────────
function exec(cmd, options) {
try {
var result = childProcess.execSync(cmd, Object.assign({
encoding: "utf8",
stdio: ["pipe", "pipe", "pipe"]
}, options || {}));
return { ok: true, output: result.trim() };
} catch (err) {
return { ok: false, output: err.stderr ? err.stderr.trim() : err.message };
}
}
function getRepoRoot() {
var result = exec("git rev-parse --show-toplevel");
if (!result.ok) {
// Might be in a bare repo
result = exec("git rev-parse --git-dir");
if (result.ok) {
return path.resolve(result.output);
}
console.error("Error: not inside a git repository.");
process.exit(1);
}
return result.output;
}
function listWorktrees() {
var result = exec("git worktree list --porcelain");
if (!result.ok) return [];
var worktrees = [];
var current = {};
result.output.split("\n").forEach(function (line) {
if (line.startsWith("worktree ")) {
current = { path: line.replace("worktree ", "") };
} else if (line.startsWith("HEAD ")) {
current.head = line.replace("HEAD ", "").substring(0, 8);
} else if (line.startsWith("branch ")) {
current.branch = line.replace("branch refs/heads/", "");
} else if (line === "detached") {
current.detached = true;
current.branch = "(detached)";
} else if (line.startsWith("locked")) {
current.locked = true;
} else if (line === "bare") {
current.bare = true;
current.branch = "(bare)";
} else if (line === "") {
if (current.path) {
worktrees.push(current);
current = {};
}
}
});
if (current.path) {
worktrees.push(current);
}
return worktrees;
}
// ─── Commands ───────────────────────────────────────────────────────
function cmdList() {
var worktrees = listWorktrees();
if (worktrees.length === 0) {
console.log("No worktrees found.");
return;
}
console.log("Active Worktrees:");
console.log("─".repeat(72));
worktrees.forEach(function (wt) {
var status = [];
if (wt.locked) status.push("LOCKED");
if (wt.bare) status.push("BARE");
if (!fs.existsSync(wt.path)) status.push("MISSING");
var statusStr = status.length > 0 ? " [" + status.join(", ") + "]" : "";
var age = getWorktreeAge(wt.path);
console.log(" " + wt.head + " " + (wt.branch || "(detached)"));
console.log(" " + wt.path + statusStr);
if (age) console.log(" Created: " + age);
console.log("");
});
}
function getWorktreeAge(wtPath) {
try {
var stats = fs.statSync(wtPath);
var days = Math.floor((Date.now() - stats.birthtimeMs) / 86400000);
if (days === 0) return "today";
if (days === 1) return "1 day ago";
return days + " days ago";
} catch (e) {
return null;
}
}
function cmdReview(prNumber) {
if (!prNumber) {
console.error("Usage: node wt-manager.js review <pr-number>");
process.exit(1);
}
var repoRoot = getRepoRoot();
var wtPath = path.resolve(repoRoot, "..", "review-pr-" + prNumber);
console.log("Fetching PR #" + prNumber + "...");
var fetchResult = exec(
"git fetch origin pull/" + prNumber + "/head:pr-" + prNumber
);
if (!fetchResult.ok) {
console.error("Failed to fetch PR: " + fetchResult.output);
process.exit(1);
}
console.log("Creating worktree at " + wtPath + "...");
var addResult = exec(
'git worktree add "' + wtPath + '" pr-' + prNumber
);
if (!addResult.ok) {
console.error("Failed to create worktree: " + addResult.output);
process.exit(1);
}
// Install dependencies if package.json exists
var packagePath = path.join(wtPath, "package.json");
if (fs.existsSync(packagePath)) {
console.log("Installing dependencies...");
var installResult = exec("npm install --prefer-offline", { cwd: wtPath });
if (installResult.ok) {
console.log("Dependencies installed.");
} else {
console.warn("npm install had issues: " + installResult.output);
}
}
console.log("");
console.log("Review worktree ready!");
console.log(" cd " + wtPath);
console.log(" npm test");
console.log(" npm start");
console.log("");
console.log("When done: node wt-manager.js cleanup " + wtPath);
}
function cmdCreate(branchName, baseBranch) {
if (!branchName) {
console.error("Usage: node wt-manager.js create <branch> [base-branch]");
process.exit(1);
}
var repoRoot = getRepoRoot();
var dirName = branchName.replace(/\//g, "-");
var wtPath = path.resolve(repoRoot, "..", dirName);
var base = baseBranch || "main";
// Check if branch exists
var branchCheck = exec("git rev-parse --verify " + branchName);
var cmd;
if (branchCheck.ok) {
cmd = 'git worktree add "' + wtPath + '" ' + branchName;
} else {
cmd = 'git worktree add -b ' + branchName + ' "' + wtPath + '" ' + base;
}
console.log("Creating worktree for " + branchName + "...");
var result = exec(cmd);
if (!result.ok) {
console.error("Failed: " + result.output);
process.exit(1);
}
// Install dependencies
var packagePath = path.join(wtPath, "package.json");
if (fs.existsSync(packagePath)) {
console.log("Installing dependencies...");
exec("npm install --prefer-offline", { cwd: wtPath });
}
console.log("Worktree created at: " + wtPath);
}
function cmdCleanup(targetPath) {
if (targetPath) {
// Remove specific worktree
var absPath = path.resolve(targetPath);
console.log("Removing worktree at " + absPath + "...");
// Check for uncommitted changes
var statusResult = exec("git status --porcelain", { cwd: absPath });
if (statusResult.ok && statusResult.output.length > 0) {
console.warn("WARNING: Worktree has uncommitted changes:");
console.warn(statusResult.output);
console.warn("Use --force to remove anyway.");
process.exit(1);
}
var result = exec('git worktree remove "' + absPath + '"');
if (result.ok) {
console.log("Removed successfully.");
} else {
console.error("Failed: " + result.output);
process.exit(1);
}
return;
}
// Clean up all stale worktrees
console.log("Pruning stale worktree references...");
exec("git worktree prune");
var worktrees = listWorktrees();
var stale = worktrees.filter(function (wt) {
return !fs.existsSync(wt.path) || wt.path === "";
});
if (stale.length === 0) {
console.log("No stale worktrees found.");
} else {
console.log("Cleaned up " + stale.length + " stale reference(s).");
}
}
function cmdPrune(maxAgeDays) {
var days = parseInt(maxAgeDays, 10) || 7;
var worktrees = listWorktrees();
var cutoff = Date.now() - (days * 86400000);
var pruned = 0;
worktrees.forEach(function (wt, index) {
if (index === 0) return; // Skip main worktree
if (wt.locked) return; // Skip locked worktrees
try {
var stats = fs.statSync(wt.path);
if (stats.birthtimeMs < cutoff) {
console.log("Removing stale worktree (" + days + "+ days old): " + wt.path);
exec('git worktree remove "' + wt.path + '"');
pruned++;
}
} catch (e) {
// Directory does not exist, prune the reference
exec("git worktree prune");
pruned++;
}
});
console.log("Pruned " + pruned + " worktree(s) older than " + days + " days.");
}
// ─── CLI Entry Point ────────────────────────────────────────────────
var args = process.argv.slice(2);
var command = args[0];
switch (command) {
case "list":
case "ls":
cmdList();
break;
case "review":
cmdReview(args[1]);
break;
case "create":
case "new":
cmdCreate(args[1], args[2]);
break;
case "cleanup":
case "rm":
cmdCleanup(args[1]);
break;
case "prune":
cmdPrune(args[1]);
break;
default:
console.log("Git Worktree Manager");
console.log("====================");
console.log("");
console.log("Commands:");
console.log(" list List all active worktrees");
console.log(" review <pr#> Create a review worktree from a GitHub PR");
console.log(" create <branch> Create a worktree for a branch");
console.log(" cleanup [path] Remove a worktree or prune stale refs");
console.log(" prune [days] Remove worktrees older than N days (default: 7)");
console.log("");
console.log("Examples:");
console.log(" node wt-manager.js review 42");
console.log(" node wt-manager.js create feature/new-api");
console.log(" node wt-manager.js create hotfix/urgent main");
console.log(" node wt-manager.js cleanup ../review-pr-42");
console.log(" node wt-manager.js prune 14");
break;
}
Usage in practice:
# Review a PR
node wt-manager.js review 42
# Fetching PR #42...
# Creating worktree at /home/dev/review-pr-42...
# Installing dependencies...
# Dependencies installed.
#
# Review worktree ready!
# cd /home/dev/review-pr-42
# npm test
# npm start
#
# When done: node wt-manager.js cleanup /home/dev/review-pr-42
# List all worktrees
node wt-manager.js list
# Active Worktrees:
# ────────────────────────────────────────────────────────────────────────
# a1b2c3d4 main
# /home/dev/main-repo
# Created: 45 days ago
#
# f7e8d9c0 feature/dashboard
# /home/dev/review-pr-42
# Created: 2 days ago
# Prune worktrees older than 5 days
node wt-manager.js prune 5
# Removing stale worktree (5+ days old): /home/dev/review-pr-31
# Pruned 1 worktree(s) older than 5 days.
Common Issues and Troubleshooting
Issue 1: "fatal: 'branch-name' is already checked out"
$ git worktree add ../test main
fatal: 'main' is already checked out at '/home/dev/main-repo'
Cause: Git prevents the same branch from being checked out in two worktrees simultaneously to avoid conflicting edits to the same branch tip.
Fix: Use a detached HEAD if you just need the code at that commit:
git worktree add --detach ../test main
Or create a new branch from the target:
git worktree add -b test/from-main ../test main
Issue 2: "fatal: could not open ... /worktrees/name/HEAD: No such file or directory"
$ git worktree list
fatal: could not open '/home/dev/main-repo/.git/worktrees/hotfix-auth/HEAD': No such file or directory
Cause: The worktree directory was manually deleted (rm -rf) instead of using git worktree remove. Git still has metadata pointing to the deleted directory.
Fix:
git worktree prune
# This removes stale worktree metadata for directories that no longer exist
Always use git worktree remove instead of deleting directories manually.
Issue 3: npm install Conflicts Between Worktrees
$ cd ../hotfix-auth && npm install
npm ERR! code ENOTEMPTY
npm ERR! syscall rename
npm ERR! path /home/dev/hotfix-auth/node_modules/.package-lock.json
Cause: If you symlinked node_modules or if there is a shared npm cache corruption, installs can conflict between worktrees running simultaneously.
Fix: Do not run npm install simultaneously in worktrees that share symlinked node_modules. Each worktree should have its own node_modules. If you get cache corruption:
npm cache clean --force
cd ../hotfix-auth && rm -rf node_modules && npm install
Issue 4: Hooks Running in Wrong Worktree Context
$ cd ../review-pr-42 && git commit -m "test"
pre-commit hook: lint failed
# But the files being linted are from the MAIN worktree, not this one
Cause: Git hooks are shared across all worktrees (they live in .git/hooks/). If a hook script uses hardcoded paths or does not respect $GIT_WORK_TREE, it may operate on the wrong directory.
Fix: Update hooks to use the current working directory, not hardcoded paths:
#!/bin/sh
# WRONG -- hardcoded path
cd /home/dev/main-repo && npm run lint
# CORRECT -- uses current worktree
npm run lint
Or use the GIT_WORK_TREE environment variable that Git sets for hooks:
#!/bin/sh
cd "$GIT_WORK_TREE" && npm run lint
Issue 5: Worktree Breaks After Deleting a Branch
$ git branch -D feature/old-experiment
$ git worktree list
/home/dev/main-repo a1b2c3d [main]
/home/dev/old-experiment f7e8d9c [feature/old-experiment] (error: cannot resolve ref)
Cause: You deleted the branch that a worktree had checked out. The worktree now points to a branch that does not exist.
Fix: Remove the worktree first, then delete the branch:
git worktree remove ../old-experiment
git branch -D feature/old-experiment
If you already deleted the branch, force-remove the worktree:
git worktree remove --force ../old-experiment
git worktree prune
Best Practices
Name worktree directories after the branch purpose, not the branch name. Use
../hotfix-authinstead of../hotfix-auth-bypass-token-validation-fix-2026. Keep paths short. You will be typingcdcommands constantly.Always use
git worktree removeinstead ofrm -rf. Manual deletion leaves stale metadata that causes confusing errors later. If you forget, rungit worktree pruneto clean up.Lock long-running worktrees. If a worktree is tied to a deployment pipeline or a long review process, lock it so a colleague's cleanup script does not delete it:
git worktree lock ../deploy-staging --reason "Active deployment".Keep worktree count under control. Each worktree with its own
node_modulesconsumes hundreds of megabytes. Set a team convention: remove worktrees when the associated PR is merged. Use the prune command from the manager script above on a weekly basis.Run
git worktree pruneperiodically. Add it to your shell startup or run it as part of a weekly cleanup script. Stale references accumulate and cause errors that waste debugging time.Use the bare repository pattern for worktree-heavy workflows. If your team regularly has 3+ worktrees active, the bare repo pattern provides a cleaner mental model and prevents the "main worktree is special" problem.
Do not stash when using worktrees. The entire point is that each branch gets its own directory. Stash is shared across worktrees and leads to confusion. Leave uncommitted work in the worktree directory where it belongs.
Each worktree should have its own
node_modules. Sharing via symlinks seems clever until you hit version mismatches between branches. The npm cache already accelerates installs for the second worktree. The disk space trade-off is worth the reliability.Set up shell aliases or the manager script from day one. Worktrees involve more typing than
git checkout. Aliases likewt,wt-rm, andwt-lsbring the friction back down and encourage consistent usage patterns across the team.Integrate worktree cleanup into your PR merge workflow. After a PR is merged, the associated worktree is dead weight. A post-merge hook or CI step that reminds developers to clean up prevents disk bloat on shared machines.
References
- Git Worktree Documentation -- Official git-worktree reference with all flags and options
- Git Internals - Worktree Data Structures -- How .git/worktrees/ metadata is structured
- Pro Git Book - Chapter 7: Git Tools -- Broader context for advanced Git tools
- Git 2.15 Release Notes -- Key worktree bug fixes that made them production-ready
- GitHub CLI Documentation --
gh pr checkoutfor GitHub-specific PR workflows - npm Cache Documentation -- Understanding the shared npm cache that benefits worktree workflows