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-worktree creates a new worktree named my-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 rm removes a worktree and deletes the branch. Probably need to add a --force or something to handle dirty worktrees.
  • gwt ls lists current worktrees
  • gwt cd helper 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
}