DEV Community

James Jeremy Foong
James Jeremy Foong

Posted on Edited on

Git Worktrees Are Great Until Every Branch Needs Its Own node_modules

Git Worktrees Are Great Until Every Branch Needs Its Own node_modules

Git worktrees make it easy to keep several branches checked out at once. That sounds ideal for a large monolith: keep a bugfix branch, a feature branch, and the current integration branch ready without stashing work.

Then the ignored files arrive.

Every worktree may need its own .env, .venv, node_modules/, build output, framework cache, or generated artifact. A large node_modules/ directory can take minutes to install and several gigabytes across a handful of worktrees. Setting up .env again for every branch is less expensive, but just as repetitive. Repeating all of it every time a branch is created gets old quickly.

That is the part Git worktrees do not solve: tracked files are cheap to switch, but local runtime state still needs a policy.

The obvious fix is to symlink everything to the default branch worktree. That is too broad. A shared .env may be intentional. A shared dependency directory can let one branch change another branch's installed packages or generated files.

I wanted one personal Worktrunk workflow with a narrower policy:

  • Share configuration only when branches intentionally use the same runtime settings.
  • Keep lockfile-derived install trees local by default.
  • Share package-manager stores and caches when the tool supports safe reuse.
  • Keep branch-local generated output when source or build inputs can differ.
  • Never print or copy environment values.

This lives in my user Worktrunk configuration. It is not committed to the repository, so teammates with a normal clone or a different worktree layout are unaffected.

The large-monolith problem

Suppose one monolith has six active worktrees. Each branch contains a separate node_modules/ directory. If each install is large, disk usage grows roughly with the number of worktrees. Install time grows too, especially when native dependencies run build steps.

A second worktree may need the exact same dependency tree as the first. A third branch may change one package. A fourth may use a different Node version. Treating all four cases as identical is where the trouble starts.

The goal is not to share every byte. The goal is to share safe state and isolate state that can affect correctness.

Personal Worktrunk configuration

My local setup uses a bare Git repository with a default-branch worktree and feature worktrees beside it. Worktrunk creates branch worktrees beside the repository directory.

The user config points one repository at a personal setup script:

[projects."github.com/example/example-repo"]
worktree-path = "{{ repo_path }}/../{{ branch | sanitize }}"
pre-start.env = "~/bin/wt-setup-worktree"
Enter fullscreen mode Exit fullscreen mode

The exact path is personal. Someone using a normal clone can use the same policy with a different way to locate the base worktree, or skip this workflow entirely.

The hook runs before the worktree is ready. It discovers the default-branch worktree from Git metadata, finds project directories, and handles each project independently. A repository with several applications gets one decision per project folder, not one repository-wide decision.

Start with file categories

Before linking an ignored directory, classify it.

Configuration

Examples:

  • .env
  • local tool configuration
  • development certificates and similar machine-local files

Share configuration only when every linked worktree should use the same values. A symlink is not a copy, so changing the file through one worktree changes what every linked worktree reads.

Never create .env from .env.example automatically. Missing source means missing source. Do not copy secrets into more worktrees than necessary.

Installed dependencies

Examples:

  • Python .venv/
  • JavaScript node_modules/
  • Ruby vendor/bundle/
  • generated dependency trees from other package managers

These directories contain installed packages plus tool-specific metadata. A branch can change them through an install, upgrade, postinstall script, native rebuild, or generated file.

Share them only after comparing the inputs that control installation. Even then, preserve an existing local directory instead of deleting it without an explicit migration step.

Caches

Examples:

  • uv package cache
  • npm, pnpm, or Yarn download cache
  • Cargo registry and Git caches
  • compiler caches designed for cross-build reuse
  • Docker or BuildKit cache stored outside the worktree

Caches are usually safer to share when the tool owns cache identity and stores entries by content or package coordinates. Share the cache, not necessarily the project directory that consumes it.

Generated output

Examples:

  • dist/
  • build/
  • .next/
  • target/
  • coverage reports
  • generated client code

Keep these local by default. They depend on source files, build flags, platform, compiler version, environment variables, and sometimes the current Git revision. A stale shared artifact can look like a successful build while serving old code.

.env: link by project path

Each project can have its own runtime configuration. Map the source path exactly:

worktree/path/to/project/.env
default-branch/path/to/project/.env
Enter fullscreen mode Exit fullscreen mode

If the default branch has a real .env and the new worktree does not, the hook creates a relative symlink. If the worktree already has a regular .env, the hook leaves it alone.

That rule avoids two problems: destructive replacement of deliberate local configuration and unnecessary duplication of secret-bearing files.

.venv and node_modules: compare installation inputs

A Python virtual environment and a JavaScript dependency tree have the same basic risk. They are installed state, not source state.

For Python, compare inputs such as:

pyproject.toml
uv.lock
Enter fullscreen mode Exit fullscreen mode

For JavaScript, compare the files used by the selected package manager:

package.json
package-lock.json
Enter fullscreen mode Exit fullscreen mode

or:

package.json
pnpm-lock.yaml
Enter fullscreen mode Exit fullscreen mode

or:

package.json
yarn.lock
Enter fullscreen mode Exit fullscreen mode

The comparison should use the files that actually control the install. Do not compare only package.json when a lockfile determines the resolved tree.

A small fingerprint function can express the rule:

fingerprint() {
  sha256sum "$@" | sha256sum | cut -d' ' -f1
}
Enter fullscreen mode Exit fullscreen mode

A matching fingerprint is a prerequisite, not blanket permission to share the installed directory. It proves that the declared inputs match at one point in time. It does not prevent a package install, postinstall script, native rebuild, or branch-local tool from mutating the directory later.

For node_modules/, the safer default is to keep the installed tree local even when lockfiles match. Share the package-manager store or download cache instead. pnpm's content-addressed store is a good example: each worktree can keep its own consumer layout while identical package content is deduplicated underneath. npm, Yarn, uv, and Cargo provide related cache mechanisms with different guarantees.

For Python .venv/, reuse may be reasonable when dependency inputs, interpreter, platform, and tool behavior match. Still, preserve existing local environments and stop sharing when inputs drift.

Also consider platform and native packages. Matching lockfiles may not be enough when branches run on different operating systems, CPU architectures, Node versions, or libc variants. In those cases, keep the installed directory local and share only the package manager's supported cache or store.

What not to symlink blindly

dist/, build/, and .next/

These are outputs of source and configuration. A branch can compile successfully and still read another branch's output if both worktrees share the directory.

Keep build output local. If build time matters, use the framework's supported cache configuration or an external cache rather than linking the whole output directory.

Rust target/

target/ can be large, which makes sharing attractive. It also contains incremental compilation state and build-script output. Reuse may work in controlled setups, but stale or incompatible artifacts can waste time or create confusing results.

Prefer Cargo's supported shared target directory or compiler cache configuration when the team understands the trade-off. Do not treat a raw symlink as a universal Rust optimization.

Generated code and coverage

Generated clients, codegen output, snapshots, coverage reports, and test artifacts should stay with the worktree that produced them. Their contents may reflect branch-specific schemas, tests, or source files.

Share the cache, isolate the consumer

This is the useful compromise.

A package-manager cache can save downloads without making one worktree execute another worktree's installed package tree. uv can reuse its package cache while each branch keeps its own .venv. npm, pnpm, Yarn, Cargo, and compiler tools offer similar cache concepts, with different guarantees.

The cache must remain tool-managed. Do not share arbitrary directories just because they are large or ignored by Git.

Policy table

State Recommended action Reason
Shared runtime configuration Link only with deliberate agreement All worktrees read the same values
Matching Python dependency inputs Reuse .venv only when interpreter, platform, and tool assumptions match Installation inputs alone are not enough
Matching JavaScript lockfile and runtime Keep node_modules/ local; share the package-manager store or cache Installed layout can still mutate; stores deduplicate content safely
Changed lockfile or dependency manifest Keep local dependency directory Prevent branch-to-branch mutation
Package-manager download cache Share through the tool's supported cache Cache entries are designed for reuse
dist/, build/, .next/, generated code Keep local Output can be stale or branch-specific
Existing regular directory or file Preserve Avoid destructive automation

Verification

The personal helper passed shell syntax checking:

bash -n ~/bin/wt-setup-worktree
Enter fullscreen mode Exit fullscreen mode

Worktrunk detected the user hook:

wt hook show
Enter fullscreen mode Exit fullscreen mode

Then I ran the hook explicitly:

wt hook pre-start --yes
Enter fullscreen mode Exit fullscreen mode

The hook reported links, preserved local directories, or skipped missing sources. It printed status only, not configuration values or credentials.

Trade-off

This workflow is convenient because it is personal and narrow. It assumes my bare-repository layout, my default-branch worktree, and my decision to share selected runtime files. Those assumptions should not be hidden inside repository configuration that every contributor inherits.

The broader lesson is not to share or isolate everything by file extension. Ask what the path contains, what inputs control it, whether the tool supports sharing it, and whether stale state can change the result.

configuration: deliberate
installed dependencies: fingerprinted
package caches: tool-managed
build output: local
team workflow: untouched
Enter fullscreen mode Exit fullscreen mode

Top comments (2)

Collapse
 
mrsaynothing profile image
Mr Say Nothing •

The quiet killer with symlinked node_modules is lockfile drift: the share only works while every branch resolves the same tree, and the moment one branch bumps a dep you debug ghost bugs that exist in exactly one worktree. Branch-invariant state (.env, caches) shares fine; anything derived from a lockfile wants its own copy. Did you get numbers on pnpm's content-addressed store as the dedupe layer instead? It's designed for exactly this across checkouts.

Collapse
 
jamesjf7 profile image
James Jeremy Foong •

Good point. I agree that node_modules should stay local once lockfiles can diverge. The safer optimisation is sharing the package-manager store, not the installed tree. pnpm’s content-addressed store is a good example: worktrees keep separate consumers while package content is deduplicated underneath. The same principle applies to uv’s package cache. I’ll sharpen this distinction in the article.