Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
197 changes: 184 additions & 13 deletions .agents/skills/worktrees-pnpm/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: worktrees-pnpm
description: Create and work in git worktrees in this pnpm workspace. Use when creating a worktree, delegating to a background agent with worktree isolation, or when a worktree fails at commit time with a missing prettier/eslint/tsx binary. Covers why a worktree needs its own pnpm install, and why worktrees live beside the repo rather than inside it.
description: Create and work in git worktrees in this pnpm workspace. Use when creating a worktree, delegating to a background agent with worktree isolation, or when a worktree fails at commit time with a missing prettier/eslint/tsx binary. Covers why a worktree needs its own pnpm install, where worktrees live, and remediations for branch locks, moving a worktree, and failures that look like worktree problems but are not.
---

# Worktrees in a pnpm workspace
Expand All @@ -14,14 +14,14 @@ problem in this repo.
**`git worktree add` is not finished until `pnpm install` has run inside the new worktree.**

```bash
git worktree add ../skills-worktrees/<name> <branch> # or -b <branch> for a new one
cd ../skills-worktrees/<name>
git worktree add worktrees/<name> <branch> # or -b <branch> for a new one
cd worktrees/<name>
pnpm install # ← not optional
```

Worktrees go **beside the repo**, in `../skills-worktrees/`, never inside it. Agent worktrees land there too: the `WorktreeCreate` / `WorktreeRemove` hooks in `.claude/settings.json` (scripts under `.claude/hooks/`) replace the default placement, which would otherwise nest them at `.claude/worktrees/<id>`.
Worktrees go in **`worktrees/` at the repo root**, gitignored. Agent worktrees land there too: the `WorktreeCreate` / `WorktreeRemove` hooks configured in `.claude/settings.json` (scripts in this skill's `scripts/` directory) replace the default placement, which would otherwise nest them at `.claude/worktrees/<id>`. `ls worktrees/` answers "what worktrees do I have?" at a glance.

That is not a matter of taste. A worktree is a complete second checkout, so nesting it inside the repo means every tool that walks the tree from the root walks into it. We hit exactly that: a root `eslint .` traversed 2983 files across two agent worktrees and failed on code an agent had half-written. An ignore rule patches one tool; a sibling directory makes the whole class of problem impossible. One directory also answers "what worktrees do I have?" at a glance — `ls ../skills-worktrees/`.
A sibling directory (`../<repo>-worktrees/`) is the obvious alternative and was tried first. It is genuinely better on one axis — nothing inside the repo, so no tool can walk into it — but its path depends on what the clone directory is _named_, which committed config cannot know. A teammate whose checkout is not named the same thing gets permission prompts for every file operation, silently and per person. `worktrees/` is the same path in every clone; the price is the ignore entries in the next section, which are three lines you control.

A branch can only be checked out in one worktree at a time. If the branch you want is checked out in the primary tree, move that tree to another branch first.

Expand Down Expand Up @@ -60,15 +60,186 @@ risk. Do not re-propose it.

So a worktree install is a normal install. Budget for it; do not skip it.

## The in-repo ignore is a backstop, not the mechanism
## Setup (per repo)

This skill ships the hook scripts it needs, in `scripts/` beside this file. A repo that installs
the skill gets them; wiring them up is two edits.

**1. Register the hooks** in the repo's committed `.claude/settings.json`. `WorktreeCreate`
replaces the default placement logic entirely — it runs `git worktree add` itself and prints the
path it made — and `WorktreeRemove` is its counterpart.

```json
{
"hooks": {
"WorktreeCreate": [
{
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR/.agents/skills/worktrees-pnpm/scripts/worktree-create.sh\"",
"statusMessage": "Creating worktree"
}
]
}
],
"WorktreeRemove": [
{
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR/.agents/skills/worktrees-pnpm/scripts/worktree-remove.sh\""
}
]
}
]
}
}
```

Two things that will waste your time if you get them wrong. The **nested** `[{ "hooks": [...] }]`
shape is required — the flatter `[{ type, command }]` form shown on some documentation pages does
not validate, and an invalid settings file disables _every_ setting in it silently. And the
scripts need `jq` on `PATH`; they read their payload from stdin.

Verify without waiting for an agent, by feeding a hook its payload directly:

```bash
printf '{"hook_event_name":"WorktreeCreate","cwd":"%s","worktree_id":"probe"}' "$PWD" \
| .agents/skills/worktrees-pnpm/scripts/worktree-create.sh
# expect: <repo>/worktrees/probe on stdout, git chatter on stderr, exit 0

printf '{"hook_event_name":"WorktreeRemove","worktree_path":"%s"}' "$PWD/worktrees/probe" \
| .agents/skills/worktrees-pnpm/scripts/worktree-remove.sh
```

**2. Add the ignores** described in the next section. Skipping this is the single most common way
to end up debugging a lint failure in code you never wrote.

## Every root-level tool must ignore the worktrees directory

This is the one real cost of keeping worktrees in the repo, and it is not optional. A worktree is
a **complete second checkout**. Anything that walks the tree from the root will walk into it —
linting, formatting, or type-checking another branch's code, and failing on whatever an agent has
half-written. The symptom is bewildering: your tooling fails on code you never wrote.

Placing worktrees outside the repo avoids this, but the path then depends on what the clone
directory is named, so committed config cannot reference it portably. In-repo plus ignores is the
trade: three one-line entries you control, instead of a path that silently differs per teammate.

**Add `worktrees/` to every tool that walks the tree from the root.** In this repo that is:

```gitignore
# .gitignore
worktrees/
```

```gitignore
# .prettierignore
worktrees/
```

```js
// eslint.config.js (flat config)
export default [{ ignores: ["worktrees/", ".claude/worktrees/"] }];
```

Other ecosystems, same idea:

```json
// .eslintrc.json (legacy)
{ "ignorePatterns": ["worktrees/"] }
```

```jsonc
// biome.json
{ "files": { "ignore": ["worktrees/"] } }
```

```toml
# pyproject.toml — ruff / black
[tool.ruff]
exclude = ["worktrees"]
[tool.black]
extend-exclude = "worktrees"
```

```json
// tsconfig.json — only if the root config globs sources itself
{ "exclude": ["worktrees"] }
```

Also check anything else that globs from the root: test runners (`vitest`/`jest` `exclude`),
bundlers, coverage tools, `.dockerignore`, and workspace globs in `pnpm-workspace.yaml`,
`package.json` `workspaces`, or a Cargo/Go workspace file.

**Do not assume which tools are affected — measure.** In this repo, `pnpm` workspaces and `tsc`
turned out _not_ to need an entry (the workspace glob is root-anchored, and typecheck runs
per-package through turbo), while `prettier` did — and it was invisible until worktrees moved out
of a dot-directory, because its globs skip dotfiles. Your repo's answer will differ.

The check that settles it, run with a worktree actually present:

```bash
git worktree add worktrees/probe -b throwaway-probe
touch worktrees/probe/UNFORMATTED.md

git status --porcelain | grep -c '^?? worktrees' # expect 0
npx prettier --list-different "**/*.md" | grep -c worktrees # expect 0
npx eslint --debug . 2>&1 | grep -c worktrees/probe # expect 0
# ...and the same shape for every other root-level tool

git worktree remove worktrees/probe && git branch -D throwaway-probe
```

Any non-zero count is a tool that still needs an ignore entry.

## Gotchas and remediations

Each of these was hit for real in this repo.

**A branch can live in only one worktree.** If you need to edit a branch an agent worktree holds,
you have three options, in order of preference: hand the edit to the agent that owns it; work
directly inside that worktree, but _only_ if the agent is idle — never while it is running; or
remove the worktree if it has no work worth keeping.

**An idle agent's worktree still holds its branch lock.** A finished or stopped agent leaves the
worktree registered, so its branch stays unavailable. Check before reclaiming it:

```bash
git -C <worktree> status --short # uncommitted work?
git -C <worktree> log --oneline origin/<branch>..HEAD # unpushed commits?
git worktree remove <worktree> # only when both are empty
```

**`git worktree list` shows a `worktree-<id>` branch you did not create.** That is the
placeholder branch made at creation time; an agent checks out the branch it actually needs
afterward, so the placeholder is not where the work is. Look at the branch the agent reports, not
the one in the listing.

**Never `mv` a worktree.** Use `git worktree move` — it rewrites the `.git` pointers and carries
`node_modules` along, so no reinstall is needed. Moving the directory by hand leaves the worktree
pointing at a path that no longer exists.

**A worktree survives between agent runs.** Resuming an agent reuses its worktree and its
`node_modules`, so a resumed agent does not pay the install again. Tell it to `cd $PWD` fresh
rather than trusting a path it cached in an earlier run — the worktree may have been moved.

## Problems that look like worktree problems and are not

`eslint.config.js` still ignores `.claude/worktrees/`, and `.gitignore` still lists it. With the
hooks in place nothing should land there — the ignores exist so that a worktree created by hand
in the old location, or by a tool that bypasses the hooks, cannot silently break a root lint.
Cheap insurance against a failure that is otherwise invisible.
Worth knowing, because misdiagnosing these wastes real time:

Verified as unaffected by nesting either way: prettier (its globs do not descend into
dot-directories) and `tsc` (typecheck runs per-package through turbo, not from the root).
- **`--force-with-lease` fails with `stale info` on every branch.** That is a shallow or
single-branch clone, not a worktree issue — there is no remote-tracking ref to lease against.
See the shallow-clone entry in `CLAUDE.md`. The same cause breaks `git push -u` and makes
`gh pr create` demand an explicit `--head`.
- **Several branches suddenly need rebasing and force-pushing at once.** That is `main` moving
under an open stack, which happens whenever a PR merges. It is a stacking-cadence concern; see
the stacked-PR guidance in `CLAUDE.md`.
- **A `git push` is rejected as non-fast-forward.** Check whether the remote branch was rebased
independently (for example by GitHub's _Update branch_) before assuming your local history is
wrong. If the remote already contains your commits under new SHAs, replay only what is missing
with `git rebase --onto origin/<branch> <old-base> <branch>` rather than force-pushing over it.

## Delegating to a background agent with worktree isolation

Expand All @@ -88,7 +259,7 @@ dot-directories) and `tsc` (typecheck runs per-package through turbo, not from t
## Cleaning up

```bash
git worktree remove ../skills-worktrees/<name> # add --force if it has uncommitted changes
git worktree remove worktrees/<name> # add --force if it has uncommitted changes
git worktree list # confirm
```

Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
#!/usr/bin/env bash
#
# WorktreeCreate hook — place worktrees BESIDE the repo, not inside it.
# WorktreeCreate hook — place worktrees at <repo>/worktrees/<id>.
#
# Default Claude Code behavior creates worktrees at <repo>/.claude/worktrees/<id>.
# A worktree is a complete second checkout, so nesting it inside the repo means
# every tool that walks the tree from the root walks into it. We hit that: a root
# `eslint .` traversed 2983 files across two agent worktrees and failed on code an
# agent had half-written. An ignore rule patches one tool; placing worktrees
# outside the repo makes the whole class of problem impossible.
# Default Claude Code behavior uses <repo>/.claude/worktrees/<id>; this moves them
# to a top-level `worktrees/` directory instead.
#
# Layout: /path/to/<repo> -> /path/to/<repo>-worktrees/<worktree_id>
# A sibling directory (<repo>-worktrees/) was tried first and rejected: its path
# depends on what the clone directory is named, so committed settings.json cannot
# reference it portably — a teammate whose checkout is not named `skills` would be
# prompted for every file operation, silently and per-person.
#
# In-repo costs three ignore surfaces, because a worktree is a complete second
# checkout that root-level tooling walks into: .gitignore, .prettierignore, and
# eslint.config.js all exclude `worktrees/`. Verified as NOT needing one: pnpm
# workspaces (`packages/*` is root-anchored) and tsc (per-package via turbo).
#
# Layout: /path/to/<repo> -> /path/to/<repo>/worktrees/<worktree_id>
#
# Contract (docs: code.claude.com/docs/en/hooks):
# stdin - JSON with .worktree_id (and .base_path, .cwd, .session_id, ...)
Expand Down Expand Up @@ -38,7 +44,7 @@ fi
# Resolve against the repo this hook was invoked for, not $PWD.
cwd=$(printf '%s' "$payload" | jq -r '.cwd // empty')
repo_root=$(git -C "${cwd:-$PWD}" rev-parse --show-toplevel)
worktree_dir="$(dirname "$repo_root")/$(basename "$repo_root")-worktrees/$worktree_id"
worktree_dir="$repo_root/worktrees/$worktree_id"

# Idempotent, but only for a real worktree. A bare directory test would hand back
# a path that git knows nothing about — left by a partial cleanup, an interrupted
Expand Down
6 changes: 3 additions & 3 deletions .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,8 @@
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/worktree-create.sh\"",
"statusMessage": "Creating worktree beside the repo"
"command": "\"$CLAUDE_PROJECT_DIR/.agents/skills/worktrees-pnpm/scripts/worktree-create.sh\"",
"statusMessage": "Creating worktree"
}
]
}
Expand All @@ -69,7 +69,7 @@
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/worktree-remove.sh\""
"command": "\"$CLAUDE_PROJECT_DIR/.agents/skills/worktrees-pnpm/scripts/worktree-remove.sh\""
}
]
}
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,6 @@ agents.lock
# Python bytecode (skill scripts)
__pycache__/
*.pyc

# Agent and manual git worktrees (full second checkouts; see worktrees-pnpm skill)
worktrees/
3 changes: 3 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,6 @@ __generated__
# unformattables
*.sh
*.cfg

# Worktrees are second checkouts; formatting them would touch other branches
worktrees/
12 changes: 7 additions & 5 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,13 @@ export default tseslint.config(
"openspec/",
"**/test/fixtures/",
"tmp/",
// Agent worktrees are full checkouts nested inside the repo. Without
// this, a root `eslint .` lints every worktree's copy of the tree —
// slow, and it fails on whatever an agent has mid-edit. Scoped to
// `worktrees` rather than all of `.claude/` so anything else we put
// there is still checked.
// Worktrees are full checkouts nested inside the repo. Without this, a
// root `eslint .` lints every worktree's copy of the tree — slow, and it
// fails on whatever an agent has mid-edit. `.claude/worktrees/` is the
// harness default and stays listed as a backstop for anything that
// bypasses the WorktreeCreate hook; both are scoped to `worktrees` rather
// than all of `.claude/` so anything else we put there is still checked.
"worktrees/",
".claude/worktrees/",
// Zero-dependency CommonJS workflow scripts (covered by their own
// node:test suite); the app's TS/ESM-oriented rules don't apply.
Expand Down
Loading