Patrick Reagan

articles projects résumé

Git Worktrees as Swimlanes

Git worktrees let you have multiple branches checked out at the same time, which is genuinely useful — but the advice you usually hear (“one worktree per feature branch”) leaves you juggling a pile of ephemeral directories, each one missing the .env file, the node_modules, or the install step that makes it runnable. That friction keeps most people on a single checkout.

Here’s a different way to use worktrees: as persistent roles, not per-feature containers.

The Mental Flip

A worktree is a directory with a role. The directory stays put; whichever branch I need rotates through it.

I open each one in its own VS Code window, so switching swimlanes is a window switch. Once I’m in a swimlane, I pick the branch with git checkout.

That’s it. The rest of this post is about how to make that comfortable in practice.

The Swimlanes I Use

I keep four worktrees around at all times for any repo I spend real time in:

Swimlane Role
development Stays on the development branch. Used to run the latest version of the app and as the base I branch off.
work-queue Where I create new branches for each ticket I work on.
review Where I check out whatever branch is currently under review.
experiments Longer-running spikes that may or may not produce commits.

development and experiments are stable: the branch checked out in each one rarely changes. work-queue and review are different — the directory is stable, but the branch checked out inside it rotates constantly.

Set them up once. Clone your repo into a directory you’ll treat as the main checkout — I name mine after the default branch (development), but any name works. Then add the rest as siblings:

$ git clone <repo-url> ~/Projects/<project>/<main-checkout>
$ cd ~/Projects/<project>/<main-checkout>
$ git worktree add ../work-queue
$ git worktree add ../review
$ git worktree add ../experiments

Without a branch argument, git worktree add creates a new branch named after the directory (so ../work-queue gets a branch called work-queue) off HEAD. I never commit to these — they exist only so the worktree has some branch to initialize with. The real work happens on branches I check out into the directory later.

Rotating Branches Through a Directory

When a new task comes in, I switch to my work-queue window and check out a fresh branch off development:

$ git fetch origin
$ git checkout -b TICKET-1234-fix-login origin/development

That’s the whole ceremony. The directory doesn’t care what branch it hosts. When the work is done and the PR is opened, I leave the branch where it is and move on to the next one — git checkout -b TICKET-1235-... — and the previous branch keeps living on the remote.

review works the same way, but the branches it hosts already exist. When a PR lands in my review queue, I switch to the review window and pull it in:

$ git fetch origin
$ git checkout TICKET-1234-fix-login

I review in the directory, make any fixup commits directly, push, and when I’m done I rotate to the next PR. No new worktrees to create, no old ones to clean up.

In practice I let an agent run most of these commands for me — the mechanics are identical, just wrapped in a slash command. The next post in this series covers that layer.

Solving the Untracked-File Tax

The real reason worktrees feel painful is untracked files. A new worktree is a fresh checkout — no .env, no installed dependencies, no local config. Before you can run anything, you’re copying files by hand or re-running setup scripts.

I had Claude Code write a small git subcommand called git worktree-copy that handles this. It wraps git worktree add and, after the new worktree is created, reads a .worktree-copy file from the repo root to decide what to copy or run. The config is plain text:

# Files and directories to copy from the source checkout
.env
.env.local
config/master.key

# Lines prefixed with ! are shell commands, run inside the new worktree
! bundle install
! pnpm install

Each entry is either a path to copy over from the source checkout, a ! prefix that runs the rest of the line as a shell command in the new worktree, or a comment. I use it like this:

$ git worktree-copy ../work-queue

Everything after the path is passed straight through to git worktree add, so any flag that works there works here too. See the gist for the full script.

Shared Caches, Separate Databases

Two other gotchas, both about state that lives alongside the worktree rather than inside it.

Node dependencies. Copying node_modules across worktrees burns disk and install time quickly. If your project is on pnpm, this is a non-issue — packages land once in a global content-addressable store and get symlinked into each worktree’s node_modules, so a fresh worktree costs almost no disk. If you’re still on npm or yarn, multiplying node_modules across worktrees is a reasonable excuse to switch.

Rails + Postgres databases. If your worktrees share a database, they’ll step on each other’s schema migrations and data. I parameterize config/database.yml so the database name derives from the worktree’s directory, with an environment variable escape hatch when I want to override it:

<%
  database_base_name = ENV.fetch("DATABASE_BASE_NAME") do
    "myapp_#{Rails.root.basename.to_s.gsub(/\W+/, "_")}"
  end
%>

development:
  database: <%= database_base_name %>

Each worktree now gets its own database keyed to its directory name. The gsub scrubs dashes and other non-word characters so Postgres identifiers stay clean, and setting DATABASE_BASE_NAME (in .env or on the CLI) overrides the auto-inferred name when I want to point two worktrees at a shared database intentionally. SQLite doesn’t need any of this — the database file lives in the repo and gets its own copy per worktree naturally.

A Note on Branch Naming

Because the work-queue directory hosts many branches over its lifetime, the branch names need to be self-descriptive. I prefix every branch with the ticket ID from my tracker (for example, TICKET-1234-fix-login). That way git branch --list and gh pr list both tell me what I have in flight without me having to remember which branch was about what. It also makes the next post in this series — where I let Claude Code drive the pipeline — possible, because the agent can match branches back to tickets without guessing.

When Not to Use This

Worktrees-as-swimlanes earns its keep when:

  • You regularly have more than one branch in flight.
  • Your setup tax (install, migrate, configure) is non-trivial — enough that a blank checkout is painful.
  • You switch contexts often: feature work → review → hotfix → back to feature.

It is overkill when the repo is small enough that a single checkout and the occasional git stash is fine, or when branches are short-lived enough that they merge before the overhead of setting up a worktree pays off.

What’s Next

This setup is worth the effort on its own if you’ve ever lost ten minutes to rebuilding a checkout after a context switch. But the real reason I landed on it is that it lets me pipeline work with an AI agent — one swimlane executing on the next ticket while I’m reviewing the previous one in another. That’s the subject of the next post.