diff --git a/.agents/skills/worktrees-pnpm/SKILL.md b/.agents/skills/worktrees-pnpm/SKILL.md index ba2d05b1..affa9591 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 @@ -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. @@ -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: /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 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 -`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/ ` rather than force-pushing over it. ## Delegating to a background agent with worktree isolation @@ -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/ # 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/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 8ca4ed73..f3dc7eaf 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -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" } ] } @@ -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.