From 0a2def9072a9debd88dd6c766dda70e0ceceab97 Mon Sep 17 00:00:00 2001 From: christophe dervieux Date: Thu, 7 May 2026 19:09:51 +0200 Subject: [PATCH 01/34] plan: cargo xtask create-worktree + CLAUDE.local.md (bd-spsv) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Initial design plan for new xtask subcommand that automates worktree setup (git worktree add + .beads/redirect + CLAUDE.local.md context section). Three modes: positional bd-id, --issue N, --upgrade. Plan went through two design-review passes via sub-agent before commit. Pass 1 surfaced 1 blocking + ~12 required issues — main themes: idempotency holes (CRLF, missing END marker, multi-BEGIN), clap-derive struct shape mismatch, hand-rolled date math, missing Manual bootstrap fallback, slug stop-words. Pass 2 caught the `time` feature flags (`macros` + `formatting` not in default set), line-ending sniff edge cases, and stale verification fixtures. Implementation deferred to /writing-plans phase. --- .../plans/2026-05-07-create-worktree-xtask.md | 453 ++++++++++++++++++ 1 file changed, 453 insertions(+) create mode 100644 claude-notes/plans/2026-05-07-create-worktree-xtask.md diff --git a/claude-notes/plans/2026-05-07-create-worktree-xtask.md b/claude-notes/plans/2026-05-07-create-worktree-xtask.md new file mode 100644 index 000000000..26746fb46 --- /dev/null +++ b/claude-notes/plans/2026-05-07-create-worktree-xtask.md @@ -0,0 +1,453 @@ +# Plan: `cargo xtask create-worktree` + CLAUDE.local.md worktree context + +## Context + +Worktree creation is currently manual: each skill (`triage`, `investigate-beads`, `upgrade-cargo-deps`) has a copy of the same `git worktree add` + `echo ... > .beads/redirect` bash commands — duplicated, no automation, no context left behind for the next session. + +When starting a new Claude Code session in an existing worktree, there is no file that immediately answers: "what are we working on here, where is the main repo, what's the beads issue?" The developer (or Claude) has to re-explore. CLAUDE.local.md solves this — Claude Code loads it automatically at session start. + +Two additional problems solved: +- `CLAUDE.local.md` is in Chris's global gitignore but NOT in q2's `.gitignore` — other contributors would accidentally commit one without the global rule +- Similar patterns in other quarto-dev projects confirm this is the right approach; q2 needs its own equivalent + +**Goals:** +1. Single `cargo xtask create-worktree` command handles all worktree setup +2. Command prepends a clearly-marked worktree context section to `CLAUDE.local.md` (safe for existing content) +3. CLAUDE.local.md holds *context* only — beads tracks status, not this file +4. Works safely for all devs; `br` and `gh` are project dependencies — hard fail if missing + +## CLAUDE.local.md design + +The xtask prepends a delimited section to the file. Delimiter markers allow idempotent updates (re-running the command updates the section rather than duplicating it). + +**Content:** worktree declaration, main repo relative path, beads ID (pointer only — run `br show` for live status), GitHub URL, plan file placeholder. + +```markdown + +# Worktree Context + +This is a **worktree** of the q2 repository. Main repo: `../..` + +**Beads:** bd-1d3e — Fix CRLF test failures in quarto-doctemplate on Windows +**GitHub:** https://github.com/quarto-dev/q2/issues/157 +**Plan:** + +Run `br show bd-1d3e` for current status and notes. + + +``` + +For issue workflow (no pre-existing beads issue): +```markdown + +# Worktree Context + +This is a **worktree** of the q2 repository. Main repo: `../..` + +**GitHub issue:** #157 — +**URL:** https://github.com/quarto-dev/q2/issues/157 +**Beads:** (run `br search 157` to find or create a beads issue) +**Plan:** <!-- fill in after creating: claude-notes/plans/YYYY-MM-DD-name.md --> +<!-- END WORKTREE CONTEXT --> + +``` + +For upgrade workflow: +```markdown +<!-- BEGIN WORKTREE CONTEXT — managed by cargo xtask create-worktree --> +# Worktree Context + +This is a **worktree** of the q2 repository. Main repo: `../..` + +**Task:** Cargo dependency upgrade — 2026-05-07 +**Plan:** <!-- fill in if needed --> +<!-- END WORKTREE CONTEXT --> + +``` + +## Command interface + +```bash +cargo xtask create-worktree bd-1d3e # beads workflow +cargo xtask create-worktree --issue 157 # GH issue triage workflow +cargo xtask create-worktree --upgrade # cargo-upgrade (date-based branch) +``` + +Optional flags (all modes): +- `--slug <slug>` — override auto-derived slug (default: derived from title — see § Slug derivation) +- `--base <branch>` — base branch (default: `main`) + +Mode selection enforced by `clap::ArgGroup(required=true, multiple=false)` so that exactly one of `<bd-id>` / `--issue` / `--upgrade` is set; clap auto-generates "exactly one of" error message. No runtime mode-validation needed. + +## Files to modify + +| File | Change | +|---|---| +| `.gitignore` | Add `CLAUDE.local.md` line — protects the **main repo root** case (a contributor without a global gitignore rule could otherwise commit one). `.worktrees/` is already gitignored (line 32), so worktree-internal CLAUDE.local.md is already safe. Use top-level pattern (matches root + recursive — gitignore semantics: bare filename matches in any directory unless prefixed with `/`). | +| `crates/xtask/Cargo.toml` | Add `time = { version = "0.3", features = ["macros", "formatting"] }` to `[dependencies]` for date formatting (already in `Cargo.lock` transitively — zero compile cost). Both features are required: `macros` for `format_description!`, `formatting` for `OffsetDateTime::format`. | +| `crates/xtask/src/main.rs` | Add `CreateWorktree { ... }` struct-style variant to `Command` enum (matches existing pattern — see `DevSetup`, `Lint`, `Verify`) + `mod create_worktree;` + update top-level doc comment | +| `crates/xtask/src/create_worktree.rs` | New file — full implementation | +| `.claude/rules/xtask.md` | Add `create-worktree` row to commands table | +| `.claude/rules/worktrees.md` | Add § CLAUDE.local.md; replace § Fresh worktree bootstrap with xtask-first guidance + new § Manual bootstrap (fallback when xtask unbuilt — referenced from skills) | +| `.claude/skills/investigate-beads/SKILL.md` | Replace inline git commands with `cargo xtask create-worktree <id>`; add "pass `--slug X` to override auto-derived slug" guidance | +| `.claude/skills/triage/SKILL.md` | Replace inline git commands with `cargo xtask create-worktree --issue <N>`; explicitly note this runs BEFORE the skill's beads-creation step (the `--issue` template has no Beads line on purpose) | +| `.claude/skills/upgrade-cargo-deps/SKILL.md` | Replace inline git commands with `cargo xtask create-worktree --upgrade` | + +## Implementation: create_worktree.rs + +```rust +#[derive(clap::Args)] +#[command(group(clap::ArgGroup::new("mode").required(true).multiple(false)))] +pub struct Args { + /// Beads issue ID, e.g. `bd-1d3e`. Reads `br show <id>` for title and external_ref. + #[arg(group = "mode")] + beads_id: Option<String>, + + /// GitHub issue number, e.g. `157`. Reads `gh issue view`. + #[arg(long, group = "mode")] + issue: Option<u32>, + + /// Cargo dependency upgrade — uses today's date for branch name. + #[arg(long, group = "mode")] + upgrade: bool, + + /// Override auto-derived slug. Behavior depends on mode: + /// beads = full override of derived slug; issue / upgrade = appended as suffix + /// (for test isolation or parallel-worktree workflows). + #[arg(long)] + slug: Option<String>, + + /// Base branch. + #[arg(long, default_value = "main")] + base: String, +} +``` + +**Steps in `pub fn run(args: Args) -> Result<()>`:** + +### 1. Mode determination +Handled by `clap::ArgGroup` (required + single) — no runtime check needed. Match on which `Option<...>`/`bool` is set. + +### 2. Fetch metadata + +**Beads mode:** run `br show <id> --json`, parse JSON array: +- `.[0].title` — used for slug derivation + CLAUDE.local.md template +- `.[0].external_ref` — used for GitHub URL when present (format: `gh-157` → `https://github.com/quarto-dev/q2/issues/157`) + +If `external_ref` is `null` or absent: omit the **GitHub** line in the CLAUDE.local.md template; do not error. If non-`gh-` external ref (e.g. some future system): omit and warn on stderr. + +If `br` exits non-zero: surface stderr verbatim plus prefix `br show <id> failed:`. On success, ignore stderr (br can emit non-fatal warnings on stderr — e.g. sync-state notes — that should not be surfaced). If `br` not found in PATH: error `br is required — install via cargo install beads-rust or see project README`. + +**Issue mode:** run `gh issue view <N> --repo quarto-dev/q2 --json title,url`, parse result. + +If `gh` issue does not exist: surface gh's error verbatim. (`gh issue view` will not match a PR — `gh pr view` is a separate subcommand.) If `gh` not found: error `gh is required — see https://cli.github.com/`. + +**Upgrade mode:** no fetch. Use today's date (see § Date formatting). + +### 3. Derive slug + +If `--slug` provided: use it verbatim (no validation — caller's responsibility). + +Otherwise from title, with explicit handling for kebab boundaries, stop-words, and empty result: + +1. Lowercase the title. +2. Split on whitespace **and** `-` (so `quarto-doctemplate` becomes `["quarto", "doctemplate"]`, preserving kebab boundaries). +3. For each token: keep only ASCII alphanumerics (`[a-z0-9]`). This drops punctuation including smart quotes, en-dashes, parens, brackets, colons. Non-ASCII Unicode (CJK, accented chars) is also dropped — explicit ASCII-only policy keeps slugs predictable on every filesystem and shell. If a contributor needs different characters, `--slug` overrides. +4. Drop empty tokens. +5. Drop stop-words: `a`, `an`, `the`, `and`, `or`, `in`, `on`, `of`, `to`, `for`, `with`, `from`, `at`, `by`, `is`, `as`. Document this list in a `const STOP_WORDS: &[&str]` so it's discoverable + testable. +6. Take first **4** remaining tokens. +7. Join with `-`. +8. **Empty-result fallback:** if step 7 produces an empty string, return error `unable to derive slug from title "<title>" — pass --slug <name> to override`. + +Worked example: "Fix CRLF test failures in quarto-doctemplate on Windows" +- after step 1–2: `["fix", "crlf", "test", "failures", "in", "quarto", "doctemplate", "on", "windows"]` +- after step 3–4: same (already alphanumeric) +- after step 5 (drop `in`, `on`): `["fix", "crlf", "test", "failures", "quarto", "doctemplate", "windows"]` +- after step 6 (first 4): `["fix", "crlf", "test", "failures"]` +- after step 7: `fix-crlf-test-failures` ✓ + +### 4. Determine branch + directory + +| Mode | Default branch | Default directory | When `--slug X` provided | +|---|---|---|---| +| Beads | `beads/<id>-<derived-slug>` | `.worktrees/<id>-<derived-slug>` | `<derived-slug>` is **replaced** by `<X>` (override) | +| Issue | `issue-<N>` | `.worktrees/issue-<N>` | Appended as suffix → `issue-<N>-<X>` (for test isolation) | +| Upgrade | `cargo-upgrade-<YYYY-MM-DD>` | `.worktrees/cargo-upgrade-<YYYY-MM-DD>` | Appended → `cargo-upgrade-<DATE>-<X>` | + +Rationale for asymmetry: in beads mode the slug carries the issue title and is the natural override target. In issue + upgrade modes the canonical directory format is the stable identity (issue number, date) — `--slug X` exists only to enable parallel-worktree workflows or test isolation, so it's a suffix. + +Error if the resulting directory already exists. + +### 5. `git worktree add` + +``` +git worktree add -b <branch> <dir> <base> +``` + +Pre-check: if `<dir>` already exists or `<branch>` already exists locally, return clear error before invoking git (`worktree directory already exists: <dir>`, `branch already exists: <branch> — remove it or pass --slug to disambiguate`). Otherwise propagate git's exit + stderr verbatim. We don't try to recover from git failures — surface them. + +### 6. `.beads/redirect` + +Write `../../../.beads\n` to `<dir>/.beads/redirect` (LF line ending, even on Windows — git on Windows reads it fine and avoids gratuitous CRLF noise). + +(`.beads/` already exists in the worktree from git; `.beads/redirect` is already in `.beads/.gitignore` — no git noise.) + +### 7. CLAUDE.local.md — prepend with markers (idempotent) + +**Markers (constants):** +- `BEGIN_MARKER`: `<!-- BEGIN WORKTREE CONTEXT — managed by cargo xtask create-worktree -->` +- `END_MARKER`: `<!-- END WORKTREE CONTEXT -->` + +**Algorithm:** + +1. Path: `<dir>/CLAUDE.local.md`. +2. If the path exists, check `metadata().is_file()` — if it's a directory, symlink, or junction (Windows reparse point) that does not point at a regular file, error: `CLAUDE.local.md exists but is not a regular file: <path>`. (The xtask only writes to fresh worktrees; we will not silently overwrite an existing target.) If the file does not exist, treat existing content as empty. +3. Read existing content if present (`fs::read_to_string` — fails on non-UTF-8, which is desired). +4. **Detect old section.** Search for `BEGIN_MARKER`. If absent → no strip needed; new content prepends with a single blank line separator before existing content (or no separator if existing content is empty). +5. **If `BEGIN_MARKER` present:** + - Find the **first** occurrence (multiple BEGIN markers indicate prior corruption — use first, log a stderr warning suggesting manual review). + - Search **after** the first BEGIN for `END_MARKER`. If `END_MARKER` is missing, error: `CLAUDE.local.md has BEGIN marker without END marker — refusing to modify; resolve manually`. Do not consume to EOF. + - Strip everything from the start of `BEGIN_MARKER` line through the end of `END_MARKER` line, plus exactly one trailing newline if present (but not more — preserves blank lines authored by the user). +6. **Line-ending handling.** Detect `\r\n` vs `\n` once on read (sniff first 1KB). Decision rule: + - File new, empty, or no newline observed in sniff window → default to **LF**. + - Sniff window contains `\r\n` (any count) → use **CRLF** for the entire write. + - Sniff window contains only `\n` → use **LF**. + - Mixed `\r\n` + bare `\n` → use **LF** (treat as primarily-LF file with stray CR; do not propagate inconsistency). + + Write the new section using the chosen ending; never mix endings within a single write. (Worktree CLAUDE.local.md is gitignored, so `.gitattributes` does not normalize at commit; detection has to be runtime-correct.) +7. **Prepend new section** (template from `## CLAUDE.local.md design`) followed by a single blank line, followed by the (possibly stripped) remaining content. Ensure final file ends with a single trailing newline. +8. Write atomically: write to `<path>.tmp` then rename. (Avoids half-written file on crash.) + +This makes the command **idempotent**: running it twice updates the section in place without duplicating or destroying other content. + +**Failure cases tabulated:** + +| Condition | Behavior | +|---|---| +| File missing | Create with new section + trailing newline | +| File present, no BEGIN | Prepend section + blank line + existing content | +| File present, BEGIN + END | Strip section, prepend new | +| File present, BEGIN without END | Error, refuse to modify | +| File present, multiple BEGIN | Warn, strip from first BEGIN | +| Path is directory / non-file | Error, refuse to modify | +| Non-UTF-8 content | Error from `read_to_string`, surface | + +### 8. Print summary + +``` +Created worktree: .worktrees/bd-1d3e-fix-crlf-test-failures/ + Branch: beads/bd-1d3e-fix-crlf-test-failures + Beads: bd-1d3e — Fix CRLF test failures in quarto-doctemplate on Windows + GitHub: https://github.com/quarto-dev/q2/issues/157 + +Next steps: + 1. Fill in plan file path in CLAUDE.local.md (once plan is created) + 2. cd .worktrees/bd-1d3e-fix-crlf-test-failures && npm install (if hub-client in scope) + 3. Start Claude Code session in .worktrees/bd-1d3e-fix-crlf-test-failures/ + 4. Run: br update bd-1d3e --status in_progress +``` + +## Date formatting (upgrade mode) + +Add `time = { version = "0.3", features = ["macros", "formatting"] }` as a direct `[dependencies]` entry in `crates/xtask/Cargo.toml`. Both `time` and `chrono` are already in the workspace `Cargo.lock` transitively — adding `time` as a direct dep has zero compile cost. The `macros` feature enables `format_description!`; the `formatting` feature enables `OffsetDateTime::format` — neither is in the default feature set. + +Format using `time::OffsetDateTime::now_utc().format(&time::macros::format_description!("[year]-[month]-[day]"))`. + +Hand-rolling YYYY-MM-DD from `std::time::SystemTime` requires re-implementing Gregorian calendar conversion (epoch-seconds → year/month/day with leap-year + month-length math). ~50 lines of date arithmetic where `time` provides one well-tested function call. Not worth the dependency-zero principle in this case. + +## main.rs changes + +Match the existing `Command` enum's struct-style pattern (used by `DevSetup`, `Lint`, `Verify`, `BuildAll`). Tuple-style `CreateWorktree(Args)` is incompatible with how the rest of `main.rs` declares fields directly inside the variant — we use a flattened struct embedding via `#[command(flatten)]` instead. + +Add to top-level doc comment: `- 'create-worktree': Create git worktree with beads redirect and CLAUDE.local.md` + +Add module declaration: `mod create_worktree;` + +Add to `Command` enum: +```rust +/// Create a new git worktree with beads redirect and CLAUDE.local.md context stub. +/// +/// Modes (exactly one required): +/// <bd-id> — beads issue (positional) +/// --issue N — GitHub issue triage +/// --upgrade — cargo dependency upgrade (date-based branch) +CreateWorktree { + #[command(flatten)] + args: create_worktree::Args, +}, +``` + +Add match arm: +```rust +Command::CreateWorktree { args } => create_worktree::run(args), +``` + +## Skills update + +The xtask is **filesystem-only** — does NOT create or update beads issues. Each skill keeps its existing beads creation/update logic; only the raw git bash gets replaced. The xtask never invokes `br update`, `br create`, or any other state-changing beads command. (We considered having it `br update <id> --notes "worktree at .worktrees/..."` but rejected: skills are the right layer to track lifecycle, the xtask should stay narrow + pure.) + +All three skills currently embed: +```bash +git worktree add -b beads/<id>-<slug> .worktrees/<id>-<slug> main +echo "../../../.beads" > .worktrees/<id>-<slug>/.beads/redirect +``` + +Replace with: +```bash +cargo xtask create-worktree <id> +# Creates worktree, beads redirect, and CLAUDE.local.md stub. +# By default derives slug from `br show` title; pass `--slug X` to override. +# Fallback for fresh clones where xtask is not yet built: +# see .claude/rules/worktrees.md § Manual bootstrap. +``` + +**Per-skill notes:** + +- **`investigate-beads`:** beads issue exists before worktree creation (skill walks dep graph then creates worktree). xtask reads `br show <id>` for title + external_ref → CLAUDE.local.md gets full Beads + GitHub lines. +- **`triage`:** beads issue is created in step 6, **after** worktree creation in step 3. The `--issue` mode template intentionally has no Beads line — it shows `(run br search 157 to find or create a beads issue)`. Skill text needs an explicit callout: "step 3 sets up the worktree; the beads issue lands in step 6 and the developer fills its ID into CLAUDE.local.md manually (or re-runs `cargo xtask create-worktree <bd-id>` to upgrade the section)." +- **`upgrade-cargo-deps`:** no beads issue at worktree-creation time. CLAUDE.local.md template is the upgrade variant. + +## worktrees.md additions + +Three changes: + +**1. Replace § Fresh worktree bootstrap** with xtask-first guidance: + +```markdown +## Fresh worktree bootstrap + +Use `cargo xtask create-worktree <bd-id>` (or `--issue N` / `--upgrade`) for new worktrees — +it handles `git worktree add`, `.beads/redirect`, and the CLAUDE.local.md context stub in +one shot. After it finishes, `npm install` from the new worktree if hub-client is in scope: + +```bash +cargo xtask create-worktree bd-XXXX +cd .worktrees/<id>-<slug> +npm install # only if hub-client work is in scope +cargo xtask verify --skip-hub-build # confirm green at branch HEAD +``` + +If the xtask is not yet built (fresh clone, or branch where `cargo build -p xtask` has +not run), see § Manual bootstrap below. +``` + +**2. New § CLAUDE.local.md** after § Beads Redirect: + +```markdown +## CLAUDE.local.md + +`cargo xtask create-worktree` prepends a worktree context section to `CLAUDE.local.md`. +Claude Code loads it automatically — no need to run `br show` to orient at session start. + +The section contains: worktree declaration, main repo path (`../..`), beads ID, +GitHub URL, and a placeholder for the plan file path (fill in manually after creating +the plan). + +Status lives in beads, not in this file. Run `br show <id>` for current status + notes. + +The section is delimited by `<!-- BEGIN/END WORKTREE CONTEXT -->` markers so it can be +updated by re-running the xtask without disturbing other content. The command is +idempotent. +``` + +**3. New § Manual bootstrap** (the fallback referenced from skills + § Fresh worktree bootstrap): + +```markdown +## Manual bootstrap + +If `cargo xtask create-worktree` is unavailable (fresh clone before first build, or +the xtask binary is broken on the current branch), fall back to manual setup: + +```bash +git worktree add -b beads/<id>-<slug> .worktrees/<id>-<slug> main +echo "../../../.beads" > .worktrees/<id>-<slug>/.beads/redirect +# Optional but recommended: write a CLAUDE.local.md context stub manually +# using the template from `cargo xtask create-worktree --help` output. +``` + +Verify with `br where` from inside the worktree. +``` + +The `.gitignore` change protects the **main repo root** case: a contributor without a +global `CLAUDE.local.md` ignore rule could otherwise commit one accidentally. Worktree- +internal `CLAUDE.local.md` files are already covered by the existing `.worktrees/` entry. + +## Verification + +End-to-end verification covers all three modes plus idempotency. Per CLAUDE.md "End-to-end verification before declaring success" — record exact invocations + observed output snippets in the implementation PR description. + +```bash +# Build xtask +cargo build -p xtask + +# Help (sanity-check clap config) +cargo xtask create-worktree --help + +# --- Beads mode --- +cargo xtask create-worktree bd-1d3e --slug e2e-beads +cat .worktrees/bd-1d3e-e2e-beads/.beads/redirect # → ../../../.beads +cat .worktrees/bd-1d3e-e2e-beads/CLAUDE.local.md # → worktree context section with Beads + GitHub lines +(cd .worktrees/bd-1d3e-e2e-beads && br where) # → main .beads/ via redirect + +# --- Issue mode --- +# Pick any open issue from the repo for the smoke test; #1 may not exist or may be a PR. +ISSUE=$(gh issue list --repo quarto-dev/q2 --state open --limit 1 --json number --jq '.[0].number') +cargo xtask create-worktree --issue "$ISSUE" --slug e2e-issue +cat .worktrees/issue-${ISSUE}-e2e-issue/CLAUDE.local.md # → no Beads line, has GitHub line +# (Note: issue-mode directory format follows the issue-<N> convention; --slug suffix +# is appended for test isolation only.) + +# --- Upgrade mode --- +cargo xtask create-worktree --upgrade --slug e2e-upgrade +cat .worktrees/cargo-upgrade-<DATE>-e2e-upgrade/CLAUDE.local.md # → upgrade variant template + +# --- Idempotency --- +# Re-run the same command; should update the section in place, not duplicate. +cargo xtask create-worktree bd-1d3e --slug e2e-beads +grep -c "BEGIN WORKTREE CONTEXT" .worktrees/bd-1d3e-e2e-beads/CLAUDE.local.md # → 1 + +# --- Preserve existing content --- +# Add user content below the managed section, re-run, confirm preserved. +echo -e "\n# My notes\nfoo" >> .worktrees/bd-1d3e-e2e-beads/CLAUDE.local.md +cargo xtask create-worktree bd-1d3e --slug e2e-beads +grep "My notes" .worktrees/bd-1d3e-e2e-beads/CLAUDE.local.md # → present + +# --- Failure cases (manual checks) --- +# 1. Existing directory collision +mkdir -p .worktrees/collision-test +cargo xtask create-worktree bd-1d3e --slug collision-test # → clear error, no git operation + +# 2. Missing END marker (corrupt CLAUDE.local.md) +printf '<!-- BEGIN WORKTREE CONTEXT -->\nbroken\n' > .worktrees/bd-1d3e-e2e-beads/CLAUDE.local.md +cargo xtask create-worktree bd-1d3e --slug e2e-beads # → refuses, asks for manual fix + +# Cleanup +cd <main repo root> +git worktree remove .worktrees/bd-1d3e-e2e-beads +git worktree remove .worktrees/issue-${ISSUE}-e2e-issue +git worktree remove .worktrees/cargo-upgrade-<DATE>-e2e-upgrade +rm -rf .worktrees/collision-test +# `git branch -d` works for the e2e branches (no commits added during the smoke). +# If a branch has commits (e.g. you committed during the recipe), use `git branch -D`. +git branch -d beads/bd-1d3e-e2e-beads issue-${ISSUE}-e2e-issue cargo-upgrade-<DATE>-e2e-upgrade +``` + +**Self-bootstrap note for PR reviewers:** the worktree `bd-spsv-create-worktree-xtask` was itself created with the manual git+echo commands the new xtask replaces (chicken-and-egg: the command being added cannot be used to set up its own development worktree). After this PR lands on `main`, the next worktree any developer creates should be the first end-to-end real-world test of the new command. + +## Unit test coverage + +Add `#[cfg(test)] mod tests` to `create_worktree.rs` covering pure functions (no fs/network): + +- `derive_slug` — title with stop-words / kebab boundaries / unicode / mixed case / empty / very short +- `update_claude_local_md` — file missing, BEGIN+END, BEGIN-only, multiple BEGIN, CRLF vs LF, no-newline-in-sniff, mixed-endings, non-UTF-8 (via `&[u8]` round-trip) +- `parse_external_ref_to_github_url` — `gh-157`, null, malformed, non-`gh-` prefix +- Marker-byte stability: `const _: () = assert!(BEGIN_MARKER.contains('\u{2014}'));` — locks against accidental ASCII-hyphen substitution by an editor + +The fs/network entry points (`run`, `git_worktree_add`, `fetch_beads_metadata`) are exercised end-to-end via the verification recipes above; not unit-tested. + +## Out of scope + +- `npm install` in command — tracked by bd-7giz (`dev-setup` extension) +- Automatic CLAUDE.local.md updates at session end — personal `/end` skill territory +- xtask updating beads itself (e.g. `br update <id> --notes ...`) — skills own beads lifecycle, xtask stays filesystem-pure +- Custom per-team stop-word lists / slug strategies — `--slug` override covers edge cases From 1f5bad88f71507f8970bb55fb6f57440853c22e1 Mon Sep 17 00:00:00 2001 From: christophe dervieux <christophe.dervieux@gmail.com> Date: Mon, 11 May 2026 15:06:24 +0200 Subject: [PATCH 02/34] plan: implementation plan for cargo xtask create-worktree (bd-spsv) Phased TDD breakdown of the design at claude-notes/plans/2026-05-07-create-worktree-xtask.md: scaffolding, pure-function red-green tasks, subprocess wrappers, run() orchestration, end-to-end smoke recipe, and docs/skills updates. --- ...6-05-11-implement-create-worktree-xtask.md | 1782 +++++++++++++++++ 1 file changed, 1782 insertions(+) create mode 100644 claude-notes/plans/2026-05-11-implement-create-worktree-xtask.md diff --git a/claude-notes/plans/2026-05-11-implement-create-worktree-xtask.md b/claude-notes/plans/2026-05-11-implement-create-worktree-xtask.md new file mode 100644 index 000000000..4bfc2d12d --- /dev/null +++ b/claude-notes/plans/2026-05-11-implement-create-worktree-xtask.md @@ -0,0 +1,1782 @@ +# `cargo xtask create-worktree` — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add `cargo xtask create-worktree` so every new worktree comes with `.beads/redirect` and a marker-delimited CLAUDE.local.md context section — driven from one of three modes (beads ID / GitHub issue / upgrade-date), idempotent, filesystem-only. + +**Architecture:** All code lives in one new module `crates/xtask/src/create_worktree.rs`. The module exposes a `clap::Args` struct (`ArgGroup`-validated tri-state mode) and `run(args)` orchestrator. `run()` fans out to three pure helpers (`derive_slug`, `build_section`, `update_claude_local_md`) and three subprocess wrappers (`fetch_beads_metadata`, `fetch_gh_issue`, `git_worktree_add`). The xtask never mutates beads state — skills retain that responsibility. + +**Tech Stack:** Rust, `clap` (workspace), `anyhow` (workspace), `time = "0.3"` (new direct dep, features `macros` + `formatting`), `serde_json` via existing workspace deps for parsing `br --json` / `gh --json`. + +**Design reference:** Full design rationale lives in `claude-notes/plans/2026-05-07-create-worktree-xtask.md`. This document is the execution sequence — it does not re-derive design decisions. + +--- + +## File map + +| Path | Action | +|---|---| +| `crates/xtask/src/create_worktree.rs` | **Create** — full implementation + `#[cfg(test)] mod tests` | +| `crates/xtask/src/main.rs` | **Modify** — add `mod`, `Command` variant, match arm, doc comment | +| `crates/xtask/Cargo.toml` | **Modify** — add `time` direct dependency, add `serde_json` | +| `.gitignore` | **Modify** — append `CLAUDE.local.md` line | +| `.claude/rules/xtask.md` | **Modify** — add `create-worktree` row to commands table | +| `.claude/rules/worktrees.md` | **Modify** — replace § Fresh worktree bootstrap, add § CLAUDE.local.md, add § Manual bootstrap | +| `.claude/skills/investigate-beads/SKILL.md` | **Modify** — replace inline git commands with `cargo xtask create-worktree <id>` | +| `.claude/skills/triage/SKILL.md` | **Modify** — replace inline git commands with `cargo xtask create-worktree --issue <N>`, note step ordering | +| `.claude/skills/upgrade-cargo-deps/SKILL.md` | **Modify** — replace inline git commands with `cargo xtask create-worktree --upgrade` | + +--- + +## Phase A — Scaffolding + +### Task A1: Add direct dependencies to xtask Cargo.toml + +**Files:** +- Modify: `crates/xtask/Cargo.toml:13-20` + +- [ ] **Step 1: Edit dependencies block** + +Add the two new direct deps. After this edit the `[dependencies]` block reads: + +```toml +[dependencies] +anyhow = { workspace = true } +clap = { workspace = true } +proc-macro2 = { workspace = true } +serde_json = { workspace = true } +syn = { workspace = true } +tempfile = "3" +time = { version = "0.3", features = ["macros", "formatting"] } +walkdir = { workspace = true } +``` + +Both crates are already in the workspace `Cargo.lock` transitively (verify with `cargo tree -p xtask` after the edit), so adding them as direct deps incurs no extra compile cost. + +- [ ] **Step 2: Verify it compiles** + +Run: `cargo check -p xtask` +Expected: clean build, no warnings. + +- [ ] **Step 3: Commit** + +```bash +git add crates/xtask/Cargo.toml +git commit -m "xtask: add time + serde_json direct deps for create-worktree" +``` + +--- + +### Task A2: Create empty create_worktree module + stub Args + run() + +**Files:** +- Create: `crates/xtask/src/create_worktree.rs` + +- [ ] **Step 1: Write module stub** + +Create `crates/xtask/src/create_worktree.rs` with this content: + +```rust +//! `cargo xtask create-worktree` — set up a git worktree with beads redirect +//! and a marker-delimited CLAUDE.local.md context section. +//! +//! Three modes (exactly one required): +//! - positional `<bd-id>` — beads issue (reads `br show`) +//! - `--issue <N>` — GitHub issue triage (reads `gh issue view`) +//! - `--upgrade` — cargo dependency upgrade (date-based branch) +//! +//! Filesystem-only: never touches beads state. Skills own beads lifecycle. + +use anyhow::Result; + +const BEGIN_MARKER: &str = "<!-- BEGIN WORKTREE CONTEXT — managed by cargo xtask create-worktree -->"; +const END_MARKER: &str = "<!-- END WORKTREE CONTEXT -->"; + +const STOP_WORDS: &[&str] = &[ + "a", "an", "the", "and", "or", "in", "on", "of", "to", + "for", "with", "from", "at", "by", "is", "as", +]; + +// Lock the em-dash in BEGIN_MARKER against accidental editor substitution. +const _: () = { + let bytes = BEGIN_MARKER.as_bytes(); + // U+2014 EM DASH encodes as 0xE2 0x80 0x94 in UTF-8. + let mut i = 0; + let mut found = false; + while i + 2 < bytes.len() { + if bytes[i] == 0xE2 && bytes[i + 1] == 0x80 && bytes[i + 2] == 0x94 { + found = true; + } + i += 1; + } + assert!(found, "BEGIN_MARKER must contain U+2014 em dash"); +}; + +#[derive(clap::Args)] +#[command(group(clap::ArgGroup::new("mode").required(true).multiple(false)))] +pub struct Args { + /// Beads issue ID, e.g. `bd-1d3e`. Reads `br show <id>` for title and external_ref. + #[arg(group = "mode")] + pub beads_id: Option<String>, + + /// GitHub issue number, e.g. `157`. Reads `gh issue view`. + #[arg(long, group = "mode")] + pub issue: Option<u32>, + + /// Cargo dependency upgrade — uses today's date for branch name. + #[arg(long, group = "mode")] + pub upgrade: bool, + + /// Override auto-derived slug. In beads mode replaces the derived slug; + /// in issue/upgrade modes appended as a suffix (for parallel-worktree workflows). + #[arg(long)] + pub slug: Option<String>, + + /// Base branch. + #[arg(long, default_value = "main")] + pub base: String, +} + +pub fn run(_args: Args) -> Result<()> { + anyhow::bail!("create-worktree not yet implemented"); +} +``` + +- [ ] **Step 2: Verify it compiles** + +Run: `cargo check -p xtask` +Expected: clean build (warnings about unused fields are OK at this stage — they will be consumed in later tasks). + +- [ ] **Step 3: Commit** + +```bash +git add crates/xtask/src/create_worktree.rs +git commit -m "xtask: scaffold create_worktree module with Args + stub run()" +``` + +--- + +### Task A3: Wire CreateWorktree into main.rs + +**Files:** +- Modify: `crates/xtask/src/main.rs:8-22` (doc comment + module decls) +- Modify: `crates/xtask/src/main.rs:36-166` (Command enum) +- Modify: `crates/xtask/src/main.rs:168-232` (match arms) + +- [ ] **Step 1: Update top-level doc comment** + +In the file header (lines 8-14), add a `create-worktree` bullet between `lint` and `test`: + +```rust +//! Available commands: +//! - `dev-setup`: Install required development tools (cargo-nextest, wasm-bindgen-cli) +//! - `lint`: Run custom lint checks on the codebase +//! - `create-worktree`: Create git worktree with beads redirect and CLAUDE.local.md +//! - `test`: Run workspace tests with platform-appropriate crate exclusions +//! - `verify`: Run full project verification (build + tests for Rust and hub-client) +//! - `build-all`: Fresh-clone build orchestration (npm install + hub-client + Rust workspace) +//! - `build-trace-viewer`: Build just the trace-viewer SPA +``` + +- [ ] **Step 2: Add module declaration** + +Insert `mod create_worktree;` into the alphabetically-sorted mod list at lines 16-22. After the edit the block reads: + +```rust +mod build_all; +mod build_trace_viewer; +mod create_worktree; +mod dev_setup; +mod lint; +mod test; +mod treesitter_crlf; +mod verify; +``` + +- [ ] **Step 3: Add Command variant** + +Inside the `enum Command { ... }` block (currently 36-166), insert a new variant after `Lint { ... }` (before `Test { ... }`): + +```rust + /// Create a new git worktree with beads redirect and CLAUDE.local.md context stub. + /// + /// Modes (exactly one required): + /// <bd-id> — beads issue (positional) + /// --issue N — GitHub issue triage + /// --upgrade — cargo dependency upgrade (date-based branch) + CreateWorktree { + #[command(flatten)] + args: create_worktree::Args, + }, +``` + +- [ ] **Step 4: Add match arm** + +Inside `main()` (lines 168-232), insert a match arm in the same position (after `Command::Lint { .. }`, before `Command::Test { .. }`): + +```rust + Command::CreateWorktree { args } => create_worktree::run(args), +``` + +- [ ] **Step 5: Verify clap parses each mode correctly** + +Run each of the following and confirm clap produces the expected behavior: + +```bash +cargo run -q -p xtask -- create-worktree --help +# Expected: help text lists positional [BEADS_ID], --issue <ISSUE>, --upgrade, --slug, --base. + +cargo run -q -p xtask -- create-worktree +# Expected: error mentioning "the following required arguments" / one of mode. + +cargo run -q -p xtask -- create-worktree bd-1d3e --issue 1 +# Expected: error from ArgGroup: "argument cannot be used with one or more of the other specified arguments". + +cargo run -q -p xtask -- create-worktree bd-1d3e +# Expected: bail message "create-worktree not yet implemented". +``` + +If `--upgrade` (a bool flag) does not participate in `ArgGroup` validation in the installed clap version, fall back to defining `upgrade` as `Option<bool>` with `action = clap::ArgAction::SetTrue` and revisit; see the design doc § Command interface for rationale. + +- [ ] **Step 6: Commit** + +```bash +git add crates/xtask/src/main.rs +git commit -m "xtask: wire create-worktree subcommand into Command enum" +``` + +--- + +## Phase B — Pure helpers (TDD) + +Each task in this phase adds one pure function plus its test cases, following red-green-commit. All tests live in the `#[cfg(test)] mod tests { ... }` block at the bottom of `create_worktree.rs`. + +### Task B1: `derive_slug` + +**Files:** +- Modify: `crates/xtask/src/create_worktree.rs` (add fn + tests) + +- [ ] **Step 1: Write failing tests** + +Append to `crates/xtask/src/create_worktree.rs`: + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn slug_drops_stop_words_and_kebab_splits() { + let s = derive_slug("Fix CRLF test failures in quarto-doctemplate on Windows").unwrap(); + assert_eq!(s, "fix-crlf-test-failures"); + } + + #[test] + fn slug_caps_at_four_tokens() { + let s = derive_slug("alpha beta gamma delta epsilon zeta").unwrap(); + assert_eq!(s, "alpha-beta-gamma-delta"); + } + + #[test] + fn slug_strips_punctuation_and_unicode() { + let s = derive_slug("Don't panic — handle naïve input (v2)!").unwrap(); + // apostrophe / em dash / accent / parens / digits-with-letters all collapse + assert_eq!(s, "dont-panic-handle-nave"); + } + + #[test] + fn slug_empty_result_errors() { + let err = derive_slug("the and of on in").unwrap_err().to_string(); + assert!(err.contains("unable to derive slug")); + assert!(err.contains("--slug")); + } + + #[test] + fn slug_only_punctuation_errors() { + let err = derive_slug("!!! ??? ---").unwrap_err().to_string(); + assert!(err.contains("unable to derive slug")); + } +} +``` + +- [ ] **Step 2: Run tests — expect compile failure** + +Run: `cargo nextest run -p xtask create_worktree::tests::slug_` +Expected: compile error — `derive_slug` is not defined. + +- [ ] **Step 3: Implement `derive_slug`** + +Insert into `create_worktree.rs` between the constants block and the `Args` struct: + +```rust +pub fn derive_slug(title: &str) -> Result<String> { + let tokens: Vec<String> = title + .to_lowercase() + .split(|c: char| c.is_whitespace() || c == '-') + .map(|tok| { + tok.chars() + .filter(|c| c.is_ascii_alphanumeric()) + .collect::<String>() + }) + .filter(|tok| !tok.is_empty()) + .filter(|tok| !STOP_WORDS.contains(&tok.as_str())) + .take(4) + .collect(); + + if tokens.is_empty() { + anyhow::bail!( + "unable to derive slug from title \"{title}\" — pass --slug <name> to override" + ); + } + Ok(tokens.join("-")) +} +``` + +- [ ] **Step 4: Run tests — expect green** + +Run: `cargo nextest run -p xtask create_worktree::tests::slug_` +Expected: 5 passed. + +- [ ] **Step 5: Commit** + +```bash +git add crates/xtask/src/create_worktree.rs +git commit -m "xtask(create-worktree): derive_slug with stop-words + ASCII filter" +``` + +--- + +### Task B2: `parse_external_ref_to_github_url` + +**Files:** +- Modify: `crates/xtask/src/create_worktree.rs` + +- [ ] **Step 1: Write failing tests** + +Append inside `mod tests`: + +```rust + #[test] + fn external_ref_gh_prefix_to_url() { + assert_eq!( + parse_external_ref_to_github_url(Some("gh-157")), + Some("https://github.com/quarto-dev/q2/issues/157".to_string()) + ); + } + + #[test] + fn external_ref_none_returns_none() { + assert_eq!(parse_external_ref_to_github_url(None), None); + } + + #[test] + fn external_ref_empty_string_returns_none() { + assert_eq!(parse_external_ref_to_github_url(Some("")), None); + } + + #[test] + fn external_ref_non_gh_prefix_returns_none() { + assert_eq!(parse_external_ref_to_github_url(Some("linear-ABC-12")), None); + } + + #[test] + fn external_ref_malformed_gh_returns_none() { + // Non-numeric suffix + assert_eq!(parse_external_ref_to_github_url(Some("gh-foo")), None); + // Empty suffix + assert_eq!(parse_external_ref_to_github_url(Some("gh-")), None); + } +``` + +- [ ] **Step 2: Run tests — expect compile failure** + +Run: `cargo nextest run -p xtask create_worktree::tests::external_ref_` +Expected: compile error — function not defined. + +- [ ] **Step 3: Implement** + +Add to `create_worktree.rs`, near `derive_slug`: + +```rust +pub fn parse_external_ref_to_github_url(ext: Option<&str>) -> Option<String> { + let ext = ext?; + let n = ext.strip_prefix("gh-")?; + if !n.is_empty() && n.chars().all(|c| c.is_ascii_digit()) { + Some(format!("https://github.com/quarto-dev/q2/issues/{n}")) + } else { + None + } +} +``` + +- [ ] **Step 4: Run tests — expect green** + +Run: `cargo nextest run -p xtask create_worktree::tests::external_ref_` +Expected: 5 passed. + +- [ ] **Step 5: Commit** + +```bash +git add crates/xtask/src/create_worktree.rs +git commit -m "xtask(create-worktree): parse gh-N external_ref to GitHub URL" +``` + +--- + +### Task B3: `detect_line_ending` + +**Files:** +- Modify: `crates/xtask/src/create_worktree.rs` + +- [ ] **Step 1: Write failing tests** + +Append inside `mod tests`: + +```rust + #[test] + fn detect_le_empty_defaults_to_lf() { + assert_eq!(detect_line_ending(""), "\n"); + } + + #[test] + fn detect_le_no_newlines_defaults_to_lf() { + assert_eq!(detect_line_ending("hello world"), "\n"); + } + + #[test] + fn detect_le_lf_only() { + assert_eq!(detect_line_ending("a\nb\nc\n"), "\n"); + } + + #[test] + fn detect_le_crlf_pure() { + assert_eq!(detect_line_ending("a\r\nb\r\nc\r\n"), "\r\n"); + } + + #[test] + fn detect_le_mixed_falls_back_to_lf() { + // CRLF + bare LF -> LF (do not propagate inconsistency) + assert_eq!(detect_line_ending("a\r\nb\nc\r\n"), "\n"); + } + + #[test] + fn detect_le_sniffs_only_first_1kb() { + // Pad the head with LF, place a CRLF beyond the sniff window + let mut s = "x\n".repeat(600); // 1200 bytes of LF-terminated lines + s.push_str("z\r\n"); + assert_eq!(detect_line_ending(&s), "\n"); + } +``` + +- [ ] **Step 2: Run tests — expect compile failure** + +Run: `cargo nextest run -p xtask create_worktree::tests::detect_le_` +Expected: compile error — function not defined. + +- [ ] **Step 3: Implement** + +Add to `create_worktree.rs`: + +```rust +pub fn detect_line_ending(content: &str) -> &'static str { + // Sniff up to first 1 KiB, snapped to a char boundary so slicing is valid. + let mut sniff_end = content.len().min(1024); + while sniff_end > 0 && !content.is_char_boundary(sniff_end) { + sniff_end -= 1; + } + let sniff = &content[..sniff_end]; + + let crlf_count = sniff.matches("\r\n").count(); + let lf_total = sniff.matches('\n').count(); + let bare_lf = lf_total - crlf_count; + + if crlf_count > 0 && bare_lf == 0 { + "\r\n" + } else { + "\n" + } +} +``` + +- [ ] **Step 4: Run tests — expect green** + +Run: `cargo nextest run -p xtask create_worktree::tests::detect_le_` +Expected: 6 passed. + +- [ ] **Step 5: Commit** + +```bash +git add crates/xtask/src/create_worktree.rs +git commit -m "xtask(create-worktree): detect line ending with 1 KiB sniff" +``` + +--- + +### Task B4: `build_section` (template generation) + +**Files:** +- Modify: `crates/xtask/src/create_worktree.rs` + +- [ ] **Step 1: Define the SectionKind enum and helper** + +Add to `create_worktree.rs`, near the top of the implementation block: + +```rust +pub enum SectionKind { + Beads { + id: String, + title: String, + github_url: Option<String>, + }, + Issue { + number: u32, + title: String, + url: String, + }, + Upgrade { + date: String, + }, +} +``` + +- [ ] **Step 2: Write failing tests** + +Append inside `mod tests`: + +```rust + #[test] + fn section_beads_with_github() { + let s = build_section(&SectionKind::Beads { + id: "bd-1d3e".into(), + title: "Fix X".into(), + github_url: Some("https://github.com/quarto-dev/q2/issues/42".into()), + }); + assert!(s.starts_with(BEGIN_MARKER)); + assert!(s.trim_end().ends_with(END_MARKER)); + assert!(s.contains("**Beads:** bd-1d3e — Fix X")); + assert!(s.contains("**GitHub:** https://github.com/quarto-dev/q2/issues/42")); + assert!(s.contains("Run `br show bd-1d3e`")); + assert!(s.contains("Main repo: `../..`")); + } + + #[test] + fn section_beads_without_github_omits_line() { + let s = build_section(&SectionKind::Beads { + id: "bd-zzzz".into(), + title: "T".into(), + github_url: None, + }); + assert!(!s.contains("**GitHub:**")); + assert!(s.contains("**Beads:** bd-zzzz — T")); + } + + #[test] + fn section_issue() { + let s = build_section(&SectionKind::Issue { + number: 157, + title: "An issue".into(), + url: "https://github.com/quarto-dev/q2/issues/157".into(), + }); + assert!(s.contains("**GitHub issue:** #157 — An issue")); + assert!(s.contains("**URL:** https://github.com/quarto-dev/q2/issues/157")); + assert!(s.contains("**Beads:** (run `br search 157`")); + assert!(!s.contains("**Beads:** bd-")); // no resolved beads id + } + + #[test] + fn section_upgrade() { + let s = build_section(&SectionKind::Upgrade { + date: "2026-05-11".into(), + }); + assert!(s.contains("**Task:** Cargo dependency upgrade — 2026-05-11")); + assert!(!s.contains("**Beads:**")); + assert!(!s.contains("**GitHub:**")); + } +``` + +- [ ] **Step 3: Run tests — expect compile failure** + +Run: `cargo nextest run -p xtask create_worktree::tests::section_` +Expected: compile error — `build_section` not defined. + +- [ ] **Step 4: Implement `build_section`** + +Add to `create_worktree.rs`: + +```rust +pub fn build_section(kind: &SectionKind) -> String { + let body = match kind { + SectionKind::Beads { + id, + title, + github_url, + } => { + let mut s = String::new(); + s.push_str("# Worktree Context\n\n"); + s.push_str("This is a **worktree** of the q2 repository. Main repo: `../..`\n\n"); + s.push_str(&format!("**Beads:** {id} — {title}\n")); + if let Some(url) = github_url { + s.push_str(&format!("**GitHub:** {url}\n")); + } + s.push_str("**Plan:** <!-- fill in after creating: claude-notes/plans/YYYY-MM-DD-name.md -->\n"); + s.push('\n'); + s.push_str(&format!("Run `br show {id}` for current status and notes.\n")); + s + } + SectionKind::Issue { number, title, url } => { + let mut s = String::new(); + s.push_str("# Worktree Context\n\n"); + s.push_str("This is a **worktree** of the q2 repository. Main repo: `../..`\n\n"); + s.push_str(&format!("**GitHub issue:** #{number} — {title}\n")); + s.push_str(&format!("**URL:** {url}\n")); + s.push_str(&format!( + "**Beads:** (run `br search {number}` to find or create a beads issue)\n" + )); + s.push_str("**Plan:** <!-- fill in after creating: claude-notes/plans/YYYY-MM-DD-name.md -->\n"); + s + } + SectionKind::Upgrade { date } => { + let mut s = String::new(); + s.push_str("# Worktree Context\n\n"); + s.push_str("This is a **worktree** of the q2 repository. Main repo: `../..`\n\n"); + s.push_str(&format!("**Task:** Cargo dependency upgrade — {date}\n")); + s.push_str("**Plan:** <!-- fill in if needed -->\n"); + s + } + }; + + format!("{BEGIN_MARKER}\n{body}{END_MARKER}\n") +} +``` + +- [ ] **Step 5: Run tests — expect green** + +Run: `cargo nextest run -p xtask create_worktree::tests::section_` +Expected: 4 passed. + +- [ ] **Step 6: Commit** + +```bash +git add crates/xtask/src/create_worktree.rs +git commit -m "xtask(create-worktree): build_section templates for 3 modes" +``` + +--- + +### Task B5: `strip_managed_section` (the surgical CLAUDE.local.md slice) + +**Files:** +- Modify: `crates/xtask/src/create_worktree.rs` + +- [ ] **Step 1: Write failing tests** + +Append inside `mod tests`: + +```rust + #[test] + fn strip_no_marker_returns_input_unchanged() { + let input = "# My notes\nfoo bar\n"; + assert_eq!(strip_managed_section(input).unwrap(), input); + } + + #[test] + fn strip_full_managed_section() { + let input = format!( + "{BEGIN_MARKER}\n# Worktree Context\nstuff\n{END_MARKER}\n# My notes\nfoo\n" + ); + assert_eq!(strip_managed_section(&input).unwrap(), "# My notes\nfoo\n"); + } + + #[test] + fn strip_section_in_middle_of_file() { + let input = format!( + "# Header\n\n{BEGIN_MARKER}\nbody\n{END_MARKER}\n\n# Footer\n" + ); + assert_eq!( + strip_managed_section(&input).unwrap(), + "# Header\n\n\n# Footer\n" + ); + } + + #[test] + fn strip_begin_without_end_errors() { + let input = format!("{BEGIN_MARKER}\nbody never closed\n"); + let err = strip_managed_section(&input).unwrap_err().to_string(); + assert!(err.contains("BEGIN marker without END marker")); + } + + #[test] + fn strip_uses_first_of_multiple_begins() { + let input = format!( + "{BEGIN_MARKER}\nfirst\n{END_MARKER}\nmiddle\n{BEGIN_MARKER}\nsecond\n{END_MARKER}\n" + ); + // First section + trailing newline stripped; everything from "middle" onward preserved. + let out = strip_managed_section(&input).unwrap(); + assert!(out.starts_with("middle\n")); + assert!(out.contains(BEGIN_MARKER)); // second still present + } + + #[test] + fn strip_handles_crlf_marker_lines() { + let input = format!("{BEGIN_MARKER}\r\nbody\r\n{END_MARKER}\r\nrest\r\n"); + assert_eq!(strip_managed_section(&input).unwrap(), "rest\r\n"); + } +``` + +- [ ] **Step 2: Run tests — expect compile failure** + +Run: `cargo nextest run -p xtask create_worktree::tests::strip_` +Expected: compile error — `strip_managed_section` not defined. + +- [ ] **Step 3: Implement** + +Add to `create_worktree.rs`: + +```rust +pub fn strip_managed_section(content: &str) -> Result<String> { + let Some(begin_pos) = content.find(BEGIN_MARKER) else { + return Ok(content.to_string()); + }; + + // Warn (but proceed) if a second BEGIN appears between the first BEGIN and EOF. + let after_begin = &content[begin_pos + BEGIN_MARKER.len()..]; + if after_begin.contains(BEGIN_MARKER) { + eprintln!( + "warning: CLAUDE.local.md contains multiple BEGIN markers — using the first; \ + recommend manual review of {}", + "CLAUDE.local.md" + ); + } + + let end_search_start = begin_pos + BEGIN_MARKER.len(); + let end_rel = content[end_search_start..] + .find(END_MARKER) + .ok_or_else(|| { + anyhow::anyhow!( + "CLAUDE.local.md has BEGIN marker without END marker — refusing to modify; \ + resolve manually" + ) + })?; + let end_marker_end = end_search_start + end_rel + END_MARKER.len(); + + // Strip from the start of the BEGIN line through one trailing newline after END. + let begin_line_start = content[..begin_pos] + .rfind('\n') + .map(|i| i + 1) + .unwrap_or(0); + + let mut after_end = end_marker_end; + let rest = &content[after_end..]; + if let Some(stripped) = rest.strip_prefix("\r\n") { + after_end += rest.len() - stripped.len(); + } else if let Some(stripped) = rest.strip_prefix('\n') { + after_end += rest.len() - stripped.len(); + } + + let mut out = String::with_capacity(content.len()); + out.push_str(&content[..begin_line_start]); + out.push_str(&content[after_end..]); + Ok(out) +} +``` + +- [ ] **Step 4: Run tests — expect green** + +Run: `cargo nextest run -p xtask create_worktree::tests::strip_` +Expected: 6 passed. + +- [ ] **Step 5: Commit** + +```bash +git add crates/xtask/src/create_worktree.rs +git commit -m "xtask(create-worktree): strip managed section by markers (idempotent)" +``` + +--- + +### Task B6: `update_claude_local_md` (full file rewrite, atomic) + +**Files:** +- Modify: `crates/xtask/src/create_worktree.rs` + +- [ ] **Step 1: Write failing tests** + +Append inside `mod tests` (these touch the filesystem via `tempfile`): + +```rust + use std::fs; + use tempfile::TempDir; + + fn make_dummy_section() -> String { + build_section(&SectionKind::Beads { + id: "bd-xxxx".into(), + title: "Demo".into(), + github_url: None, + }) + } + + #[test] + fn update_creates_file_when_missing() { + let tmp = TempDir::new().unwrap(); + let p = tmp.path().join("CLAUDE.local.md"); + update_claude_local_md(&p, &make_dummy_section()).unwrap(); + let out = fs::read_to_string(&p).unwrap(); + assert!(out.starts_with(BEGIN_MARKER)); + assert!(out.trim_end().ends_with(END_MARKER)); + assert!(out.ends_with('\n')); + } + + #[test] + fn update_prepends_when_no_marker_present() { + let tmp = TempDir::new().unwrap(); + let p = tmp.path().join("CLAUDE.local.md"); + fs::write(&p, "# My notes\nfoo\n").unwrap(); + update_claude_local_md(&p, &make_dummy_section()).unwrap(); + let out = fs::read_to_string(&p).unwrap(); + assert!(out.starts_with(BEGIN_MARKER)); + assert!(out.contains("# My notes")); + } + + #[test] + fn update_is_idempotent() { + let tmp = TempDir::new().unwrap(); + let p = tmp.path().join("CLAUDE.local.md"); + update_claude_local_md(&p, &make_dummy_section()).unwrap(); + update_claude_local_md(&p, &make_dummy_section()).unwrap(); + let out = fs::read_to_string(&p).unwrap(); + assert_eq!(out.matches(BEGIN_MARKER).count(), 1); + assert_eq!(out.matches(END_MARKER).count(), 1); + } + + #[test] + fn update_preserves_user_content_below_section() { + let tmp = TempDir::new().unwrap(); + let p = tmp.path().join("CLAUDE.local.md"); + update_claude_local_md(&p, &make_dummy_section()).unwrap(); + // User edits below the managed section. + let mut content = fs::read_to_string(&p).unwrap(); + content.push_str("\n# My notes\nfoo bar\n"); + fs::write(&p, &content).unwrap(); + // Re-run — managed section refreshed, user content stays. + update_claude_local_md(&p, &make_dummy_section()).unwrap(); + let out = fs::read_to_string(&p).unwrap(); + assert!(out.contains("# My notes")); + assert!(out.contains("foo bar")); + assert_eq!(out.matches(BEGIN_MARKER).count(), 1); + } + + #[test] + fn update_preserves_crlf_when_existing_is_crlf() { + let tmp = TempDir::new().unwrap(); + let p = tmp.path().join("CLAUDE.local.md"); + fs::write(&p, "# Header\r\n\r\nnotes\r\n").unwrap(); + update_claude_local_md(&p, &make_dummy_section()).unwrap(); + let out = fs::read(&p).unwrap(); + // Output should contain CRLF; no bare LFs. + let lf_total = out.iter().filter(|&&b| b == b'\n').count(); + let crlf_pairs = out.windows(2).filter(|w| w == b"\r\n").count(); + assert_eq!(lf_total, crlf_pairs, "bare LFs found in CRLF output: {:?}", out); + } + + #[test] + fn update_errors_when_path_is_directory() { + let tmp = TempDir::new().unwrap(); + let p = tmp.path().join("CLAUDE.local.md"); + fs::create_dir(&p).unwrap(); + let err = update_claude_local_md(&p, &make_dummy_section()) + .unwrap_err() + .to_string(); + assert!(err.contains("not a regular file")); + } + + #[test] + fn update_errors_on_begin_without_end() { + let tmp = TempDir::new().unwrap(); + let p = tmp.path().join("CLAUDE.local.md"); + fs::write(&p, format!("{BEGIN_MARKER}\nbroken\n")).unwrap(); + let err = update_claude_local_md(&p, &make_dummy_section()) + .unwrap_err() + .to_string(); + assert!(err.contains("BEGIN marker without END marker")); + } +``` + +- [ ] **Step 2: Run tests — expect compile failure** + +Run: `cargo nextest run -p xtask create_worktree::tests::update_` +Expected: compile error — `update_claude_local_md` not defined. + +- [ ] **Step 3: Implement** + +Add to `create_worktree.rs`: + +```rust +use std::fs; +use std::path::{Path, PathBuf}; + +pub fn update_claude_local_md(path: &Path, new_section: &str) -> Result<()> { + // 1. Read existing content (or empty if file missing). + let existing = if path.exists() { + let meta = path.symlink_metadata().with_context(|| { + format!("reading metadata of {}", path.display()) + })?; + if !meta.is_file() { + anyhow::bail!( + "CLAUDE.local.md exists but is not a regular file: {}", + path.display() + ); + } + fs::read_to_string(path) + .with_context(|| format!("reading {}", path.display()))? + } else { + String::new() + }; + + // 2. Detect line ending from existing content. + let nl = detect_line_ending(&existing); + + // 3. Strip any existing managed section. + let body = strip_managed_section(&existing)?; + + // 4. Normalize new_section to detected line ending. + let new_section_nl = if nl == "\r\n" { + new_section.replace('\n', "\r\n") + } else { + new_section.to_string() + }; + + // 5. Compose: new section + blank line + remaining body (if any). + let mut out = new_section_nl; + if !body.is_empty() { + if !out.ends_with(nl) { + out.push_str(nl); + } + out.push_str(nl); // blank line + out.push_str(&body); + } + if !out.ends_with(nl) { + out.push_str(nl); + } + + // 6. Atomic write: tmp + rename. + // Build the temp path by appending ".tmp" to the *full* OsStr — avoids the + // `Path::with_extension("md.tmp")` ambiguity around dots in extensions. + let mut tmp_os = path.as_os_str().to_owned(); + tmp_os.push(".tmp"); + let tmp = PathBuf::from(tmp_os); + fs::write(&tmp, out.as_bytes()) + .with_context(|| format!("writing {}", tmp.display()))?; + fs::rename(&tmp, path).with_context(|| { + format!("renaming {} to {}", tmp.display(), path.display()) + })?; + + Ok(()) +} +``` + +Note the `use anyhow::Context;` import will be needed at the top of the module — add it next to the existing `use anyhow::Result;`. + +- [ ] **Step 4: Run tests — expect green** + +Run: `cargo nextest run -p xtask create_worktree::tests::update_` +Expected: 7 passed. + +- [ ] **Step 5: Verify cross-module unit tests still pass** + +Run: `cargo nextest run -p xtask` +Expected: all xtask tests pass; no regressions in other modules. + +- [ ] **Step 6: Commit** + +```bash +git add crates/xtask/src/create_worktree.rs +git commit -m "xtask(create-worktree): update_claude_local_md with atomic rename" +``` + +--- + +## Phase C — Subprocess wrappers + +These functions call out to `br`, `gh`, and `git` and cannot be unit-tested cleanly. Each is verified at compile time and exercised end-to-end in Phase E. + +### Task C1: `fetch_beads_metadata` + +**Files:** +- Modify: `crates/xtask/src/create_worktree.rs` + +- [ ] **Step 1: Define the result type** + +Add to `create_worktree.rs`: + +```rust +pub struct BeadsMetadata { + pub title: String, + pub external_ref: Option<String>, +} +``` + +- [ ] **Step 2: Implement `fetch_beads_metadata`** + +Add to `create_worktree.rs`: + +```rust +use std::process::Command; + +pub fn fetch_beads_metadata(id: &str) -> Result<BeadsMetadata> { + let output = Command::new("br") + .args(["show", id, "--json"]) + .output() + .map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + anyhow::anyhow!( + "br is required — install via `cargo install beads-rust` or see project README" + ) + } else { + anyhow::Error::new(e).context("spawning `br show`") + } + })?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("br show {id} failed:\n{stderr}"); + } + + let stdout = std::str::from_utf8(&output.stdout) + .with_context(|| format!("`br show {id} --json` produced non-UTF-8 output"))?; + + // `br show --json` returns an array; take the first element. + let arr: Vec<serde_json::Value> = serde_json::from_str(stdout) + .with_context(|| format!("parsing JSON from `br show {id} --json`"))?; + let first = arr + .into_iter() + .next() + .ok_or_else(|| anyhow::anyhow!("`br show {id} --json` returned an empty array"))?; + + let title = first + .get("title") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("`br show {id}` JSON missing `title` field"))? + .to_string(); + + let external_ref = first + .get("external_ref") + .and_then(|v| v.as_str()) + .map(str::to_string); + + Ok(BeadsMetadata { + title, + external_ref, + }) +} +``` + +- [ ] **Step 3: Verify compile** + +Run: `cargo check -p xtask` +Expected: clean. + +- [ ] **Step 4: Smoke test against real beads** + +Run from the worktree: + +```bash +cargo run -q -p xtask -- create-worktree bd-spsv --slug smoke +# Expected: bails on later step (worktree creation) — but only AFTER successfully +# fetching metadata. If `br show` fails, the error message surfaces here. +``` + +(The command will not yet complete end-to-end; Phase D wires the rest. The point here is to confirm `br` parsing works.) + +This step is informational — the command is expected to fail at a later point. Note any error reaching this point and fix. + +- [ ] **Step 5: Commit** + +```bash +git add crates/xtask/src/create_worktree.rs +git commit -m "xtask(create-worktree): fetch_beads_metadata via br show --json" +``` + +--- + +### Task C2: `fetch_gh_issue` + +**Files:** +- Modify: `crates/xtask/src/create_worktree.rs` + +- [ ] **Step 1: Define type + implement** + +Add to `create_worktree.rs`: + +```rust +pub struct GhIssue { + pub title: String, + pub url: String, +} + +pub fn fetch_gh_issue(number: u32) -> Result<GhIssue> { + let n = number.to_string(); + let output = Command::new("gh") + .args([ + "issue", + "view", + &n, + "--repo", + "quarto-dev/q2", + "--json", + "title,url", + ]) + .output() + .map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + anyhow::anyhow!("gh is required — see https://cli.github.com/") + } else { + anyhow::Error::new(e).context("spawning `gh issue view`") + } + })?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("gh issue view {n} failed:\n{stderr}"); + } + + let stdout = std::str::from_utf8(&output.stdout) + .with_context(|| format!("`gh issue view {n}` produced non-UTF-8 output"))?; + + let v: serde_json::Value = serde_json::from_str(stdout) + .with_context(|| format!("parsing JSON from `gh issue view {n}`"))?; + let title = v + .get("title") + .and_then(|x| x.as_str()) + .ok_or_else(|| anyhow::anyhow!("`gh issue view {n}` JSON missing `title`"))? + .to_string(); + let url = v + .get("url") + .and_then(|x| x.as_str()) + .ok_or_else(|| anyhow::anyhow!("`gh issue view {n}` JSON missing `url`"))? + .to_string(); + + Ok(GhIssue { title, url }) +} +``` + +- [ ] **Step 2: Verify compile** + +Run: `cargo check -p xtask` +Expected: clean. + +- [ ] **Step 3: Commit** + +```bash +git add crates/xtask/src/create_worktree.rs +git commit -m "xtask(create-worktree): fetch_gh_issue via gh issue view --json" +``` + +--- + +### Task C3: Filesystem ops — `git_worktree_add` + `write_beads_redirect` + +**Files:** +- Modify: `crates/xtask/src/create_worktree.rs` + +- [ ] **Step 1: Implement filesystem ops** + +Add to `create_worktree.rs`: + +```rust +pub fn git_worktree_add(branch: &str, dir: &Path, base: &str) -> Result<()> { + if dir.exists() { + anyhow::bail!("worktree directory already exists: {}", dir.display()); + } + + // Pre-check: does the branch already exist locally? + let check = Command::new("git") + .args(["rev-parse", "--verify", "--quiet", &format!("refs/heads/{branch}")]) + .output() + .context("spawning `git rev-parse`")?; + if check.status.success() { + anyhow::bail!( + "branch already exists: {branch} — remove it or pass --slug to disambiguate" + ); + } + + // Pass the directory as OsStr so paths with non-UTF-8 bytes (Windows UTF-16 + // halves, weird POSIX names) still round-trip correctly. + let status = Command::new("git") + .arg("worktree") + .arg("add") + .arg("-b") + .arg(branch) + .arg(dir.as_os_str()) + .arg(base) + .status() + .context("spawning `git worktree add`")?; + if !status.success() { + anyhow::bail!("git worktree add failed (exit {:?})", status.code()); + } + + Ok(()) +} + +pub fn write_beads_redirect(dir: &Path) -> Result<()> { + let redirect = dir.join(".beads").join("redirect"); + // `.beads/` is tracked in the new worktree — directory should exist. + if !redirect.parent().map(Path::is_dir).unwrap_or(false) { + anyhow::bail!( + ".beads/ directory missing in new worktree: {} — was the base branch correct?", + redirect.parent().unwrap().display() + ); + } + // LF line ending intentionally, even on Windows. + fs::write(&redirect, "../../../.beads\n") + .with_context(|| format!("writing {}", redirect.display()))?; + Ok(()) +} +``` + +- [ ] **Step 2: Verify compile** + +Run: `cargo check -p xtask` +Expected: clean. + +- [ ] **Step 3: Commit** + +```bash +git add crates/xtask/src/create_worktree.rs +git commit -m "xtask(create-worktree): git_worktree_add + write_beads_redirect" +``` + +--- + +## Phase D — Orchestration + +### Task D1: Wire `run()` to dispatch by mode + +**Files:** +- Modify: `crates/xtask/src/create_worktree.rs` + +- [ ] **Step 1: Replace the stub `run()`** + +Replace the existing stub body with a full implementation: + +```rust +pub fn run(args: Args) -> Result<()> { + // Mode is enforced by clap::ArgGroup(required, single). + let plan = if let Some(id) = args.beads_id.as_deref() { + plan_beads(id, args.slug.as_deref(), &args.base)? + } else if let Some(n) = args.issue { + plan_issue(n, args.slug.as_deref(), &args.base)? + } else if args.upgrade { + plan_upgrade(args.slug.as_deref(), &args.base)? + } else { + unreachable!("clap ArgGroup guarantees one mode is set"); + }; + + git_worktree_add(&plan.branch, &plan.dir, &plan.base)?; + write_beads_redirect(&plan.dir)?; + let section = build_section(&plan.kind); + let claude_local = plan.dir.join("CLAUDE.local.md"); + update_claude_local_md(&claude_local, §ion)?; + print_summary(&plan); + Ok(()) +} + +struct Plan { + branch: String, + dir: PathBuf, + base: String, + kind: SectionKind, +} + +fn plan_beads(id: &str, slug_override: Option<&str>, base: &str) -> Result<Plan> { + let meta = fetch_beads_metadata(id)?; + let slug = match slug_override { + Some(s) => s.to_string(), + None => derive_slug(&meta.title)?, + }; + let leaf = format!("{id}-{slug}"); + let github_url = parse_external_ref_to_github_url(meta.external_ref.as_deref()); + if github_url.is_none() { + if let Some(other) = meta + .external_ref + .as_deref() + .filter(|s| !s.is_empty() && !s.starts_with("gh-")) + { + eprintln!( + "note: external_ref {other:?} is not a `gh-` reference; omitting GitHub line" + ); + } + } + Ok(Plan { + branch: format!("beads/{leaf}"), + dir: PathBuf::from(".worktrees").join(&leaf), + base: base.to_string(), + kind: SectionKind::Beads { + id: id.to_string(), + title: meta.title, + github_url, + }, + }) +} + +fn plan_issue(number: u32, slug_suffix: Option<&str>, base: &str) -> Result<Plan> { + let gh = fetch_gh_issue(number)?; + let leaf = match slug_suffix { + Some(s) => format!("issue-{number}-{s}"), + None => format!("issue-{number}"), + }; + Ok(Plan { + branch: leaf.clone(), + dir: PathBuf::from(".worktrees").join(&leaf), + base: base.to_string(), + kind: SectionKind::Issue { + number, + title: gh.title, + url: gh.url, + }, + }) +} + +fn plan_upgrade(slug_suffix: Option<&str>, base: &str) -> Result<Plan> { + let date = time::OffsetDateTime::now_utc() + .format(&time::macros::format_description!("[year]-[month]-[day]")) + .context("formatting today's date")?; + let leaf = match slug_suffix { + Some(s) => format!("cargo-upgrade-{date}-{s}"), + None => format!("cargo-upgrade-{date}"), + }; + Ok(Plan { + branch: leaf.clone(), + dir: PathBuf::from(".worktrees").join(&leaf), + base: base.to_string(), + kind: SectionKind::Upgrade { date }, + }) +} + +fn print_summary(plan: &Plan) { + println!("Created worktree: {}/", plan.dir.display()); + println!(" Branch: {}", plan.branch); + match &plan.kind { + SectionKind::Beads { id, title, github_url } => { + println!(" Beads: {id} — {title}"); + if let Some(url) = github_url { + println!(" GitHub: {url}"); + } + } + SectionKind::Issue { number, title, url } => { + println!(" Issue: #{number} — {title}"); + println!(" URL: {url}"); + } + SectionKind::Upgrade { date } => { + println!(" Task: Cargo dependency upgrade — {date}"); + } + } + println!(); + println!("Next steps:"); + println!(" 1. Fill in plan file path in CLAUDE.local.md (once plan is created)"); + println!( + " 2. cd {} && npm install (if hub-client work is in scope)", + plan.dir.display() + ); + println!(" 3. Start Claude Code session in {}/", plan.dir.display()); + if let SectionKind::Beads { id, .. } = &plan.kind { + println!(" 4. Run: br update {id} --status in_progress"); + } +} +``` + +- [ ] **Step 2: Verify compile + tests still pass** + +Run: `cargo check -p xtask && cargo nextest run -p xtask` +Expected: clean build, all unit tests pass. + +- [ ] **Step 3: `cargo xtask verify --skip-hub-build`** + +Ask Chris to run this — per `feedback_verification_not_background`, don't run heavy verification in background. + +Run command Chris should execute (in main worktree, NOT this one): + +```bash +cargo xtask verify --skip-hub-build +``` + +Expected: passes. If failures, treat them as regressions to fix before continuing. + +- [ ] **Step 4: Commit** + +```bash +git add crates/xtask/src/create_worktree.rs +git commit -m "xtask(create-worktree): wire run() to dispatch + summary" +``` + +--- + +## Phase E — End-to-end smoke test (Chris-driven) + +Important: this worktree (`bd-spsv-create-worktree-xtask`) cannot be the smoke-test target — it was set up with the manual commands the xtask replaces. Smoke-test by creating a throwaway worktree per mode, then cleaning up. + +Chris runs each block; any failure is a defect to fix before proceeding to Phase F. + +- [ ] **Step 1: Build the binary once** + +```bash +cargo build -p xtask +cargo xtask create-worktree --help +# Expected: help text with [BEADS_ID], --issue, --upgrade, --slug, --base. +``` + +- [ ] **Step 2: Beads mode** + +```bash +cargo xtask create-worktree bd-spsv --slug e2e-beads +cat .worktrees/bd-spsv-e2e-beads/.beads/redirect # → ../../../.beads +cat .worktrees/bd-spsv-e2e-beads/CLAUDE.local.md # → managed section + Beads + (GitHub if external_ref) +(cd .worktrees/bd-spsv-e2e-beads && br where) # → main .beads via redirect +``` + +- [ ] **Step 3: Issue mode (pick an open issue dynamically)** + +```bash +ISSUE=$(gh issue list --repo quarto-dev/q2 --state open --limit 1 --json number --jq '.[0].number') +cargo xtask create-worktree --issue "$ISSUE" --slug e2e-issue +cat ".worktrees/issue-${ISSUE}-e2e-issue/CLAUDE.local.md" # → has GitHub line, no resolved Beads line +``` + +- [ ] **Step 4: Upgrade mode** + +```bash +cargo xtask create-worktree --upgrade --slug e2e-upgrade +ls .worktrees/cargo-upgrade-*-e2e-upgrade/CLAUDE.local.md # → upgrade variant +``` + +- [ ] **Step 5: Idempotency + user-content preservation** + +```bash +# Re-running must NOT duplicate the managed section. +cargo xtask create-worktree bd-spsv --slug e2e-beads +grep -c "BEGIN WORKTREE CONTEXT" .worktrees/bd-spsv-e2e-beads/CLAUDE.local.md # → 1 + +# Add user content below the managed section, re-run, confirm preserved. +printf '\n# My notes\nfoo\n' >> .worktrees/bd-spsv-e2e-beads/CLAUDE.local.md +cargo xtask create-worktree bd-spsv --slug e2e-beads +grep "My notes" .worktrees/bd-spsv-e2e-beads/CLAUDE.local.md # → present +``` + +- [ ] **Step 6: Failure cases** + +```bash +# 6a. Existing directory collision +mkdir -p .worktrees/collision-test +cargo xtask create-worktree bd-spsv --slug collision-test +# Expected: clear error before any git operation. Then: +rmdir .worktrees/collision-test + +# 6b. Corrupt managed section (missing END) +printf '<!-- BEGIN WORKTREE CONTEXT -->\nbroken\n' > .worktrees/bd-spsv-e2e-beads/CLAUDE.local.md +cargo xtask create-worktree bd-spsv --slug e2e-beads +# Expected: error "BEGIN marker without END marker — refusing to modify". +``` + +- [ ] **Step 7: Cleanup** + +```bash +git worktree remove .worktrees/bd-spsv-e2e-beads +git worktree remove ".worktrees/issue-${ISSUE}-e2e-issue" +git worktree remove .worktrees/cargo-upgrade-*-e2e-upgrade + +# Delete the branches the e2e run created (no commits should have been added) +git branch -d beads/bd-spsv-e2e-beads "issue-${ISSUE}-e2e-issue" +# Upgrade branch name embeds today's date — list and delete: +git branch | grep 'cargo-upgrade-.*-e2e-upgrade' | xargs -r git branch -d +``` + +- [ ] **Step 8: Record the smoke-test transcript** + +Capture exact output from steps 2-4 and paste into the eventual PR body under § End-to-end verification. This satisfies q2 CLAUDE.md "End-to-end verification before declaring success". + +--- + +## Phase F — Documentation and skills + +These edits happen after the code is green so the docs reference behavior that demonstrably works. + +### Task F1: `.gitignore` + +**Files:** +- Modify: `.gitignore` + +- [ ] **Step 1: Append CLAUDE.local.md ignore** + +Add after the last existing entry (currently `.claude/scheduled_tasks.lock` on line 36): + +```gitignore + +# Per-session local context (managed by `cargo xtask create-worktree` for worktrees) +CLAUDE.local.md +``` + +The bare filename matches CLAUDE.local.md in any directory, not just the root. + +- [ ] **Step 2: Verify no tracked CLAUDE.local.md exists** + +Run: `git ls-files | grep -i claude.local.md` +Expected: empty output. + +- [ ] **Step 3: Commit** + +```bash +git add .gitignore +git commit -m "gitignore: ignore CLAUDE.local.md everywhere" +``` + +--- + +### Task F2: `.claude/rules/xtask.md` + +**Files:** +- Modify: `.claude/rules/xtask.md` (commands table) + +- [ ] **Step 1: Add the row** + +Replace the commands table so it reads: + +```markdown +| Command | Alias | Purpose | +|---------|-------|---------| +| `cargo xtask dev-setup` | `cargo dev-setup` | Install required dev tools (cargo-nextest, wasm-bindgen-cli) | +| `cargo xtask lint` | — | Run custom lint checks | +| `cargo xtask create-worktree` | — | Create git worktree + `.beads/redirect` + CLAUDE.local.md context stub | +| `cargo xtask verify` | — | Full project verification (build + tests for Rust and hub-client) | +``` + +- [ ] **Step 2: Commit** + +```bash +git add .claude/rules/xtask.md +git commit -m "rules/xtask: document create-worktree command" +``` + +--- + +### Task F3: `.claude/rules/worktrees.md` + +**Files:** +- Modify: `.claude/rules/worktrees.md` + +- [ ] **Step 1: Replace § Fresh worktree bootstrap (lines 14-24)** + +Replace that section with: + +```markdown +## Fresh worktree bootstrap + +Use `cargo xtask create-worktree <bd-id>` (or `--issue N` / `--upgrade`) for new worktrees — +it handles `git worktree add`, `.beads/redirect`, and the CLAUDE.local.md context stub in +one shot. After it finishes, run `npm install` from the new worktree if hub-client is in scope: + +```bash +cargo xtask create-worktree bd-XXXX +cd .worktrees/<id>-<slug> +npm install # only if hub-client work is in scope +cargo xtask verify --skip-hub-build # confirm green at branch HEAD +``` + +If the xtask is not yet built (fresh clone, or a branch where `cargo build -p xtask` has +not run), see § Manual bootstrap below. + +`cargo xtask dev-setup` exists for Rust dev tools (cargo-nextest, wasm-bindgen-cli) but +does not currently run `npm install`. bd-7giz tracks extending it. +``` + +- [ ] **Step 2: Add § CLAUDE.local.md after § Beads Redirect** + +Insert a new section after the existing § Beads Redirect (which ends around line 36 with `Verify with \`br where\` from inside the worktree.`): + +```markdown + +## CLAUDE.local.md + +`cargo xtask create-worktree` prepends a worktree context section to `CLAUDE.local.md`. +Claude Code loads it automatically — no need to run `br show` to orient at session start. + +The section contains: worktree declaration, main repo path (`../..`), beads ID, +GitHub URL, and a placeholder for the plan file path (fill in manually after creating +the plan). + +Status lives in beads, not in this file. Run `br show <id>` for current status + notes. + +The section is delimited by `<!-- BEGIN/END WORKTREE CONTEXT -->` markers so it can be +updated by re-running the xtask without disturbing other content. The command is +idempotent. +``` + +- [ ] **Step 3: Add § Manual bootstrap at the end** + +Append at the end of the file (after § Pushing for PR): + +```markdown + +## Manual bootstrap + +If `cargo xtask create-worktree` is unavailable (fresh clone before first build, or +the xtask binary is broken on the current branch), fall back to manual setup: + +```bash +git worktree add -b beads/<id>-<slug> .worktrees/<id>-<slug> main +echo "../../../.beads" > .worktrees/<id>-<slug>/.beads/redirect +# Optional but recommended: write a CLAUDE.local.md context stub manually +# using the template from `cargo xtask create-worktree --help` output. +``` + +Verify with `br where` from inside the worktree. +``` + +- [ ] **Step 4: Commit** + +```bash +git add .claude/rules/worktrees.md +git commit -m "rules/worktrees: xtask-first bootstrap + CLAUDE.local.md + Manual fallback" +``` + +--- + +### Task F4: Skill — `investigate-beads` + +**Files:** +- Modify: `.claude/skills/investigate-beads/SKILL.md` (around lines 76-80) + +- [ ] **Step 1: Replace the inline git commands** + +Find this block: + +```bash +git worktree add -b beads/<id>-<slug> .worktrees/<id>-<slug> main +echo "../../../.beads" > .worktrees/<id>-<slug>/.beads/redirect +``` + +Replace with: + +```bash +cargo xtask create-worktree <id> +# Creates the worktree, .beads/redirect, and CLAUDE.local.md context stub. +# Slug is auto-derived from the beads title; pass `--slug X` to override. +# Fallback for fresh clones where the xtask is not yet built: +# see .claude/rules/worktrees.md § Manual bootstrap. +``` + +- [ ] **Step 2: Commit** + +```bash +git add .claude/skills/investigate-beads/SKILL.md +git commit -m "skills/investigate-beads: use cargo xtask create-worktree" +``` + +--- + +### Task F5: Skill — `triage` + +**Files:** +- Modify: `.claude/skills/triage/SKILL.md` (around lines 50-54) + +- [ ] **Step 1: Replace the inline git commands** + +Find: + +```bash +git worktree add -b issue-<N> .worktrees/issue-<N> main +echo "../../../.beads" > .worktrees/issue-<N>/.beads/redirect +``` + +Replace with: + +```bash +cargo xtask create-worktree --issue <N> +# Creates the worktree, .beads/redirect, and CLAUDE.local.md context stub. +# This step runs BEFORE the beads issue is created (step 6) — the `--issue` template +# intentionally has no Beads line. After step 6, either fill the bd-XXXX ID into +# CLAUDE.local.md manually, or re-run `cargo xtask create-worktree <bd-id>` to +# upgrade the section. +# Fallback for fresh clones where the xtask is not yet built: +# see .claude/rules/worktrees.md § Manual bootstrap. +``` + +- [ ] **Step 2: Commit** + +```bash +git add .claude/skills/triage/SKILL.md +git commit -m "skills/triage: use cargo xtask create-worktree --issue" +``` + +--- + +### Task F6: Skill — `upgrade-cargo-deps` + +**Files:** +- Modify: `.claude/skills/upgrade-cargo-deps/SKILL.md` (around lines 118-127) + +- [ ] **Step 1: Replace the inline git commands** + +Find the two-block sequence: + +```bash +DATE=$(date +%Y-%m-%d) +git worktree add -b cargo-upgrade-$DATE .worktrees/cargo-upgrade-$DATE main +``` + +```bash +echo "../../../.beads" > .worktrees/cargo-upgrade-$DATE/.beads/redirect +``` + +Replace both with one block: + +```bash +cargo xtask create-worktree --upgrade +# Creates a cargo-upgrade-YYYY-MM-DD worktree with .beads/redirect and CLAUDE.local.md. +# Fallback for fresh clones where the xtask is not yet built: +# see .claude/rules/worktrees.md § Manual bootstrap. +``` + +- [ ] **Step 2: Commit** + +```bash +git add .claude/skills/upgrade-cargo-deps/SKILL.md +git commit -m "skills/upgrade-cargo-deps: use cargo xtask create-worktree --upgrade" +``` + +--- + +## Phase G — Final verification and handoff + +### Task G1: Full verify pass + +- [ ] **Step 1: Ask Chris to run `cargo xtask verify --skip-hub-build`** + +Per `feedback_verification_not_background`, hand the command to Chris rather than running it in this session. If the only Rust changes are inside xtask + docs + skills, `--skip-hub-build` is sufficient. Re-running with full `cargo xtask verify` is only needed if any change touched `quarto-core`, `quarto-pandoc-types`, or anything else hub-client depends on — none of those are in the touched-file list, so `--skip-hub-build` covers the change. + +Expected: green. + +- [ ] **Step 2: Confirm clean working tree** + +Run: `git status` +Expected: clean (all phase F edits already committed). + +- [ ] **Step 3: Update beads with progress** + +Append a comment to `bd-spsv`: + +```bash +br comments add bd-spsv "Implementation complete on branch beads/bd-spsv-create-worktree-xtask. Smoke tested all 3 modes plus idempotency, preservation, and collision cases." +``` + +- [ ] **Step 4: Stop and hand off** + +Per CLAUDE.md "NEVER push to the remote repository without explicit user permission" and `feedback_explain_shared_file_changes`, do NOT push. Summarize the final commit list to Chris and ask for permission before any push or PR. + +--- + +## Open caveats called out in the design + +1. **Self-bootstrap caveat:** this worktree was set up with manual git+echo. The new command cannot be used to bootstrap its own development worktree (chicken-and-egg). The first real-world end-to-end test of the new command happens after this PR lands on `main` and a developer creates the next worktree. + +2. **`--upgrade` bool in ArgGroup:** Task A3 Step 5 explicitly tests this works in the installed clap version. If it does not, fall back per the note there before proceeding to Phase B. + +3. **Filesystem-pure:** the xtask never calls `br create`, `br update`, or any state-changing beads command. Skill instructions in Phase F preserve the existing per-skill beads lifecycle steps. From bfab9d9a84516a4d7f9258a1d78a6e5a0bccd611 Mon Sep 17 00:00:00 2001 From: christophe dervieux <christophe.dervieux@gmail.com> Date: Mon, 11 May 2026 15:45:11 +0200 Subject: [PATCH 03/34] plan: tighten create-worktree contract (bd-spsv) - Idempotency is file-level only. update_claude_local_md can be re-run safely; cargo xtask create-worktree itself errors on existing dir, by design. Phase E test rewritten to assert that behavior. - Add validate_slug helper for --slug overrides: ASCII alnum + dash/ underscore, length cap, no leading/trailing dash, no '.'/'..'. plan_*() invoke it; auto-derived slugs are safe by construction. - run() rolls back (git worktree remove --force + branch -D) on any failure between git_worktree_add and update_claude_local_md, so retries aren't blocked by half-initialized state. - git_worktree_add switches to .output() and surfaces stderr in the anyhow context. - build_section sends external titles through marker_safe() so a title containing the BEGIN/END marker substring cannot terminate the section. - Drop Task C1's premature smoke step (run() is still a stub there); Phase E covers the integration. Phase E preamble notes Git Bash on Windows for the cat/grep/printf commands. --- ...6-05-11-implement-create-worktree-xtask.md | 245 ++++++++++++++---- 1 file changed, 190 insertions(+), 55 deletions(-) diff --git a/claude-notes/plans/2026-05-11-implement-create-worktree-xtask.md b/claude-notes/plans/2026-05-11-implement-create-worktree-xtask.md index 4bfc2d12d..d9b5b4e5d 100644 --- a/claude-notes/plans/2026-05-11-implement-create-worktree-xtask.md +++ b/claude-notes/plans/2026-05-11-implement-create-worktree-xtask.md @@ -251,10 +251,12 @@ git commit -m "xtask: wire create-worktree subcommand into Command enum" Each task in this phase adds one pure function plus its test cases, following red-green-commit. All tests live in the `#[cfg(test)] mod tests { ... }` block at the bottom of `create_worktree.rs`. -### Task B1: `derive_slug` +### Task B1: `derive_slug` + `validate_slug` + +`derive_slug` produces an auto-slug from titles (already ASCII-only by filter); `validate_slug` enforces a grammar contract on `--slug` overrides (rejects `/`, `..`, whitespace, etc.) so the override can't produce invalid branch names or path-escape. **Files:** -- Modify: `crates/xtask/src/create_worktree.rs` (add fn + tests) +- Modify: `crates/xtask/src/create_worktree.rs` (add fns + tests) - [ ] **Step 1: Write failing tests** @@ -296,15 +298,55 @@ mod tests { let err = derive_slug("!!! ??? ---").unwrap_err().to_string(); assert!(err.contains("unable to derive slug")); } + + #[test] + fn validate_slug_accepts_safe_input() { + assert!(validate_slug("e2e-beads").is_ok()); + assert!(validate_slug("issue42").is_ok()); + assert!(validate_slug("a_b-c").is_ok()); + } + + #[test] + fn validate_slug_rejects_empty() { + let err = validate_slug("").unwrap_err().to_string(); + assert!(err.contains("must not be empty")); + } + + #[test] + fn validate_slug_rejects_path_separators_and_traversal() { + assert!(validate_slug("foo/bar").is_err()); + assert!(validate_slug("foo\\bar").is_err()); + assert!(validate_slug("..").is_err()); + assert!(validate_slug(".").is_err()); + } + + #[test] + fn validate_slug_rejects_whitespace_and_other_punct() { + assert!(validate_slug("foo bar").is_err()); + assert!(validate_slug("foo.bar").is_err()); + assert!(validate_slug("foo:bar").is_err()); + } + + #[test] + fn validate_slug_rejects_leading_or_trailing_dash() { + assert!(validate_slug("-leading").is_err()); + assert!(validate_slug("trailing-").is_err()); + } + + #[test] + fn validate_slug_rejects_too_long() { + let too_long = "a".repeat(65); + assert!(validate_slug(&too_long).is_err()); + } } ``` - [ ] **Step 2: Run tests — expect compile failure** -Run: `cargo nextest run -p xtask create_worktree::tests::slug_` -Expected: compile error — `derive_slug` is not defined. +Run: `cargo nextest run -p xtask create_worktree::tests::slug_ create_worktree::tests::validate_slug_` +Expected: compile error — `derive_slug` / `validate_slug` not defined. -- [ ] **Step 3: Implement `derive_slug`** +- [ ] **Step 3: Implement `derive_slug` and `validate_slug`** Insert into `create_worktree.rs` between the constants block and the `Args` struct: @@ -330,18 +372,44 @@ pub fn derive_slug(title: &str) -> Result<String> { } Ok(tokens.join("-")) } + +/// Validate a user-provided `--slug` override. Auto-derived slugs already +/// satisfy these rules by construction; this only applies to overrides. +pub fn validate_slug(slug: &str) -> Result<()> { + if slug.is_empty() { + anyhow::bail!("--slug must not be empty"); + } + if slug.len() > 64 { + anyhow::bail!("--slug too long ({} chars, max 64): {slug:?}", slug.len()); + } + if slug == "." || slug == ".." { + anyhow::bail!("--slug must not be {slug:?}"); + } + if slug.starts_with('-') || slug.ends_with('-') { + anyhow::bail!("--slug must not start or end with '-': {slug:?}"); + } + if let Some(bad) = slug + .chars() + .find(|c| !(c.is_ascii_alphanumeric() || *c == '-' || *c == '_')) + { + anyhow::bail!( + "--slug contains invalid character {bad:?} — only ASCII alphanumeric, '-', '_' allowed: {slug:?}" + ); + } + Ok(()) +} ``` - [ ] **Step 4: Run tests — expect green** -Run: `cargo nextest run -p xtask create_worktree::tests::slug_` -Expected: 5 passed. +Run: `cargo nextest run -p xtask create_worktree::tests::slug_ create_worktree::tests::validate_slug_` +Expected: 5 slug_ + 6 validate_slug_ = 11 passed. - [ ] **Step 5: Commit** ```bash git add crates/xtask/src/create_worktree.rs -git commit -m "xtask(create-worktree): derive_slug with stop-words + ASCII filter" +git commit -m "xtask(create-worktree): derive_slug + validate_slug grammar" ``` --- @@ -592,6 +660,23 @@ Append inside `mod tests`: assert!(!s.contains("**Beads:**")); assert!(!s.contains("**GitHub:**")); } + + #[test] + fn section_strips_marker_from_title() { + // A title that literally contains the END marker must not be interpolated + // verbatim — `strip_managed_section` would otherwise pick it up as the + // section terminator on the next run. + let evil = format!("real title {END_MARKER} oops"); + let s = build_section(&SectionKind::Beads { + id: "bd-x".into(), + title: evil, + github_url: None, + }); + // END_MARKER must appear exactly once — at the section's actual close. + assert_eq!(s.matches(END_MARKER).count(), 1); + // BEGIN_MARKER ditto. + assert_eq!(s.matches(BEGIN_MARKER).count(), 1); + } ``` - [ ] **Step 3: Run tests — expect compile failure** @@ -599,11 +684,20 @@ Append inside `mod tests`: Run: `cargo nextest run -p xtask create_worktree::tests::section_` Expected: compile error — `build_section` not defined. -- [ ] **Step 4: Implement `build_section`** +- [ ] **Step 4: Implement `build_section` (with marker-safe title interpolation)** Add to `create_worktree.rs`: ```rust +/// Neutralize any occurrences of BEGIN/END marker substrings inside +/// externally-sourced text (titles from `br`/`gh`). Without this, a title +/// containing `<!-- END WORKTREE CONTEXT -->` would terminate the section +/// prematurely on the next idempotent strip pass. +fn marker_safe(s: &str) -> String { + s.replace(BEGIN_MARKER, "[BEGIN marker scrubbed]") + .replace(END_MARKER, "[END marker scrubbed]") +} + pub fn build_section(kind: &SectionKind) -> String { let body = match kind { SectionKind::Beads { @@ -611,6 +705,7 @@ pub fn build_section(kind: &SectionKind) -> String { title, github_url, } => { + let title = marker_safe(title); let mut s = String::new(); s.push_str("# Worktree Context\n\n"); s.push_str("This is a **worktree** of the q2 repository. Main repo: `../..`\n\n"); @@ -624,6 +719,7 @@ pub fn build_section(kind: &SectionKind) -> String { s } SectionKind::Issue { number, title, url } => { + let title = marker_safe(title); let mut s = String::new(); s.push_str("# Worktree Context\n\n"); s.push_str("This is a **worktree** of the q2 repository. Main repo: `../..`\n\n"); @@ -652,7 +748,7 @@ pub fn build_section(kind: &SectionKind) -> String { - [ ] **Step 5: Run tests — expect green** Run: `cargo nextest run -p xtask create_worktree::tests::section_` -Expected: 4 passed. +Expected: 5 passed. - [ ] **Step 6: Commit** @@ -1075,21 +1171,11 @@ pub fn fetch_beads_metadata(id: &str) -> Result<BeadsMetadata> { Run: `cargo check -p xtask` Expected: clean. -- [ ] **Step 4: Smoke test against real beads** - -Run from the worktree: - -```bash -cargo run -q -p xtask -- create-worktree bd-spsv --slug smoke -# Expected: bails on later step (worktree creation) — but only AFTER successfully -# fetching metadata. If `br show` fails, the error message surfaces here. -``` - -(The command will not yet complete end-to-end; Phase D wires the rest. The point here is to confirm `br` parsing works.) - -This step is informational — the command is expected to fail at a later point. Note any error reaching this point and fix. +(No standalone smoke test here — `run()` is still the stub, so any CLI invocation +bails before reaching `fetch_beads_metadata`. End-to-end coverage lives in Phase E +after `run()` is wired in Task D1.) -- [ ] **Step 5: Commit** +- [ ] **Step 4: Commit** ```bash git add crates/xtask/src/create_worktree.rs @@ -1201,17 +1287,23 @@ pub fn git_worktree_add(branch: &str, dir: &Path, base: &str) -> Result<()> { // Pass the directory as OsStr so paths with non-UTF-8 bytes (Windows UTF-16 // halves, weird POSIX names) still round-trip correctly. - let status = Command::new("git") + // `.output()` captures stderr so we can include git's actual error message + // in our anyhow context — `.status()` would just give us an exit code. + let output = Command::new("git") .arg("worktree") .arg("add") .arg("-b") .arg(branch) .arg(dir.as_os_str()) .arg(base) - .status() + .output() .context("spawning `git worktree add`")?; - if !status.success() { - anyhow::bail!("git worktree add failed (exit {:?})", status.code()); + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!( + "git worktree add failed (exit {:?}):\n{stderr}", + output.status.code() + ); } Ok(()) @@ -1272,10 +1364,32 @@ pub fn run(args: Args) -> Result<()> { }; git_worktree_add(&plan.branch, &plan.dir, &plan.base)?; - write_beads_redirect(&plan.dir)?; - let section = build_section(&plan.kind); - let claude_local = plan.dir.join("CLAUDE.local.md"); - update_claude_local_md(&claude_local, §ion)?; + + // From here on, on any error we roll back the worktree+branch we just + // created so a retry is not blocked by directory/branch collision. + let post = (|| -> Result<()> { + write_beads_redirect(&plan.dir)?; + let section = build_section(&plan.kind); + let claude_local = plan.dir.join("CLAUDE.local.md"); + update_claude_local_md(&claude_local, §ion)?; + Ok(()) + })(); + + if let Err(e) = post { + eprintln!("error after worktree creation: {e:#}"); + eprintln!("rolling back worktree {} and branch {}", plan.dir.display(), plan.branch); + let _ = Command::new("git") + .arg("worktree") + .arg("remove") + .arg("--force") + .arg(plan.dir.as_os_str()) + .status(); + let _ = Command::new("git") + .args(["branch", "-D", &plan.branch]) + .status(); + return Err(e); + } + print_summary(&plan); Ok(()) } @@ -1290,7 +1404,10 @@ struct Plan { fn plan_beads(id: &str, slug_override: Option<&str>, base: &str) -> Result<Plan> { let meta = fetch_beads_metadata(id)?; let slug = match slug_override { - Some(s) => s.to_string(), + Some(s) => { + validate_slug(s)?; + s.to_string() + } None => derive_slug(&meta.title)?, }; let leaf = format!("{id}-{slug}"); @@ -1319,6 +1436,9 @@ fn plan_beads(id: &str, slug_override: Option<&str>, base: &str) -> Result<Plan> } fn plan_issue(number: u32, slug_suffix: Option<&str>, base: &str) -> Result<Plan> { + if let Some(s) = slug_suffix { + validate_slug(s)?; + } let gh = fetch_gh_issue(number)?; let leaf = match slug_suffix { Some(s) => format!("issue-{number}-{s}"), @@ -1337,6 +1457,9 @@ fn plan_issue(number: u32, slug_suffix: Option<&str>, base: &str) -> Result<Plan } fn plan_upgrade(slug_suffix: Option<&str>, base: &str) -> Result<Plan> { + if let Some(s) = slug_suffix { + validate_slug(s)?; + } let date = time::OffsetDateTime::now_utc() .format(&time::macros::format_description!("[year]-[month]-[day]")) .context("formatting today's date")?; @@ -1414,6 +1537,10 @@ git commit -m "xtask(create-worktree): wire run() to dispatch + summary" Important: this worktree (`bd-spsv-create-worktree-xtask`) cannot be the smoke-test target — it was set up with the manual commands the xtask replaces. Smoke-test by creating a throwaway worktree per mode, then cleaning up. +**Idempotency scope:** the command is **not** rerun-idempotent end-to-end — `git worktree add` errors on existing directories, by design. File-level idempotency of `update_claude_local_md` (re-running on an existing CLAUDE.local.md updates the section in place) is covered by unit tests in Task B6 (`update_is_idempotent`, `update_preserves_user_content_below_section`). Phase E does not re-verify what those tests already cover. + +**Shell:** these commands assume Git Bash on Windows (or any POSIX shell on Linux/macOS). On Windows: open Git Bash, not PowerShell — `cat`, `grep`, `printf`, `xargs`, `mkdir -p`, and `$(...)` substitution all rely on it. + Chris runs each block; any failure is a defect to fix before proceeding to Phase F. - [ ] **Step 1: Build the binary once** @@ -1448,35 +1575,29 @@ cargo xtask create-worktree --upgrade --slug e2e-upgrade ls .worktrees/cargo-upgrade-*-e2e-upgrade/CLAUDE.local.md # → upgrade variant ``` -- [ ] **Step 5: Idempotency + user-content preservation** - -```bash -# Re-running must NOT duplicate the managed section. -cargo xtask create-worktree bd-spsv --slug e2e-beads -grep -c "BEGIN WORKTREE CONTEXT" .worktrees/bd-spsv-e2e-beads/CLAUDE.local.md # → 1 - -# Add user content below the managed section, re-run, confirm preserved. -printf '\n# My notes\nfoo\n' >> .worktrees/bd-spsv-e2e-beads/CLAUDE.local.md -cargo xtask create-worktree bd-spsv --slug e2e-beads -grep "My notes" .worktrees/bd-spsv-e2e-beads/CLAUDE.local.md # → present -``` - -- [ ] **Step 6: Failure cases** +- [ ] **Step 5: Failure cases** ```bash -# 6a. Existing directory collision +# 5a. Existing directory collision mkdir -p .worktrees/collision-test cargo xtask create-worktree bd-spsv --slug collision-test # Expected: clear error before any git operation. Then: rmdir .worktrees/collision-test -# 6b. Corrupt managed section (missing END) -printf '<!-- BEGIN WORKTREE CONTEXT -->\nbroken\n' > .worktrees/bd-spsv-e2e-beads/CLAUDE.local.md +# 5b. Invalid --slug grammar (path-separator, traversal, whitespace) +cargo xtask create-worktree bd-spsv --slug "foo/bar" +# Expected: error from validate_slug — no worktree created. +cargo xtask create-worktree bd-spsv --slug ".." +# Expected: error from validate_slug — no worktree created. + +# 5c. Re-running on existing worktree (idempotency is NOT a goal here) cargo xtask create-worktree bd-spsv --slug e2e-beads -# Expected: error "BEGIN marker without END marker — refusing to modify". +# Expected: fails with "worktree directory already exists" — by design. +# If you need to refresh the CLAUDE.local.md section, remove the worktree +# and recreate, or hand-edit the file (the BEGIN/END markers make this safe). ``` -- [ ] **Step 7: Cleanup** +- [ ] **Step 6: Cleanup** ```bash git worktree remove .worktrees/bd-spsv-e2e-beads @@ -1489,7 +1610,7 @@ git branch -d beads/bd-spsv-e2e-beads "issue-${ISSUE}-e2e-issue" git branch | grep 'cargo-upgrade-.*-e2e-upgrade' | xargs -r git branch -d ``` -- [ ] **Step 8: Record the smoke-test transcript** +- [ ] **Step 7: Record the smoke-test transcript** Capture exact output from steps 2-4 and paste into the eventual PR body under § End-to-end verification. This satisfies q2 CLAUDE.md "End-to-end verification before declaring success". @@ -1605,8 +1726,14 @@ the plan). Status lives in beads, not in this file. Run `br show <id>` for current status + notes. The section is delimited by `<!-- BEGIN/END WORKTREE CONTEXT -->` markers so it can be -updated by re-running the xtask without disturbing other content. The command is -idempotent. +refreshed in place (e.g. when a worktree is recreated, or by hand-editing the file). +The `update_claude_local_md` rewrite is idempotent at the file level: re-running it on +a file that already has a managed section replaces that section without duplicating it +and preserves any user content below. + +`cargo xtask create-worktree` itself is **not** idempotent end-to-end — `git worktree add` +fails fast if the directory already exists. To refresh a worktree's CLAUDE.local.md, +either edit it by hand (the markers make this safe) or remove the worktree and recreate. ``` - [ ] **Step 3: Add § Manual bootstrap at the end** @@ -1780,3 +1907,11 @@ Per CLAUDE.md "NEVER push to the remote repository without explicit user permiss 2. **`--upgrade` bool in ArgGroup:** Task A3 Step 5 explicitly tests this works in the installed clap version. If it does not, fall back per the note there before proceeding to Phase B. 3. **Filesystem-pure:** the xtask never calls `br create`, `br update`, or any state-changing beads command. Skill instructions in Phase F preserve the existing per-skill beads lifecycle steps. + +4. **Idempotency scope (file-level only).** The design doc used the word "idempotent" loosely. The actual contract is: + - `update_claude_local_md` is idempotent at the **file** level — re-running it on a file with an existing managed section replaces that section in place. + - `cargo xtask create-worktree` is **not** idempotent at the **command** level — `git worktree add` errors fast on directory collision. A retry must first remove the partial worktree (the rollback path in `run()` does this on failure between `git_worktree_add` and `update_claude_local_md`). + +5. **`--slug` grammar.** Overrides go through `validate_slug` (ASCII alnum + `-` + `_`, no leading/trailing dash, no `..`/`.`, max 64 chars). Auto-derived slugs already satisfy this by construction. + +6. **Marker-collision defense.** Externally-sourced titles (`br`/`gh`) are passed through `marker_safe` before interpolation so a literal `<!-- END WORKTREE CONTEXT -->` in a title cannot terminate the managed section prematurely. From 7d15a1deb3038cde25adde3dc5b4a3a7e1b8f75d Mon Sep 17 00:00:00 2001 From: christophe dervieux <christophe.dervieux@gmail.com> Date: Mon, 11 May 2026 16:17:24 +0200 Subject: [PATCH 04/34] plan: harden rollback path + fix collision smoke test (bd-spsv) - run()'s rollback now checks exit status and captures stderr for both `git worktree remove --force` and `git branch -D`. On rollback failure it logs explicit manual cleanup commands rather than silently leaving a half-cleaned state. - Add Q2_CREATE_WORKTREE_INJECT_FAIL test hook (one env-var check at the top of the post-worktree-add closure) so Phase E can exercise the rollback end-to-end without modifying production logic. - Phase E Step 5a: the pre-created collision dir must match the computed target path (`.worktrees/bd-spsv-collision-test`, not `.worktrees/ collision-test`). The old smoke would silently succeed at creating a real worktree. - Phase E adds Step 5d: inject failure, assert dir and branch both cleaned up. --- ...6-05-11-implement-create-worktree-xtask.md | 84 +++++++++++++++++-- 1 file changed, 75 insertions(+), 9 deletions(-) diff --git a/claude-notes/plans/2026-05-11-implement-create-worktree-xtask.md b/claude-notes/plans/2026-05-11-implement-create-worktree-xtask.md index d9b5b4e5d..3b532bfe7 100644 --- a/claude-notes/plans/2026-05-11-implement-create-worktree-xtask.md +++ b/claude-notes/plans/2026-05-11-implement-create-worktree-xtask.md @@ -1368,6 +1368,15 @@ pub fn run(args: Args) -> Result<()> { // From here on, on any error we roll back the worktree+branch we just // created so a retry is not blocked by directory/branch collision. let post = (|| -> Result<()> { + // Test-only injection point: lets the Phase E smoke test exercise the + // rollback path without modifying production logic. + if std::env::var("Q2_CREATE_WORKTREE_INJECT_FAIL").as_deref() + == Ok("after_worktree_add") + { + anyhow::bail!( + "Q2_CREATE_WORKTREE_INJECT_FAIL=after_worktree_add (test hook)" + ); + } write_beads_redirect(&plan.dir)?; let section = build_section(&plan.kind); let claude_local = plan.dir.join("CLAUDE.local.md"); @@ -1377,16 +1386,58 @@ pub fn run(args: Args) -> Result<()> { if let Err(e) = post { eprintln!("error after worktree creation: {e:#}"); - eprintln!("rolling back worktree {} and branch {}", plan.dir.display(), plan.branch); - let _ = Command::new("git") + eprintln!( + "rolling back worktree {} and branch {} ...", + plan.dir.display(), + plan.branch + ); + let mut rollback_issues: Vec<String> = Vec::new(); + + match Command::new("git") .arg("worktree") .arg("remove") .arg("--force") .arg(plan.dir.as_os_str()) - .status(); - let _ = Command::new("git") + .output() + { + Ok(out) if out.status.success() => {} + Ok(out) => rollback_issues.push(format!( + "`git worktree remove --force {}` failed:\n {}\n manual cleanup: git worktree remove --force {}", + plan.dir.display(), + String::from_utf8_lossy(&out.stderr).trim().replace('\n', "\n "), + plan.dir.display(), + )), + Err(spawn_err) => rollback_issues.push(format!( + "could not spawn `git worktree remove`: {spawn_err}\n manual cleanup: git worktree remove --force {}", + plan.dir.display() + )), + } + + match Command::new("git") .args(["branch", "-D", &plan.branch]) - .status(); + .output() + { + Ok(out) if out.status.success() => {} + Ok(out) => rollback_issues.push(format!( + "`git branch -D {}` failed:\n {}\n manual cleanup: git branch -D {}", + plan.branch, + String::from_utf8_lossy(&out.stderr).trim().replace('\n', "\n "), + plan.branch, + )), + Err(spawn_err) => rollback_issues.push(format!( + "could not spawn `git branch -D`: {spawn_err}\n manual cleanup: git branch -D {}", + plan.branch + )), + } + + if rollback_issues.is_empty() { + eprintln!("rollback complete."); + } else { + eprintln!("rollback incomplete — manual steps required:"); + for issue in &rollback_issues { + eprintln!(" - {issue}"); + } + } return Err(e); } @@ -1578,11 +1629,13 @@ ls .worktrees/cargo-upgrade-*-e2e-upgrade/CLAUDE.local.md # → upgrade varian - [ ] **Step 5: Failure cases** ```bash -# 5a. Existing directory collision -mkdir -p .worktrees/collision-test +# 5a. Existing directory collision — pre-create the COMPUTED target path. +# For `bd-spsv --slug collision-test` the computed dir is .worktrees/bd-spsv-collision-test. +mkdir -p .worktrees/bd-spsv-collision-test cargo xtask create-worktree bd-spsv --slug collision-test -# Expected: clear error before any git operation. Then: -rmdir .worktrees/collision-test +# Expected: clear error before any git operation, no branch created. Then: +rmdir .worktrees/bd-spsv-collision-test +git branch | grep 'bd-spsv-collision-test' || echo "OK: no branch created" # 5b. Invalid --slug grammar (path-separator, traversal, whitespace) cargo xtask create-worktree bd-spsv --slug "foo/bar" @@ -1595,6 +1648,19 @@ cargo xtask create-worktree bd-spsv --slug e2e-beads # Expected: fails with "worktree directory already exists" — by design. # If you need to refresh the CLAUDE.local.md section, remove the worktree # and recreate, or hand-edit the file (the BEGIN/END markers make this safe). + +# 5d. Rollback path — inject a failure AFTER git_worktree_add and confirm cleanup. +# The Q2_CREATE_WORKTREE_INJECT_FAIL hook in run() triggers a bail inside the +# post-worktree-add closure, which exercises the rollback code path. +Q2_CREATE_WORKTREE_INJECT_FAIL=after_worktree_add \ + cargo xtask create-worktree bd-spsv --slug rollback-test +# Expected: +# - Original error message: "Q2_CREATE_WORKTREE_INJECT_FAIL=after_worktree_add (test hook)" +# - "rollback complete." on stderr (assuming git removal succeeds) +# - No leftover directory and no leftover branch: +test ! -d .worktrees/bd-spsv-rollback-test && echo "OK: dir cleaned" +git branch | grep 'beads/bd-spsv-rollback-test' && echo "FAIL: branch leaked" \ + || echo "OK: branch cleaned" ``` - [ ] **Step 6: Cleanup** From 27ef5bb04ff1d3284ec1fd99f39aec75d1ec8863 Mon Sep 17 00:00:00 2001 From: christophe dervieux <christophe.dervieux@gmail.com> Date: Mon, 11 May 2026 16:41:27 +0200 Subject: [PATCH 05/34] xtask: add time + serde_json direct deps for create-worktree --- Cargo.lock | 2 ++ crates/xtask/Cargo.toml | 2 ++ 2 files changed, 4 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index c95016ddb..cc90eb540 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7258,8 +7258,10 @@ dependencies = [ "anyhow", "clap", "proc-macro2", + "serde_json", "syn", "tempfile", + "time", "walkdir", ] diff --git a/crates/xtask/Cargo.toml b/crates/xtask/Cargo.toml index c03803f2d..f8de2a917 100644 --- a/crates/xtask/Cargo.toml +++ b/crates/xtask/Cargo.toml @@ -14,8 +14,10 @@ path = "src/main.rs" anyhow = { workspace = true } clap = { workspace = true } proc-macro2 = { workspace = true } +serde_json = { workspace = true } syn = { workspace = true } tempfile = "3" +time = { version = "0.3", features = ["macros", "formatting"] } walkdir = { workspace = true } [lints] From 85a93eced21c5f44d2507a4c055535ad1e8cae1d Mon Sep 17 00:00:00 2001 From: christophe dervieux <christophe.dervieux@gmail.com> Date: Mon, 11 May 2026 16:45:12 +0200 Subject: [PATCH 06/34] xtask: scaffold create_worktree module with Args + stub run() --- crates/xtask/src/create_worktree.rs | 64 +++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 crates/xtask/src/create_worktree.rs diff --git a/crates/xtask/src/create_worktree.rs b/crates/xtask/src/create_worktree.rs new file mode 100644 index 000000000..d758a5929 --- /dev/null +++ b/crates/xtask/src/create_worktree.rs @@ -0,0 +1,64 @@ +//! `cargo xtask create-worktree` — set up a git worktree with beads redirect +//! and a marker-delimited CLAUDE.local.md context section. +//! +//! Three modes (exactly one required): +//! - positional `<bd-id>` — beads issue (reads `br show`) +//! - `--issue <N>` — GitHub issue triage (reads `gh issue view`) +//! - `--upgrade` — cargo dependency upgrade (date-based branch) +//! +//! Filesystem-only: never touches beads state. Skills own beads lifecycle. + +use anyhow::Result; + +const BEGIN_MARKER: &str = + "<!-- BEGIN WORKTREE CONTEXT — managed by cargo xtask create-worktree -->"; +const END_MARKER: &str = "<!-- END WORKTREE CONTEXT -->"; + +const STOP_WORDS: &[&str] = &[ + "a", "an", "the", "and", "or", "in", "on", "of", "to", "for", "with", "from", "at", "by", "is", + "as", +]; + +// Lock the em-dash in BEGIN_MARKER against accidental editor substitution. +const _: () = { + let bytes = BEGIN_MARKER.as_bytes(); + // U+2014 EM DASH encodes as 0xE2 0x80 0x94 in UTF-8. + let mut i = 0; + let mut found = false; + while i + 2 < bytes.len() { + if bytes[i] == 0xE2 && bytes[i + 1] == 0x80 && bytes[i + 2] == 0x94 { + found = true; + } + i += 1; + } + assert!(found, "BEGIN_MARKER must contain U+2014 em dash"); +}; + +#[derive(clap::Args)] +#[command(group(clap::ArgGroup::new("mode").required(true).multiple(false)))] +pub struct Args { + /// Beads issue ID, e.g. `bd-1d3e`. Reads `br show <id>` for title and external_ref. + #[arg(group = "mode")] + pub beads_id: Option<String>, + + /// GitHub issue number, e.g. `157`. Reads `gh issue view`. + #[arg(long, group = "mode")] + pub issue: Option<u32>, + + /// Cargo dependency upgrade — uses today's date for branch name. + #[arg(long, group = "mode")] + pub upgrade: bool, + + /// Override auto-derived slug. In beads mode replaces the derived slug; + /// in issue/upgrade modes appended as a suffix (for parallel-worktree workflows). + #[arg(long)] + pub slug: Option<String>, + + /// Base branch. + #[arg(long, default_value = "main")] + pub base: String, +} + +pub fn run(_args: Args) -> Result<()> { + anyhow::bail!("create-worktree not yet implemented"); +} From c41e5656886a2d34808e04d9bfe974dac4977478 Mon Sep 17 00:00:00 2001 From: christophe dervieux <christophe.dervieux@gmail.com> Date: Mon, 11 May 2026 16:52:04 +0200 Subject: [PATCH 07/34] xtask: wire create-worktree subcommand into Command enum --- crates/xtask/src/main.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/crates/xtask/src/main.rs b/crates/xtask/src/main.rs index 664bb11cb..601bfab85 100644 --- a/crates/xtask/src/main.rs +++ b/crates/xtask/src/main.rs @@ -8,6 +8,7 @@ //! Available commands: //! - `dev-setup`: Install required development tools (cargo-nextest, wasm-bindgen-cli) //! - `lint`: Run custom lint checks on the codebase +//! - `create-worktree`: Create git worktree with beads redirect and CLAUDE.local.md //! - `test`: Run workspace tests with platform-appropriate crate exclusions //! - `verify`: Run full project verification (build + tests for Rust and hub-client) //! - `build-all`: Fresh-clone build orchestration (npm install + hub-client + Rust workspace) @@ -15,6 +16,7 @@ mod build_all; mod build_trace_viewer; +mod create_worktree; mod dev_setup; mod lint; mod test; @@ -56,6 +58,17 @@ enum Command { quiet: bool, }, + /// Create a new git worktree with beads redirect and CLAUDE.local.md context stub. + /// + /// Modes (exactly one required): + /// <bd-id> — beads issue (positional) + /// --issue N — GitHub issue triage + /// --upgrade — cargo dependency upgrade (date-based branch) + CreateWorktree { + #[command(flatten)] + args: create_worktree::Args, + }, + /// Run workspace tests with platform-appropriate crate exclusions. /// /// On Windows, automatically excludes crates that depend on v8 (which cannot @@ -174,6 +187,7 @@ fn main() -> Result<()> { let config = lint::LintConfig { verbose, quiet }; lint::run(&config) } + Command::CreateWorktree { args } => create_worktree::run(args), Command::Test { deny_warnings, args, From b95b9f855ee67a416631ad1b645e2aaeeac9e3ec Mon Sep 17 00:00:00 2001 From: christophe dervieux <christophe.dervieux@gmail.com> Date: Mon, 11 May 2026 16:56:52 +0200 Subject: [PATCH 08/34] xtask(create-worktree): derive_slug + validate_slug grammar --- crates/xtask/src/create_worktree.rs | 125 ++++++++++++++++++++++++++++ 1 file changed, 125 insertions(+) diff --git a/crates/xtask/src/create_worktree.rs b/crates/xtask/src/create_worktree.rs index d758a5929..3036cf1af 100644 --- a/crates/xtask/src/create_worktree.rs +++ b/crates/xtask/src/create_worktree.rs @@ -34,6 +34,54 @@ const _: () = { assert!(found, "BEGIN_MARKER must contain U+2014 em dash"); }; +pub fn derive_slug(title: &str) -> Result<String> { + let tokens: Vec<String> = title + .to_lowercase() + .split(|c: char| c.is_whitespace() || c == '-') + .map(|tok| { + tok.chars() + .filter(|c| c.is_ascii_alphanumeric()) + .collect::<String>() + }) + .filter(|tok| !tok.is_empty()) + .filter(|tok| !STOP_WORDS.contains(&tok.as_str())) + .take(4) + .collect(); + + if tokens.is_empty() { + anyhow::bail!( + "unable to derive slug from title \"{title}\" — pass --slug <name> to override" + ); + } + Ok(tokens.join("-")) +} + +/// Validate a user-provided `--slug` override. Auto-derived slugs already +/// satisfy these rules by construction; this only applies to overrides. +pub fn validate_slug(slug: &str) -> Result<()> { + if slug.is_empty() { + anyhow::bail!("--slug must not be empty"); + } + if slug.len() > 64 { + anyhow::bail!("--slug too long ({} chars, max 64): {slug:?}", slug.len()); + } + if slug == "." || slug == ".." { + anyhow::bail!("--slug must not be {slug:?}"); + } + if slug.starts_with('-') || slug.ends_with('-') { + anyhow::bail!("--slug must not start or end with '-': {slug:?}"); + } + if let Some(bad) = slug + .chars() + .find(|c| !(c.is_ascii_alphanumeric() || *c == '-' || *c == '_')) + { + anyhow::bail!( + "--slug contains invalid character {bad:?} — only ASCII alphanumeric, '-', '_' allowed: {slug:?}" + ); + } + Ok(()) +} + #[derive(clap::Args)] #[command(group(clap::ArgGroup::new("mode").required(true).multiple(false)))] pub struct Args { @@ -62,3 +110,80 @@ pub struct Args { pub fn run(_args: Args) -> Result<()> { anyhow::bail!("create-worktree not yet implemented"); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn slug_drops_stop_words_and_kebab_splits() { + let s = derive_slug("Fix CRLF test failures in quarto-doctemplate on Windows").unwrap(); + assert_eq!(s, "fix-crlf-test-failures"); + } + + #[test] + fn slug_caps_at_four_tokens() { + let s = derive_slug("alpha beta gamma delta epsilon zeta").unwrap(); + assert_eq!(s, "alpha-beta-gamma-delta"); + } + + #[test] + fn slug_strips_punctuation_and_unicode() { + let s = derive_slug("Don't panic — handle naïve input (v2)!").unwrap(); + // apostrophe / em dash / accent / parens / digits-with-letters all collapse + assert_eq!(s, "dont-panic-handle-nave"); + } + + #[test] + fn slug_empty_result_errors() { + let err = derive_slug("the and of on in").unwrap_err().to_string(); + assert!(err.contains("unable to derive slug")); + assert!(err.contains("--slug")); + } + + #[test] + fn slug_only_punctuation_errors() { + let err = derive_slug("!!! ??? ---").unwrap_err().to_string(); + assert!(err.contains("unable to derive slug")); + } + + #[test] + fn validate_slug_accepts_safe_input() { + assert!(validate_slug("e2e-beads").is_ok()); + assert!(validate_slug("issue42").is_ok()); + assert!(validate_slug("a_b-c").is_ok()); + } + + #[test] + fn validate_slug_rejects_empty() { + let err = validate_slug("").unwrap_err().to_string(); + assert!(err.contains("must not be empty")); + } + + #[test] + fn validate_slug_rejects_path_separators_and_traversal() { + assert!(validate_slug("foo/bar").is_err()); + assert!(validate_slug("foo\\bar").is_err()); + assert!(validate_slug("..").is_err()); + assert!(validate_slug(".").is_err()); + } + + #[test] + fn validate_slug_rejects_whitespace_and_other_punct() { + assert!(validate_slug("foo bar").is_err()); + assert!(validate_slug("foo.bar").is_err()); + assert!(validate_slug("foo:bar").is_err()); + } + + #[test] + fn validate_slug_rejects_leading_or_trailing_dash() { + assert!(validate_slug("-leading").is_err()); + assert!(validate_slug("trailing-").is_err()); + } + + #[test] + fn validate_slug_rejects_too_long() { + let too_long = "a".repeat(65); + assert!(validate_slug(&too_long).is_err()); + } +} From fd194b33693c043488b120763f56ec3cb886d023 Mon Sep 17 00:00:00 2001 From: christophe dervieux <christophe.dervieux@gmail.com> Date: Mon, 11 May 2026 16:59:53 +0200 Subject: [PATCH 09/34] xtask(create-worktree): parse gh-N external_ref to GitHub URL --- crates/xtask/src/create_worktree.rs | 44 +++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/crates/xtask/src/create_worktree.rs b/crates/xtask/src/create_worktree.rs index 3036cf1af..665609444 100644 --- a/crates/xtask/src/create_worktree.rs +++ b/crates/xtask/src/create_worktree.rs @@ -107,6 +107,16 @@ pub struct Args { pub base: String, } +pub fn parse_external_ref_to_github_url(ext: Option<&str>) -> Option<String> { + let ext = ext?; + let n = ext.strip_prefix("gh-")?; + if !n.is_empty() && n.chars().all(|c| c.is_ascii_digit()) { + Some(format!("https://github.com/quarto-dev/q2/issues/{n}")) + } else { + None + } +} + pub fn run(_args: Args) -> Result<()> { anyhow::bail!("create-worktree not yet implemented"); } @@ -186,4 +196,38 @@ mod tests { let too_long = "a".repeat(65); assert!(validate_slug(&too_long).is_err()); } + + #[test] + fn external_ref_gh_prefix_to_url() { + assert_eq!( + parse_external_ref_to_github_url(Some("gh-157")), + Some("https://github.com/quarto-dev/q2/issues/157".to_string()) + ); + } + + #[test] + fn external_ref_none_returns_none() { + assert_eq!(parse_external_ref_to_github_url(None), None); + } + + #[test] + fn external_ref_empty_string_returns_none() { + assert_eq!(parse_external_ref_to_github_url(Some("")), None); + } + + #[test] + fn external_ref_non_gh_prefix_returns_none() { + assert_eq!( + parse_external_ref_to_github_url(Some("linear-ABC-12")), + None + ); + } + + #[test] + fn external_ref_malformed_gh_returns_none() { + // Non-numeric suffix + assert_eq!(parse_external_ref_to_github_url(Some("gh-foo")), None); + // Empty suffix + assert_eq!(parse_external_ref_to_github_url(Some("gh-")), None); + } } From ecae7ec6c4486a0df65ff7ade3468d094c2c4c11 Mon Sep 17 00:00:00 2001 From: christophe dervieux <christophe.dervieux@gmail.com> Date: Mon, 11 May 2026 17:02:37 +0200 Subject: [PATCH 10/34] xtask(create-worktree): detect line ending with 1 KiB sniff --- crates/xtask/src/create_worktree.rs | 53 +++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/crates/xtask/src/create_worktree.rs b/crates/xtask/src/create_worktree.rs index 665609444..6c8bada4f 100644 --- a/crates/xtask/src/create_worktree.rs +++ b/crates/xtask/src/create_worktree.rs @@ -121,6 +121,25 @@ pub fn run(_args: Args) -> Result<()> { anyhow::bail!("create-worktree not yet implemented"); } +pub fn detect_line_ending(content: &str) -> &'static str { + // Sniff up to first 1 KiB, snapped to a char boundary so slicing is valid. + let mut sniff_end = content.len().min(1024); + while sniff_end > 0 && !content.is_char_boundary(sniff_end) { + sniff_end -= 1; + } + let sniff = &content[..sniff_end]; + + let crlf_count = sniff.matches("\r\n").count(); + let lf_total = sniff.matches('\n').count(); + let bare_lf = lf_total - crlf_count; + + if crlf_count > 0 && bare_lf == 0 { + "\r\n" + } else { + "\n" + } +} + #[cfg(test)] mod tests { use super::*; @@ -230,4 +249,38 @@ mod tests { // Empty suffix assert_eq!(parse_external_ref_to_github_url(Some("gh-")), None); } + + #[test] + fn detect_le_empty_defaults_to_lf() { + assert_eq!(detect_line_ending(""), "\n"); + } + + #[test] + fn detect_le_no_newlines_defaults_to_lf() { + assert_eq!(detect_line_ending("hello world"), "\n"); + } + + #[test] + fn detect_le_lf_only() { + assert_eq!(detect_line_ending("a\nb\nc\n"), "\n"); + } + + #[test] + fn detect_le_crlf_pure() { + assert_eq!(detect_line_ending("a\r\nb\r\nc\r\n"), "\r\n"); + } + + #[test] + fn detect_le_mixed_falls_back_to_lf() { + // CRLF + bare LF -> LF (do not propagate inconsistency) + assert_eq!(detect_line_ending("a\r\nb\nc\r\n"), "\n"); + } + + #[test] + fn detect_le_sniffs_only_first_1kb() { + // Pad the head with LF, place a CRLF beyond the sniff window + let mut s = "x\n".repeat(600); // 1200 bytes of LF-terminated lines + s.push_str("z\r\n"); + assert_eq!(detect_line_ending(&s), "\n"); + } } From 453eeab2f6e3da3a47ccc17ed0a7f012e0ae511d Mon Sep 17 00:00:00 2001 From: christophe dervieux <christophe.dervieux@gmail.com> Date: Mon, 11 May 2026 17:07:06 +0200 Subject: [PATCH 11/34] xtask(create-worktree): build_section templates for 3 modes --- crates/xtask/src/create_worktree.rs | 141 ++++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) diff --git a/crates/xtask/src/create_worktree.rs b/crates/xtask/src/create_worktree.rs index 6c8bada4f..55aacf87d 100644 --- a/crates/xtask/src/create_worktree.rs +++ b/crates/xtask/src/create_worktree.rs @@ -34,6 +34,22 @@ const _: () = { assert!(found, "BEGIN_MARKER must contain U+2014 em dash"); }; +pub enum SectionKind { + Beads { + id: String, + title: String, + github_url: Option<String>, + }, + Issue { + number: u32, + title: String, + url: String, + }, + Upgrade { + date: String, + }, +} + pub fn derive_slug(title: &str) -> Result<String> { let tokens: Vec<String> = title .to_lowercase() @@ -117,6 +133,65 @@ pub fn parse_external_ref_to_github_url(ext: Option<&str>) -> Option<String> { } } +/// Neutralize any occurrences of BEGIN/END marker substrings inside +/// externally-sourced text (titles from `br`/`gh`). Without this, a title +/// containing `<!-- END WORKTREE CONTEXT -->` would terminate the section +/// prematurely on the next idempotent strip pass. +fn marker_safe(s: &str) -> String { + s.replace(BEGIN_MARKER, "[BEGIN marker scrubbed]") + .replace(END_MARKER, "[END marker scrubbed]") +} + +pub fn build_section(kind: &SectionKind) -> String { + let body = match kind { + SectionKind::Beads { + id, + title, + github_url, + } => { + let title = marker_safe(title); + let mut s = String::new(); + s.push_str("# Worktree Context\n\n"); + s.push_str("This is a **worktree** of the q2 repository. Main repo: `../..`\n\n"); + s.push_str(&format!("**Beads:** {id} \u{2014} {title}\n")); + if let Some(url) = github_url { + s.push_str(&format!("**GitHub:** {url}\n")); + } + s.push_str("**Plan:** <!-- fill in after creating: claude-notes/plans/YYYY-MM-DD-name.md -->\n"); + s.push('\n'); + s.push_str(&format!( + "Run `br show {id}` for current status and notes.\n" + )); + s + } + SectionKind::Issue { number, title, url } => { + let title = marker_safe(title); + let mut s = String::new(); + s.push_str("# Worktree Context\n\n"); + s.push_str("This is a **worktree** of the q2 repository. Main repo: `../..`\n\n"); + s.push_str(&format!("**GitHub issue:** #{number} \u{2014} {title}\n")); + s.push_str(&format!("**URL:** {url}\n")); + s.push_str(&format!( + "**Beads:** (run `br search {number}` to find or create a beads issue)\n" + )); + s.push_str("**Plan:** <!-- fill in after creating: claude-notes/plans/YYYY-MM-DD-name.md -->\n"); + s + } + SectionKind::Upgrade { date } => { + let mut s = String::new(); + s.push_str("# Worktree Context\n\n"); + s.push_str("This is a **worktree** of the q2 repository. Main repo: `../..`\n\n"); + s.push_str(&format!( + "**Task:** Cargo dependency upgrade \u{2014} {date}\n" + )); + s.push_str("**Plan:** <!-- fill in if needed -->\n"); + s + } + }; + + format!("{BEGIN_MARKER}\n{body}{END_MARKER}\n") +} + pub fn run(_args: Args) -> Result<()> { anyhow::bail!("create-worktree not yet implemented"); } @@ -283,4 +358,70 @@ mod tests { s.push_str("z\r\n"); assert_eq!(detect_line_ending(&s), "\n"); } + + #[test] + fn section_beads_with_github() { + let s = build_section(&SectionKind::Beads { + id: "bd-1d3e".into(), + title: "Fix X".into(), + github_url: Some("https://github.com/quarto-dev/q2/issues/42".into()), + }); + assert!(s.starts_with(BEGIN_MARKER)); + assert!(s.trim_end().ends_with(END_MARKER)); + assert!(s.contains("**Beads:** bd-1d3e — Fix X")); + assert!(s.contains("**GitHub:** https://github.com/quarto-dev/q2/issues/42")); + assert!(s.contains("Run `br show bd-1d3e`")); + assert!(s.contains("Main repo: `../..`")); + } + + #[test] + fn section_beads_without_github_omits_line() { + let s = build_section(&SectionKind::Beads { + id: "bd-zzzz".into(), + title: "T".into(), + github_url: None, + }); + assert!(!s.contains("**GitHub:**")); + assert!(s.contains("**Beads:** bd-zzzz — T")); + } + + #[test] + fn section_issue() { + let s = build_section(&SectionKind::Issue { + number: 157, + title: "An issue".into(), + url: "https://github.com/quarto-dev/q2/issues/157".into(), + }); + assert!(s.contains("**GitHub issue:** #157 — An issue")); + assert!(s.contains("**URL:** https://github.com/quarto-dev/q2/issues/157")); + assert!(s.contains("**Beads:** (run `br search 157`")); + assert!(!s.contains("**Beads:** bd-")); // no resolved beads id + } + + #[test] + fn section_upgrade() { + let s = build_section(&SectionKind::Upgrade { + date: "2026-05-11".into(), + }); + assert!(s.contains("**Task:** Cargo dependency upgrade — 2026-05-11")); + assert!(!s.contains("**Beads:**")); + assert!(!s.contains("**GitHub:**")); + } + + #[test] + fn section_strips_marker_from_title() { + // A title that literally contains the END marker must not be interpolated + // verbatim — `strip_managed_section` would otherwise pick it up as the + // section terminator on the next run. + let evil = format!("real title {END_MARKER} oops"); + let s = build_section(&SectionKind::Beads { + id: "bd-x".into(), + title: evil, + github_url: None, + }); + // END_MARKER must appear exactly once — at the section's actual close. + assert_eq!(s.matches(END_MARKER).count(), 1); + // BEGIN_MARKER ditto. + assert_eq!(s.matches(BEGIN_MARKER).count(), 1); + } } From 89cd13e5eae64bf0b84b0f75383ea550724e9f42 Mon Sep 17 00:00:00 2001 From: christophe dervieux <christophe.dervieux@gmail.com> Date: Mon, 11 May 2026 17:10:14 +0200 Subject: [PATCH 12/34] xtask(create-worktree): strip managed section by markers (idempotent) --- crates/xtask/src/create_worktree.rs | 89 +++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/crates/xtask/src/create_worktree.rs b/crates/xtask/src/create_worktree.rs index 55aacf87d..70af786b2 100644 --- a/crates/xtask/src/create_worktree.rs +++ b/crates/xtask/src/create_worktree.rs @@ -142,6 +142,49 @@ fn marker_safe(s: &str) -> String { .replace(END_MARKER, "[END marker scrubbed]") } +pub fn strip_managed_section(content: &str) -> Result<String> { + let Some(begin_pos) = content.find(BEGIN_MARKER) else { + return Ok(content.to_string()); + }; + + // Warn (but proceed) if a second BEGIN appears after the first. + let after_begin = &content[begin_pos + BEGIN_MARKER.len()..]; + if after_begin.contains(BEGIN_MARKER) { + eprintln!( + "warning: CLAUDE.local.md contains multiple BEGIN markers \u{2014} using the first; \ + recommend manual review of {}", + "CLAUDE.local.md" + ); + } + + let end_search_start = begin_pos + BEGIN_MARKER.len(); + let end_rel = content[end_search_start..] + .find(END_MARKER) + .ok_or_else(|| { + anyhow::anyhow!( + "CLAUDE.local.md has BEGIN marker without END marker \u{2014} refusing to modify; \ + resolve manually" + ) + })?; + let end_marker_end = end_search_start + end_rel + END_MARKER.len(); + + // Strip from the start of the BEGIN line through one trailing newline after END. + let begin_line_start = content[..begin_pos].rfind('\n').map(|i| i + 1).unwrap_or(0); + + let mut after_end = end_marker_end; + let rest = &content[after_end..]; + if rest.starts_with("\r\n") { + after_end += 2; + } else if rest.starts_with('\n') { + after_end += 1; + } + + let mut out = String::with_capacity(content.len()); + out.push_str(&content[..begin_line_start]); + out.push_str(&content[after_end..]); + Ok(out) +} + pub fn build_section(kind: &SectionKind) -> String { let body = match kind { SectionKind::Beads { @@ -424,4 +467,50 @@ mod tests { // BEGIN_MARKER ditto. assert_eq!(s.matches(BEGIN_MARKER).count(), 1); } + + #[test] + fn strip_no_marker_returns_input_unchanged() { + let input = "# My notes\nfoo bar\n"; + assert_eq!(strip_managed_section(input).unwrap(), input); + } + + #[test] + fn strip_full_managed_section() { + let input = + format!("{BEGIN_MARKER}\n# Worktree Context\nstuff\n{END_MARKER}\n# My notes\nfoo\n"); + assert_eq!(strip_managed_section(&input).unwrap(), "# My notes\nfoo\n"); + } + + #[test] + fn strip_section_in_middle_of_file() { + let input = format!("# Header\n\n{BEGIN_MARKER}\nbody\n{END_MARKER}\n\n# Footer\n"); + assert_eq!( + strip_managed_section(&input).unwrap(), + "# Header\n\n\n# Footer\n" + ); + } + + #[test] + fn strip_begin_without_end_errors() { + let input = format!("{BEGIN_MARKER}\nbody never closed\n"); + let err = strip_managed_section(&input).unwrap_err().to_string(); + assert!(err.contains("BEGIN marker without END marker")); + } + + #[test] + fn strip_uses_first_of_multiple_begins() { + let input = format!( + "{BEGIN_MARKER}\nfirst\n{END_MARKER}\nmiddle\n{BEGIN_MARKER}\nsecond\n{END_MARKER}\n" + ); + // First section + trailing newline stripped; everything from "middle" onward preserved. + let out = strip_managed_section(&input).unwrap(); + assert!(out.starts_with("middle\n")); + assert!(out.contains(BEGIN_MARKER)); // second still present + } + + #[test] + fn strip_handles_crlf_marker_lines() { + let input = format!("{BEGIN_MARKER}\r\nbody\r\n{END_MARKER}\r\nrest\r\n"); + assert_eq!(strip_managed_section(&input).unwrap(), "rest\r\n"); + } } From 8b74f048b50b741132bdde0cfd62200176af6da7 Mon Sep 17 00:00:00 2001 From: christophe dervieux <christophe.dervieux@gmail.com> Date: Mon, 11 May 2026 17:14:11 +0200 Subject: [PATCH 13/34] xtask(create-worktree): update_claude_local_md with atomic rename --- crates/xtask/src/create_worktree.rs | 158 ++++++++++++++++++++++++++++ 1 file changed, 158 insertions(+) diff --git a/crates/xtask/src/create_worktree.rs b/crates/xtask/src/create_worktree.rs index 70af786b2..281f7b916 100644 --- a/crates/xtask/src/create_worktree.rs +++ b/crates/xtask/src/create_worktree.rs @@ -8,7 +8,10 @@ //! //! Filesystem-only: never touches beads state. Skills own beads lifecycle. +use anyhow::Context; use anyhow::Result; +use std::fs; +use std::path::{Path, PathBuf}; const BEGIN_MARKER: &str = "<!-- BEGIN WORKTREE CONTEXT — managed by cargo xtask create-worktree -->"; @@ -235,6 +238,62 @@ pub fn build_section(kind: &SectionKind) -> String { format!("{BEGIN_MARKER}\n{body}{END_MARKER}\n") } +pub fn update_claude_local_md(path: &Path, new_section: &str) -> Result<()> { + // 1. Read existing content (or empty if file missing). + let existing = if path.exists() { + let meta = path + .symlink_metadata() + .with_context(|| format!("reading metadata of {}", path.display()))?; + if !meta.is_file() { + anyhow::bail!( + "CLAUDE.local.md exists but is not a regular file: {}", + path.display() + ); + } + fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))? + } else { + String::new() + }; + + // 2. Detect line ending from existing content. + let nl = detect_line_ending(&existing); + + // 3. Strip any existing managed section. + let body = strip_managed_section(&existing)?; + + // 4. Normalize new_section to detected line ending. + let new_section_nl = if nl == "\r\n" { + new_section.replace('\n', "\r\n") + } else { + new_section.to_string() + }; + + // 5. Compose: new section + blank line + remaining body (if any). + let mut out = new_section_nl; + if !body.is_empty() { + if !out.ends_with(nl) { + out.push_str(nl); + } + out.push_str(nl); // blank line separator + out.push_str(&body); + } + if !out.ends_with(nl) { + out.push_str(nl); + } + + // 6. Atomic write: write to .tmp then rename over target. + // Build the temp path by appending ".tmp" to the full OsStr — avoids the + // `Path::with_extension` ambiguity around dots in extensions. + let mut tmp_os = path.as_os_str().to_owned(); + tmp_os.push(".tmp"); + let tmp = PathBuf::from(tmp_os); + fs::write(&tmp, out.as_bytes()).with_context(|| format!("writing {}", tmp.display()))?; + fs::rename(&tmp, path) + .with_context(|| format!("renaming {} to {}", tmp.display(), path.display()))?; + + Ok(()) +} + pub fn run(_args: Args) -> Result<()> { anyhow::bail!("create-worktree not yet implemented"); } @@ -261,6 +320,105 @@ pub fn detect_line_ending(content: &str) -> &'static str { #[cfg(test)] mod tests { use super::*; + use std::fs; + use tempfile::TempDir; + + fn make_dummy_section() -> String { + build_section(&SectionKind::Beads { + id: "bd-xxxx".into(), + title: "Demo".into(), + github_url: None, + }) + } + + #[test] + fn update_creates_file_when_missing() { + let tmp = TempDir::new().unwrap(); + let p = tmp.path().join("CLAUDE.local.md"); + update_claude_local_md(&p, &make_dummy_section()).unwrap(); + let out = fs::read_to_string(&p).unwrap(); + assert!(out.starts_with(BEGIN_MARKER)); + assert!(out.trim_end().ends_with(END_MARKER)); + assert!(out.ends_with('\n')); + } + + #[test] + fn update_prepends_when_no_marker_present() { + let tmp = TempDir::new().unwrap(); + let p = tmp.path().join("CLAUDE.local.md"); + fs::write(&p, "# My notes\nfoo\n").unwrap(); + update_claude_local_md(&p, &make_dummy_section()).unwrap(); + let out = fs::read_to_string(&p).unwrap(); + assert!(out.starts_with(BEGIN_MARKER)); + assert!(out.contains("# My notes")); + } + + #[test] + fn update_is_idempotent() { + let tmp = TempDir::new().unwrap(); + let p = tmp.path().join("CLAUDE.local.md"); + update_claude_local_md(&p, &make_dummy_section()).unwrap(); + update_claude_local_md(&p, &make_dummy_section()).unwrap(); + let out = fs::read_to_string(&p).unwrap(); + assert_eq!(out.matches(BEGIN_MARKER).count(), 1); + assert_eq!(out.matches(END_MARKER).count(), 1); + } + + #[test] + fn update_preserves_user_content_below_section() { + let tmp = TempDir::new().unwrap(); + let p = tmp.path().join("CLAUDE.local.md"); + update_claude_local_md(&p, &make_dummy_section()).unwrap(); + // User edits below the managed section. + let mut content = fs::read_to_string(&p).unwrap(); + content.push_str("\n# My notes\nfoo bar\n"); + fs::write(&p, &content).unwrap(); + // Re-run — managed section refreshed, user content stays. + update_claude_local_md(&p, &make_dummy_section()).unwrap(); + let out = fs::read_to_string(&p).unwrap(); + assert!(out.contains("# My notes")); + assert!(out.contains("foo bar")); + assert_eq!(out.matches(BEGIN_MARKER).count(), 1); + } + + #[test] + fn update_preserves_crlf_when_existing_is_crlf() { + let tmp = TempDir::new().unwrap(); + let p = tmp.path().join("CLAUDE.local.md"); + fs::write(&p, "# Header\r\n\r\nnotes\r\n").unwrap(); + update_claude_local_md(&p, &make_dummy_section()).unwrap(); + let out = fs::read(&p).unwrap(); + // Output should contain CRLF; no bare LFs. + let lf_total = out.iter().filter(|&&b| b == b'\n').count(); + let crlf_pairs = out.windows(2).filter(|w| w == b"\r\n").count(); + assert_eq!( + lf_total, crlf_pairs, + "bare LFs found in CRLF output: {:?}", + out + ); + } + + #[test] + fn update_errors_when_path_is_directory() { + let tmp = TempDir::new().unwrap(); + let p = tmp.path().join("CLAUDE.local.md"); + fs::create_dir(&p).unwrap(); + let err = update_claude_local_md(&p, &make_dummy_section()) + .unwrap_err() + .to_string(); + assert!(err.contains("not a regular file")); + } + + #[test] + fn update_errors_on_begin_without_end() { + let tmp = TempDir::new().unwrap(); + let p = tmp.path().join("CLAUDE.local.md"); + fs::write(&p, format!("{BEGIN_MARKER}\nbroken\n")).unwrap(); + let err = update_claude_local_md(&p, &make_dummy_section()) + .unwrap_err() + .to_string(); + assert!(err.contains("BEGIN marker without END marker")); + } #[test] fn slug_drops_stop_words_and_kebab_splits() { From 35bb97c7ab1f180c6f0acf199ff9fed5de7337d6 Mon Sep 17 00:00:00 2001 From: christophe dervieux <christophe.dervieux@gmail.com> Date: Mon, 11 May 2026 17:18:09 +0200 Subject: [PATCH 14/34] xtask(create-worktree): fetch_beads_metadata via br show --json --- crates/xtask/src/create_worktree.rs | 53 +++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/crates/xtask/src/create_worktree.rs b/crates/xtask/src/create_worktree.rs index 281f7b916..323dd3c2b 100644 --- a/crates/xtask/src/create_worktree.rs +++ b/crates/xtask/src/create_worktree.rs @@ -12,6 +12,7 @@ use anyhow::Context; use anyhow::Result; use std::fs; use std::path::{Path, PathBuf}; +use std::process::Command; const BEGIN_MARKER: &str = "<!-- BEGIN WORKTREE CONTEXT — managed by cargo xtask create-worktree -->"; @@ -294,6 +295,58 @@ pub fn update_claude_local_md(path: &Path, new_section: &str) -> Result<()> { Ok(()) } +pub struct BeadsMetadata { + pub title: String, + pub external_ref: Option<String>, +} + +pub fn fetch_beads_metadata(id: &str) -> Result<BeadsMetadata> { + let output = Command::new("br") + .args(["show", id, "--json"]) + .output() + .map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + anyhow::anyhow!( + "br is required \u{2014} install via `cargo install beads-rust` or see project README" + ) + } else { + anyhow::Error::new(e).context("spawning `br show`") + } + })?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("br show {id} failed:\n{stderr}"); + } + + let stdout = std::str::from_utf8(&output.stdout) + .with_context(|| format!("`br show {id} --json` produced non-UTF-8 output"))?; + + // `br show --json` returns an array; take the first element. + let arr: Vec<serde_json::Value> = serde_json::from_str(stdout) + .with_context(|| format!("parsing JSON from `br show {id} --json`"))?; + let first = arr + .into_iter() + .next() + .ok_or_else(|| anyhow::anyhow!("`br show {id} --json` returned an empty array"))?; + + let title = first + .get("title") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("`br show {id}` JSON missing `title` field"))? + .to_string(); + + let external_ref = first + .get("external_ref") + .and_then(|v| v.as_str()) + .map(str::to_string); + + Ok(BeadsMetadata { + title, + external_ref, + }) +} + pub fn run(_args: Args) -> Result<()> { anyhow::bail!("create-worktree not yet implemented"); } From 7bd5fe517d2b2d0c08300c2327582b5435f5cda6 Mon Sep 17 00:00:00 2001 From: christophe dervieux <christophe.dervieux@gmail.com> Date: Mon, 11 May 2026 17:21:10 +0200 Subject: [PATCH 15/34] xtask(create-worktree): fetch_gh_issue via gh issue view --json --- crates/xtask/src/create_worktree.rs | 50 +++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/crates/xtask/src/create_worktree.rs b/crates/xtask/src/create_worktree.rs index 323dd3c2b..e1cf7904f 100644 --- a/crates/xtask/src/create_worktree.rs +++ b/crates/xtask/src/create_worktree.rs @@ -347,6 +347,56 @@ pub fn fetch_beads_metadata(id: &str) -> Result<BeadsMetadata> { }) } +pub struct GhIssue { + pub title: String, + pub url: String, +} + +pub fn fetch_gh_issue(number: u32) -> Result<GhIssue> { + let n = number.to_string(); + let output = Command::new("gh") + .args([ + "issue", + "view", + &n, + "--repo", + "quarto-dev/q2", + "--json", + "title,url", + ]) + .output() + .map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + anyhow::anyhow!("gh is required \u{2014} see https://cli.github.com/") + } else { + anyhow::Error::new(e).context("spawning `gh issue view`") + } + })?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("gh issue view {n} failed:\n{stderr}"); + } + + let stdout = std::str::from_utf8(&output.stdout) + .with_context(|| format!("`gh issue view {n}` produced non-UTF-8 output"))?; + + let v: serde_json::Value = serde_json::from_str(stdout) + .with_context(|| format!("parsing JSON from `gh issue view {n}`"))?; + let title = v + .get("title") + .and_then(|x| x.as_str()) + .ok_or_else(|| anyhow::anyhow!("`gh issue view {n}` JSON missing `title`"))? + .to_string(); + let url = v + .get("url") + .and_then(|x| x.as_str()) + .ok_or_else(|| anyhow::anyhow!("`gh issue view {n}` JSON missing `url`"))? + .to_string(); + + Ok(GhIssue { title, url }) +} + pub fn run(_args: Args) -> Result<()> { anyhow::bail!("create-worktree not yet implemented"); } From 1336404fd5e4014b211027cc4b1d06c79fe1cbde Mon Sep 17 00:00:00 2001 From: christophe dervieux <christophe.dervieux@gmail.com> Date: Mon, 11 May 2026 17:24:53 +0200 Subject: [PATCH 16/34] xtask(create-worktree): git_worktree_add + write_beads_redirect --- crates/xtask/src/create_worktree.rs | 60 +++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/crates/xtask/src/create_worktree.rs b/crates/xtask/src/create_worktree.rs index e1cf7904f..b76235ed8 100644 --- a/crates/xtask/src/create_worktree.rs +++ b/crates/xtask/src/create_worktree.rs @@ -397,6 +397,66 @@ pub fn fetch_gh_issue(number: u32) -> Result<GhIssue> { Ok(GhIssue { title, url }) } +pub fn git_worktree_add(branch: &str, dir: &Path, base: &str) -> Result<()> { + if dir.exists() { + anyhow::bail!("worktree directory already exists: {}", dir.display()); + } + + // Pre-check: does the branch already exist locally? + let check = Command::new("git") + .args([ + "rev-parse", + "--verify", + "--quiet", + &format!("refs/heads/{branch}"), + ]) + .output() + .context("spawning `git rev-parse`")?; + if check.status.success() { + anyhow::bail!( + "branch already exists: {branch} \u{2014} remove it or pass --slug to disambiguate" + ); + } + + // Pass the directory as OsStr so paths with non-UTF-8 bytes (Windows UTF-16 + // halves, weird POSIX names) still round-trip correctly. + // `.output()` captures stderr so we can include git's actual error message + // in our anyhow context — `.status()` would just give us an exit code. + let output = Command::new("git") + .arg("worktree") + .arg("add") + .arg("-b") + .arg(branch) + .arg(dir.as_os_str()) + .arg(base) + .output() + .context("spawning `git worktree add`")?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!( + "git worktree add failed (exit {:?}):\n{stderr}", + output.status.code() + ); + } + + Ok(()) +} + +pub fn write_beads_redirect(dir: &Path) -> Result<()> { + let redirect = dir.join(".beads").join("redirect"); + // `.beads/` is tracked in the new worktree — directory should exist. + if !redirect.parent().map(Path::is_dir).unwrap_or(false) { + anyhow::bail!( + ".beads/ directory missing in new worktree: {} \u{2014} was the base branch correct?", + redirect.parent().unwrap().display() + ); + } + // LF line ending intentionally, even on Windows. + fs::write(&redirect, "../../../.beads\n") + .with_context(|| format!("writing {}", redirect.display()))?; + Ok(()) +} + pub fn run(_args: Args) -> Result<()> { anyhow::bail!("create-worktree not yet implemented"); } From 417a1d2bb3ec52450079a4165ddc4a2ae55bbadc Mon Sep 17 00:00:00 2001 From: christophe dervieux <christophe.dervieux@gmail.com> Date: Mon, 11 May 2026 17:31:11 +0200 Subject: [PATCH 17/34] xtask(create-worktree): wire run() to dispatch + rollback + summary --- crates/xtask/src/create_worktree.rs | 208 +++++++++++++++++++++++++++- 1 file changed, 206 insertions(+), 2 deletions(-) diff --git a/crates/xtask/src/create_worktree.rs b/crates/xtask/src/create_worktree.rs index b76235ed8..8b7b9aae8 100644 --- a/crates/xtask/src/create_worktree.rs +++ b/crates/xtask/src/create_worktree.rs @@ -457,8 +457,212 @@ pub fn write_beads_redirect(dir: &Path) -> Result<()> { Ok(()) } -pub fn run(_args: Args) -> Result<()> { - anyhow::bail!("create-worktree not yet implemented"); +pub fn run(args: Args) -> Result<()> { + // Mode is enforced by clap::ArgGroup(required, single). + let plan = if let Some(id) = args.beads_id.as_deref() { + plan_beads(id, args.slug.as_deref(), &args.base)? + } else if let Some(n) = args.issue { + plan_issue(n, args.slug.as_deref(), &args.base)? + } else if args.upgrade { + plan_upgrade(args.slug.as_deref(), &args.base)? + } else { + unreachable!("clap ArgGroup guarantees one mode is set"); + }; + + git_worktree_add(&plan.branch, &plan.dir, &plan.base)?; + + // From here on, on any error we roll back the worktree+branch we just + // created so a retry is not blocked by directory/branch collision. + let post = (|| -> Result<()> { + // Test-only injection point: lets the Phase E smoke test exercise the + // rollback path without modifying production logic. + if std::env::var("Q2_CREATE_WORKTREE_INJECT_FAIL").as_deref() == Ok("after_worktree_add") { + anyhow::bail!("Q2_CREATE_WORKTREE_INJECT_FAIL=after_worktree_add (test hook)"); + } + write_beads_redirect(&plan.dir)?; + let section = build_section(&plan.kind); + let claude_local = plan.dir.join("CLAUDE.local.md"); + update_claude_local_md(&claude_local, §ion)?; + Ok(()) + })(); + + if let Err(e) = post { + eprintln!("error after worktree creation: {e:#}"); + eprintln!( + "rolling back worktree {} and branch {} ...", + plan.dir.display(), + plan.branch + ); + let mut rollback_issues: Vec<String> = Vec::new(); + + match Command::new("git") + .arg("worktree") + .arg("remove") + .arg("--force") + .arg(plan.dir.as_os_str()) + .output() + { + Ok(out) if out.status.success() => {} + Ok(out) => rollback_issues.push(format!( + "`git worktree remove --force {}` failed:\n {}\n manual cleanup: git worktree remove --force {}", + plan.dir.display(), + String::from_utf8_lossy(&out.stderr).trim().replace('\n', "\n "), + plan.dir.display(), + )), + Err(spawn_err) => rollback_issues.push(format!( + "could not spawn `git worktree remove`: {spawn_err}\n manual cleanup: git worktree remove --force {}", + plan.dir.display() + )), + } + + match Command::new("git") + .args(["branch", "-D", &plan.branch]) + .output() + { + Ok(out) if out.status.success() => {} + Ok(out) => rollback_issues.push(format!( + "`git branch -D {}` failed:\n {}\n manual cleanup: git branch -D {}", + plan.branch, + String::from_utf8_lossy(&out.stderr) + .trim() + .replace('\n', "\n "), + plan.branch, + )), + Err(spawn_err) => rollback_issues.push(format!( + "could not spawn `git branch -D`: {spawn_err}\n manual cleanup: git branch -D {}", + plan.branch + )), + } + + if rollback_issues.is_empty() { + eprintln!("rollback complete."); + } else { + eprintln!("rollback incomplete \u{2014} manual steps required:"); + for issue in &rollback_issues { + eprintln!(" - {issue}"); + } + } + return Err(e); + } + + print_summary(&plan); + Ok(()) +} + +struct Plan { + branch: String, + dir: PathBuf, + base: String, + kind: SectionKind, +} + +fn plan_beads(id: &str, slug_override: Option<&str>, base: &str) -> Result<Plan> { + let meta = fetch_beads_metadata(id)?; + let slug = match slug_override { + Some(s) => { + validate_slug(s)?; + s.to_string() + } + None => derive_slug(&meta.title)?, + }; + let leaf = format!("{id}-{slug}"); + let github_url = parse_external_ref_to_github_url(meta.external_ref.as_deref()); + if github_url.is_none() { + if let Some(other) = meta + .external_ref + .as_deref() + .filter(|s| !s.is_empty() && !s.starts_with("gh-")) + { + eprintln!( + "note: external_ref {other:?} is not a `gh-` reference; omitting GitHub line" + ); + } + } + Ok(Plan { + branch: format!("beads/{leaf}"), + dir: PathBuf::from(".worktrees").join(&leaf), + base: base.to_string(), + kind: SectionKind::Beads { + id: id.to_string(), + title: meta.title, + github_url, + }, + }) +} + +fn plan_issue(number: u32, slug_suffix: Option<&str>, base: &str) -> Result<Plan> { + if let Some(s) = slug_suffix { + validate_slug(s)?; + } + let gh = fetch_gh_issue(number)?; + let leaf = match slug_suffix { + Some(s) => format!("issue-{number}-{s}"), + None => format!("issue-{number}"), + }; + Ok(Plan { + branch: leaf.clone(), + dir: PathBuf::from(".worktrees").join(&leaf), + base: base.to_string(), + kind: SectionKind::Issue { + number, + title: gh.title, + url: gh.url, + }, + }) +} + +fn plan_upgrade(slug_suffix: Option<&str>, base: &str) -> Result<Plan> { + if let Some(s) = slug_suffix { + validate_slug(s)?; + } + let date = time::OffsetDateTime::now_utc() + .format(&time::macros::format_description!("[year]-[month]-[day]")) + .context("formatting today's date")?; + let leaf = match slug_suffix { + Some(s) => format!("cargo-upgrade-{date}-{s}"), + None => format!("cargo-upgrade-{date}"), + }; + Ok(Plan { + branch: leaf.clone(), + dir: PathBuf::from(".worktrees").join(&leaf), + base: base.to_string(), + kind: SectionKind::Upgrade { date }, + }) +} + +fn print_summary(plan: &Plan) { + println!("Created worktree: {}/", plan.dir.display()); + println!(" Branch: {}", plan.branch); + match &plan.kind { + SectionKind::Beads { + id, + title, + github_url, + } => { + println!(" Beads: {id} \u{2014} {title}"); + if let Some(url) = github_url { + println!(" GitHub: {url}"); + } + } + SectionKind::Issue { number, title, url } => { + println!(" Issue: #{number} \u{2014} {title}"); + println!(" URL: {url}"); + } + SectionKind::Upgrade { date } => { + println!(" Task: Cargo dependency upgrade \u{2014} {date}"); + } + } + println!(); + println!("Next steps:"); + println!(" 1. Fill in plan file path in CLAUDE.local.md (once plan is created)"); + println!( + " 2. cd {} && npm install (if hub-client work is in scope)", + plan.dir.display() + ); + println!(" 3. Start Claude Code session in {}/", plan.dir.display()); + if let SectionKind::Beads { id, .. } = &plan.kind { + println!(" 4. Run: br update {id} --status in_progress"); + } } pub fn detect_line_ending(content: &str) -> &'static str { From aa8778cd9c8988771b1d0fa18f16b305587d20db Mon Sep 17 00:00:00 2001 From: christophe dervieux <christophe.dervieux@gmail.com> Date: Mon, 11 May 2026 17:33:21 +0200 Subject: [PATCH 18/34] gitignore: ignore CLAUDE.local.md everywhere --- .gitignore | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 0cad81d41..f0d3efd94 100644 --- a/.gitignore +++ b/.gitignore @@ -33,4 +33,7 @@ crates/wasm-quarto-hub-client/pkg/ /.luarc.json -.claude/scheduled_tasks.lock \ No newline at end of file +.claude/scheduled_tasks.lock + +# Per-session local context (managed by `cargo xtask create-worktree` for worktrees) +CLAUDE.local.md \ No newline at end of file From d7e1f933f66c3fc53ab44d1b94f251a6cc54498e Mon Sep 17 00:00:00 2001 From: christophe dervieux <christophe.dervieux@gmail.com> Date: Mon, 11 May 2026 17:34:17 +0200 Subject: [PATCH 19/34] rules/xtask: document create-worktree command --- .claude/rules/xtask.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.claude/rules/xtask.md b/.claude/rules/xtask.md index 140e32c83..d99a82a09 100644 --- a/.claude/rules/xtask.md +++ b/.claude/rules/xtask.md @@ -21,6 +21,7 @@ paths: |---------|-------|---------| | `cargo xtask dev-setup` | `cargo dev-setup` | Install required dev tools (cargo-nextest, wasm-bindgen-cli) | | `cargo xtask lint` | — | Run custom lint checks | +| `cargo xtask create-worktree` | — | Create git worktree + `.beads/redirect` + CLAUDE.local.md context stub | | `cargo xtask verify` | — | Full project verification (build + tests for Rust and hub-client) | ## Dev tool version pinning From ccc5163184bedb214a72dca4bb2c66cff64431e0 Mon Sep 17 00:00:00 2001 From: christophe dervieux <christophe.dervieux@gmail.com> Date: Mon, 11 May 2026 17:35:24 +0200 Subject: [PATCH 20/34] rules/worktrees: xtask-first bootstrap + CLAUDE.local.md + Manual fallback --- .claude/rules/worktrees.md | 52 ++++++++++++++++++++++++++++++++++---- 1 file changed, 47 insertions(+), 5 deletions(-) diff --git a/.claude/rules/worktrees.md b/.claude/rules/worktrees.md index 736f71ca6..823bb9699 100644 --- a/.claude/rules/worktrees.md +++ b/.claude/rules/worktrees.md @@ -13,15 +13,22 @@ The directory mirrors the leaf of the branch name. The conventions are stable so ## Fresh worktree bootstrap -A fresh worktree has no `node_modules/`. `cargo xtask verify` runs the hub-client TypeScript build, which fails on missing npm deps. Bootstrap with `npm install` from the worktree root before re-running verify: +Use `cargo xtask create-worktree <bd-id>` (or `--issue N` / `--upgrade`) for new worktrees — +it handles `git worktree add`, `.beads/redirect`, and the CLAUDE.local.md context stub in +one shot. After it finishes, run `npm install` from the new worktree if hub-client is in scope: ```bash -cd .worktrees/<name> -npm install -cargo xtask verify --skip-hub-build # or full verify if hub-client is in scope +cargo xtask create-worktree bd-XXXX +cd .worktrees/<id>-<slug> +npm install # only if hub-client work is in scope +cargo xtask verify --skip-hub-build # confirm green at branch HEAD ``` -`cargo xtask dev-setup` exists for Rust dev tools (cargo-nextest, wasm-bindgen-cli) but does not currently run `npm install`. bd-7giz tracks extending it; once that lands, the bootstrap step above becomes a single `cargo xtask dev-setup`. +If the xtask is not yet built (fresh clone, or a branch where `cargo build -p xtask` has +not run), see § Manual bootstrap below. + +`cargo xtask dev-setup` exists for Rust dev tools (cargo-nextest, wasm-bindgen-cli) but +does not currently run `npm install`. bd-7giz tracks extending it. ## Beads Redirect @@ -35,6 +42,27 @@ echo "../../../.beads" > .worktrees/<name>/.beads/redirect The `redirect` file is already in `.beads/.gitignore`, so it won't show as a git change. Verify with `br where` from inside the worktree. +## CLAUDE.local.md + +`cargo xtask create-worktree` prepends a worktree context section to `CLAUDE.local.md`. +Claude Code loads it automatically — no need to run `br show` to orient at session start. + +The section contains: worktree declaration, main repo path (`../..`), beads ID, +GitHub URL, and a placeholder for the plan file path (fill in manually after creating +the plan). + +Status lives in beads, not in this file. Run `br show <id>` for current status + notes. + +The section is delimited by `<!-- BEGIN/END WORKTREE CONTEXT -->` markers so it can be +refreshed in place (e.g. when a worktree is recreated, or by hand-editing the file). +The `update_claude_local_md` rewrite is idempotent at the file level: re-running it on +a file that already has a managed section replaces that section without duplicating it +and preserves any user content below. + +`cargo xtask create-worktree` itself is **not** idempotent end-to-end — `git worktree add` +fails fast if the directory already exists. To refresh a worktree's CLAUDE.local.md, +either edit it by hand (the markers make this safe) or remove the worktree and recreate. + ## Committing beads changes With a redirect active, all beads data lives physically in the main repo's `.beads/`. JSONL changes from worktree work are only visible in `git status` from the main repo. All beads git commits must happen from the main repo, not from a worktree branch. @@ -52,3 +80,17 @@ git push -u origin beads/<id>-<slug>:feature/<id>-<slug> ``` This keeps local branches short and consistent while remote refs are self-describing in PR lists. + +## Manual bootstrap + +If `cargo xtask create-worktree` is unavailable (fresh clone before first build, or +the xtask binary is broken on the current branch), fall back to manual setup: + +```bash +git worktree add -b beads/<id>-<slug> .worktrees/<id>-<slug> main +echo "../../../.beads" > .worktrees/<id>-<slug>/.beads/redirect +# Optional but recommended: write a CLAUDE.local.md context stub manually +# using the template from `cargo xtask create-worktree --help` output. +``` + +Verify with `br where` from inside the worktree. From 14951d3cc44f64fb8499cd6ddbcf1ab0138a63af Mon Sep 17 00:00:00 2001 From: christophe dervieux <christophe.dervieux@gmail.com> Date: Mon, 11 May 2026 17:36:07 +0200 Subject: [PATCH 21/34] skills/investigate-beads: use cargo xtask create-worktree --- .claude/skills/investigate-beads/SKILL.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.claude/skills/investigate-beads/SKILL.md b/.claude/skills/investigate-beads/SKILL.md index 247132bd2..dcc6b838c 100644 --- a/.claude/skills/investigate-beads/SKILL.md +++ b/.claude/skills/investigate-beads/SKILL.md @@ -75,8 +75,11 @@ Spot-check the area: does the code the issue points at still exist with the same Branch + directory naming follows `.claude/rules/worktrees.md` § Branch naming (`beads/<id>-<slug>` where `<slug>` is a short kebab-case form of the issue title, 3–5 words). Beads redirect setup follows § Beads Redirect. ```bash -git worktree add -b beads/<id>-<slug> .worktrees/<id>-<slug> main -echo "../../../.beads" > .worktrees/<id>-<slug>/.beads/redirect +cargo xtask create-worktree <id> +# Creates the worktree, .beads/redirect, and CLAUDE.local.md context stub. +# Slug is auto-derived from the beads title; pass `--slug X` to override. +# Fallback for fresh clones where the xtask is not yet built: +# see .claude/rules/worktrees.md § Manual bootstrap. ``` Verify with `br where` from inside the worktree. From 46c3e4801d8a46d74b75df19e5df8acf4057e734 Mon Sep 17 00:00:00 2001 From: christophe dervieux <christophe.dervieux@gmail.com> Date: Mon, 11 May 2026 17:36:47 +0200 Subject: [PATCH 22/34] skills/triage: use cargo xtask create-worktree --issue --- .claude/skills/triage/SKILL.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.claude/skills/triage/SKILL.md b/.claude/skills/triage/SKILL.md index 57b65c83e..0b6ff2ec3 100644 --- a/.claude/skills/triage/SKILL.md +++ b/.claude/skills/triage/SKILL.md @@ -49,8 +49,14 @@ Read the body and every comment. If the issue contains multiple distinct reports Branch + directory naming follows `.claude/rules/worktrees.md` § Branch naming (`issue-<N>` for triage). Beads redirect setup follows § Beads Redirect. ```bash -git worktree add -b issue-<N> .worktrees/issue-<N> main -echo "../../../.beads" > .worktrees/issue-<N>/.beads/redirect +cargo xtask create-worktree --issue <N> +# Creates the worktree, .beads/redirect, and CLAUDE.local.md context stub. +# This step runs BEFORE the beads issue is created (step 6) — the `--issue` template +# intentionally has no Beads line. After step 6, either fill the bd-XXXX ID into +# CLAUDE.local.md manually, or re-run `cargo xtask create-worktree <bd-id>` to +# upgrade the section. +# Fallback for fresh clones where the xtask is not yet built: +# see .claude/rules/worktrees.md § Manual bootstrap. ``` Verify with `br where` from inside the worktree. From 1672540a89d202089fe3a48b0760cb64fa16d804 Mon Sep 17 00:00:00 2001 From: christophe dervieux <christophe.dervieux@gmail.com> Date: Mon, 11 May 2026 17:37:34 +0200 Subject: [PATCH 23/34] skills/upgrade-cargo-deps: use cargo xtask create-worktree --upgrade --- .claude/skills/upgrade-cargo-deps/SKILL.md | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/.claude/skills/upgrade-cargo-deps/SKILL.md b/.claude/skills/upgrade-cargo-deps/SKILL.md index 00bc75137..f7b7daa6b 100644 --- a/.claude/skills/upgrade-cargo-deps/SKILL.md +++ b/.claude/skills/upgrade-cargo-deps/SKILL.md @@ -116,14 +116,10 @@ If a major-upgrade candidate's only consumer is one of the vendored/pinned crate ### 5. Create the worktree ```bash -DATE=$(date +%Y-%m-%d) -git worktree add -b cargo-upgrade-$DATE .worktrees/cargo-upgrade-$DATE main -``` - -Add the beads redirect (the `.beads/` directory already exists from git — do not delete or overwrite it): - -```bash -echo "../../../.beads" > .worktrees/cargo-upgrade-$DATE/.beads/redirect +cargo xtask create-worktree --upgrade +# Creates a cargo-upgrade-YYYY-MM-DD worktree with .beads/redirect and CLAUDE.local.md. +# Fallback for fresh clones where the xtask is not yet built: +# see .claude/rules/worktrees.md § Manual bootstrap. ``` Verify with `br where` from inside the worktree. From e7719626c1fe963583dcc801e2ecf5e3ef3fe5e3 Mon Sep 17 00:00:00 2001 From: christophe dervieux <christophe.dervieux@gmail.com> Date: Mon, 11 May 2026 17:49:13 +0200 Subject: [PATCH 24/34] xtask(create-worktree): fix CRLF detection at sniff boundary (bd-spsv) detect_line_ending sniffs the first 1 KiB of the file. If a CRLF pair straddled byte 1023/1024, the sniff saw the bare \r at the end of the window and no \n, falling back to LF and silently writing mixed line endings into a CRLF file. Peek one byte past the window when the sniff ends with \r. Add a regression test. Drop the manual-bootstrap catch-22 in worktrees.md (the CLAUDE.local.md stub was sourced from `cargo xtask create-worktree --help`, which is unavailable in the exact scenario the manual bootstrap exists for). Point to the template's source location instead. --- .claude/rules/worktrees.md | 8 +++++--- crates/xtask/src/create_worktree.rs | 22 +++++++++++++++++++++- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/.claude/rules/worktrees.md b/.claude/rules/worktrees.md index 823bb9699..312a44d9b 100644 --- a/.claude/rules/worktrees.md +++ b/.claude/rules/worktrees.md @@ -89,8 +89,10 @@ the xtask binary is broken on the current branch), fall back to manual setup: ```bash git worktree add -b beads/<id>-<slug> .worktrees/<id>-<slug> main echo "../../../.beads" > .worktrees/<id>-<slug>/.beads/redirect -# Optional but recommended: write a CLAUDE.local.md context stub manually -# using the template from `cargo xtask create-worktree --help` output. ``` -Verify with `br where` from inside the worktree. +Verify with `br where` from inside the worktree. CLAUDE.local.md is not part +of the manual bootstrap — once the xtask binary is built, re-running +`cargo xtask create-worktree` is not safe on the existing worktree (see +above), but the template lives in `crates/xtask/src/create_worktree.rs` +(`build_section`) for hand-copying if needed. diff --git a/crates/xtask/src/create_worktree.rs b/crates/xtask/src/create_worktree.rs index 8b7b9aae8..c76c0130b 100644 --- a/crates/xtask/src/create_worktree.rs +++ b/crates/xtask/src/create_worktree.rs @@ -673,10 +673,17 @@ pub fn detect_line_ending(content: &str) -> &'static str { } let sniff = &content[..sniff_end]; - let crlf_count = sniff.matches("\r\n").count(); + let mut crlf_count = sniff.matches("\r\n").count(); let lf_total = sniff.matches('\n').count(); let bare_lf = lf_total - crlf_count; + // Boundary case: the sniff may end with `\r` and the matching `\n` falls + // just past the window. Peek one byte ahead so a CRLF pair split exactly + // on the 1 KiB boundary is not mis-classified as LF. + if sniff.ends_with('\r') && content.as_bytes().get(sniff_end) == Some(&b'\n') { + crlf_count += 1; + } + if crlf_count > 0 && bare_lf == 0 { "\r\n" } else { @@ -927,6 +934,19 @@ mod tests { assert_eq!(detect_line_ending(&s), "\n"); } + #[test] + fn detect_le_crlf_split_at_sniff_boundary() { + // \r at byte 1023 (last byte of sniff window), \n at byte 1024 (first + // byte past the window). The sniff sees no \r\n pair and no bare \n + // — without the boundary fix, this would mis-classify as LF. + let mut s = String::with_capacity(1100); + s.push_str(&"x".repeat(1023)); + s.push('\r'); + s.push('\n'); + s.push_str("more"); + assert_eq!(detect_line_ending(&s), "\r\n"); + } + #[test] fn section_beads_with_github() { let s = build_section(&SectionKind::Beads { From 09eead55d626a84c25eb2d89025768bfd566d2a2 Mon Sep 17 00:00:00 2001 From: christophe dervieux <christophe.dervieux@gmail.com> Date: Mon, 11 May 2026 18:40:42 +0200 Subject: [PATCH 25/34] skills/triage: correct CLAUDE.local.md Beads-line guidance (bd-spsv) The previous wording claimed the --issue template "intentionally has no Beads line" and recommended re-running `cargo xtask create-worktree <bd-id>` to refresh it. Both were wrong: - The --issue template includes a placeholder Beads line pointing users at `br search <N>`; it is not absent, just unresolved. - Re-running with `<bd-id>` creates a separate beads worktree at `.worktrees/<bd-id>-<slug>`, not an update of the existing `.worktrees/issue-<N>`. The xtask is not command-level idempotent by design. Replace with the correct guidance: hand-edit the placeholder line after step 6 creates the bd-XXXX. Explicit DO-NOT for the rerun. --- .claude/skills/triage/SKILL.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.claude/skills/triage/SKILL.md b/.claude/skills/triage/SKILL.md index 0b6ff2ec3..7af7c0f95 100644 --- a/.claude/skills/triage/SKILL.md +++ b/.claude/skills/triage/SKILL.md @@ -51,10 +51,12 @@ Branch + directory naming follows `.claude/rules/worktrees.md` § Branch naming ```bash cargo xtask create-worktree --issue <N> # Creates the worktree, .beads/redirect, and CLAUDE.local.md context stub. -# This step runs BEFORE the beads issue is created (step 6) — the `--issue` template -# intentionally has no Beads line. After step 6, either fill the bd-XXXX ID into -# CLAUDE.local.md manually, or re-run `cargo xtask create-worktree <bd-id>` to -# upgrade the section. +# This step runs BEFORE the beads issue is created (step 6). The `--issue` +# template's Beads line is a placeholder — `(run `br search <N>` to find or +# create a beads issue)`. After step 6 creates the bd-XXXX, edit the Beads +# line in CLAUDE.local.md manually to point at the new ID. Do NOT re-run +# the xtask with `<bd-id>` to "refresh" — that creates a separate beads +# worktree at `.worktrees/<bd-id>-<slug>` rather than updating this one. # Fallback for fresh clones where the xtask is not yet built: # see .claude/rules/worktrees.md § Manual bootstrap. ``` From 2a087e96d54159ff8c1823bdb35b9acbc9b72085 Mon Sep 17 00:00:00 2001 From: christophe dervieux <christophe.dervieux@gmail.com> Date: Tue, 12 May 2026 13:27:09 +0200 Subject: [PATCH 26/34] xtask(create-worktree): preserve modes block in long --help (bd-spsv) The three-line "Modes (exactly one required)" block in the CreateWorktree doc comment was collapsing to a single paragraph in `cargo xtask create-worktree --help`. Adding `#[command(verbatim_doc_comment)]` keeps the literal line breaks so each mode lands on its own line. Short `-h` still shows only the first sentence (clap default). --- crates/xtask/src/main.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/xtask/src/main.rs b/crates/xtask/src/main.rs index 601bfab85..b95afcb5b 100644 --- a/crates/xtask/src/main.rs +++ b/crates/xtask/src/main.rs @@ -64,6 +64,7 @@ enum Command { /// <bd-id> — beads issue (positional) /// --issue N — GitHub issue triage /// --upgrade — cargo dependency upgrade (date-based branch) + #[command(verbatim_doc_comment)] CreateWorktree { #[command(flatten)] args: create_worktree::Args, From 1221d48ce87710ae15f25a4121af6f98bce0f0b7 Mon Sep 17 00:00:00 2001 From: christophe dervieux <christophe.dervieux@gmail.com> Date: Tue, 12 May 2026 13:41:50 +0200 Subject: [PATCH 27/34] xtask(create-worktree): anchor worktrees to main repo root (bd-spsv) `cargo xtask create-worktree` was building the new worktree path as `PathBuf::from(".worktrees").join(<leaf>)`, which `git worktree add` resolves against CWD. Running the command from inside an existing worktree therefore nested the new one as `<wt>/.worktrees/<leaf>` instead of placing it next to its siblings at `<repo-root>/.worktrees/`. Resolve the main repository root via `git rev-parse --path-format=absolute --git-common-dir` once at the top of `run()`, then anchor each planner's `Plan.dir` to it. Result: the command produces the same layout regardless of CWD (main, subdir, or another worktree). Add a small util module with `with_native_separators` so paths emitted by git on Windows (which uses `/`) display consistently with `PathBuf::join` output (which uses `\`). Apply it inside `repo_root()` so the entire chain downstream sees one separator. Tests: +3 (repo_root smoke + 2 util tests), 58 pass total. --- crates/xtask/src/create_worktree.rs | 84 +++++++++++++++++++++++++---- crates/xtask/src/main.rs | 1 + crates/xtask/src/util.rs | 47 ++++++++++++++++ 3 files changed, 123 insertions(+), 9 deletions(-) create mode 100644 crates/xtask/src/util.rs diff --git a/crates/xtask/src/create_worktree.rs b/crates/xtask/src/create_worktree.rs index c76c0130b..1e2abbb0f 100644 --- a/crates/xtask/src/create_worktree.rs +++ b/crates/xtask/src/create_worktree.rs @@ -14,6 +14,8 @@ use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; +use crate::util::with_native_separators; + const BEGIN_MARKER: &str = "<!-- BEGIN WORKTREE CONTEXT — managed by cargo xtask create-worktree -->"; const END_MARKER: &str = "<!-- END WORKTREE CONTEXT -->"; @@ -397,6 +399,38 @@ pub fn fetch_gh_issue(number: u32) -> Result<GhIssue> { Ok(GhIssue { title, url }) } +/// Absolute path to the main repository working tree, resolved via +/// `git rev-parse --path-format=absolute --git-common-dir`. +/// +/// Worktree creation must anchor `.worktrees/<leaf>` to this root so the +/// new worktree always lands at `<main-repo>/.worktrees/<leaf>` regardless +/// of whether the command is invoked from the main worktree, a nested +/// worktree, or a subdirectory. +pub fn repo_root() -> Result<PathBuf> { + let output = Command::new("git") + .args(["rev-parse", "--path-format=absolute", "--git-common-dir"]) + .output() + .context("spawning `git rev-parse --git-common-dir`")?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!( + "git rev-parse --git-common-dir failed (exit {:?}):\n{stderr}", + output.status.code() + ); + } + let raw = String::from_utf8(output.stdout) + .context("git common-dir path was not valid UTF-8")? + .trim_end_matches(['\r', '\n']) + .to_string(); + // git emits forward slashes on Windows; normalize so every downstream + // `PathBuf::join` and `.display()` produces a consistent separator. + let common_dir = with_native_separators(Path::new(&raw)); + common_dir + .parent() + .map(Path::to_path_buf) + .with_context(|| format!("git common-dir has no parent: {}", common_dir.display())) +} + pub fn git_worktree_add(branch: &str, dir: &Path, base: &str) -> Result<()> { if dir.exists() { anyhow::bail!("worktree directory already exists: {}", dir.display()); @@ -458,13 +492,17 @@ pub fn write_beads_redirect(dir: &Path) -> Result<()> { } pub fn run(args: Args) -> Result<()> { + // Always anchor new worktrees to the main repo root so the command works + // identically from main, from another worktree, or from any subdirectory. + let root = repo_root()?; + // Mode is enforced by clap::ArgGroup(required, single). let plan = if let Some(id) = args.beads_id.as_deref() { - plan_beads(id, args.slug.as_deref(), &args.base)? + plan_beads(id, args.slug.as_deref(), &args.base, &root)? } else if let Some(n) = args.issue { - plan_issue(n, args.slug.as_deref(), &args.base)? + plan_issue(n, args.slug.as_deref(), &args.base, &root)? } else if args.upgrade { - plan_upgrade(args.slug.as_deref(), &args.base)? + plan_upgrade(args.slug.as_deref(), &args.base, &root)? } else { unreachable!("clap ArgGroup guarantees one mode is set"); }; @@ -556,7 +594,7 @@ struct Plan { kind: SectionKind, } -fn plan_beads(id: &str, slug_override: Option<&str>, base: &str) -> Result<Plan> { +fn plan_beads(id: &str, slug_override: Option<&str>, base: &str, repo_root: &Path) -> Result<Plan> { let meta = fetch_beads_metadata(id)?; let slug = match slug_override { Some(s) => { @@ -580,7 +618,7 @@ fn plan_beads(id: &str, slug_override: Option<&str>, base: &str) -> Result<Plan> } Ok(Plan { branch: format!("beads/{leaf}"), - dir: PathBuf::from(".worktrees").join(&leaf), + dir: repo_root.join(".worktrees").join(&leaf), base: base.to_string(), kind: SectionKind::Beads { id: id.to_string(), @@ -590,7 +628,12 @@ fn plan_beads(id: &str, slug_override: Option<&str>, base: &str) -> Result<Plan> }) } -fn plan_issue(number: u32, slug_suffix: Option<&str>, base: &str) -> Result<Plan> { +fn plan_issue( + number: u32, + slug_suffix: Option<&str>, + base: &str, + repo_root: &Path, +) -> Result<Plan> { if let Some(s) = slug_suffix { validate_slug(s)?; } @@ -601,7 +644,7 @@ fn plan_issue(number: u32, slug_suffix: Option<&str>, base: &str) -> Result<Plan }; Ok(Plan { branch: leaf.clone(), - dir: PathBuf::from(".worktrees").join(&leaf), + dir: repo_root.join(".worktrees").join(&leaf), base: base.to_string(), kind: SectionKind::Issue { number, @@ -611,7 +654,7 @@ fn plan_issue(number: u32, slug_suffix: Option<&str>, base: &str) -> Result<Plan }) } -fn plan_upgrade(slug_suffix: Option<&str>, base: &str) -> Result<Plan> { +fn plan_upgrade(slug_suffix: Option<&str>, base: &str, repo_root: &Path) -> Result<Plan> { if let Some(s) = slug_suffix { validate_slug(s)?; } @@ -624,7 +667,7 @@ fn plan_upgrade(slug_suffix: Option<&str>, base: &str) -> Result<Plan> { }; Ok(Plan { branch: leaf.clone(), - dir: PathBuf::from(".worktrees").join(&leaf), + dir: repo_root.join(".worktrees").join(&leaf), base: base.to_string(), kind: SectionKind::Upgrade { date }, }) @@ -697,6 +740,29 @@ mod tests { use std::fs; use tempfile::TempDir; + #[test] + fn repo_root_returns_absolute_directory_with_dot_git() { + // Test runs inside this checkout, so repo_root() must succeed and + // point at a directory containing a `.git` entry (file or dir). + let root = repo_root().expect("repo_root() should succeed inside a git checkout"); + assert!( + root.is_absolute(), + "repo_root() must return an absolute path, got {}", + root.display() + ); + assert!( + root.is_dir(), + "repo_root() must point at an existing directory, got {}", + root.display() + ); + let git_entry = root.join(".git"); + assert!( + git_entry.exists(), + "repo_root() result {} should contain a .git entry", + root.display() + ); + } + fn make_dummy_section() -> String { build_section(&SectionKind::Beads { id: "bd-xxxx".into(), diff --git a/crates/xtask/src/main.rs b/crates/xtask/src/main.rs index b95afcb5b..2f93f8e11 100644 --- a/crates/xtask/src/main.rs +++ b/crates/xtask/src/main.rs @@ -21,6 +21,7 @@ mod dev_setup; mod lint; mod test; mod treesitter_crlf; +mod util; mod verify; use anyhow::Result; diff --git a/crates/xtask/src/util.rs b/crates/xtask/src/util.rs new file mode 100644 index 000000000..c3da12529 --- /dev/null +++ b/crates/xtask/src/util.rs @@ -0,0 +1,47 @@ +//! Small shared utilities for xtask subcommands. + +use std::path::{Path, PathBuf}; + +/// Return a copy of `path` with forward slashes replaced by the platform's +/// main separator. No-op on POSIX where `MAIN_SEPARATOR == '/'`. +/// +/// External tools on Windows (git, gh) commonly emit paths with `/`. Once +/// those paths feed into [`PathBuf::join`], which uses `MAIN_SEPARATOR`, +/// the result mixes separators (`C:/Users/.../q2\.worktrees\foo`) — still +/// valid, but ugly when displayed to a user. Normalize once on entry so +/// every downstream `join` and `display` produces a consistent path. +pub fn with_native_separators(path: &Path) -> PathBuf { + if std::path::MAIN_SEPARATOR == '/' { + return path.to_path_buf(); + } + let s = path.to_string_lossy(); + PathBuf::from(s.replace('/', std::path::MAIN_SEPARATOR_STR)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn with_native_separators_is_noop_on_posix_like_paths() { + // The function must always return a path the OS can resolve; we + // don't try to test the Windows-only branch from here (it's a pure + // string replace, and main.rs wires it correctly). + let input = Path::new("/tmp/foo/bar"); + let out = with_native_separators(input); + #[cfg(not(windows))] + assert_eq!(out, PathBuf::from("/tmp/foo/bar")); + #[cfg(windows)] + assert_eq!(out, PathBuf::from(r"\tmp\foo\bar")); + } + + #[test] + fn with_native_separators_preserves_already_native_paths() { + // PathBuf with platform separators round-trips unchanged. + let mut p = PathBuf::new(); + p.push("a"); + p.push("b"); + p.push("c"); + assert_eq!(with_native_separators(&p), p); + } +} From 98fd93bba57ecd6ab66bea3017d3e73e25a94481 Mon Sep 17 00:00:00 2001 From: christophe dervieux <christophe.dervieux@gmail.com> Date: Tue, 12 May 2026 14:03:39 +0200 Subject: [PATCH 28/34] xtask(create-worktree): clearer post-create UX (bd-spsv) Three changes, all targeting "what should the user / Claude do next": CLI output: by default print only the worktree summary + "cd <dir>, open Claude there (CLAUDE.local.md has the checklist)" pointer. Add a `-v/--verbose` flag that re-enables the manual command list inline. The old default was a numbered 4-step list that conflated `cd && npm install` on one line and described the plan-file step too vaguely. CLAUDE.local.md: each managed section now ends with `## Initial setup`, tailored per mode. - Beads: 3-step checklist (verify --skip-hub-build, npm install if hub-client is in scope, br update --status in_progress) with inline notes explaining that `--skip-hub-build` keeps the step Rust-only and that `npm install` is intentionally separate from `cargo xtask dev-setup` today (tracked in bd-7giz). - Issue: shorter checklist + a note about no beads issue being linked yet and what to edit when triage creates one. - Upgrade: pointer to the `upgrade-cargo-deps` skill, which drives the rest, with a manual-fallback line. Tests: 58/58, existing assertions are content-presence checks so the expanded sections do not regress them. --- crates/xtask/src/create_worktree.rs | 76 ++++++++++++++++++++++++----- 1 file changed, 65 insertions(+), 11 deletions(-) diff --git a/crates/xtask/src/create_worktree.rs b/crates/xtask/src/create_worktree.rs index 1e2abbb0f..183fba482 100644 --- a/crates/xtask/src/create_worktree.rs +++ b/crates/xtask/src/create_worktree.rs @@ -127,6 +127,11 @@ pub struct Args { /// Base branch. #[arg(long, default_value = "main")] pub base: String, + + /// Print the manual command checklist after creating the worktree. + /// Default output is terse and points at CLAUDE.local.md for details. + #[arg(short, long)] + pub verbose: bool, } pub fn parse_external_ref_to_github_url(ext: Option<&str>) -> Option<String> { @@ -209,8 +214,24 @@ pub fn build_section(kind: &SectionKind) -> String { s.push_str("**Plan:** <!-- fill in after creating: claude-notes/plans/YYYY-MM-DD-name.md -->\n"); s.push('\n'); s.push_str(&format!( - "Run `br show {id}` for current status and notes.\n" + "Run `br show {id}` for current status and notes.\n\n" + )); + s.push_str("## Initial setup\n\n"); + s.push_str("Prep this worktree (skip steps already done):\n\n"); + s.push_str("- `cargo xtask verify --skip-hub-build` \u{2014} confirm branch HEAD is\n"); + s.push_str(" green. `--skip-hub-build` keeps this Rust-only so no `npm install`\n"); + s.push_str(" is needed. If verify errors with \"tool not found\", run\n"); + s.push_str(" `cargo xtask dev-setup` first (one-time install of cargo-nextest\n"); + s.push_str(" and wasm-bindgen-cli).\n"); + s.push_str("- `npm install` \u{2014} only if hub-client work is in scope. Not yet\n"); + s.push_str(" part of `cargo xtask dev-setup` (tracked in bd-7giz).\n"); + s.push_str(&format!( + "- `br update {id} --status in_progress` \u{2014} claim the beads issue.\n" )); + s.push('\n'); + s.push_str("When you create a plan file at\n"); + s.push_str("`claude-notes/plans/YYYY-MM-DD-<name>.md`, edit the **Plan:** line\n"); + s.push_str("above to point at it.\n"); s } SectionKind::Issue { number, title, url } => { @@ -224,6 +245,18 @@ pub fn build_section(kind: &SectionKind) -> String { "**Beads:** (run `br search {number}` to find or create a beads issue)\n" )); s.push_str("**Plan:** <!-- fill in after creating: claude-notes/plans/YYYY-MM-DD-name.md -->\n"); + s.push('\n'); + s.push_str("## Initial setup\n\n"); + s.push_str("Prep this worktree (skip steps already done):\n\n"); + s.push_str("- `cargo xtask verify --skip-hub-build` \u{2014} confirm branch HEAD is\n"); + s.push_str(" green. If verify errors with \"tool not found\", run\n"); + s.push_str(" `cargo xtask dev-setup` first.\n"); + s.push_str("- `npm install` \u{2014} only if hub-client work is in scope.\n"); + s.push('\n'); + s.push_str("No beads issue exists yet; the triage skill creates one when\n"); + s.push_str("investigation surfaces real work. When that happens, edit the\n"); + s.push_str("**Beads:** line above with the new bd-XXXX. Edit **Plan:** likewise\n"); + s.push_str("once a plan or triage doc exists.\n"); s } SectionKind::Upgrade { date } => { @@ -233,7 +266,11 @@ pub fn build_section(kind: &SectionKind) -> String { s.push_str(&format!( "**Task:** Cargo dependency upgrade \u{2014} {date}\n" )); - s.push_str("**Plan:** <!-- fill in if needed -->\n"); + s.push_str("**Plan:** <!-- fill in if needed -->\n\n"); + s.push_str("## Initial setup\n\n"); + s.push_str("The `upgrade-cargo-deps` skill drives this worktree end-to-end. If\n"); + s.push_str("running manually instead, start with\n"); + s.push_str("`cargo xtask verify --skip-hub-build` to confirm HEAD is green.\n"); s } }; @@ -583,7 +620,7 @@ pub fn run(args: Args) -> Result<()> { return Err(e); } - print_summary(&plan); + print_summary(&plan, args.verbose); Ok(()) } @@ -673,7 +710,7 @@ fn plan_upgrade(slug_suffix: Option<&str>, base: &str, repo_root: &Path) -> Resu }) } -fn print_summary(plan: &Plan) { +fn print_summary(plan: &Plan, verbose: bool) { println!("Created worktree: {}/", plan.dir.display()); println!(" Branch: {}", plan.branch); match &plan.kind { @@ -696,16 +733,33 @@ fn print_summary(plan: &Plan) { } } println!(); + + if !verbose { + // Terse default: point at the worktree and CLAUDE.local.md. + println!("Next: cd {}", plan.dir.display()); + println!( + "Open a Claude Code session there, or read CLAUDE.local.md for the setup checklist." + ); + println!(); + println!("(Pass -v / --verbose for the manual command list.)"); + return; + } + + // Verbose: include the manual command checklist inline. println!("Next steps:"); - println!(" 1. Fill in plan file path in CLAUDE.local.md (once plan is created)"); - println!( - " 2. cd {} && npm install (if hub-client work is in scope)", - plan.dir.display() - ); - println!(" 3. Start Claude Code session in {}/", plan.dir.display()); + println!(" cd {}", plan.dir.display()); + println!(); + println!(" Open a Claude Code session there \u{2014} CLAUDE.local.md has the same"); + println!(" checklist, expanded. Or run the prep yourself:"); + println!(); + println!(" cargo xtask verify --skip-hub-build # confirm branch HEAD is green"); + println!(" npm install # hub-client deps (only if in scope)"); if let SectionKind::Beads { id, .. } = &plan.kind { - println!(" 4. Run: br update {id} --status in_progress"); + println!(" br update {id} --status in_progress # claim the beads issue"); } + println!(); + println!(" Once a plan file exists at claude-notes/plans/YYYY-MM-DD-<name>.md,"); + println!(" edit the **Plan:** line in CLAUDE.local.md to point at it."); } pub fn detect_line_ending(content: &str) -> &'static str { From e601f00fa098a9fd6a9afa8cb12504227a2ce5e1 Mon Sep 17 00:00:00 2001 From: christophe dervieux <christophe.dervieux@gmail.com> Date: Tue, 12 May 2026 14:12:51 +0200 Subject: [PATCH 29/34] xtask(create-worktree): keep setup checklist on stdout, not in CLAUDE.local.md (bd-spsv) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts the per-mode `## Initial setup` section that previous commit added to `CLAUDE.local.md`. That file is loaded by Claude Code on every session in the worktree; embedding "do this" steps risks Claude re-running them or treating them as ground truth long after the prep is done. Instead, print the checklist once on stdout at creation time, grouped by frequency so the user can see at a glance which lines apply: # Once per machine (skip if already done) cargo xtask dev-setup # installs cargo-nextest, wasm-bindgen-cli # Per worktree cargo xtask verify --skip-hub-build # confirm HEAD is green (Rust only) npm install # only if hub-client work is in scope # (separate from dev-setup today — bd-7giz) # Per beads issue (this worktree) br update bd-spsv --status in_progress # claim it For issue and upgrade modes the per-issue group is replaced with a single-line note pointing at the relevant skill / next action. Drops the `-v/--verbose` flag introduced earlier in this branch (the checklist is short enough that conditional output stopped being worth the surface area). --- crates/xtask/src/create_worktree.rs | 98 +++++++++++------------------ 1 file changed, 36 insertions(+), 62 deletions(-) diff --git a/crates/xtask/src/create_worktree.rs b/crates/xtask/src/create_worktree.rs index 183fba482..19892361e 100644 --- a/crates/xtask/src/create_worktree.rs +++ b/crates/xtask/src/create_worktree.rs @@ -127,11 +127,6 @@ pub struct Args { /// Base branch. #[arg(long, default_value = "main")] pub base: String, - - /// Print the manual command checklist after creating the worktree. - /// Default output is terse and points at CLAUDE.local.md for details. - #[arg(short, long)] - pub verbose: bool, } pub fn parse_external_ref_to_github_url(ext: Option<&str>) -> Option<String> { @@ -214,24 +209,8 @@ pub fn build_section(kind: &SectionKind) -> String { s.push_str("**Plan:** <!-- fill in after creating: claude-notes/plans/YYYY-MM-DD-name.md -->\n"); s.push('\n'); s.push_str(&format!( - "Run `br show {id}` for current status and notes.\n\n" - )); - s.push_str("## Initial setup\n\n"); - s.push_str("Prep this worktree (skip steps already done):\n\n"); - s.push_str("- `cargo xtask verify --skip-hub-build` \u{2014} confirm branch HEAD is\n"); - s.push_str(" green. `--skip-hub-build` keeps this Rust-only so no `npm install`\n"); - s.push_str(" is needed. If verify errors with \"tool not found\", run\n"); - s.push_str(" `cargo xtask dev-setup` first (one-time install of cargo-nextest\n"); - s.push_str(" and wasm-bindgen-cli).\n"); - s.push_str("- `npm install` \u{2014} only if hub-client work is in scope. Not yet\n"); - s.push_str(" part of `cargo xtask dev-setup` (tracked in bd-7giz).\n"); - s.push_str(&format!( - "- `br update {id} --status in_progress` \u{2014} claim the beads issue.\n" + "Run `br show {id}` for current status and notes.\n" )); - s.push('\n'); - s.push_str("When you create a plan file at\n"); - s.push_str("`claude-notes/plans/YYYY-MM-DD-<name>.md`, edit the **Plan:** line\n"); - s.push_str("above to point at it.\n"); s } SectionKind::Issue { number, title, url } => { @@ -245,18 +224,6 @@ pub fn build_section(kind: &SectionKind) -> String { "**Beads:** (run `br search {number}` to find or create a beads issue)\n" )); s.push_str("**Plan:** <!-- fill in after creating: claude-notes/plans/YYYY-MM-DD-name.md -->\n"); - s.push('\n'); - s.push_str("## Initial setup\n\n"); - s.push_str("Prep this worktree (skip steps already done):\n\n"); - s.push_str("- `cargo xtask verify --skip-hub-build` \u{2014} confirm branch HEAD is\n"); - s.push_str(" green. If verify errors with \"tool not found\", run\n"); - s.push_str(" `cargo xtask dev-setup` first.\n"); - s.push_str("- `npm install` \u{2014} only if hub-client work is in scope.\n"); - s.push('\n'); - s.push_str("No beads issue exists yet; the triage skill creates one when\n"); - s.push_str("investigation surfaces real work. When that happens, edit the\n"); - s.push_str("**Beads:** line above with the new bd-XXXX. Edit **Plan:** likewise\n"); - s.push_str("once a plan or triage doc exists.\n"); s } SectionKind::Upgrade { date } => { @@ -266,11 +233,7 @@ pub fn build_section(kind: &SectionKind) -> String { s.push_str(&format!( "**Task:** Cargo dependency upgrade \u{2014} {date}\n" )); - s.push_str("**Plan:** <!-- fill in if needed -->\n\n"); - s.push_str("## Initial setup\n\n"); - s.push_str("The `upgrade-cargo-deps` skill drives this worktree end-to-end. If\n"); - s.push_str("running manually instead, start with\n"); - s.push_str("`cargo xtask verify --skip-hub-build` to confirm HEAD is green.\n"); + s.push_str("**Plan:** <!-- fill in if needed -->\n"); s } }; @@ -620,7 +583,7 @@ pub fn run(args: Args) -> Result<()> { return Err(e); } - print_summary(&plan, args.verbose); + print_summary(&plan); Ok(()) } @@ -710,7 +673,7 @@ fn plan_upgrade(slug_suffix: Option<&str>, base: &str, repo_root: &Path) -> Resu }) } -fn print_summary(plan: &Plan, verbose: bool) { +fn print_summary(plan: &Plan) { println!("Created worktree: {}/", plan.dir.display()); println!(" Branch: {}", plan.branch); match &plan.kind { @@ -733,32 +696,43 @@ fn print_summary(plan: &Plan, verbose: bool) { } } println!(); - - if !verbose { - // Terse default: point at the worktree and CLAUDE.local.md. - println!("Next: cd {}", plan.dir.display()); - println!( - "Open a Claude Code session there, or read CLAUDE.local.md for the setup checklist." - ); - println!(); - println!("(Pass -v / --verbose for the manual command list.)"); - return; - } - - // Verbose: include the manual command checklist inline. - println!("Next steps:"); + println!("Next:"); println!(" cd {}", plan.dir.display()); println!(); - println!(" Open a Claude Code session there \u{2014} CLAUDE.local.md has the same"); - println!(" checklist, expanded. Or run the prep yourself:"); + println!(" Open a Claude Code session there \u{2014} CLAUDE.local.md gives it the"); + println!(" worktree context (branch, beads/GitHub link, base). Copy whichever of"); + println!(" the prep commands below apply:"); println!(); - println!(" cargo xtask verify --skip-hub-build # confirm branch HEAD is green"); - println!(" npm install # hub-client deps (only if in scope)"); - if let SectionKind::Beads { id, .. } = &plan.kind { - println!(" br update {id} --status in_progress # claim the beads issue"); + println!(" # Once per machine (skip if already done)"); + println!( + " cargo xtask dev-setup # installs cargo-nextest, wasm-bindgen-cli" + ); + println!(); + println!(" # Per worktree"); + println!(" cargo xtask verify --skip-hub-build # confirm HEAD is green (Rust only)"); + println!(" npm install # only if hub-client work is in scope"); + println!( + " # (separate from dev-setup today \u{2014} bd-7giz)" + ); + match &plan.kind { + SectionKind::Beads { id, .. } => { + println!(); + println!(" # Per beads issue (this worktree)"); + println!(" br update {id} --status in_progress # claim it"); + } + SectionKind::Issue { .. } => { + println!(); + println!(" # No beads issue is linked yet \u{2014} the triage skill creates one"); + println!(" # when investigation surfaces real work. Edit the **Beads:** line in"); + println!(" # CLAUDE.local.md with the new bd-XXXX when that happens."); + } + SectionKind::Upgrade { .. } => { + println!(); + println!(" # The `upgrade-cargo-deps` skill drives the rest of this worktree."); + } } println!(); - println!(" Once a plan file exists at claude-notes/plans/YYYY-MM-DD-<name>.md,"); + println!(" When you create a plan file at claude-notes/plans/YYYY-MM-DD-<name>.md,"); println!(" edit the **Plan:** line in CLAUDE.local.md to point at it."); } From 469d699141becfffccbc96a30cd7ee375c952704 Mon Sep 17 00:00:00 2001 From: christophe dervieux <christophe.dervieux@gmail.com> Date: Tue, 12 May 2026 14:28:41 +0200 Subject: [PATCH 30/34] xtask(create-worktree): self-documenting placeholders, drop redundant stdout (bd-spsv) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two trims to the post-create output: CLAUDE.local.md: the **Plan:** placeholder (and the **Beads:** placeholder in issue mode) was an HTML comment, invisible in rendered markdown and easy to miss in raw source. Replace with italic prose that explains the expected replacement inline — `_none yet — replace this with claude-notes/plans/YYYY-MM-DD-<name>.md once you create the plan file._` This is readable in both source and rendered views, so anyone (human or Claude) opening the file sees what to fill in without a separate prompt. Stdout: now that the placeholder self-documents, drop the trailing "When you create a plan file... edit the **Plan:** line in CLAUDE.local.md" reminder. Also drop the parenthetical "# (separate from dev-setup today — bd-7giz)" line — that context belongs on the beads issue itself, not on every worktree creation. A corresponding comment is being added to bd-7giz so when it lands the `npm install` line here can be folded into `cargo xtask dev-setup`. Issue mode's "Edit the **Beads:** line in CLAUDE.local.md" reminder is likewise dropped — the new in-place placeholder covers it. --- crates/xtask/src/create_worktree.rs | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/crates/xtask/src/create_worktree.rs b/crates/xtask/src/create_worktree.rs index 19892361e..b6aca6fab 100644 --- a/crates/xtask/src/create_worktree.rs +++ b/crates/xtask/src/create_worktree.rs @@ -206,7 +206,7 @@ pub fn build_section(kind: &SectionKind) -> String { if let Some(url) = github_url { s.push_str(&format!("**GitHub:** {url}\n")); } - s.push_str("**Plan:** <!-- fill in after creating: claude-notes/plans/YYYY-MM-DD-name.md -->\n"); + s.push_str("**Plan:** _none yet \u{2014} replace this with `claude-notes/plans/YYYY-MM-DD-<name>.md` once you create the plan file._\n"); s.push('\n'); s.push_str(&format!( "Run `br show {id}` for current status and notes.\n" @@ -221,9 +221,9 @@ pub fn build_section(kind: &SectionKind) -> String { s.push_str(&format!("**GitHub issue:** #{number} \u{2014} {title}\n")); s.push_str(&format!("**URL:** {url}\n")); s.push_str(&format!( - "**Beads:** (run `br search {number}` to find or create a beads issue)\n" + "**Beads:** _none yet \u{2014} run `br search {number}` to find an existing issue, or `br create` to file one, then replace this line with the bd-XXXX._\n" )); - s.push_str("**Plan:** <!-- fill in after creating: claude-notes/plans/YYYY-MM-DD-name.md -->\n"); + s.push_str("**Plan:** _none yet \u{2014} replace this with `claude-notes/plans/YYYY-MM-DD-<name>.md` once you create the plan file._\n"); s } SectionKind::Upgrade { date } => { @@ -233,7 +233,7 @@ pub fn build_section(kind: &SectionKind) -> String { s.push_str(&format!( "**Task:** Cargo dependency upgrade \u{2014} {date}\n" )); - s.push_str("**Plan:** <!-- fill in if needed -->\n"); + s.push_str("**Plan:** _none yet \u{2014} replace this with a plan file path if you create one._\n"); s } }; @@ -711,9 +711,6 @@ fn print_summary(plan: &Plan) { println!(" # Per worktree"); println!(" cargo xtask verify --skip-hub-build # confirm HEAD is green (Rust only)"); println!(" npm install # only if hub-client work is in scope"); - println!( - " # (separate from dev-setup today \u{2014} bd-7giz)" - ); match &plan.kind { SectionKind::Beads { id, .. } => { println!(); @@ -723,17 +720,13 @@ fn print_summary(plan: &Plan) { SectionKind::Issue { .. } => { println!(); println!(" # No beads issue is linked yet \u{2014} the triage skill creates one"); - println!(" # when investigation surfaces real work. Edit the **Beads:** line in"); - println!(" # CLAUDE.local.md with the new bd-XXXX when that happens."); + println!(" # when investigation surfaces real work."); } SectionKind::Upgrade { .. } => { println!(); println!(" # The `upgrade-cargo-deps` skill drives the rest of this worktree."); } } - println!(); - println!(" When you create a plan file at claude-notes/plans/YYYY-MM-DD-<name>.md,"); - println!(" edit the **Plan:** line in CLAUDE.local.md to point at it."); } pub fn detect_line_ending(content: &str) -> &'static str { @@ -1076,7 +1069,8 @@ mod tests { }); assert!(s.contains("**GitHub issue:** #157 — An issue")); assert!(s.contains("**URL:** https://github.com/quarto-dev/q2/issues/157")); - assert!(s.contains("**Beads:** (run `br search 157`")); + assert!(s.contains("**Beads:** _none yet")); + assert!(s.contains("br search 157")); assert!(!s.contains("**Beads:** bd-")); // no resolved beads id } From 205f91c6cfc52c5eae37ee939f9457dddd281cb6 Mon Sep 17 00:00:00 2001 From: christophe dervieux <christophe.dervieux@gmail.com> Date: Tue, 12 May 2026 15:09:06 +0200 Subject: [PATCH 31/34] xtask(create-worktree): name the continuing skill in CLAUDE.local.md and stdout (bd-spsv) Add a **Skill:** line to each managed section: `/investigate-beads` for beads worktrees, `/triage` for issue worktrees, `/upgrade-cargo-deps` for upgrade worktrees. CLAUDE.local.md is the durable surface a fresh session lands on weeks later; the stdout is transient. Tweak the stdout tails too: use the slash-prefix form, use "continues" instead of vague "drives". Teach the three skills to skip the create-worktree step when already inside the matching worktree (re-running would fail noisily on `git worktree add`). Each skill explains how to recognise its own worktree from CLAUDE.local.md. --- .claude/skills/investigate-beads/SKILL.md | 8 ++++++-- .claude/skills/triage/SKILL.md | 8 ++++++-- .claude/skills/upgrade-cargo-deps/SKILL.md | 6 +++++- crates/xtask/src/create_worktree.rs | 14 +++++++++++--- 4 files changed, 28 insertions(+), 8 deletions(-) diff --git a/.claude/skills/investigate-beads/SKILL.md b/.claude/skills/investigate-beads/SKILL.md index dcc6b838c..287efc048 100644 --- a/.claude/skills/investigate-beads/SKILL.md +++ b/.claude/skills/investigate-beads/SKILL.md @@ -70,9 +70,11 @@ If the description references a plan file (`claude-notes/plans/...`), read it. I Spot-check the area: does the code the issue points at still exist with the same shape? Beads issues age — a six-month-old issue may have been overtaken by a refactor. -### 5. Create the worktree +### 5. Create the worktree (skip if already inside it) -Branch + directory naming follows `.claude/rules/worktrees.md` § Branch naming (`beads/<id>-<slug>` where `<slug>` is a short kebab-case form of the issue title, 3–5 words). Beads redirect setup follows § Beads Redirect. +**First, check if you're already in the right worktree.** A `CLAUDE.local.md` whose `**Beads:**` line matches `<id>` means the worktree exists and you're in it — skip to step 6. This skill is often re-invoked from inside an existing worktree to reload context; re-running `cargo xtask create-worktree` from there would fail noisily (`git worktree add` errors on existing directories). + +If you're in the main checkout or a different worktree, create it now: ```bash cargo xtask create-worktree <id> @@ -82,6 +84,8 @@ cargo xtask create-worktree <id> # see .claude/rules/worktrees.md § Manual bootstrap. ``` +Branch + directory naming follows `.claude/rules/worktrees.md` § Branch naming (`beads/<id>-<slug>` where `<slug>` is a short kebab-case form of the issue title, 3–5 words). Beads redirect setup follows § Beads Redirect. + Verify with `br where` from inside the worktree. ### 6. Bootstrap the worktree diff --git a/.claude/skills/triage/SKILL.md b/.claude/skills/triage/SKILL.md index 7af7c0f95..85bccc364 100644 --- a/.claude/skills/triage/SKILL.md +++ b/.claude/skills/triage/SKILL.md @@ -44,9 +44,11 @@ gh issue view <N> --repo quarto-dev/q2 --json title,body,author,createdAt,labels Read the body and every comment. If the issue contains multiple distinct reports (a list of unrelated bugs in one issue is common), confirm with the user which one(s) you're triaging. Capture that scope decision in the triage doc. -### 3. Create the worktree +### 3. Create the worktree (skip if already inside it) -Branch + directory naming follows `.claude/rules/worktrees.md` § Branch naming (`issue-<N>` for triage). Beads redirect setup follows § Beads Redirect. +**First, check if you're already in the right worktree.** A `CLAUDE.local.md` whose `**GitHub issue:**` line matches `#<N>` means the worktree exists and you're in it — skip to step 4. Re-running `cargo xtask create-worktree --issue <N>` from there would fail (`git worktree add` errors on existing directories). + +If you're in the main checkout or a different worktree, create it now: ```bash cargo xtask create-worktree --issue <N> @@ -61,6 +63,8 @@ cargo xtask create-worktree --issue <N> # see .claude/rules/worktrees.md § Manual bootstrap. ``` +Branch + directory naming follows `.claude/rules/worktrees.md` § Branch naming (`issue-<N>` for triage). Beads redirect setup follows § Beads Redirect. + Verify with `br where` from inside the worktree. ### 4. Bootstrap the worktree diff --git a/.claude/skills/upgrade-cargo-deps/SKILL.md b/.claude/skills/upgrade-cargo-deps/SKILL.md index f7b7daa6b..fcb368f70 100644 --- a/.claude/skills/upgrade-cargo-deps/SKILL.md +++ b/.claude/skills/upgrade-cargo-deps/SKILL.md @@ -113,7 +113,11 @@ Don't propose changes for these — they're either upstream vendored, workspace- If a major-upgrade candidate's only consumer is one of the vendored/pinned crates, list it under "Skipped" with the reason; don't file a beads issue. -### 5. Create the worktree +### 5. Create the worktree (skip if already inside it) + +**First, check if you're already in the right worktree.** A `CLAUDE.local.md` whose `**Task:**` line says `Cargo dependency upgrade — YYYY-MM-DD` for today's date means the worktree exists and you're in it — skip to step 6. Re-running `cargo xtask create-worktree --upgrade` from there would fail (`git worktree add` errors on existing directories). + +If you're in the main checkout or a different worktree, create it now: ```bash cargo xtask create-worktree --upgrade diff --git a/crates/xtask/src/create_worktree.rs b/crates/xtask/src/create_worktree.rs index b6aca6fab..11014e3e6 100644 --- a/crates/xtask/src/create_worktree.rs +++ b/crates/xtask/src/create_worktree.rs @@ -207,6 +207,7 @@ pub fn build_section(kind: &SectionKind) -> String { s.push_str(&format!("**GitHub:** {url}\n")); } s.push_str("**Plan:** _none yet \u{2014} replace this with `claude-notes/plans/YYYY-MM-DD-<name>.md` once you create the plan file._\n"); + s.push_str("**Skill:** `/investigate-beads` continues this worktree's work.\n"); s.push('\n'); s.push_str(&format!( "Run `br show {id}` for current status and notes.\n" @@ -224,6 +225,7 @@ pub fn build_section(kind: &SectionKind) -> String { "**Beads:** _none yet \u{2014} run `br search {number}` to find an existing issue, or `br create` to file one, then replace this line with the bd-XXXX._\n" )); s.push_str("**Plan:** _none yet \u{2014} replace this with `claude-notes/plans/YYYY-MM-DD-<name>.md` once you create the plan file._\n"); + s.push_str("**Skill:** `/triage` continues the investigation; file a beads issue once concrete work surfaces.\n"); s } SectionKind::Upgrade { date } => { @@ -234,6 +236,7 @@ pub fn build_section(kind: &SectionKind) -> String { "**Task:** Cargo dependency upgrade \u{2014} {date}\n" )); s.push_str("**Plan:** _none yet \u{2014} replace this with a plan file path if you create one._\n"); + s.push_str("**Skill:** `/upgrade-cargo-deps` continues this worktree's work.\n"); s } }; @@ -716,15 +719,16 @@ fn print_summary(plan: &Plan) { println!(); println!(" # Per beads issue (this worktree)"); println!(" br update {id} --status in_progress # claim it"); + println!(" # `/investigate-beads` reloads context if you need it."); } SectionKind::Issue { .. } => { println!(); - println!(" # No beads issue is linked yet \u{2014} the triage skill creates one"); - println!(" # when investigation surfaces real work."); + println!(" # `/triage` continues the investigation \u{2014} it files a beads issue"); + println!(" # when concrete work surfaces."); } SectionKind::Upgrade { .. } => { println!(); - println!(" # The `upgrade-cargo-deps` skill drives the rest of this worktree."); + println!(" # `/upgrade-cargo-deps` continues this worktree's work."); } } } @@ -1045,6 +1049,7 @@ mod tests { assert!(s.trim_end().ends_with(END_MARKER)); assert!(s.contains("**Beads:** bd-1d3e — Fix X")); assert!(s.contains("**GitHub:** https://github.com/quarto-dev/q2/issues/42")); + assert!(s.contains("**Skill:** `/investigate-beads`")); assert!(s.contains("Run `br show bd-1d3e`")); assert!(s.contains("Main repo: `../..`")); } @@ -1058,6 +1063,7 @@ mod tests { }); assert!(!s.contains("**GitHub:**")); assert!(s.contains("**Beads:** bd-zzzz — T")); + assert!(s.contains("**Skill:** `/investigate-beads`")); } #[test] @@ -1071,6 +1077,7 @@ mod tests { assert!(s.contains("**URL:** https://github.com/quarto-dev/q2/issues/157")); assert!(s.contains("**Beads:** _none yet")); assert!(s.contains("br search 157")); + assert!(s.contains("**Skill:** `/triage`")); assert!(!s.contains("**Beads:** bd-")); // no resolved beads id } @@ -1080,6 +1087,7 @@ mod tests { date: "2026-05-11".into(), }); assert!(s.contains("**Task:** Cargo dependency upgrade — 2026-05-11")); + assert!(s.contains("**Skill:** `/upgrade-cargo-deps`")); assert!(!s.contains("**Beads:**")); assert!(!s.contains("**GitHub:**")); } From 3ea99c16df70c8cc3aff64146859f9dac8483c62 Mon Sep 17 00:00:00 2001 From: christophe dervieux <christophe.dervieux@gmail.com> Date: Tue, 12 May 2026 15:54:12 +0200 Subject: [PATCH 32/34] plan: record Phase E smoke-test results (bd-spsv) Append the Phase E transcript (help, three modes, four failure cases, anchor-to-repo-root verification, cleanup) to the implementation plan and tick the Phase E checklist boxes. Matches the End-to-end verification section in the PR body. --- ...6-05-11-implement-create-worktree-xtask.md | 187 +++++++++++++++++- 1 file changed, 180 insertions(+), 7 deletions(-) diff --git a/claude-notes/plans/2026-05-11-implement-create-worktree-xtask.md b/claude-notes/plans/2026-05-11-implement-create-worktree-xtask.md index 3b532bfe7..2e2cb772e 100644 --- a/claude-notes/plans/2026-05-11-implement-create-worktree-xtask.md +++ b/claude-notes/plans/2026-05-11-implement-create-worktree-xtask.md @@ -1594,7 +1594,7 @@ Important: this worktree (`bd-spsv-create-worktree-xtask`) cannot be the smoke-t Chris runs each block; any failure is a defect to fix before proceeding to Phase F. -- [ ] **Step 1: Build the binary once** +- [x] **Step 1: Build the binary once** ```bash cargo build -p xtask @@ -1602,7 +1602,7 @@ cargo xtask create-worktree --help # Expected: help text with [BEADS_ID], --issue, --upgrade, --slug, --base. ``` -- [ ] **Step 2: Beads mode** +- [x] **Step 2: Beads mode** ```bash cargo xtask create-worktree bd-spsv --slug e2e-beads @@ -1611,7 +1611,7 @@ cat .worktrees/bd-spsv-e2e-beads/CLAUDE.local.md # → managed section + Be (cd .worktrees/bd-spsv-e2e-beads && br where) # → main .beads via redirect ``` -- [ ] **Step 3: Issue mode (pick an open issue dynamically)** +- [x] **Step 3: Issue mode (pick an open issue dynamically)** ```bash ISSUE=$(gh issue list --repo quarto-dev/q2 --state open --limit 1 --json number --jq '.[0].number') @@ -1619,14 +1619,14 @@ cargo xtask create-worktree --issue "$ISSUE" --slug e2e-issue cat ".worktrees/issue-${ISSUE}-e2e-issue/CLAUDE.local.md" # → has GitHub line, no resolved Beads line ``` -- [ ] **Step 4: Upgrade mode** +- [x] **Step 4: Upgrade mode** ```bash cargo xtask create-worktree --upgrade --slug e2e-upgrade ls .worktrees/cargo-upgrade-*-e2e-upgrade/CLAUDE.local.md # → upgrade variant ``` -- [ ] **Step 5: Failure cases** +- [x] **Step 5: Failure cases** ```bash # 5a. Existing directory collision — pre-create the COMPUTED target path. @@ -1663,7 +1663,7 @@ git branch | grep 'beads/bd-spsv-rollback-test' && echo "FAIL: branch leaked" \ || echo "OK: branch cleaned" ``` -- [ ] **Step 6: Cleanup** +- [x] **Step 6: Cleanup** ```bash git worktree remove .worktrees/bd-spsv-e2e-beads @@ -1676,10 +1676,183 @@ git branch -d beads/bd-spsv-e2e-beads "issue-${ISSUE}-e2e-issue" git branch | grep 'cargo-upgrade-.*-e2e-upgrade' | xargs -r git branch -d ``` -- [ ] **Step 7: Record the smoke-test transcript** +- [x] **Step 7: Record the smoke-test transcript** Capture exact output from steps 2-4 and paste into the eventual PR body under § End-to-end verification. This satisfies q2 CLAUDE.md "End-to-end verification before declaring success". +### Phase E results — 2026-05-12 + +The Phase E smoke test was executed on 2026-05-12 on Windows (Git Bash + PowerShell). All three modes, all four failure cases, and the anchor-to-repo-root invariant verified. Representative outputs below. + +#### `--help` + +``` +$ cargo xtask create-worktree --help +Create a new git worktree with beads redirect and CLAUDE.local.md context stub. + +Modes (exactly one required): + <bd-id> — beads issue (positional) + --issue N — GitHub issue triage + --upgrade — cargo dependency upgrade (date-based branch) + +Usage: xtask.exe create-worktree [OPTIONS] <BEADS_ID|--issue <ISSUE>|--upgrade> + +Arguments: + [BEADS_ID] + Beads issue ID, e.g. `bd-1d3e`. Reads `br show <id>` for title and external_ref + +Options: + --issue <ISSUE> + GitHub issue number, e.g. `157`. Reads `gh issue view` + + --upgrade + Cargo dependency upgrade — uses today's date for branch name + + --slug <SLUG> + Override auto-derived slug. In beads mode replaces the derived slug; + in issue/upgrade modes appended as a suffix (for parallel-worktree workflows) + + --base <BASE> + Base branch + + [default: main] + + -h, --help + Print help (see a summary with '-h') +``` + +The `Modes (exactly one required)` block renders as three separate lines thanks +to `#[command(verbatim_doc_comment)]` on the `CreateWorktree` variant. + +#### Beads mode + anchor-to-root verification + +Invoked from `crates/xtask/` to confirm the worktree lands at the **main-repo +root** regardless of CWD: + +``` +$ cd crates/xtask +$ cargo xtask create-worktree bd-spsv --slug anchor-test +Created worktree: C:\Users\chris\Documents\DEV_R\q2\.worktrees\bd-spsv-anchor-test/ + Branch: beads/bd-spsv-anchor-test + Beads: bd-spsv — Add cargo xtask create-worktree command with CLAUDE.local.md stub + +Next: + cd C:\Users\chris\Documents\DEV_R\q2\.worktrees\bd-spsv-anchor-test + + Open a Claude Code session there — CLAUDE.local.md gives it the + worktree context (branch, beads/GitHub link, base). Copy whichever of + the prep commands below apply: + + # Once per machine (skip if already done) + cargo xtask dev-setup # installs cargo-nextest, wasm-bindgen-cli + + # Per worktree + cargo xtask verify --skip-hub-build # confirm HEAD is green (Rust only) + npm install # only if hub-client work is in scope + + # Per beads issue (this worktree) + br update bd-spsv --status in_progress # claim it + # `/investigate-beads` reloads context if you need it. +``` + +The path is absolute and anchored at `C:\Users\chris\Documents\DEV_R\q2\` — not +nested under `crates\xtask\.worktrees\`. `CLAUDE.local.md` contains the managed +BEGIN/END section with `**Beads:**`, the self-documenting +`**Plan:** _none yet — ..._` placeholder, and the +`**Skill:** /investigate-beads ...` continuation hint. + +#### Issue mode + +``` +$ cargo xtask create-worktree --issue 184 --slug ts-issue +Created worktree: C:\Users\chris\Documents\DEV_R\q2\.worktrees\issue-184-ts-issue/ + Branch: issue-184-ts-issue + Issue: #184 — Indented (4-space) code blocks are parsed as paragraphs, and re-emitted unindented + URL: https://github.com/quarto-dev/q2/issues/184 + +Next: + cd C:\Users\chris\Documents\DEV_R\q2\.worktrees\issue-184-ts-issue + + Open a Claude Code session there — CLAUDE.local.md gives it the + worktree context (branch, beads/GitHub link, base). Copy whichever of + the prep commands below apply: + + # Once per machine (skip if already done) + cargo xtask dev-setup # installs cargo-nextest, wasm-bindgen-cli + + # Per worktree + cargo xtask verify --skip-hub-build # confirm HEAD is green (Rust only) + npm install # only if hub-client work is in scope + + # `/triage` continues the investigation — it files a beads issue + # when concrete work surfaces. +``` + +Issue title fetched via `gh issue view`. `CLAUDE.local.md` includes a +self-documenting `**Beads:** _none yet — run \`br search 184\` ..._` placeholder +and `**Skill:** /triage ...` continuation hint. + +#### Upgrade mode + +``` +$ cargo xtask create-worktree --upgrade --slug ts-upgrade +Created worktree: C:\Users\chris\Documents\DEV_R\q2\.worktrees\cargo-upgrade-2026-05-12-ts-upgrade/ + Branch: cargo-upgrade-2026-05-12-ts-upgrade + Task: Cargo dependency upgrade — 2026-05-12 + +Next: + cd C:\Users\chris\Documents\DEV_R\q2\.worktrees\cargo-upgrade-2026-05-12-ts-upgrade + + Open a Claude Code session there — CLAUDE.local.md gives it the + worktree context (branch, beads/GitHub link, base). Copy whichever of + the prep commands below apply: + + # Once per machine (skip if already done) + cargo xtask dev-setup # installs cargo-nextest, wasm-bindgen-cli + + # Per worktree + cargo xtask verify --skip-hub-build # confirm HEAD is green (Rust only) + npm install # only if hub-client work is in scope + + # `/upgrade-cargo-deps` continues this worktree's work. +``` + +Branch name embeds today's date (`2026-05-12`). No `**Beads:**` or +`**GitHub:**` lines — upgrade worktrees aren't tied to a single issue. + +#### Failure cases + +- **Existing directory collision (5a)** — pre-created + `.worktrees/bd-spsv-collision-test/`; the xtask errored before + `git worktree add` ran, no branch was created. +- **Invalid `--slug` grammar (5b)** — both `foo/bar` (path separator) and `..` + (traversal) rejected by `validate_slug` with no FS or git side effects. +- **Re-run on existing worktree (5c)** — failed with + `worktree directory already exists: <path>` (by design — file-level + idempotency only, see "Idempotency scope" above). +- **Rollback path (5d)** — `Q2_CREATE_WORKTREE_INJECT_FAIL=after_worktree_add` + injects a synthetic failure between `git worktree add` and the post-add + steps: + +``` +$ Q2_CREATE_WORKTREE_INJECT_FAIL=after_worktree_add \ + cargo xtask create-worktree bd-spsv --slug rollback-test +error after worktree creation: Q2_CREATE_WORKTREE_INJECT_FAIL=after_worktree_add (test hook) +rolling back worktree C:\Users\chris\Documents\DEV_R\q2\.worktrees\bd-spsv-rollback-test and branch beads/bd-spsv-rollback-test ... +rollback complete. +Error: Q2_CREATE_WORKTREE_INJECT_FAIL=after_worktree_add (test hook) +``` + +The injected failure fired **after** `git worktree add` succeeded, the rollback +block cleaned up both the directory and the branch, and the original error +propagated as the final exit error. Post-run verification confirmed no leftover +dir and no leftover branch. + +#### Cleanup + +All e2e worktrees and branches removed via `git worktree remove` + +`git branch -d`. `git worktree list` showed no e2e leftovers. + --- ## Phase F — Documentation and skills From 09bf93d069bee2dd3f0fe53628c9a59ac3bcae39 Mon Sep 17 00:00:00 2001 From: christophe dervieux <christophe.dervieux@gmail.com> Date: Tue, 12 May 2026 18:06:37 +0200 Subject: [PATCH 33/34] docs: align worktrees rule and triage skill with current CLAUDE.local.md (bd-spsv) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rules/worktrees.md § CLAUDE.local.md: extend the inventory of fields the managed section carries to include `**GitHub issue:**`, `**Skill:**`, and note that placeholders are self-documenting. skills/triage: tighten the description of the issue-mode `**Beads:**` placeholder to match the current italic-prose shape. --- .claude/rules/worktrees.md | 9 ++++++--- .claude/skills/triage/SKILL.md | 11 ++++++----- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/.claude/rules/worktrees.md b/.claude/rules/worktrees.md index 312a44d9b..cc50fd8ae 100644 --- a/.claude/rules/worktrees.md +++ b/.claude/rules/worktrees.md @@ -47,9 +47,12 @@ The `redirect` file is already in `.beads/.gitignore`, so it won't show as a git `cargo xtask create-worktree` prepends a worktree context section to `CLAUDE.local.md`. Claude Code loads it automatically — no need to run `br show` to orient at session start. -The section contains: worktree declaration, main repo path (`../..`), beads ID, -GitHub URL, and a placeholder for the plan file path (fill in manually after creating -the plan). +The section contains: worktree declaration, main repo path (`../..`), beads ID +(or `**GitHub issue:** #N` in `--issue` mode), GitHub URL when available, an +italic-prose placeholder for the plan file path, and a `**Skill:**` line +naming the slash-command that continues the work (`/investigate-beads`, +`/triage`, or `/upgrade-cargo-deps`). Placeholders are self-documenting — +they say exactly what to replace them with. Status lives in beads, not in this file. Run `br show <id>` for current status + notes. diff --git a/.claude/skills/triage/SKILL.md b/.claude/skills/triage/SKILL.md index 85bccc364..1c107cd7e 100644 --- a/.claude/skills/triage/SKILL.md +++ b/.claude/skills/triage/SKILL.md @@ -54,11 +54,12 @@ If you're in the main checkout or a different worktree, create it now: cargo xtask create-worktree --issue <N> # Creates the worktree, .beads/redirect, and CLAUDE.local.md context stub. # This step runs BEFORE the beads issue is created (step 6). The `--issue` -# template's Beads line is a placeholder — `(run `br search <N>` to find or -# create a beads issue)`. After step 6 creates the bd-XXXX, edit the Beads -# line in CLAUDE.local.md manually to point at the new ID. Do NOT re-run -# the xtask with `<bd-id>` to "refresh" — that creates a separate beads -# worktree at `.worktrees/<bd-id>-<slug>` rather than updating this one. +# template's `**Beads:**` line is a self-documenting placeholder pointing +# at `br search <N>` / `br create`. After step 6 creates the bd-XXXX, +# edit that line in CLAUDE.local.md manually to point at the new ID. +# Do NOT re-run the xtask with `<bd-id>` to "refresh" — that creates a +# separate beads worktree at `.worktrees/<bd-id>-<slug>` rather than +# updating this one. # Fallback for fresh clones where the xtask is not yet built: # see .claude/rules/worktrees.md § Manual bootstrap. ``` From 747daa49c5d64a7e73550c256c0fd30ea66c4ecd Mon Sep 17 00:00:00 2001 From: christophe dervieux <christophe.dervieux@gmail.com> Date: Tue, 12 May 2026 18:52:33 +0200 Subject: [PATCH 34/34] xtask(create-worktree): add `cargo create-worktree` alias (bd-spsv) `create-worktree` is the most frequent xtask developers run (every new worktree), so it earns a top-level cargo alias the same way `dev-setup` does. Saves typing `xtask` and matches what the existing alias precedent sets up. Both forms work side-by-side: `cargo xtask create-worktree ...` still resolves through the generic `xtask` alias, while `cargo create-worktree ...` shortcuts past it. --- .cargo/config.toml | 1 + .claude/rules/xtask.md | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.cargo/config.toml b/.cargo/config.toml index 80e59fcfa..daab58d75 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -5,3 +5,4 @@ # See crates/xtask/src/main.rs for available commands xtask = "run --package xtask --" dev-setup = "xtask dev-setup" +create-worktree = "xtask create-worktree" diff --git a/.claude/rules/xtask.md b/.claude/rules/xtask.md index d99a82a09..3f5b41b1c 100644 --- a/.claude/rules/xtask.md +++ b/.claude/rules/xtask.md @@ -21,7 +21,7 @@ paths: |---------|-------|---------| | `cargo xtask dev-setup` | `cargo dev-setup` | Install required dev tools (cargo-nextest, wasm-bindgen-cli) | | `cargo xtask lint` | — | Run custom lint checks | -| `cargo xtask create-worktree` | — | Create git worktree + `.beads/redirect` + CLAUDE.local.md context stub | +| `cargo xtask create-worktree` | `cargo create-worktree` | Create git worktree + `.beads/redirect` + CLAUDE.local.md context stub | | `cargo xtask verify` | — | Full project verification (build + tests for Rust and hub-client) | ## Dev tool version pinning