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
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
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.
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
I wanted a development database belonging to feature/login.
If I switched back to:
main
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.
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.
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
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
Brunch stops the environment belonging to main and starts the one belonging to feature/login.
Later:
git switch main
and the previous environment comes back, including its persistent volumes.
The source code follows Git.
Now the development environment can follow it too.
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.
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]
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.
Git worktrees let several branches be checked out simultaneously:
~/my-app
~/my-app-login
~/my-app-payments
Now all three applications may need to run at the same time.
They cannot all bind to:
localhost:3000
So Brunch separates two concepts:
The environment belongs to the branch.
The host port belongs to the worktree.
worktree 1
localhost:3000
βββ main
βββ feature/search
worktree 2
localhost:3001
βββ feature/login
worktree 3
localhost:3002
βββ feature/payments
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
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.
Brunch takes care of making the worktrees reachable on different host ports.
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
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
That is the model Brunch is built around.
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
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
A minimal brunch.yml can be as small as:
compose_file: compose.yaml
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:
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
Check that everything is configured correctly:
brunch doctor
Then activate the environment for the branch you are currently on:
brunch activate
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
list environments grouped by worktree:
brunch list
and print the port assigned to the current worktree:
brunch port
Docker Compose is the default environment manager, and Brunch also supports Podman Compose.
But the core idea is not:
branch β Docker container
It is:
branch β development environment
For example, Brunch can manage a local process:
manager: local_process
command: bin/dev
or project-specific commands:
manager: command
commands:
create: bin/environment create
start: bin/environment start
stop: bin/environment stop
remove: bin/environment remove
Containers are convenient because they already provide strong isolation, but they are not required by the model.
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
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
I want:
git switch feature/login
Git already knows that the context changed.
Brunch reacts to that change.
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 ...
as a frontend for:
git worktree ...
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
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
The wrapper works, but I would rather not need it.
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 ...
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:
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.
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.