Written by a human, not by AIBazelGoogle's build system for fast, reproducible builds at scale.RustThe systems language: toolchains, crates and builds.JavaScriptThe language of the web and its package ecosystem.TypeScriptTyped JavaScript: the compiler, its resolution rules and tooling.DevToolsDeveloper tools, CLIs, and productivity utilities.

Make git worktrees cheap for agents

I run most of my agent work in git worktrees now. Claude Code makes one per task under .claude/worktrees/, the agent gets its own checkout and branch, and nothing it does can break the tree I’m looking at. There are usually a few of them around.

The catch is that a worktree is only the tracked files. No node_modules, no generated code, no build cache, nothing a tool wrote into the tree.

If getting from git worktree add to a green check takes ten minutes and a page of instructions, the agent will skip it, and so will I. Three things made it cheap in https://note.town/.

Share every cache across checkouts

Some tools can share a cache between checkouts. Make sure you take advantage of it.

PNPM was already in decent shape. It downloads every package once into a store in your home directory, and pnpm install in a new worktree only links files from there, nothing comes from the network. But linking is still work: it took 15 seconds, and each worktree ended up with its own two-gigabyte node_modules.

Recent PNPM has a global virtual store. With virtualStoreType: global in pnpm-workspace.yaml, node_modules becomes symlinks into one shared tree keyed by dependency graph, and a worktree’s install becomes a second and a couple of megabytes. Run it with --frozen-lockfile --prefer-offline: the lockfile already says what to install, so there is no reason to talk to the registry at all.

The one catch I hit: packages now live outside the repo. TypeScript resolving import "react" from a library’s typings walks up from the library’s real path and never passes the hoisted node_modules, so every react library’s types quietly became any.

A .pnpmfile.cjs that gives each package peering on react an optional @types/react peer fixes it. PNPM then links the types next to the package, where TypeScript looks first.

The hook, if you need it
const TYPES = { react: "@types/react", "react-dom": "@types/react-dom" };

function readPackage(pkg) {
  for (const [peer, types] of Object.entries(TYPES)) {
    const uses = pkg.peerDependencies?.[peer] ?? pkg.dependencies?.[peer];
    if (!uses || pkg.name === types) continue;
    if (pkg.dependencies?.[types] || pkg.peerDependencies?.[types]) continue;
    pkg.peerDependencies = { ...pkg.peerDependencies, [types]: "*" };
    pkg.peerDependenciesMeta = { ...pkg.peerDependenciesMeta, [types]: { optional: true } };
  }
  return pkg;
}

module.exports = { hooks: { readPackage } };

Bazel keys everything by workspace path, so a new worktree is a new output base that starts from zero. Two settings fix most of that.

build --disk_cache=~/.cache/bazel/disk in .bazelrc shares action outputs, so a fresh worktree restores compiled code instead of rebuilding it.

And every external repository must be reproducible. Bazel 9 keeps fetched repos in one shared directory, the repo contents cache, but only the ones whose rule promises repo_metadata(reproducible = True). Mine didn’t, which meant unpacking a gigabyte of toolchains into every output base. Pinning the Rust toolchain’s sha256s (Bazel prints the exact dict in a DEBUG: line, easy to scroll past) and a small patch to the zig rule took the first Bazel call in a new worktree from 30 seconds to 9.

The pins and the patch, if you need them

The Rust toolchain, in MODULE.bazel. rules_rust ships a table of known checksums; for a version newer than the table you supply them, and Bazel prints this exact dict when they are missing:

rust.toolchain(
    sha256s = {
        "rustc-1.99.1-aarch64-apple-darwin.tar.xz": "23948a0a9c16…",
        "cargo-1.99.1-aarch64-apple-darwin.tar.xz": "685e563d94db…",
        "rust-std-1.99.1-aarch64-apple-darwin.tar.xz": "fe79d6ab5a03…",
        # one line per archive the toolchain downloads
    },
    versions = ["1.99.1"],
)

The zig SDK, from hermetic_cc_toolchain. Its repository rule never returns the metadata, so the patch adds it at the end of the implementation function, guarded so the rule still loads on older Bazel:

--- a/toolchain/defs.bzl
+++ b/toolchain/defs.bzl
@@ -281,6 +281,13 @@
         repository_ctx.symlink("tools/zig-wrapper{}".format(exe), tool_path)

+    # Everything above is a function of the rule's attributes (the archives are
+    # pinned by sha256), so Bazel may keep the repo in its repo contents cache.
+    if hasattr(repository_ctx, "repo_metadata"):
+        return repository_ctx.repo_metadata(reproducible = True)
+    return None
+
 zig_repository = repository_rule(

Applied with a single_version_override:

single_version_override(
    module_name = "hermetic_cc_toolchain",
    version = "4.3.0",
    patches = ["//patches/bazel:hermetic-cc-toolchain-reproducible-zig-repository.patch"],
    patch_strip = 1,
)

The 9 seconds that are left are Bazel itself. Each worktree gets its own Bazel server, and a new server has to start up and analyse the whole build graph before it can do anything. There is no flag for that: the analysis cache lives only in the server’s memory, and the issue asking for one on disk has unfortunately been open since 2020.

Put the generated files back

This is the part that confuses agents the most. Every repo has files that are generated and gitignored: code from protobuf definitions, bindings, typed clients. In the main checkout they are just there, so it is easy to forget they exist. A fresh worktree has none of them.

Bazel doesn’t mind, it generates them itself when a build needs them. The tools that run outside Bazel do mind. The type checker, the dev server and cargo all expect those files to be sitting in the tree, and without them they fail in confusing ways: a type that “doesn’t exist”, an import that resolves in the main checkout but not here.

An agent that hits this has no way to tell a missing generated file from a bug. It will read the error at face value, go looking for the type, and start “fixing” code that was fine. I have watched one spend ten minutes on that before I understood what had happened.

So the setup has one more job: ask Bazel for the generated files once, and copy them into the places where the other tools look. Since Bazel pulls them from the shared disk cache, that takes seconds, not a compile.

Make it one command

Nobody should have to remember all of the above, neither a person nor an agent. The point is to make a worktree the easy choice, and that means one command that does the whole thing. Mine is ops worktree create <name> in the repo’s own CLI: git worktree add -b <name> from main, trust the mise configs, pnpm install from the store, generate the files the other tools read.

ops worktree setup does the same inside a worktree someone else made, which is what Claude Code does. ops worktree remove deletes the worktree and clears its Bazel caches, which would otherwise stay behind and take gigabytes.

CLAUDE.md names those three commands and nothing else. Same idea as the check command: the agent should know one verb, not this post.

All together this saves around 40 seconds of provisioning per worktree, and a fresh one is ready to build in under half a minute, with no network and nothing the agent has to understand.