You're three files deep into a feature. A "quick" bug report lands. You git stash,
switch branches, fix it, switch back, git stash pop… and now your dev server is
rebuilding, your containers restarted, and you've lost your train of thought.
There's a better way, and it's been hiding in git this whole time: worktrees.
I got tired of the friction, so I wrapped the workflow in a tiny zsh tool called
gwt. This post is about the idea (which you can steal even without the tool) and
the few tricks that make it actually pleasant to use day to day.
The idea: one folder per branch
A git worktree lets a single repository have multiple working directories checked out
at once — different branch in each. No stashing, no switching. Each branch is a real
folder you can open in its own editor window and run independently.
I take it one step further with the bare-clone pattern: instead of a normal clone,
you clone the repo bare (just the .git database, no working tree), and every branch
becomes a sibling worktree next to it:
~/projects/myapp/
├── myapp.git/ ← bare repo (the git database)
├── myapp-staging/ ← worktree on "staging"
├── myapp-feature-login/ ← worktree on "feature/login"
└── myapp-hotfix-123/ ← worktree on the hotfix
Want to look at the hotfix while your feature keeps running? Just cd into the other
folder. They don't know about each other. Nothing rebuilds.
The killer trick: share dependencies with symlinks
Here's the catch everyone hits first: if each branch is a full checkout, do you
composer install / npm install in every folder? That's gigabytes and minutes per
branch. No thanks.
The fix: pick one worktree as the reference (say myapp-staging), install
dependencies there once, and symlink them into every other worktree:
myapp-feature-login/
├── vendor -> ../myapp-staging/vendor
├── node_modules -> ../myapp-staging/node_modules
└── ...your branch's actual source...
Now a new branch is ready in seconds and costs almost no disk. If a particular
branch changes its dependencies, you just install real ones in that folder and it
overrides the symlink.
This one decision is what makes the whole pattern practical.
What the tool does
gwt automates the boring parts. Setup is once per project:
mkdir -p ~/projects/myapp && cd $_
gwt init # clone bare, create the reference worktree, install deps, set up .env
After that, spinning up a branch is one command:
gwt feature/login # check out an existing branch into its own folder
gwt my-experiment staging # create a new branch off "staging" and check it out
gwt list # see all worktrees
gwt remove my-experiment # tear it down (symlinks + dev server + folder)
Each gwt <branch> adds the worktree, symlinks the shared deps, copies .env, and —
for Laravel projects — even links a local Herd site with TLS. A new fully-working
environment in a couple of seconds.
Three design choices I'm happy with
1. Zero global config — context comes from where you are
There's no config file to keep in sync. The tool figures out which project you're in by
walking up from your current directory (git rev-parse --git-common-dir). The only two
per-project settings — project type and reference branch — are stored in the bare
repo's own git config:
git config gwt.type laravel
git config gwt.ref staging
So it travels with the repo, and you can use the tool across any number of projects with
nothing shared between them. cd into a project, and it just works.
2. Project types are pluggable "drivers"
Different stacks need different things: Laravel wants composer + yarn + a Herd site;
Node just wants npm/yarn/pnpm; Python wants a venv. Instead of if/else sprinkled
everywhere, each type is a set of small functions and the core flow calls them blindly:
_gwt_rust_ref_default() { echo "main"; }
_gwt_rust_dep_dirs() { echo "target"; } # what to share via symlink
_gwt_rust_install_deps() { local dir="$1"; ( cd "$dir" && cargo fetch ); }
Adding a new language is just adding a block like that. Anything a type doesn't define
falls back to a no-op default.
3. A --test mode that previews everything
Destructive-ish operations make me nervous, so every command takes --test (a dry run)
that prints exactly what it would do without touching anything:
gwt init --test
gwt remove old-branch --test
Great for "wait, where is this going to put things?" moments.
Try it, tweak it
It's a single ~200-line zsh file, no dependencies beyond git and your stack's usual
tools. Source it from ~/.zshrc and run gwt -h.
If you live in one repo and constantly context-switch between branches, give worktrees a
shot — even by hand, without the tool. Once you stop stashing, you won't go back.
Happy to hear how others manage parallel branches — drop a comment. 👇
Top comments (0)