The problem
You're halfway through a feature. The dev server is running, six files are modified, and your head is full of the exact shape of the change. Then a review request lands in your queue and it's blocking someone. While you're looking at that, a question comes in about how the app behaves in production, which means main.
Two interruptions. Neither one is the expensive part.
The expensive part is that to answer either of them, you have to formally put your current work down. On a normal single-checkout clone, every switch costs the same tax:
- Stash your changes, or make a throwaway "WIP" commit you'll unwind later.
git checkoutthe other branch.- Watch your dev server fall over as half the files change underneath it.
- Maybe
npm ci, because the lockfile is different over there. - Do the actual thing.
- Reverse all of it, and try to remember what you were thinking twenty minutes ago.
The killer isn't any single step. It's that the mental state, the thing you were actually holding in your head, gets thrown away every time. Stash and checkout don't preserve it. They can't.
Here's the dream, and it's the whole point of this post: you never put the work down again. Every branch you care about is its own folder, fully checked out, with its own node_modules and its own editor window. Switching context is cd. Coming back is cd back, and everything is exactly, byte for byte, where you left it. Dirty files still dirty, build output still in place, editor exactly as you left it.
Git has supported this since 2015. It's called worktrees. Most people have just never wired it up properly. Let's wire it up properly.
A few terms, so we're speaking the same language
Working tree. The actual files on disk you edit. A normal clone has exactly one.
Worktree. An additional working tree attached to the same repository. Same history, same objects, different folder, different checked-out branch.
Bare repository. A repo with no working tree at all. Just the git database, the stuff that normally hides inside .git/. You can't edit it directly, which is exactly what we want. It becomes the shared engine every worktree plugs into.
Detached HEAD. When you're sitting on a raw commit with no branch. Commits you make there are easy to lose. One of our rules exists purely to prevent this.
One rule worth knowing up front: git will not let two worktrees check out the same branch at once. That sounds like a limitation. It's actually the safety feature that makes the whole model sane.
The layout
Everything lives in one container folder. Inside it, one hidden folder holds the entire git database, and every branch you're actively touching is a plain, visible folder next to it.
~/work/web/ <- the container (NOT a worktree itself)
├── .bare/ <- the entire git database. Never edit here.
├── .git <- a one-line text file pointing at .bare
├── main/ <- worktree: production branch
├── develop/ <- worktree: integration branch
├── feature-PROJ-1234/ <- worktree: a feature you're authoring
├── feature-PROJ-1290/ <- worktree: another feature, in parallel
└── pr-812/ <- worktree: someone's PR you're reviewing
One database, many simultaneous checkouts, all peers. There's no "primary" checkout the others hang off of, and that's deliberate. No branch is special, so there's nothing to protect or tiptoe around. Deleting a worktree folder never threatens the repo, because the history lives in .bare/. And every worktree is a complete, normal-feeling project folder. Any of them can run the app; none of them needs a checkout first.
The container root itself is not a worktree. Don't put source there. It holds .bare/, the .git pointer, the worktree folders, and the occasional local note. That's it.
That .git file at the root, a file, not a folder, contains exactly one line:
gitdir: ./.bare
It exists so that running git from the container root works at all. Handy for git worktree list, git fetch, and any tooling you point at the whole container.
Build it once
Six steps. Copy-paste friendly. The whole thing takes about two minutes plus however long npm takes.
1. Clone bare into a hidden folder.
mkdir ~/work/web && cd ~/work/web
git clone --bare git@github.com:you/web.git .bare
--bare means "give me the database, skip the checkout." This is the engine.
2. Drop the pointer file.
echo "gitdir: ./.bare" > .git
Now git commands work from the container root.
3. Fix the fetch config.
git config remote.origin.fetch "+refs/heads/*:refs/remotes/origin/*"
git fetch origin
This one matters and nobody tells you about it. A bare clone doesn't create origin/* remote-tracking refs the way a normal clone does. Without this fix, half the recipes below have nothing to point at. Run it once, forget it forever.
4. Add worktrees for your permanent branches.
git worktree add ./main main
git worktree add ./develop develop
main/ and develop/ are now permanent residents. They stay forever. You git pull inside them to keep them fresh, and they're what you diff against, branch from, and sanity-check "what does prod actually do" in.
5. Set up upstream tracking, once, for every branch.
The second bare-clone gotcha: the branches that came with the clone don't know they track origin. Symptom: git pull says "There is no tracking information for the current branch." This one-liner fixes all of them at once and is safe to run any time:
for b in $(git for-each-ref --format='%(refname:short)' refs/heads/); do
[ -n "$(git config --get "branch.${b}.remote")" ] && continue
git show-ref --verify --quiet "refs/remotes/origin/$b" && git branch --set-upstream-to="origin/$b" "$b"
done
It walks every local branch, skips the ones already tracking, and wires up any that have an origin/ counterpart. Idempotent, so no harm running it twice.
6. Install dependencies per worktree.
(cd main && npm ci)
(cd develop && npm ci)
Yes, each worktree gets its own node_modules. That's the price of admission: disk space. What you buy with it is that you never reinstall dependencies because of a branch switch again. Disk is cheap. Your train of thought is not.
Done. Everything from here is daily usage.
Two kinds of worktree
Worktrees come in two flavors, and it's worth being deliberate about which you're making.
Flavor one: features you're authoring. These are yours. Branch off develop, name the folder after the ticket, expect it to live for days or weeks:
git worktree add -b feature/PROJ-1234 ./feature-PROJ-1234 develop
cd feature-PROJ-1234 && npm ci
First push needs -u, since the branch doesn't exist on the remote yet:
git push -u origin feature/PROJ-1234
After that, plain git push and git pull work forever.
Flavor two: PR reviews. These are disposable. Name them after the PR number so you can tell at a glance what's a review and what's your own work:
git fetch origin feature/their-branch
git worktree add -b feature/their-branch ./pr-812 origin/feature/their-branch
cd pr-812 && npm ci
Now you can run the PR. Not just read the diff in a browser: actually start the app, click through the author's testing steps, set breakpoints. Your own feature work sits untouched two folders away. This is where the layout stops being a convenience and starts changing how well you review.
When the PR merges:
git worktree remove pr-812
The one command you must never run
Always pair a remote ref with -b. This is the detached-HEAD trap. The wrong version doesn't error. It just quietly drops you somewhere unsafe, where any commit you make is an orphan waiting to be garbage-collected.
# WRONG: detached HEAD. Commits here are orphans.
git worktree add ./pr-812 origin/feature/their-branch
# RIGHT: a real local branch, tracking set up automatically.
git worktree add -b feature/their-branch ./pr-812 origin/feature/their-branch
Make the -b a reflex.
Where it all comes together
Open the container folder itself in your editor:
code ~/work/web
VS Code scans one level deep, finds every worktree, and lists each as its own repository in the Source Control panel. This is the payoff.
Every branch's dirty state is visible at once. Your feature's six modified files and the PR review's scratch edits are separate entries in one panel. Nothing hides behind a stash. You can diff two branches side by side without a single checkout. Search scopes naturally: look inside develop/ to ask "what does the integration branch say," or the whole container to compare across branches. And you get a terminal per worktree: a test watcher running in one, a build in another, a git log in a third, none of them stepping on each other.
The one shared resource is the dev server. Each app serves on a fixed port, so two checkouts of the same app can't serve at once. Run it from whichever worktree currently has your attention, and stop it before you start it elsewhere.
Two small settings, both easy. The default git.repositoryScanMaxDepth: 1 is exactly right for this layout; leave it. And give the container a .vscode/settings.json so the file watcher doesn't choke on N copies of node_modules:
{
"files.watcherExclude": {
"**/node_modules/**": true,
"**/dist/**": true
}
}
When you want to focus, open a single worktree in its own window with code ~/work/web/feature-PROJ-1234. That window looks and feels like a completely normal single-repo project. A new teammate doesn't even need to know it's a worktree.
A day in the life
Remember the interrupted day from the top? Replay it in this layout. You're mid-feature in feature-PROJ-1234/, dev server running, six dirty files, head full of context.
A blocking review lands:
cd ~/work/web
git fetch origin feature/their-branch && git worktree add -b feature/their-branch ./pr-820 origin/feature/their-branch
cd pr-820 && npm ci
npm run dev # wants the same port, so stop the one in your feature first
You review it properly, running the actual app. The dev server is the one thing you hand over. Everything else about your feature is untouched: dirty files, editor state, all of it.
The production question arrives:
cd ../main
git pull
grep -rn "the thing in question" src/
main/ is always there, always clean, always current after a pull. Answer the question. Done.
Back to your feature:
cd ../feature-PROJ-1234
That's the whole return trip. Your six files are still dirty and your editor window is exactly as you left it. Restart the dev server and you're back in it. The stash never happened. The WIP commit never happened. The "wait, what was I doing" never happened.
Multiply that by every interruption in a week, and this layout pays for its disk space many times over.
Where I think this actually goes
Here's the part I'm most interested in lately, and it's why I don't think of this as just an ergonomics trick.
The container root can see every branch at once through one shared database. Every branch is a real, isolated, fully-installed folder. That's not only convenient for a human bouncing between tabs. It's the exact shape you want when you hand work to an AI coding agent.
Point an agent at feature-PROJ-1290/ and let it run tasks, edit files, run the build, all inside that one isolated checkout, and it never touches your working state two folders over. You can have a feature open in one worktree while an agent grinds on a different branch in another, in parallel, neither stepping on the other. The isolation that makes context-switching painless for you is the same isolation that makes it safe to delegate.
I'm still exploring that direction, so I won't oversell it. But the layout is already shaped for it, and that's the reason I reach for it now instead of a plain clone.
What it costs, and what I'm still figuring out
The honest tradeoff is disk. One shared copy of the history in .bare/, plus one full checkout and one node_modules per worktree. The history is shared, so ten worktrees is nowhere near ten clones, but node_modules dominates. Prune your review worktrees when the PR merges and it stays very manageable.
I also wrote a small shell helper, wt, that collapses the create-a-worktree dance into one command. It figures out whether you're making a brand-new branch, tracking an existing remote one, or adding a worktree for a branch you already have locally, and it gets the tracking details right in each case. It's newer than the rest of this workflow and I'm still road-testing it, so a fair warning: learn the raw commands first. When something goes sideways, you want to know what it actually did on your behalf.
But the core of this is not exotic. One bare database. Many real folders. No branch is special. Nothing to stash, nothing to protect.
You didn't put the work down. You walked away from it, and it waited for you.