Two problems, not one
Switching branches in one working directory mixes two things: which source files are checked out, and what's been built or installed on top of them. Git handles the first; that's what checkout does. It has no opinion about the second. node_modules, compiled output, a Python virtualenv, anything your tools generated for the last branch, all stays put, possibly wrong for the branch you're on now, until something rebuilds it or you remember to clean up.
The pairing that fixes both
Git already covers the first half. git worktree gives a branch its own working directory (git worktree add ../feature-a feature-a) while sharing the repository's history and objects with the main checkout. No stashing, no losing your place; both branches exist as real directories at once. Add the second half: one workspace per worktree, each cloned from the same base image, so every branch also gets its own toolchain, its own node_modules and its own build cache, none of it shared and none of it stale.
What lives where
It's the same host/guest split as one workspace per project. Each worktree directory is a shared folder mounted into its own workspace, so the source and Git history stay on the Mac where your editor expects them. What differs per branch (dependencies, build output, anything an install generates) lives on each workspace's own disk, cloned from a shared base image, so each new branch starts from the same toolchain without a fresh setup.
main
├── worktree: main → workspace: proj-main
├── worktree: feature-a → workspace: proj-feature-a
├── worktree: feature-b → workspace: proj-feature-b
└── worktree: experiment → workspace: proj-experiment
Naming and cleanup
Name each workspace after its branch, the same habit the base image naming post argues for: proj-feature-a, not a generic name you'll have three copies of within a week. A per-branch workspace sits between the two ends in disposability vs. snapshots: it isn't thrown away after one run like a CI workspace, but it isn't meant to last forever. Delete it, and its worktree, when the branch is merged or abandoned, at the same moment you delete the branch.
When it's overkill
A one-line hotfix doesn't need its own VM; a new worktree and workspace cost more than the branch-switching friction they'd save. The pattern pays off when branches have different dependencies or long-running build state worth protecting from each other, not as a rule for every branch.
Related reading: one workspace per project, base image vs. snapshot vs. clone, and base images as versioned infrastructure. Or download Velo Workspaces and try it yourself.