It is annoying to stash changes in the middle of a task because something else needs attention first. I knew Git worktrees could help here, but I kept putting off learning them. In applications like Copilot App it is pretty common to run a session in its own worktree, because running multiple agents in parallel in one working directory gets messy fast. Although agents can create worktrees automatically, I still want to understand the workflow so I know how to create them manually when needed.
What is a worktree?
A Git worktree lets me check out multiple branches of the same repository, each in its own folder, but all backed by the same Git database. Instead of stashing changes and switching branches I add a worktree for that branch and open it in its own VS Code window. I can leave half-finished work as-is and come back to it later.
Because the worktrees share one Git database, commit history, hooks, aliases, and repository config are shared. Git also refuses to check out the same branch in two worktrees at the same time, which is a nice safety feature to prevent working in the same branch in different places.
The worktree workflow I settled on is the bare repo pattern. The bare repository contains no working files, only the Git database. I like having the Git database in the root instead of “hidden” as part of a default branch like main.
This layout also makes room for a personal folder at the root of the workspace, outside every worktree and invisible to Git. This is a useful side effect of keeping the working files in separate folders. I’ll explain that later in the post.
Creating a bare repo
The steps below create a bare repo in a new folder my-repo, restore remote-tracking refs, and add a worktree for the main branch.
# Create a new folder where all repo files will live
mkdir my-repo && cd my-repo
# Clone the repo as a bare repo into folder `.bare`
git clone --bare git@github.com:me/some-repo.git .bare
# Reference the bare folder from the root of the workspace
echo "gitdir: ./.bare" > .git
# Bare clones don't set up remote-tracking refs. Map all remote branches:
git config remote.origin.fetch "+refs/heads/*:refs/remotes/origin/*"
# Fetch remote branches from origin
git fetch origin
# Add a worktree based on main
git worktree add main
# Connect the local main branch to origin/main
git branch --set-upstream-to=origin/main main
This creates a workspace layout like this:
my-repo/
├── .bare/ # the git database
├── .git # one-line pointer: gitdir: ./.bare
├── main/ # worktree: main
Walking through the commands:
git clone --bare … .barecreates the repository without a working tree. The.barefolder holds the Git database.echo "gitdir: ./.bare" > .gitwrites a pointer file so the root folder is a valid Git repository and I can run Git commands from there.git config remote.origin.fetch …restores remote-tracking refs. Without it,--barecopies remote branches straight to local branches instead of storing them underorigin/*.git fetch originpulls those remote-tracking refs down.git worktree add maincreates amainfolder with the localmainbranch checked out.
Everything for the project lives in its own folder. .bare holds the Git objects, the .git file points at it, and each subfolder is a branch checked out as a worktree. Delete the folder and everything is gone, no orphaned clones left behind.
Git does not automatically set upstream tracking when it creates the worktree. Without tracking information, git push and git pull will fail. From inside the worktree, set the remote:
- New branch:
git push -u origin <branch-name> - Existing local branch without an upstream:
git branch --set-upstream-to=origin/<branch-name>
Worktree commands
Adding a worktree
The command to add a worktree is git worktree add [-b <branch>] <folder path> [<base>]. The <folder path> is where Git creates the new worktree directory. Without -b, Git uses the folder name as the branch name. If that branch exists, Git checks it out. If it does not exist, Git creates it off the current HEAD. The optional <base> is the branch or commit to base a new branch on.
# new branch 'feature-a' off the current HEAD in folder feature-a
git worktree add -b feature-a feature-a
# new branch 'feature-a' off origin/main, regardless of where HEAD is
# created in folder feature/a (note the slash in the folder name)
git worktree add -b feature-a feature/a origin/main
# branch 'feature-a' already exists locally
git worktree add feature-a
It is possible to create nested folder structures for worktrees, e.g. git worktree add -b feature/redesign feature/redesign origin/main but I like to keep the worktree folder structure flat. It is easier to see all worktrees at a glance and it avoids confusion with nested branches. Instead of the nested folder structure I use a flat structure with dashes in the worktree folder name, e.g. feature-redesign instead of feature/redesign. This way every directory represents a single worktree.
Listing worktrees
# List worktrees
git worktree list
Cleaning up
# Remove a worktree from the root
git worktree remove feature-a
# Clear stale worktree entries if the folder was deleted manually
git worktree prune
Removing a worktree removes its folder, including any symlinks in it. Ignore rules in .bare/info/exclude remain until I remove them myself.
Personal files at the root
When working in a shared repo, not every teammate wants the same skills, settings, or tools. Some files are only for my workflow, but I still want them available in every branch. The bare pattern gives me a place for that. The workspace root sits outside every worktree, so anything I drop there is invisible to Git.
my-repo/
├── .bare/
├── .git
├── skills/ # only mine
├── .env # only mine
├── main/ # git only sees files under here
Inside main, Git is not aware of the files that exist outside the worktree. There is nothing to accidentally stage. This does mean that I can lose my personal files if I delete the root folder, so I make sure to back them up somewhere safe.
Make VS Code see them
There is one catch. VS Code can’t find files outside the worktree. It opens a worktree folder as the workspace root and stops there. I have two workarounds. I can symlink the personal files into each worktree, or copy them in and tell Git to ignore the copies. Either way, the personal files can’t share paths with tracked files, so they need unique names.
Ignore the personal files or folder
The .bare/info/exclude file behaves like .gitignore but lives in the Git database instead of the worktree. Add the paths once from the workspace root:
echo "skills/my-skill" >> .bare/info/exclude
echo ".env" >> .bare/info/exclude
Add symlinks
Create the links from the worktree:
ln -s <path_to_my-skill> skills/my-skill
ln -s <path_to_.env> .env
Repeat this for each worktree that needs the files.
Wrapping up
The bare pattern is a bit more setup than a plain clone, but I think I will stick with it knowing parallel work will be more common than ever. Small scripts also lowered the barrier for me. I currently have scripts to create a bare repository, create a worktree, remove a worktree, and show an enhanced status report for bare repository branches.