Worktree disk budget
Worktree disk budget
Per-worktree disk cost on this monorepo, and how to keep it under control.
TL;DR
node_modulesstays inside each worktree so Vite can load every package.- The reported
node_modulessize is approximately 1.8 GB per worktree (pnpm 12). - The root
.venvis approximately 301 MB with uv symlink mode and no bytecode compilation. - pnpm 12’s global content-addressable store at
~/Library/pnpm/store/v11is shared across all worktrees (7.5 GB). - Remove unused worktrees regularly to control total disk use.
The two configs that matter:
pnpm-workspace.yamlat repo root (pnpm 12’s hoisted linker + shamefully-hoist).pyproject.toml[tool.uv]+.mise.tomlUV_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_modulesesbuild: postinstall downloads the platform binarylefthook: 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.
uv link-mode
.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.pycfiles per venv is pure waste — Python JIT-compiles on first import regardless.
The .pth footgun — why we DO NOT share .venv across worktrees
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:
- copies
pnpm-workspace.yamlfrom$MAIN_CHECKOUTinto the worktree (see bug note above), rm -rf node_modules && pnpm install(writes package files inside the worktree; shared dedup at the file level via~/Library/pnpm/store/v11),rm -rf .venv && uv sync(symlinks into~/.cache/uv/),uv syncinsidepackages/classificationanddeploy/pyinfraif 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)