<!--
  canonical-url: https://www.avonture.be/blog/git-worktree
  published:     2026-06-29
  This is a static plain-Markdown mirror generated at build time.
  Visit the canonical URL above for the fully rendered page, with images and interactive components.
-->

# git worktree: Work on Two Branches at the Same Time

> git worktree lets you check out multiple branches as real directories simultaneously — no stashing, no context loss. Keep your Docker stack running while handling hotfixes in parallel.

---
![git worktree: Work on Two Branches at the Same Time](/img/v2/git_worktree.webp)

**TL;DR**

You're deep in a feature branch — half-written code, running Docker containers, open files everywhere. Then: "urgent hotfix needed on main." With the usual workflow, you stash everything, switch branches, fix, commit, push, then unstash and hope nothing explodes. With `git worktree`, you check out a second branch into a separate folder. Both branches exist simultaneously, as real directories, with their own terminals and their own Docker stacks.

It's 3pm on a Thursday. You've been working on a new feature for two days. The Docker containers are running, your editor has eight files open, and you're in the middle of a refactor that's not compiling yet.

Your phone buzzes. Production is broken. A fix needs to go out on `main` in the next thirty minutes.

What do you do?

## The stash workflow and its pain

The classic approach:

```bash
$ git stash push -m "WIP: feature/user-notifications"
$ git checkout main
$ git pull
// ... fix the bug ...
$ git commit -m "fix: correct invoice calculation"
$ git push
$ git checkout feature/user-notifications
$ git stash pop
```

It works. Most of the time. But:

* Your Docker containers are now pointing at the wrong codebase — you need to restart them
* Your editor has lost context — all those open files are gone or pointing at main's versions
* If `git stash pop` has a conflict, you now have a three-way mess to resolve while still stressed from the hotfix
* You can only have one active branch at a time

The deeper issue: a git stash is fragile state. It's not a branch. It has no history. It can conflict. And it disappears once you pop it.

*Since we're talking about juggling branches: [Git - Some tips for your .gitconfig file](/blog/git-config) shows how to sort `git branch` by last commit date, and [Showing the last 3 updated branches when you jump in a git repo](/blog/git-branches-gst) displays them automatically as you enter a repository.*

## What git worktree does

A git worktree is a second (or third, or fourth) working directory linked to the same git repository.

Instead of one folder with one checked-out branch, you get:

```plaintext
~/projects/my-blog/                     ← main branch (original)
~/projects/my-blog-hotfix/              ← hotfix/invoice-fix branch (worktree)
~/projects/my-blog-feature-auth/        ← feature/auth branch (worktree)
```

Each folder is a **real directory** with its own files, its own terminal, its own running processes. They share the same `.git` database (commits, objects, history), but each has an independently checked-out branch.

You never leave your feature branch. The hotfix lives next door.

## The basic commands

### Add a worktree

```bash
$ git worktree add <path> [branch]
```

Create a new directory at `<path>` with `[branch]` checked out:

```bash
$ git worktree add ../my-blog-hotfix hotfix/invoice-fix
```

If the branch doesn't exist yet, create it at the same time:

```bash
$ git worktree add -b hotfix/invoice-fix ../my-blog-hotfix main
```

This creates `hotfix/invoice-fix` branching off `main`, checked out into `../my-blog-hotfix`.

### List all worktrees

```bash
$ git worktree list
```

Output:

```plaintext
/home/christophe/projects/my-blog              a4b2c1d [main]
/home/christophe/projects/my-blog-hotfix       e8f3d2a [hotfix/invoice-fix]
```

### Remove a worktree

```bash
git worktree remove ../my-blog-hotfix
```

This deletes the folder and unregisters the worktree. The branch itself (`hotfix/invoice-fix`) still exists — you can delete it separately with `git branch -d`.

## Seeing It in Action with Docker

`git` needs no installing — it's already on your machine. What's worth trying risk-free is the
scenario itself: a throwaway repo, already mid-refactor on a feature branch with uncommitted
changes, and a VAT calculation bug waiting on `main`. Exactly the Thursday-afternoon setup below,
minus the stress.

> **Why Docker first?:**
>
> The Dockerfile below builds a repo already sitting on `feature/user-notifications` with two
> unstaged files — the same starting point as step 1 below — plus the `gwt` fzf navigator from
> further down, wired via real ZSH autoload. Nothing on your own repos is touched.

**Dockerfile**

```docker
FROM ubuntu:24.04

ENV DEBIAN_FRONTEND=noninteractive

# Ubuntu's docker image only enables "main" + "restricted" by default; fzf
# (used by the gwt() navigator below) lives in "universe".
RUN sed -i 's/^Components: main restricted$/Components: main restricted universe multiverse/' \
        /etc/apt/sources.list.d/ubuntu.sources && \
    apt-get update && \
    apt-get install -y --no-install-recommends \
        git \
        zsh \
        fzf \
        vim && \
    rm -rf /var/lib/apt/lists/*

RUN git config --global user.email "demo@example.com" && \
    git config --global user.name "Demo" && \
    git config --global init.defaultBranch main

# A bare repo standing in for GitHub/GitLab, so `git push origin ...` in the
# scenario below has somewhere real to go instead of failing on a missing
# remote.
RUN git init -q --bare /root/origin.git

# Build the exact starting point of "The hotfix scenario, step by step":
# a repo on `feature/user-notifications`, mid-refactor, with uncommitted
# changes — and a billing bug on `main` waiting to be hotfixed.
RUN mkdir -p /root/projects/my-blog/src/components /root/projects/my-blog/src/api /root/projects/my-blog/src/billing && \
    cd /root/projects/my-blog && \
    git init -q && \
    git remote add origin /root/origin.git && \
    printf 'export function Notifications() {\n  return null;\n}\n' \
        > src/components/Notifications.tsx && \
    printf 'export function fetchNotifications() {\n  return [];\n}\n' \
        > src/api/notifications.ts && \
    printf 'export function calculateVat(amount, rate) {\n  return amount * rate;\n}\n' \
        > src/billing/invoice.ts && \
    git add -A && \
    git commit -q -m "chore: update dependencies" && \
    git push -q origin main && \
    git checkout -q -b feature/user-notifications && \
    printf 'export function Notifications({ items }) {\n  return items.map((i) => i.text);\n}\n' \
        > src/components/Notifications.tsx && \
    printf 'export function fetchNotifications() {\n  return fetch("/api/notifications").then((r) => r.json());\n}\n' \
        > src/api/notifications.ts

# ~/.zsh/fns/gwt — the exact fzf-powered worktree navigator from this
# article, wired via real zsh autoload
RUN mkdir -p /root/.zsh/fns

RUN printf '%s\n' \
    '# ~/.zsh/fns/gwt' \
    '# git worktree navigator — select a worktree with fzf and cd into it' \
    '' \
    'gwt() {' \
    '  local worktrees selected dir' \
    '' \
    '  worktrees=$(git worktree list 2>/dev/null) || {' \
    '    echo "Not inside a git repository."' \
    '    return 1' \
    '  }' \
    '' \
    '  if [[ -z "$1" ]]; then' \
    '    # Interactive: pick a worktree with fzf' \
    '    selected=$(echo "$worktrees" \' \
    '      | fzf --prompt="Worktree > " \' \
    '            --preview="ls -la {1}" \' \
    '            --height=40%)' \
    '    [[ -z "$selected" ]] && return 0' \
    '    dir=$(echo "$selected" | awk '\''{print $1}'\'')' \
    '  else' \
    '    # Direct: create a new worktree for the given branch' \
    '    local branch="$1"' \
    '    dir="${$(git rev-parse --show-toplevel)}-${branch//\//-}"' \
    '    git worktree add -b "$branch" "$dir" main || return 1' \
    '    echo "Created worktree at $dir"' \
    '  fi' \
    '' \
    '  cd "$dir"' \
    '}' \
    > /root/.zsh/fns/gwt

RUN printf '%s\n' \
    'fpath=(~/.zsh/fns $fpath)' \
    'autoload -Uz gwt' \
    >> /root/.zshrc

WORKDIR /root/projects/my-blog

# Signal clearly that this shell runs inside a container
RUN echo "PROMPT='%B%F{yellow}🐳 %F{cyan}%n%f%b %~ %# '" >> /root/.zshrc

CMD ["/bin/zsh", "-i", "-l"]
```

Build and run it:

```bash
$ docker build -t worktree-demo .
[+] Building 24.1s (8/8) FINISHED
✔ exporting to image
$ docker run --rm -it worktree-demo
🐳 root ~/projects/my-blog # git status
On branch feature/user-notifications
Changes not staged for commit:
modified:   src/api/notifications.ts
modified:   src/components/Notifications.tsx
```

That's step 1 below, already set up. Now run the rest of the scenario for real, exactly as
written — `git worktree add -b hotfix/invoice-fix ../my-blog-hotfix main`, fix
`src/billing/invoice.ts` in the new folder, commit, `git worktree remove`, and confirm with `git
status` that your feature branch never moved. `gwt` is wired too, if you want to try the fzf
navigator from the "ZSH function" section further down.

> **Run step 4's commands one at a time:**
>
> Don't paste the whole block from step 4 in one shot: `vim` is a full-screen editor, so anything
> you paste after `vim src/billing/invoice.ts` gets typed *into vim* instead of run as shell
> commands. Run that line alone, make the edit (see the tip in step 4), save and quit, then paste
> the rest. `git push` works in this container too — the image wires a local bare repo as `origin`
> so there's something real to push to, no GitHub account needed.

## The hotfix scenario, step by step

Back to Thursday afternoon. Here's the same situation handled with worktrees.

### 1. You're on your feature branch, in the middle of work

```bash
// Current state
$ git branch
* feature/user-notifications
main
$ git status
On branch feature/user-notifications
Changes not staged for commit:
modified: src/components/Notifications.tsx
modified: src/api/notifications.ts
```

You don't touch this. You don't stash anything.

### 2. Create a hotfix worktree next to your project

```bash
git worktree add -b hotfix/invoice-fix ../my-blog-hotfix main
```

```bash
Preparing worktree (new branch 'hotfix/invoice-fix')
HEAD is now at a4b2c1d chore: update dependencies
```

### 3. Open a new terminal, cd into the hotfix folder

```bash
cd ../my-blog-hotfix
```

This directory has `main`'s files. Your feature branch directory is untouched, still running, still open in your editor.

### 4. Fix the bug and push

```bash
// Inside ../my-blog-hotfix
vim src/billing/invoice.ts
git add src/billing/invoice.ts
git commit -m "fix: correct VAT calculation for EU invoices"
git push origin hotfix/invoice-fix
// open PR, merge, done
```

> **The actual VAT fix:**
>
> `calculateVat()` returns `amount * rate` — just the VAT portion, not the invoice total. In vim:
> press `i`, change the line to `return amount * (1 + rate);`, then `Esc` followed by `:wq` to save
> and exit. Only then run the `git add`/`commit`/`push` lines.

### 5. Clean up the worktree

```bash
// Back in the original repo
git worktree remove ../my-blog-hotfix
git branch -D hotfix/invoice-fix
```

> **Why -D and not -d:**
>
> You're still on `feature/user-notifications` here, which never received the hotfix commit — only
> `origin/hotfix/invoice-fix` did, via the merged PR. `git branch -d` refuses to delete a branch it
> can't see as merged into your *current* branch, and errors with "not fully merged". Since you know
> it's safely merged on the remote, `-D` (force) is the correct call, not a workaround for a mistake.

### 6. Back to your feature branch — nothing changed

```bash
cd ~/projects/my-blog
// Docker containers: still running
// Editor: still open on the same files
//git status: exactly as you left it
```

No stash, no conflicts, no context switch.

## One constraint: a branch can only be in one worktree at a time

Git enforces this. If `feature/user-notifications` is checked out in your main directory, you cannot add a second worktree with the same branch. Attempting it gives:

```bash
fatal: 'feature/user-notifications' is already checked out at '/home/christophe/projects/my-blog'
```

This is intentional — two directories writing to the same branch simultaneously would corrupt the working tree. Each worktree gets its own branch.

## Docker + worktrees: each branch gets its own stack

This is where worktrees become genuinely powerful for a Docker workflow.

Each worktree is a separate directory. Each directory can have its own `.env` file and its own running Docker Compose stack, as long as you avoid port conflicts.

A practical convention: use a different port per worktree. Keep the ports in `.env`, which is `.gitignore`d:

```bash
// ~/projects/my-blog/.env
APP_PORT=3000
DB_PORT=5432
// ~/projects/my-blog-hotfix/.env
APP_PORT=3001
DB_PORT=5433
```

Now you can run both stacks simultaneously:

```bash
// Terminal 1 — feature branch
cd ~/projects/my-blog
docker compose up
// Terminal 2 — hotfix branch
cd ~/projects/my-blog-hotfix
docker compose up
```

Both servers are running at the same time. You can test the hotfix at `http://localhost:3001` without stopping your feature development at `http://localhost:3000`.

> **Named Compose projects:**
>
> Docker Compose uses the folder name as the default project name. Since your worktrees are in different folders, Compose creates separate networks and container sets automatically — no additional configuration needed.

## A ZSH function to navigate between worktrees

If you follow the [modular ZSH workflow](/blog/modular-zsh-workflow) pattern, you can add an interactive worktree navigator using [fzf](/blog/linux-fzf-introduction):

**gwt.zsh**

```bash
# ~/.zsh/fns/gwt
# git worktree navigator — select a worktree with fzf and cd into it

gwt() {
  local worktrees selected dir

  worktrees=$(git worktree list 2>/dev/null) || {
    echo "Not inside a git repository."
    return 1
  }

  if [[ -z "$1" ]]; then
    # Interactive: pick a worktree with fzf
    selected=$(echo "$worktrees" \
      | fzf --prompt="Worktree > " \
            --preview="ls -la {1}" \
            --height=40%)
    [[ -z "$selected" ]] && return 0
    dir=$(echo "$selected" | awk '{print $1}')
  else
    # Direct: create a new worktree for the given branch
    local branch="$1"
    dir="${$(git rev-parse --show-toplevel)}-${branch//\//-}"
    git worktree add -b "$branch" "$dir" main || return 1
    echo "Created worktree at $dir"
  fi

  cd "$dir"
}
```

Usage:

```bash
gwt                         # fzf picker — select any active worktree and cd into it
gwt feature/dark-mode       # create + cd into a new worktree for that branch
```

## Practical tips

### Name worktrees consistently

A predictable naming scheme avoids confusion:

```bash
# Pattern: <repo-name>-<branch-slug>
git worktree add ../my-blog-hotfix-invoice hotfix/invoice-fix
git worktree add ../my-blog-feat-auth      feature/auth
```

### Worktrees work with VS Code

Open a worktree folder in a new VS Code window:

```bash
$ code ../my-blog-hotfix
```

Each window operates independently, with its own open files and terminal sessions.

### Prune stale worktrees

If you manually delete a worktree folder (instead of using `git worktree remove`), git may still think it exists. Clean up the stale references:

```bash
git worktree prune
```

## Conclusion

|                      | `git stash`      | `git worktree`           |
| -------------------- | ---------------- | ------------------------ |
| Branch switch        | Yes, required    | No — branches coexist    |
| Docker containers    | Must restart     | Run independently        |
| Editor context       | Lost             | Preserved                |
| Parallel development | No               | Yes                      |
| Conflict risk        | On `stash pop`   | None                     |
| Cleanup              | Automatic on pop | Manual `worktree remove` |

Stash remains useful for tiny, quick things — saving half a line of typing before a `git pull`. For anything that involves running services, an editor session, or more than five minutes of work, a worktree is cleaner.

The first time you handle a hotfix without leaving your feature branch, you'll wonder how you ever worked any other way.
