From 50f6e305d3b1025a6f040ef8c6720756f111bb84 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Wed, 29 Jul 2026 12:08:48 -0700 Subject: [PATCH 1/4] docs: fold worktree gotchas and remediations into the skill Every entry is something hit for real this session: a branch locked by an idle agent's worktree, the placeholder worktree- branch that isn't where the work is, `mv` breaking a worktree where `git worktree move` preserves it, and worktrees surviving between agent runs. Also separates the failures that merely surfaced during worktree work from the ones worktrees actually cause. --force-with-lease failing on every branch is a shallow clone; a fan-out of rebases is main moving under an open stack; a rejected push may be a remote that was rebased independently, where replaying the missing commit beats force-pushing over it. Misdiagnosing those as worktree problems is what cost the most time, so they are named rather than left to be rediscovered. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CDv57zHq7abms3RReSQw6q --- .agents/skills/worktrees-pnpm/SKILL.md | 49 +++++++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/.agents/skills/worktrees-pnpm/SKILL.md b/.agents/skills/worktrees-pnpm/SKILL.md index ba2d05b1..4a01a5f5 100644 --- a/.agents/skills/worktrees-pnpm/SKILL.md +++ b/.agents/skills/worktrees-pnpm/SKILL.md @@ -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 @@ -70,6 +70,53 @@ Cheap insurance against a failure that is otherwise invisible. 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). +## 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 status --short # uncommitted work? +git -C log --oneline origin/..HEAD # unpushed commits? +git worktree remove # only when both are empty +``` + +**`git worktree list` shows a `worktree-` 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 + +Worth knowing, because misdiagnosing these wastes real time: + +- **`--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/ ` rather than force-pushing over it. + ## Delegating to a background agent with worktree isolation - **Never point the agent at the main repo path.** Given an absolute path to the primary From bcfaf8a18a64a79520f36707f414595249096900 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Thu, 30 Jul 2026 09:31:33 -0700 Subject: [PATCH 2/4] chore: bring the worktree directory into the permission scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Worktrees live at ../-worktrees/ now, which is outside the project root, so a fresh session or a teammate would be prompted for every file operation there — the placement fixed the tooling problem but left the permission scope behind it. Scoped to that one sibling directory rather than the parent, and the read.deny globs are all **/-prefixed so keys, .env files, and credentials stay denied inside worktrees exactly as they are inside the repo. Note the path is literal: it matches a clone directory named `skills`, which is what the create hook derives `skills-worktrees` from. A clone under a different directory name gets a correspondingly different worktree directory and would need this entry adjusted. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CDv57zHq7abms3RReSQw6q --- .claude/settings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.claude/settings.json b/.claude/settings.json index 8ca4ed73..33a7d242 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -38,7 +38,7 @@ "Bash(pnpm dlx @taskless/cli*)", "Bash(openspec:*)" ], - "additionalDirectories": ["openspec/*"] + "additionalDirectories": ["openspec/*", "../skills-worktrees"] }, "read": { "deny": [ From f363a6fcdad305add7df7cb82607e8f1520e7c41 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Thu, 30 Jul 2026 10:37:14 -0700 Subject: [PATCH 3/4] chore: move worktrees in-repo and ship the hooks with the skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sibling location could not be referenced portably from committed config: its path derives from the clone directory name, so a teammate whose checkout is not named `skills` gets permission prompts for every file operation — silently, and per person. `/worktrees/` is the same path in every clone. The cost is that root-level tooling walks into it, so `worktrees/` is excluded in .gitignore, .prettierignore, and eslint.config.js. Prettier is a new entry rather than an oversight: its globs skip dotfiles, so `.claude/worktrees/` had been invisible to it. Measured as NOT needing one: pnpm workspaces (the glob is root-anchored) and tsc (per-package via turbo). All three ignores verified against a live worktree. The hook scripts move to `scripts/` under the skill, the way iterate-pr ships its own, so a repo installing this skill through dotagents gets them. settings.json references them by project-relative path, which is stable across repos. The skill gains a Setup section with the settings.json block and a way to test the hooks by piping them a payload, plus ignore examples for eslint (flat and legacy), prettier, biome, ruff/black, tsconfig, and a verification recipe — since which tools traverse differs per repo and guessing is what caused the prettier miss here. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CDv57zHq7abms3RReSQw6q --- .agents/skills/worktrees-pnpm/SKILL.md | 138 +++++++++++++++++- .../scripts}/worktree-create.sh | 24 +-- .../scripts}/worktree-remove.sh | 0 .claude/settings.json | 6 +- .gitignore | 3 + .prettierignore | 3 + eslint.config.js | 12 +- 7 files changed, 162 insertions(+), 24 deletions(-) rename {.claude/hooks => .agents/skills/worktrees-pnpm/scripts}/worktree-create.sh (72%) rename {.claude/hooks => .agents/skills/worktrees-pnpm/scripts}/worktree-remove.sh (100%) diff --git a/.agents/skills/worktrees-pnpm/SKILL.md b/.agents/skills/worktrees-pnpm/SKILL.md index 4a01a5f5..47ad0863 100644 --- a/.agents/skills/worktrees-pnpm/SKILL.md +++ b/.agents/skills/worktrees-pnpm/SKILL.md @@ -60,15 +60,139 @@ 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: /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: -`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. +```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 +``` -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). +Any non-zero count is a tool that still needs an ignore entry. ## Gotchas and remediations diff --git a/.claude/hooks/worktree-create.sh b/.agents/skills/worktrees-pnpm/scripts/worktree-create.sh similarity index 72% rename from .claude/hooks/worktree-create.sh rename to .agents/skills/worktrees-pnpm/scripts/worktree-create.sh index 994b9eda..eeeaf84b 100755 --- a/.claude/hooks/worktree-create.sh +++ b/.agents/skills/worktrees-pnpm/scripts/worktree-create.sh @@ -1,15 +1,21 @@ #!/usr/bin/env bash # -# WorktreeCreate hook — place worktrees BESIDE the repo, not inside it. +# WorktreeCreate hook — place worktrees at /worktrees/. # -# Default Claude Code behavior creates worktrees at /.claude/worktrees/. -# 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 /.claude/worktrees/; this moves them +# to a top-level `worktrees/` directory instead. # -# Layout: /path/to/ -> /path/to/-worktrees/ +# A sibling directory (-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/ -> /path/to//worktrees/ # # Contract (docs: code.claude.com/docs/en/hooks): # stdin - JSON with .worktree_id (and .base_path, .cwd, .session_id, ...) @@ -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 diff --git a/.claude/hooks/worktree-remove.sh b/.agents/skills/worktrees-pnpm/scripts/worktree-remove.sh similarity index 100% rename from .claude/hooks/worktree-remove.sh rename to .agents/skills/worktrees-pnpm/scripts/worktree-remove.sh diff --git a/.claude/settings.json b/.claude/settings.json index 33a7d242..cb25cc75 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -38,7 +38,7 @@ "Bash(pnpm dlx @taskless/cli*)", "Bash(openspec:*)" ], - "additionalDirectories": ["openspec/*", "../skills-worktrees"] + "additionalDirectories": ["openspec/*"] }, "read": { "deny": [ @@ -58,7 +58,7 @@ "hooks": [ { "type": "command", - "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/worktree-create.sh\"", + "command": "\"$CLAUDE_PROJECT_DIR/.agents/skills/worktrees-pnpm/scripts/worktree-create.sh\"", "statusMessage": "Creating worktree beside the repo" } ] @@ -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\"" } ] } diff --git a/.gitignore b/.gitignore index d6780c54..0b521c7c 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/.prettierignore b/.prettierignore index 847bcaf9..76b675bf 100644 --- a/.prettierignore +++ b/.prettierignore @@ -7,3 +7,6 @@ __generated__ # unformattables *.sh *.cfg + +# Worktrees are second checkouts; formatting them would touch other branches +worktrees/ diff --git a/eslint.config.js b/eslint.config.js index 16c2dd44..f20473a3 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -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. From 33b3835e7ce7f3e90ed2171165bff27fad805f5d Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Thu, 30 Jul 2026 11:46:55 -0700 Subject: [PATCH 4/4] fix: make the skill agree with what the hooks now do MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review caught the doc opening by telling readers the opposite of the setup section further down. I rewrote the intro for the sibling model, then moved worktrees in-repo and only *added* the Setup section without going back — so the intro, its rationale, and the cleanup example all still described `../skills-worktrees/`, and the intro credited the hook scripts to `.claude/hooks/` after they moved under the skill. For a skill whose entire purpose is telling people where worktrees go, contradicting itself is the worst available bug. The sibling model is now recorded as the rejected alternative with the reason it lost — its path depends on the clone directory name, which committed config cannot know — rather than deleted, so the next person does not re-propose it. Also fixes the `statusMessage` left reading "Creating worktree beside the repo". Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CDv57zHq7abms3RReSQw6q --- .agents/skills/worktrees-pnpm/SKILL.md | 10 +++++----- .claude/settings.json | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.agents/skills/worktrees-pnpm/SKILL.md b/.agents/skills/worktrees-pnpm/SKILL.md index 47ad0863..affa9591 100644 --- a/.agents/skills/worktrees-pnpm/SKILL.md +++ b/.agents/skills/worktrees-pnpm/SKILL.md @@ -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/ # or -b for a new one -cd ../skills-worktrees/ +git worktree add worktrees/ # or -b for a new one +cd worktrees/ 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/`. +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/`. `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 (`../-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. @@ -259,7 +259,7 @@ Worth knowing, because misdiagnosing these wastes real time: ## Cleaning up ```bash -git worktree remove ../skills-worktrees/ # add --force if it has uncommitted changes +git worktree remove worktrees/ # add --force if it has uncommitted changes git worktree list # confirm ``` diff --git a/.claude/settings.json b/.claude/settings.json index cb25cc75..f3dc7eaf 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -59,7 +59,7 @@ { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR/.agents/skills/worktrees-pnpm/scripts/worktree-create.sh\"", - "statusMessage": "Creating worktree beside the repo" + "statusMessage": "Creating worktree" } ] }