Faster Git worktree setup without breaking branch isolation

Create 3 Git worktrees and the duplication appears immediately: the Python branch gets another .venv, the Ruby branch gets another bundle, and the Rust branch gets another target/ directory.
I used to treat that as the price of isolation, but it isn't.
The right setup keeps a separate dependency environment inside each worktree while sharing package downloads and compiler cache entries outside it. You still install or sync dependencies in every worktree, but expensive inputs don't need to come from the network or be compiled from scratch every time.
That's the boundary.
The commands below assume macOS or Linux with Bash or Zsh, and you should run only the sections for languages present in your repository. Windows users can apply the same structure with PowerShell paths and environment-variable syntax (but I’m not a Windows user and I don’t have much experience).
Contents
- Why worktrees appear to duplicate everything
- The safe sharing boundary
- Python with uv
- Ruby with Bundler
- Rust with Cargo and sccache
- JavaScript and Node with pnpm
- A setup you can copy
- Automate the setup with worktrunk
Why worktrees appear to duplicate everything
Git worktrees share one repository while checking out different branches into separate directories, and Git keeps private administrative metadata for each linked worktree that points back to a common repository directory. The official `git worktree` documentation describes that split as a private $GIT_DIR plus a shared $GIT_COMMON_DIR.
Your package manager doesn't see that relationship because it sees another project directory with its own checked-out files.
That distinction matters because branches can carry different dependency state:
uv.lockcan resolve a new Python package version.Gemfile.lockcan add a gem with a native extension.Cargo.lockcan select another crate revision.- Rust features, build profiles, targets, and compiler flags can differ.
A single mutable environment across those branches erases the isolation that worktrees provide because one branch can change installed state while another assumes its own lockfile still controls that state.
Not a Git problem. The actual problem is the sharing boundary.
Git already shares repository objects, and package managers can share downloaded artifacts too. What they shouldn't share by default is the worktree's active environment or build directory.
The safe sharing boundary
I use one rule: share inputs that can be recreated, isolate state that represents the current branch.
Share these:
- Downloaded package archives, wheels, gems, and crate sources
- Built wheels and globally cached gem extensions
- Compiler results stored behind a cache key
- Tool downloads that aren't part of the application's dependency graph
Keep these per worktree:
- Python virtual environments
- Bundler installation paths
- Cargo
target/directories - Lockfiles and project configuration
- Generated files whose meaning depends on the checked-out branch
The shared layer should behave like a disposable, read-mostly cache, while the worktree layer remains the environment you can delete and recreate from that branch's lockfile.
That gives you a useful failure test: if deleting the shared directory destroys the only valid copy of project state, it wasn't a cache, and if one branch can silently change what another branch executes, it wasn't isolated.
This setup doesn't promise zero installation work, but it reduces repeated downloads and compilation when the inputs match. Cache misses remain normal when a lockfile, platform, interpreter, toolchain, feature set, or compiler input changes.
Python with uv
uv already separates these layers. By default, it creates a dedicated .venv beside the project's pyproject.toml, while its dependency cache lives in a system cache directory. Astral documents both the project environment layout and the storage locations.
The cache stores downloaded and built dependency artifacts, which uv uses to avoid downloading and building dependencies it has already seen, according to its cache documentation.
Set one cache location for your user account, then sync each worktree normally:
# Bash or Zsh
export UV_CACHE_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/uv"
uv cache dir && uv syncRun uv sync inside every worktree so each branch gets its own .venv, based on the files checked out in that directory. The shared cache supplies matching wheels, source archives, Git dependencies, and prior build results when uv's cache keys permit reuse.
Local projects need one more check. uv's default invalidation heuristic watches pyproject.toml, setup.py, setup.cfg, and selected directory state. If package metadata depends on Git commits or tags, generated files, extra requirement files, or environment variables, add those inputs to `tool.uv.cache-keys`. Use uv sync --reinstall-package <package> for a targeted rebuild when that metadata can't be expressed cleanly.
Keep the cache and worktrees on the same filesystem when practical because Astral states that uv's cache should share a filesystem with virtual environments for optimal performance. That lets uv use its preferred linking behavior instead of falling back to copying between filesystems.
Don't point UV_PROJECT_ENVIRONMENT at one global virtual environment for every worktree because that converts a safe package cache into shared mutable runtime state, where a sync from one branch can replace packages required by another.
Recovery stays local:
# Recreate only this worktree's environment
rm -rf .venv
uv sync
# Inspect the shared cache without changing the environment
uv cache dirThe environment is disposable, and the lockfile is authoritative.
Ruby with Bundler
Ruby needs a more explicit choice. Bundler 5 defaults its install path to .bundle under the repository root. Earlier versions use RubyGems' system path when path isn't set. The current Bundler configuration reference documents both behaviors, plus path, path.system, disable_shared_gems, and global_gem_cache.
For worktrees, I make the local path explicit and enable Bundler's global cache:
# Run once for your user account
bundle config set --global global_gem_cache true
# Run inside each worktree
bundle config set --local path .bundle && bundle installBundler defines global_gem_cache as caching gems and compiled extensions globally instead of beside the configured installation path. The active bundle still lives in this worktree's .bundle, while the cache sits above it.
Inspect the effective settings before changing an established repository:
bundle config list
bundle config get pathBundler reads configuration in this order: local config, environment variables, global config, then defaults. The official configuration reference explains that bundle config list shows every active value and where it came from, while bundle config get path narrows the install-path decision. Check both before changing a setting. Keep worktree-local .bundle as the recommended default.
A shared RubyGems installation is a valid tradeoff for some teams, but it is a mutable environment rather than a cache and needs strict Ruby and platform scoping. RubyGems documents that native gems target concrete CPU and operating-system platforms, while extensions built from source compile during installation in its platform guide. Its extension guide gives the sharper warning: an extension built for one Ruby version may not work with another.
If you choose the system path, let a Ruby version manager provide a separate gem location for each Ruby engine and version. Keep per-worktree .bundle installs for lockfile migrations, native-extension debugging, and branches that need stronger isolation.
Rust with Cargo and sccache
Rust already shares downloads because Cargo's home directory defaults to $HOME/.cargo and acts as a download and source cache for registry packages and Git dependencies, according to the Cargo Home guide.
Leave that shared while keeping target/ local.
Cargo places compiler output in the workspace's target/ directory by default, and the Cargo build-cache reference lists final artifacts, incremental output, build-script output, and dependency artifacts inside that structure. Pointing unrelated repositories at one global CARGO_TARGET_DIR forces all of that mutable build state into one directory.
Use sccache for broader compiler reuse instead:
# Install once. This compiles sccache from source.
cargo install sccache --locked
# Bash or Zsh session
export RUSTC_WRAPPER=sccache
# Run inside each worktree
cargo build && sccache --show-statsCargo's documentation explicitly recommends sccache for sharing built dependencies across workspaces. You can persist the wrapper in Cargo configuration too:
# $HOME/.cargo/config.toml
[build]
rustc-wrapper = "sccache"sccache wraps rustc and stores matching compiler results in a local disk cache unless you configure another backend. Matching paths matter: absolute paths must match by default, so the official sccache Configuration documentation explains how SCCACHE_BASEDIRS can normalize checkout roots under one stable parent:
export SCCACHE_BASEDIRS="$HOME/src"The reuse has limits. The sccache Rust documentation states that Rust incremental compilations and crates that invoke the system linker aren't cacheable. Its Local documentation sets the default cache at 10 GB and supports one local sccache server at a time. Verify the actual result with sccache --show-stats.
A cache miss costs compilation time, while a shared mutable target/ can cost confidence in the build. Take the miss.
JavaScript and Node with pnpm
Node has the same duplication problem in a heavier form. node_modules is gitignored, so every new worktree starts empty, and a naive install writes hundreds of megabytes per branch.
pnpm documents the worktree case directly in its pnpm and Git worktrees guide. The setup is a bare clone with the worktrees as sibling directories, plus a global virtual store. Each worktree still gets its own node_modules, but the packages inside it are symlinks into one shared store instead of files written into the worktree.
# pnpm-workspace.yaml
packages:
- 'packages/*'
virtualStoreType: global
Then install once inside each worktree:
cd main && pnpm install
cd ../feature-auth && pnpm install
cd ../fix-api && pnpm install
That is the same boundary as uv and Bundler: share the extracted package content, isolate the active install per branch. Without the setting, pnpm hardlinks files into a local node_modules/.pnpm in every worktree. With it, the global virtual store holds those hardlinks once, and nothing is copied or hardlinked into the worktree itself. The first install fills the shared store, and later installs in other worktrees mostly create symlinks.
Three limits before you copy this:
- The key is
virtualStoreType: globalfrom pnpm 11.23.0. Earlier versions spell itenableGlobalVirtualStore: true. - It is experimental and off by default for project installs, because some tools mishandle a symlinked
node_modules. It also stays off in CI, where an absent cache makes it slower rather than faster. - pnpm states the trust rule directly: the setup assumes the worktrees and agents share one trust boundary, and a single writable store must not serve mutually untrusted agents or users. That is the same failure mode as a shared virtual environment or a shared gem path.
npm has no symlinked virtual store to enable. ~/.npm/_cacache holds compressed tarballs rather than extracted packages, so every worktree still pays extraction cost on install. Sharing the download cache is worth it, but it does not collapse install time the way pnpm's store does. Yarn Berry is the opposite case: with Plug'n'Play and the global cache, both defaults in Yarn 4, there is no node_modules tree to duplicate at all.
One caution that connects to the worktrunk section below: node_modules is a reasonable entry in .worktreeinclude, but only if an install follows the copy. A copied tree reflects the source branch's lockfile, and under the global virtual store it is a set of symlinks rather than real files. Let pnpm install reconcile it against this branch's lockfile before a dev server starts watching it.
A setup you can copy
Put only shared cache settings in your shell profile:
# ~/.zshrc or ~/.bashrc on macOS or Linux
export UV_CACHE_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/uv"
export CARGO_HOME="$HOME/.cargo"
export RUSTC_WRAPPER="sccache"
# Optional when your worktrees share one stable parent directory
export SCCACHE_BASEDIRS="$HOME/src"Configure Bundler once:
bundle config set --global global_gem_cache trueThen create and prepare a worktree:
# Run from the main repository
# Replace project-auth and feature/auth with your directory and branch names.
git worktree add ../project-auth -b feature/auth && cd ../project-auth
# Run only the commands supported by this repository.
uv sync
bundle config set --local path .bundle && bundle install
cargo buildUse this checklist when a new worktree behaves differently from the main checkout:
- Compare the lockfiles before blaming the cache.
- Confirm the interpreter, Ruby engine and version, Rust toolchain, CPU architecture, and operating system.
- Inspect
uv cache dir, Bundler's effective config,CARGO_HOME, andRUSTC_WRAPPER. - First, Delete and recreate the worktree-local environment.
- Clear or bypass a shared cache only after the local reset fails.
Automate the setup with worktrunk
Everything above is per-worktree work you have to remember. Worktrunk is a CLI that addresses worktrees by branch name and runs hooks at each point of the worktree lifecycle, so the setup runs itself.
Put the per-worktree commands in the project config and commit it, so every worktree and every teammate provisions the same way:
# .config/wt.toml, committed to the repository
# Keep only the lines your repository needs.
[pre-start]
python = "uv sync"
ruby = "bundle config set --local path .bundle && bundle install"
[post-start]
rust = "cargo build"
pre-start blocks until it completes, so a later hook or an --execute command sees a ready environment. post-start runs in the background, which suits long compiles. Worktrunk asks for approval the first time a project hook runs, and again whenever the command changes.
Keep the cache settings out of this file. Shared cache locations belong in your shell profile and in Bundler's global config, exactly as in the previous section, because they describe your machine rather than the repository.
Creating a prepared worktree is then one command:
wt switch --create feature-auth
The shared caches still do the heavy lifting: uv pulls wheels from UV_CACHE_DIR, Bundler reuses the global gem cache, and sccache serves matching compiler results.
Worktrunk also offers a second strategy. wt step copy-ignored copies gitignored files from the main worktree using reflinks, so a new worktree starts warm instead of empty. Copy-on-write means the copies share disk blocks until a file changes, and Worktrunk's documentation reports a 14 GB target/ directory copying in about 20 seconds against 2 minutes for cp -R.
# .config/wt.toml
[[post-start]]
copy = "wt step copy-ignored"
[[post-start]]
install = "uv sync"
A [[post-start]] pipeline runs each block in order, so the copy lands before the install reuses it.
This still fits the boundary, because each worktree gets its own copy that diverges on the first write. Be selective about what you copy, though, because copied state can carry the source path with it. A Python virtual environment is the clearest case: console scripts in .venv/bin keep an absolute shebang, so a copied environment runs the source worktree's interpreter, and a plain uv sync treats the environment as current and leaves that shebang in place. Only uv sync --reinstall, or deleting .venv first, rewrites it.
Limit the copy with .worktreeinclude, where a file must be both gitignored and listed:
node_modules/
target/
.env
Leave .venv and .bundle out, and let uv sync and bundle install rebuild them from the shared caches.
If you want that guard on every repository, including ones with no .worktreeinclude yet, put it in your user config instead. User hooks apply everywhere and run before project hooks:
# ~/.config/worktrunk/config.toml
[post-start]
copy = "wt step copy-ignored --require-include"
With --require-include, the copy does nothing until a repository opts in with a .worktreeinclude file. That keeps the fast path for large build directories and the correct path for environments that record where they live.
Copy what a build can reproduce. Rebuild what records its own location.
The economic choice is simple: reuse network transfers and valid compiler work, but don't save a little disk by making branch state harder to audit.
Your worktrees should be isolated where correctness changes and shared where content is safely reproducible: separate environments, shared caches, predictable failures.
If repeated worktree setup or coding-agent collisions still cost your team time, test the boundary before standardizing it. Run two representative worktrees and capture cold and warm setup time, physical disk growth, cache hit rate, and branch-isolation failures. Bring that evidence to the Majestic Labs AI workflow diagnostic to decide which caches to share, which environments to isolate, and which exceptions need a documented escape hatch.