feat: git-init-friendly workspace layout + --worktree resume heads-up#99
Conversation
Groups everything bough generates at the monorepo root so a git-initialised root needs only two .gitignore entries — `.bough/` and `worktrees/`: <root>/.bough/repos/<name> source checkouts (was <root>/<name>) <root>/worktrees/<name> per-feature trees (was <root>/.worktrees/<name>) Why this matters: Claude Code's `--worktree` is git-native. In a non-git monorepo root, bough's WorktreeCreate hook still lets `claude --worktree` run (the hook is the documented non-git escape hatch), but Claude Code anchors such hook-based worktree sessions to the launch directory — so `claude --worktree <name> --resume <id>` (which looks in the worktree's own project bucket) cannot find them; only plain `claude --resume <id>` from the root does. Initialising the root as a git repo switches Claude Code onto the git-native path and makes `--worktree ... --resume` work — matching a git-rooted monorepo. This change makes that git-init clean (two ignore lines instead of one per sub-repo). - layout.go: resolveRepoSrc / worktreesDir resolvers. Both are backward-compatible: an existing root-level <root>/<name> checkout or a legacy <root>/.worktrees/ is detected and kept in place, so upgrading never orphans an already-materialised workspace. Only fresh artifacts adopt the new locations. - warnIfRootNotGit: bough create prints a heads-up when the root is not inside a git work tree, explaining the resume caveat and SUGGESTING (never writing — the ignore policy is the operator's) the `.bough/` + `worktrees/` gitignore lines. - create/remove/helpers/verify/backfill all route through the resolvers.
…veat - resolveRegistryPath now prefers <root>/.bough/ports.json, then the pre-v0.11 <root>/.bough-ports.json, then the operator's configured registry.path — so a migrated monorepo stops reading the flat file while an un-migrated one keeps working. registry.Save already MkdirAll's the parent, so no write-path change is needed. - README: new "Workspace layout & resumable worktree sessions" section (the .bough/ + worktrees/ layout, the two-line .gitignore, and why git-init'ing the root makes `claude --worktree <name> --resume <id>` work); registry.path example bumped to .bough/ports.json; worktrees path references de-hidden. - CHANGELOG: v0.11.0.
resolveMonorepoRoot's fast-path (jump to the prefix before the worktrees segment, guarded by the .bough.yaml marker) previously only matched the hidden `/.worktrees/`. It now matches the v0.11 `/worktrees/` as well, so a worktree sub-repo carrying a stray .bough.yaml cannot shadow the real monorepo root under the new layout (the plain ancestor-walk fallback would wrongly stop at it). Both segments are recognised. Also refreshes the now-stale layout wording in backfill's help/errors and a few doc comments (config.Repository, helpers, envwriter) to the non-hidden worktrees/ path.
| runner := gitwt.NewRunner() | ||
| for _, repo := range cfg.Repositories { | ||
| repoSrc := filepath.Join(monorepoRoot, repo.Name) | ||
| repoSrc := resolveRepoSrc(monorepoRoot, repo.Name) |
There was a problem hiding this comment.
resolveRepoSrc is re-derived here at remove time from live on-disk state, so it can resolve to a different location than materializeRepositories used at create time. If a checkout is migrated to .bough/repos/<name> after the worktree was already created against the old <root>/<name> location, runner.Remove (line 129) runs git worktree remove against a repo that never registered this worktree. That error is only logged, then the worktree dir is unconditionally removed (line 139) — leaving orphaned worktree admin metadata in the real source repo, so the next bough create <name> fails until a manual git worktree prune. Deriving the source from the worktree's own gitlink (git -C <repoDst> rev-parse --git-common-dir) would be robust against this drift.
| // A monorepo that already has the legacy hidden <root>/.worktrees/ keeps | ||
| // using it so existing worktrees stay findable by remove / verify / | ||
| // backfill; a fresh monorepo uses the non-hidden <root>/worktrees/. | ||
| func worktreesDir(monorepoRoot string) string { |
There was a problem hiding this comment.
The unprefixed worktrees (and .bough) directory names are now reserved at the monorepo root, but Repository.Name has no reserved-word check (validate.go only rejects ./../separators/duplicates). A repo declared name: worktrees on the pre-v0.11 root-level layout is checked out at <root>/worktrees — the same path worktreesDir() returns for a fresh monorepo. bough create would then write feature worktrees into that repo's working tree, and bough remove's os.RemoveAll could delete files inside it. Suggest rejecting worktrees / .worktrees / .bough as repo names in config validation.
| logf(stderr, "[bough] (Claude Code anchors hook-based worktree sessions to the launch dir). Resume with") | ||
| logf(stderr, "[bough] plain `claude --resume <id>` from %s instead.", monorepoRoot) | ||
| logf(stderr, "[bough] To enable `--worktree ... --resume`, `git init` this dir and .gitignore:") | ||
| logf(stderr, "[bough] .bough/") |
There was a problem hiding this comment.
These two suggested .gitignore lines are hardcoded to the v0.11 layout, but this monorepo may still be on the legacy one. worktreesDir() keeps routing new worktrees to .worktrees/ when it already exists (not worktrees/), and resolveRepoSrc() keeps reusing root-level <root>/<name> checkouts — neither is covered by these lines. An operator who git inits and adds exactly these two entries could end up tracking a legacy .worktrees/ tree or root-level checkouts. Compute the actual paths in use (worktreesDir + per-repo resolveRepoSrc) and suggest those instead.
| // failing). Walks up like git itself, so a monorepo root nested inside | ||
| // a parent repo also counts. | ||
| func insideGitWorkTree(dir string) bool { | ||
| out, err := exec.Command("git", "-C", dir, "rev-parse", "--is-inside-work-tree").Output() |
There was a problem hiding this comment.
insideGitWorkTree returns false on any error, including a missing/unreachable git binary — indistinguishable from a genuine non-git root. On a host without git on PATH, warnIfRootNotGit then prints the full "run git init" guidance even when the root already is a git repo. Distinguish an *exec.ExitError (git ran and said not-a-work-tree) from a launch failure (git missing) and skip the warning in the latter case.
| // `.bough-ports.json`, then whatever the operator wrote in | ||
| // registry.path. The registry loader auto-upgrades legacy keys in | ||
| // any case, so operators can migrate at their own pace. | ||
| for _, rel := range []string{filepath.Join(boughDir, portsFile), registry.CanonicalPath} { |
There was a problem hiding this comment.
This new preference order (.bough/ports.json → .bough-ports.json → YAML path) is not reflected in the function's godoc a few lines up, which still says it "picks the v0.4 canonical .bough-ports.json … otherwise falls back to whatever the YAML declared". Update the godoc to match the three-step order.
Five issues surfaced by an independent code review of this PR, all in the new backward-compat/migration paths: - remove.go: `bough remove` re-derived the repo source via resolveRepoSrc from live disk state, which could drift from the location create registered the worktree against (e.g. after migrating a checkout into .bough/repos/), so `git worktree remove` hit a repo that never knew the worktree — failing silently and orphaning worktree admin metadata. Now reads the source from the worktree's own gitlink (worktreeSourceRepo, `git rev-parse --git-common-dir`) and only falls back to resolveRepoSrc when the worktree copy is gone. E2E-verified: create→remove→re-create leaves no orphan. - warnIfRootNotGit: the suggested .gitignore lines were hardcoded to the v0.11 layout (`.bough/`, `worktrees/`), so an operator on the legacy layout who followed them literally could end up git-tracking a real `.worktrees/` tree or root-level sub-repo checkouts. Now computes the actual paths in use via gitignoreSuggestions (real worktrees dir + every root-level legacy checkout). - insideGitWorkTree: conflated a missing/unrunnable `git` binary with a genuine non-git root, so warnIfRootNotGit mis-fired the "run git init" advice on a host without git. Now distinguishes *exec.ExitError (git ran, said no) from a launch failure and stays silent in the latter. - config validate: reject repository names that collide with bough's root-level layout dirs (worktrees / .worktrees / .bough) — such a repo checked out at <root>/<name> would share a path with the worktrees dir and get feature worktrees written into (or RemoveAll'd out of) it. - resolveRegistryPath: stale godoc updated to the v0.11 three-step order. Tests: TestWorktreeSourceRepo (real linked worktree), TestWarnIfRootNotGit legacy-layout case, TestLoad_RejectsReservedName, plus the E2E above.
Why
claude --worktreeis git-native. In a non-git monorepo root, bough'sWorktreeCreatehook still letsclaude --worktreerun (the hook is Claude Code's documented escape hatch for non-git / other-VCS workspaces), but Claude Code anchors such hook-based worktree sessions to the launch directory. So:claude --resume <id>from the monorepo root — works.claude --worktree <name> --resume <id>— can't find the session (it looks in the worktree's own project bucket).git init-ing the monorepo root switches Claude Code onto the git-native path and makes--worktree ... --resumework (matching a git-rooted monorepo). This PR makes that git-init clean and adds a heads-up.What
Layout (v0.11), all backward-compatible — a pre-v0.11 monorepo (checkouts at
<root>/<name>, worktrees under.worktrees/, registry.bough-ports.json) is detected and reused unchanged; only fresh artifacts adopt the new paths:<root>/.bough/repos/<name><root>/<name><root>/.bough/ports.json.bough-ports.json<root>/worktrees/<name>(non-hidden).worktrees/<name>→ a git-initialised root needs just two
.gitignoreentries:.bough/andworktrees/.Heads-up:
bough createprints a one-time note when the monorepo root is not inside a git work tree — explaining the resume caveat and SUGGESTING (never writing — the ignore policy stays the operator's) the two.gitignorelines.Resolvers (
internal/cli/layout.go):resolveRepoSrc/worktreesDir/ (extended)resolveRegistryPathprefer the new location, fall back to the old, and target the new one for fresh artifacts.resolveMonorepoRootrecognises both worktrees segments.Test plan
resolveRepoSrc/worktreesDir/resolveRegistryPath/warnIfRootNotGit/resolveMonorepoRoot(new + legacy + fallback cases)go test ./... -racegreen;golangci-lint run ./...0 issues; changed filesgofumpt-cleanbough createE2E, new layout (non-git root): checkout →.bough/repos/demo, worktree →worktrees/feat1/demo, registry →.bough/ports.json, warning printed, worktree path emitted on stdoutbough createE2E, backward-compat: a monorepo with an existing root-level checkout +.worktrees/reuses both in place (no re-clone, noworktrees/), worktree at.worktrees/feat2