Worktree disk budget

Worktree disk budget

Per-worktree disk cost on this monorepo, and how to keep it under control.

TL;DR

  • node_modules stays inside each worktree so Vite can load every package.
  • The reported node_modules size is approximately 1.8 GB per worktree (pnpm 12).
  • The root .venv is approximately 301 MB with uv symlink mode and no bytecode compilation.
  • pnpm 12’s global content-addressable store at ~/Library/pnpm/store/v11 is shared across all worktrees (7.5 GB).
  • Remove unused worktrees regularly to control total disk use.

The two configs that matter:

  1. pnpm-workspace.yaml at repo root (pnpm 12’s hoisted linker + shamefully-hoist).
  2. pyproject.toml [tool.uv] + .mise.toml UV_LINK_MODE (uv symlink mode + skip bytecode compilation).

pnpm’s hoisted linker

pnpm-workspace.yaml at repo root:

nodeLinker: hoisted
shamefullyHoist: true
peerDependencyRules:
  allowedVersions:
    zod: "^4.4.3"
  ignoreMissing:
    - "@types/bun"
allowBuilds:
  core-js: true
  esbuild: true
  lefthook: true

nodeLinker: hoisted recreates bun’s flat hoisted layout (a single node_modules/ at root, no .pnpm/ virtual store). The workspaces in this repo were written against bun’s flat hoisting, so this is the lowest-friction migration.

shamefullyHoist: true forces all packages — even version-conflicting ones — into the same flat node_modules/, AND forces .bin/ entries to be symlinks instead of shell shims. Without it, pnpm 12 writes a 4.2 KB shell shim in every workspace project’s node_modules/.bin/, and that shim’s relative target only resolves in the isolated layout. That bug makes pnpm run typecheck (or any script that invokes a node_modules/.bin/ tool) fail with ERR_MODULE_NOT_FOUND from any workspace project. See .agents/notes/2026-10-16.md for the full diagnosis.

peerDependencyRules.allowedVersions.zod: ^4.4.3 is defensive: it pins zod to 4.x across the workspace, so if anyone ever flips back to nodeLinker: isolated, pnpm won’t create multiple peer-dep-keyed installs of drizzle-orm@1.0.0-rc.4 (which is what produces the “TS treats them as different types” error in the first place).

peerDependencyRules.ignoreMissing: [@types/bun] silences a warning: @types/bun is consumed by tool-generated .d.ts files, not by our typecheck. We use bun only as the script runtime (bun run, bun scripts/...), which is invisible to the type system.

allowBuilds (pnpm 11+) approves the install-time build scripts that pnpm 11+ blocks by default. Under bun, these ran silently; under pnpm we have to opt in. The packages we need to build:

  • core-js: postinstall patches node_modules
  • esbuild: postinstall downloads the platform binary
  • lefthook: postinstall installs the git hook binary

Why not use the global virtual store (virtualStoreType: global)?

pnpm 12 supports a global content-addressable store that shares packages across worktrees without per-worktree copies. We considered it; rejected it because pnpm’s verifyDepsBeforeRun is incompatible with git worktree — pnpm’s node_modules/.modules.yaml is workspace-rooted, and per-worktree installs into a shared global store produce cross-worktree node_modules pollution. The flat hoisted layout is shared at the file level (via the global content-addressable store at ~/Library/pnpm/store/v11) but each worktree owns its own node_modules/. Same isolation guarantee as bun’s globalStore = false, at the cost of ~1.8 GB per worktree.

pnpm-workspace.yaml must be in the worktree

A worktree is on its own feature branch which usually predates the pnpm-workspace.yaml commit. pnpm does NOT walk up parent directories to find pnpm-workspace.yaml across worktree boundaries (the worktree is at ~/.worktrees/<ticket>/<name>/, and the main checkout is a separate tree). Without pnpm-workspace.yaml, pnpm can use a different linker or storage layout.

scripts/worktree-init.sh copies pnpm-workspace.yaml from $MAIN_CHECKOUT into the worktree before running pnpm install. The script also refuses to run if package.json doesn’t have a "workspaces" field. If the check fails, restore package.json and pnpm-lock.yaml from Git before installation.

.mise.toml line ~22: UV_LINK_MODE = "symlink" (was "hardlink").

pyproject.toml [tool.uv] adds link-mode = "symlink" and compile-bytecode = false.

  • Why symlink not hardlink: hardlinks share physical blocks but can CoW-materialize across a mv / tar / rename boundary, silently inflating disk. Symlinks survive these operations cleanly.
  • Why compile-bytecode = false: ~100 MB of .pyc files per venv is pure waste — Python JIT-compiles on first import regardless.

uv installs workspace members as editable packages by default. Each editable install writes a .pth file under .venv/lib/python*/site-packages/_editable_impl_*.pth containing the absolute path to the source directory.

Verified in ~/Documents/prjcts/_own/brioso/.venv/lib/python3.12/site-packages/:

_editable_impl_brioso_worker.pth → /Users/.../brioso/apps/worker/src
_editable_impl_obs_py.pth        → /Users/.../brioso/packages/obs-py/src
_editable_impl_inference_core.pth → /Users/.../brioso/packages/inference-core/src
_editable_impl_musicgen_adapters.pth → /Users/.../brioso/packages/musicgen-adapters/src
_editable_impl_sa3_worker.pth   → /Users/.../brioso/packages/stable-audio-3/src

If worktree B symlinked worktree A’s .venv, B’s Python would prepend A’s source paths to sys.path at startup — silent correctness bug. B would run A’s source code, not its own. Therefore each worktree owns its own .venv.

The shared dedup is at the file level (each .venv/lib/... entry is a symlink into ~/.cache/uv/) — not at the directory level.

Migration: rebuilding existing worktrees

After the configs land, run bun run worktree:init (or bash scripts/worktree-init.sh) inside every worktree. The script:

  1. copies pnpm-workspace.yaml from $MAIN_CHECKOUT into the worktree (see bug note above),
  2. rm -rf node_modules && pnpm install (writes package files inside the worktree; shared dedup at the file level via ~/Library/pnpm/store/v11),
  3. rm -rf .venv && uv sync (symlinks into ~/.cache/uv/),
  4. uv sync inside packages/classification and deploy/pyinfra if those dirs exist.

Worktree-init helper

scripts/worktree-init.sh (latest):

#!/usr/bin/env bash
# Sanity check: refuse to run if package.json doesn't look like the
# brioso monorepo root. (pnpm will silently create a fresh package.json
# if it's missing, which clobbers the workspace declaration.)
if [ ! -f package.json ]; then ...; exit 1; fi
if ! grep -q '"workspaces"' package.json; then ...; exit 1; fi

MAIN_CHECKOUT="${MAIN_CHECKOUT:-$HOME/Documents/prjcts/_own/brioso}"

# Copy pnpm-workspace.yaml into worktree if missing (worktrees don't
# walk up across the main/worktree boundary to find it).
if [ ! -f pnpm-workspace.yaml ]; then cp "$MAIN_CHECKOUT/pnpm-workspace.yaml" ./pnpm-workspace.yaml; fi

rm -rf node_modules && pnpm install
rm -rf .venv && uv sync
[ -d packages/classification ] && (cd packages/classification && uv sync) || true
[ -d deploy/pyinfra ] && (cd deploy/pyinfra && uv sync) || true

Wired into package.json as "worktree:init": "bash scripts/worktree-init.sh". Run after creating a new worktree (replaces the manual mise trust && pnpm install from AGENTS.md).

Verifying footprints

# Per-worktree footprint
for d in ~/.worktrees/*/*/; do
  printf '%-60s nm=%-6s venv=%s\n' "$(basename $(dirname $d))/$(basename $d)" \
    "$(du -sh $d/node_modules 2>/dev/null | awk '{print $1}')" \
    "$(du -sh $d/.venv 2>/dev/null | awk '{print $1}')"
done

# Shared pnpm store
du -sh ~/Library/pnpm/store/v11

# Shared uv cache
du -sh ~/.cache/uv/

# Sanity: editable-install .pth points at THIS worktree's source (not another)
cat .venv/lib/python*/site-packages/_editable_impl_*.pth | sort -u
# Every line should start with the worktree's absolute path.

# Sanity: pnpm workspace symlinks are relative (so they don't cross-pollute)
ls -l node_modules/@brioso 2>/dev/null | head -5
# expect: ../../packages/...  (relative)

# Sanity: .bin/ entries are symlinks, not shell shims
head -1 node_modules/.bin/tsc
# expect: "#!/bin/sh" → the rest is a shim. Investigate; the fix is
# shamefullyHoist: true.
file node_modules/.bin/tsc
# expect: "symbolic link" (preferred)