I only recently heard (or maybe better said, paid attention) to git worktrees. I can only assume AI agents and parallel work are the main reason they are becoming popular again. But regardless, this is something I wish I spent the time to learn a long time ago. In a nutshell, they allow you to have multiple copies of the same repo, on different branches, in different states, on your machine. In general I don’t work on more than one thing at a time, and branches and stashing have been mostly sufficient for my needs – yes, I’ve done my fair-share of commits named WIP :) With some git-fu, it’s easy enough to undo or get back to the desired state.
But after using worktrees for close to a week now, they have already become an integral part of my workflow. When I’m reviewing PRs, I now load those in a new worktree. When I tackle a bugfix or a new feature, I’m doing those all in worktrees. As I write this, I’m realizing I’m probably over-using them.
Funny enough, I remember a former colleague who wasn’t into local branches and stashing who I’d often see have multiple copies of the same repo cloned to different directories. If only I had a time-machine to go back and tell him there’s a better way.
Structure Link to heading
Worktrees need a place to live. Where they should live wasn’t clear to me at first, but I have landed on keeping a convention. If my repo is checked out at ~/code/my-project, all worktrees for that project will be checked out at ~/.code/my-project.worktrees/worktree1, with each worktree becoming a new directory. I went this route vs nested within ~/.code/my-project to avoid indexing more content, etc when loading it in an agent or an IDE.
~/code/
└── my-project/ # main repo checkout (your normal clone)
~/.code/
└── my-project.worktrees/
├── feature-auth/ # gwt feature-auth
├── bugfix-header/ # gwt bugfix-header
└── review-pr-42/ # gwt review-pr-42
Gotchas Link to heading
.gitignored files Link to heading
The first gotcha I hit with worktrees was that my .gitignored files were not being created in my worktree. That’s clearly by design, but a bit frustrating for a python (.venv) or node project (node_modules). .env files, are in the same boat, or anything else in your .gitignore. This does solve a pain-point I’ve had in the past, when switching between branches that have different dependencies (let’s say bumping a library that has breaking changes), I would always need to re-run the dependency installs when changing between branches. Problem solved with worktrees.
VS Code behaviour Link to heading
VS Code doesn’t play nice with worktrees, and I think that’s again by design. The source control view in the IDE is aware of the worktree branch, yet changing to it does not actually load the new files. You must launch a separate instance of VS Code on the worktree itself.
.worktree-init.sh Link to heading
In order to get the worktree in a good state, I have setup my gwt helper (more below) to automatically execute the .worktree-init.sh script, if it exists in the main clone. To avoid exposing this in the .gitignore, I manually add it to .git/info/exclude. My gwt helper copies it if exists, and then runs it from the new directory.
An example for a node project with Prisma might be something like:
#!/bin/bash
# my script passes the original directory as the first argument
SOURCE_DIR=$1
# symlink the .env so most things _just work_
# can always remove the symlink and create a dedicated .env per-worktree if needed
ln -s ${SOURCE_DIR}/.env .
# if using mise, trust the new directory
mise trust
# yarn or npm on pnpm or ...
yarn install
# anything else
prisma generate
It’s just a script, so that can be easily adapted for all languages or project specific needs.
Bash helpers Link to heading
I’m a CLI guy so I have created a few helpers to navigate worktrees effectively. This may change (I’ll try to update the post if so), but my current workflow is based
around a gwt bash function.
Usage:
gwt <branch-name> Create a new worktree
gwt rm <branch-name> Remove worktree and delete branch
gwt ls List worktrees
gwt cd Go to main repo
gwt cd <branch-name> Go to worktree
gwt my-worktreecreates a new worktree namedmy-worktree. It is smart enough to use an existing branch as the worktree source if you have a local branch with the same name. Same thing for a remote (it will do a git fetch internally). Otherwise it creates a new branch off the current branch.gwt rmremoves a worktree and deletes the branch. Probably need to add a--forceor something to handle dirty worktrees.gwt lslists current worktreesgwt cdhelper to navigate between worktrees, based on my naming convention.
Add these to your .bashrc or .zshrc, and spin up a new terminal to get them loaded. Adapt to suit your needs:
__gwt_usage() {
echo "Usage:"
echo " gwt <branch-name> Create a new worktree"
echo " gwt rm <branch-name> Remove worktree and delete branch"
echo " gwt ls List worktrees"
echo " gwt cd Go to main repo"
echo " gwt cd <branch-name> Go to worktree"
}
__gwt_delete() {
local name="$1"
if [[ -z "$name" ]]; then
__gwt_usage
return 1
fi
local repo_root
repo_root=$(git rev-parse --show-toplevel 2>/dev/null) || {
echo "Not in a git repo"
return 1
}
local repo_name=$(basename "$repo_root")
local worktree_dir="$(dirname "$repo_root")/$repo_name.worktrees/$name"
local current_dir=$(pwd -P)
if [[ "$current_dir" == "$worktree_dir"* ]]; then
echo "You're inside this worktree, moving to main repo..."
cd "$repo_root"
fi
git worktree remove "$worktree_dir" --force && \
git branch -D "$name" && \
echo "Removed worktree and branch: $name"
}
__gwt_create() {
local name="$1"
if [[ -z "$name" ]]; then
__gwt_usage
return 1
fi
local repo_root
repo_root=$(git rev-parse --show-toplevel 2>/dev/null) || {
echo "Not in a git repo"
return 1
}
local repo_name=$(basename "$repo_root")
local worktree_dir="$(dirname "$repo_root")/$repo_name.worktrees/$name"
mkdir -p "$(dirname "$worktree_dir")"
git fetch origin
if git show-ref --verify --quiet "refs/heads/$name"; then
git worktree add "$worktree_dir" "$name"
elif git show-ref --verify --quiet "refs/remotes/origin/$name"; then
git worktree add "$worktree_dir" -b "$name" "origin/$name"
else
git worktree add "$worktree_dir" -b "$name"
fi
[[ $? -ne 0 ]] && return 1
cd "$worktree_dir"
if [[ -f "$repo_root/.worktree-init.sh" ]]; then
cp "$repo_root/.worktree-init.sh" .worktree-init.sh
echo "Running .worktree-init.sh..."
bash .worktree-init.sh "$repo_root"
else
echo "No .worktree-init.sh found in $repo_root, skipping init"
fi
}
__gwt_list() {
local repo_root
repo_root=$(git rev-parse --show-toplevel 2>/dev/null) || {
echo "Not in a git repo"
return 1
}
local repo_name=$(basename "$repo_root")
local base_dir="$(dirname "$repo_root")/$repo_name.worktrees"
if [[ ! -d "$base_dir" ]]; then
echo "No worktrees found"
return 0
fi
local current_dir=$(pwd -P)
for dir in "$base_dir"/*/; do
[[ ! -d "$dir" ]] && continue
local name=$(basename "$dir")
if [[ "$current_dir" == "${dir%/}"* ]]; then
echo "* $name"
else
echo " $name"
fi
done
}
__gwt_cd() {
local repo_root
repo_root=$(git rev-parse --show-toplevel 2>/dev/null) || {
echo "Not in a git repo"
return 1
}
local repo_name=$(basename "$repo_root")
local base_dir="$(dirname "$repo_root")/$repo_name.worktrees"
# Find the actual main repo (not a worktree)
local main_repo
main_repo=$(git worktree list --porcelain | head -1 | sed 's/worktree //')
local name="$1"
if [[ -z "$name" ]]; then
cd "$main_repo"
else
local worktree_dir="$base_dir/$name"
if [[ -d "$worktree_dir" ]]; then
cd "$worktree_dir"
else
echo "Worktree '$name' not found"
return 1
fi
fi
}
gwt() {
if [[ "$1" == "rm" ]]; then
__gwt_delete "$2"
elif [[ "$1" == "ls" ]]; then
__gwt_list
elif [[ "$1" == "cd" ]]; then
__gwt_cd "$2"
elif [[ -z "$1" || "$1" == "-h" ]]; then
__gwt_usage
else
__gwt_create "$1"
fi
}