Maciej Ciemborowicz

Hello, my name is Maciej Ciemborowicz.

This will be my monument.

Brunch: Isolated Development Environments for Git Branches and Worktrees

Introduction

Switching branches and worktrees in Git changes the code we're working on. This allows us to work on multiple things at the same time. But what it doesn't switch is the environment.

Feature branches, bug-fix branches, and branches used for code review or refactoring often have different migrations. Different seeds. Different data that we create to test our solutions. They may also have different background jobs or different gems in their Gemfiles.

In general, branches and worktrees should have separate environments.

When working with Ruby on Rails, we already have three environments by default: development, test, and production. But what we really need is a separate environment for each branch.

That's exactly what the Brunch gem provides.

It not only lets us manage these environments, but also ties them to Git using hooks.

Brunch on GitHub

The idea dates back to 2018

Back then, the scope was much narrower. I wanted to create a gem that would isolate databases, creating a separate database for each branch, so I wouldn't have to keep dropping and recreating the database every time I switched branches.

I reserved the name Brunch on RubyGems.

The problem, however, was that Git lacked the necessary hooks. Of course, I could have built a wrapper around Git, but I decided that such a solution was unlikely to catch on.

So I abandoned the idea. For eight years.

This year, I discovered that Git has had a reference-transaction hook since 2020, and that it could be used for this purpose. To make it work, I also had to create git-hooks-ext, which I described in a separate article.

The idea was back.

What does Brunch do?

Brunch runs isolated environments for Git branches and worktrees. It connects them to Git hooks so that environment lifecycles can follow branch changes automatically.

Each worktree is assigned a port on which the application runs. Within a single worktree, that port is used by whichever branch is currently active.

Let's take a situation where we have just one worktree:

worktree
│
│ localhost:3000
│
├── main               [running]
├── feature/login      [stopped]
└── feature/payments   [stopped]

Containers are automatically created and removed when branches are created and deleted. When switching branches, the container belonging to the previously active branch is stopped, and the container for the branch we're switching to is started.

With multiple worktrees, things get a little more complex because several branches can be active at the same time.

Among other things, this allows multiple AI coding agents to work on different branches in parallel.

That's why each worktree gets its own port:

worktree 1
localhost:3000
├── main
└── feature/search

worktree 2
localhost:3001
└── feature/login

worktree 3
localhost:3002
└── feature/payments

Brunch exposes the assigned port through BRUNCH_PORT, so Docker Compose can use it like this:

services:
  web:
    ports:
      - "127.0.0.1:${BRUNCH_PORT}:3000"

Rails can keep listening on port 3000 inside the container, and Brunch takes care of making the worktrees reachable on different host ports.

Installing Brunch

Brunch relies on git-hooks-ext, so install that first and make sure ghe is available on your PATH.

Then install Brunch:

gem install brunch

Brunch doesn't need to be added to your application's Gemfile, although you can also add it to the development group.

For a Rails project using Docker Compose, you typically add:

  • Dockerfile.dev
  • compose.yaml
  • brunch.yml

A minimal brunch.yml:

compose_file: compose.yaml

Here's an example configuration for Ruby on Rails:

services:
  web:
    build:
      context: .
      dockerfile: Dockerfile.dev

    command: >
      sh -c 'bin/rails db:prepare &&
      exec bin/rails server -b 0.0.0.0 -p 3000'

    volumes:
      - .:/rails
      - storage:/rails/storage

    ports:
      - "127.0.0.1:${BRUNCH_PORT}:3000"

volumes:
  storage:

The bind mount exposes the current worktree to Rails. The named volume belongs to the branch-specific Compose project. And BRUNCH_PORT maps the application to the port assigned to the worktree.

Once you've committed the configuration, install the hooks, check that everything is configured correctly, and then activate the environment for the branch you're currently on:

brunch install
brunch doctor
brunch activate

Activation is needed because installing the hooks does not itself trigger a Git lifecycle event.

Now you can inspect the current state, list Brunch environments, or print the port assigned to the current worktree:

brunch status
brunch list
brunch port

Worktrees must use a wrapper

Branches can be observed through Git's reference machinery, but Git still has no native lifecycle hooks for worktree operations.

For now, git-hooks-ext provides a wrapper: ghe worktree ....

Instead of plain git worktree add, use:

ghe worktree add -b feature/login ../feature-login

If another tool creates the worktree directly, Brunch can still be activated manually:

cd ../feature-login
brunch activate

The wrapper works, but I'd rather rely entirely on Git hooks without having to use it.

The missing piece belongs in Git

The clean solution is for Git itself to expose worktree lifecycle hooks. Then IDEs, coding agents, Brunch, and other tools could simply use git worktree add. No wrapper would be necessary.

There is an ongoing RFC discussion about adding worktree lifecycle hooks to Git. If this would be useful in your workflow, please join the discussion:

Worktree hooks RFC discussion