DEV Community

Cover image for Brunch Gem: Isolated Development Environments for Git Branches and Worktrees
Maciej Ciemborowicz
Maciej Ciemborowicz

Posted on

Brunch Gem: Isolated Development Environments for Git Branches and Worktrees

Switching Git branches changes your code instantly.

It usually doesn't change your development environment.

You may switch from main to a feature branch with a different database schema, different seed data, or different services running in the background. Then you switch back — and the source code follows Git, while the rest of the environment is still whatever the previous branch left behind.

With worktrees, it gets even more interesting.

You may have several branches checked out at the same time:

~/my-app
~/my-app-login
~/my-app-payments
Enter fullscreen mode Exit fullscreen mode

Now each of them may need its own database, containers, persistent state, and a different host port.

This becomes especially relevant when IDEs or coding agents use worktrees to work on several tasks in parallel.

I wanted the development environment to follow Git:

branch
    ↓
isolated environment
Enter fullscreen mode Exit fullscreen mode

Switch the branch, switch the environment.

Create another worktree, give it another port and let both environments run in parallel.

Come back to a branch later, and get its previous state back.

That is what Brunch does.

And the idea behind it started in 2018.

The idea started in 2018

Back then, the idea was much smaller.

I wanted every feature branch in a Rails application to have its own database.

If I created:

feature/login
Enter fullscreen mode Exit fullscreen mode

I wanted a development database belonging to feature/login.

If I switched back to:

main
Enter fullscreen mode Exit fullscreen mode

I wanted the database belonging to main again.

The difficult part turned out not to be creating databases.

It was knowing when Git had created, deleted, or switched a branch.

At the time, I couldn't find a reliable way to do that without wrapping Git commands.

And a tool that only works when everyone remembers to use a custom Git wrapper wasn't the solution I wanted.

So I reserved the brunch name on RubyGems and abandoned the idea.

For eight years.

The missing piece

When I returned to the problem, Git had a lower-level hook called reference-transaction.

That eventually became another project of mine, git-hooks-ext.

It translates low-level Git changes into semantic lifecycle events that other tools can react to.

I wrote about the implementation in a separate article:

Git Hooks Ext: The Missing Git Callbacks for Reference Transactions

That article covers the Git internals, edge cases, worktrees, and the limits of what Git currently exposes.

For Brunch, the important part is simpler:

Git repository changes can now become lifecycle events.

Brunch attaches development environments to those events.

The project I had abandoned in 2018 finally became practical.

What Brunch does

Brunch runs isolated development environments for Git branches and worktrees.

The simplest model is:

main
    ↓
environment A

feature/login
    ↓
environment B

feature/payments
    ↓
environment C
Enter fullscreen mode Exit fullscreen mode

With Docker Compose, each branch gets its own Compose project, containers, network, and named volumes.

Suppose I am on main.

Its environment is running.

Then I switch:

git switch feature/login
Enter fullscreen mode Exit fullscreen mode

Brunch stops the environment belonging to main and starts the one belonging to feature/login.

Later:

git switch main
Enter fullscreen mode Exit fullscreen mode

and the previous environment comes back, including its persistent volumes.

The source code follows Git.

Now the development environment can follow it too.

Why branch isolation matters

Imagine a Rails application where a feature branch contains new migrations.

You switch to it, run the migrations, change some data, and work on the feature.

Then you switch back to main.

Your source code is back on main.

Your database isn't.

Sometimes that is fine.

Sometimes the branch has destructive migrations, incompatible schema changes, different seed data, different service versions, or state in Redis, queues, search indexes, or other infrastructure.

Git isolates the files.

It doesn't isolate the runtime state surrounding them.

Brunch makes that state part of the branch environment.

One worktree is the easy case

With a single Git worktree, only one branch can be checked out there at a time.

That means branches can reuse the same host port.

For example:

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

Switch branches and the active environment changes.

The port does not have to.

For the problem I originally had in 2018, that would already have been enough.

But worktrees make things more interesting.

Worktrees change the problem

Git worktrees let several branches be checked out simultaneously:

~/my-app
~/my-app-login
~/my-app-payments
Enter fullscreen mode Exit fullscreen mode

Now all three applications may need to run at the same time.

They cannot all bind to:

localhost:3000
Enter fullscreen mode Exit fullscreen mode

So Brunch separates two concepts:

The environment belongs to the branch.

The host port belongs to the worktree.

For example:

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

worktree 2
localhost:3001
└── feature/login

worktree 3
localhost:3002
└── feature/payments
Enter fullscreen mode Exit fullscreen mode

In the first worktree, I can switch between main and feature/search.

Both can use port 3000, because only one can be active there at a time.

Meanwhile, the other two worktrees can run simultaneously on ports 3001 and 3002.

Brunch exposes the assigned port through:

BRUNCH_PORT
Enter fullscreen mode Exit fullscreen mode

so Docker Compose can use it like this:

services:
  web:
    ports:
      - "127.0.0.1:${BRUNCH_PORT}:3000"
Enter fullscreen mode Exit fullscreen mode

Rails can keep listening on port 3000 inside the container.

Brunch takes care of making the worktrees reachable on different host ports.

This becomes useful with coding agents

Worktrees have been around for a long time, but they are becoming more interesting as IDEs and coding agents use them for parallel work.

Imagine:

agent A → feature/auth
agent B → feature/search
agent C → fix/payments
Enter fullscreen mode Exit fullscreen mode

Git already gives each task its own working tree.

But if all three tasks share the same database, containers, volumes, Redis instance, or port, the isolation is incomplete.

One agent can migrate a database while another is using it.

One can restart a service needed by another.

Two application servers can compete for the same port.

For parallel development, the useful unit of isolation becomes:

worktree
+
branch
+
environment
+
persistent state
+
host port
Enter fullscreen mode Exit fullscreen mode

That is the model Brunch is built around.

Installing Brunch

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

Then install Brunch:

gem install brunch
Enter fullscreen mode Exit fullscreen mode

Brunch runs on the host, so it doesn't need to be added to your application's Gemfile.

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

Dockerfile.dev
compose.yaml
brunch.yml
Enter fullscreen mode Exit fullscreen mode

A minimal brunch.yml can be as small as:

compose_file: compose.yaml
Enter fullscreen mode Exit fullscreen mode

A simple Compose configuration may look like this:

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:
Enter fullscreen mode Exit fullscreen mode

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.

If the application uses PostgreSQL instead of SQLite, the same principle applies: the database service and its volume live inside the branch-specific Compose project.

Once the configuration is committed, install the hooks:

brunch install
Enter fullscreen mode Exit fullscreen mode

Check that everything is configured correctly:

brunch doctor
Enter fullscreen mode Exit fullscreen mode

Then activate the environment for the branch you are currently on:

brunch activate
Enter fullscreen mode Exit fullscreen mode

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

After that, normal branch changes can drive the environment lifecycle.

You can inspect the current state with:

brunch status
Enter fullscreen mode Exit fullscreen mode

list environments grouped by worktree:

brunch list
Enter fullscreen mode Exit fullscreen mode

and print the port assigned to the current worktree:

brunch port
Enter fullscreen mode Exit fullscreen mode

Docker is not the abstraction

Docker Compose is the default environment manager, and Brunch also supports Podman Compose.

But the core idea is not:

branch → Docker container
Enter fullscreen mode Exit fullscreen mode

It is:

branch → development environment
Enter fullscreen mode Exit fullscreen mode

For example, Brunch can manage a local process:

manager: local_process
command: bin/dev
Enter fullscreen mode Exit fullscreen mode

or project-specific commands:

manager: command

commands:
  create: bin/environment create
  start: bin/environment start
  stop: bin/environment stop
  remove: bin/environment remove
Enter fullscreen mode Exit fullscreen mode

Containers are convenient because they already provide strong isolation, but they are not required by the model.

Why not just use Docker Compose manually?

You can.

Brunch does not make anything possible that Docker Compose fundamentally could not do before.

You can manually create separate Compose projects, volumes, and ports.

You can also manually stop one environment and start another every time you change branches:

git switch feature/login
docker compose -p feature-login up -d
Enter fullscreen mode Exit fullscreen mode

The interesting part is making the lifecycle follow Git automatically.

I don't want changing context to mean:

switch Git branch
remember the previous environment
stop it
choose the next project
choose its port
start it
Enter fullscreen mode Exit fullscreen mode

I want:

git switch feature/login
Enter fullscreen mode Exit fullscreen mode

Git already knows that the context changed.

Brunch reacts to that change.

Worktrees have one important limitation

Branches can be observed through Git's reference machinery.

Worktrees cannot.

Git currently has no native lifecycle hooks for operations such as creating, removing, moving, locking, or unlocking a worktree.

For now, git-hooks-ext provides:

ghe worktree ...
Enter fullscreen mode Exit fullscreen mode

as a frontend for:

git worktree ...
Enter fullscreen mode Exit fullscreen mode

The details of how that works are covered in my git-hooks-ext article.

For Brunch, the practical consequence is simple:

If you want automatic worktree lifecycle handling today, use:

ghe worktree add -b feature/login ../feature-login
Enter fullscreen mode Exit fullscreen mode

instead of plain git worktree add.

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

cd ../feature-login
brunch activate
Enter fullscreen mode Exit fullscreen mode

The wrapper works, but I would rather not need 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 any other tool could use ordinary:

git worktree add ...
Enter fullscreen mode Exit fullscreen mode

and Git could notify interested tools that the worktree was created.

No wrapper would be necessary.

I have started an RFC discussion about adding worktree lifecycle hooks to Git.

If this would be useful in your workflow, please consider joining the discussion:

Worktree hooks RFC discussion

The most useful contribution is not just a +1.

If your workflow would benefit from native worktree lifecycle hooks, describe the use case. If you have thoughts about the proposed interface, edge cases, or how such hooks should behave, add that feedback as well.

For this kind of change, it is useful to show why the functionality belongs in Git itself instead of another wrapper around git worktree.

For Brunch, native hooks would mean that IDEs and coding agents would not have to know that Brunch exists.

They could use Git normally.

Git would report what happened.

Brunch would react.

That is the main limitation I would like to remove from the current workflow.

Eight years later

I reserved the RubyGems name for Brunch in 2018 because I wanted separate development databases for feature branches.

Eight years later, the idea became broader.

A branch may imply not only different source code, but also a different database schema, data, services, volumes, and runtime configuration.

And with worktrees and parallel coding agents, several of those environments may need to exist at the same time.

The difficult part was never creating another container.

It was connecting the lifecycle of the environment to the lifecycle of Git.

git-hooks-ext provided that missing event layer.

Brunch finally gives those events something useful to manage.

Eight years after reserving the name, I can switch a Git branch and have the development environment switch with it.

Links

Top comments (0)