Skip to content

feat: git-init-friendly workspace layout + --worktree resume heads-up#99

Merged
ikeikeikeike merged 4 commits into
mainfrom
feat/worktree-layout-git-init-friendly
Jul 12, 2026
Merged

feat: git-init-friendly workspace layout + --worktree resume heads-up#99
ikeikeikeike merged 4 commits into
mainfrom
feat/worktree-layout-git-init-friendly

Conversation

@ikeikeikeike

Copy link
Copy Markdown
Member

Why

claude --worktree is git-native. In a non-git monorepo root, bough's WorktreeCreate hook still lets claude --worktree run (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 ... --resume work (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:

artifact new old (still honored)
source checkouts <root>/.bough/repos/<name> <root>/<name>
port registry <root>/.bough/ports.json .bough-ports.json
worktrees <root>/worktrees/<name> (non-hidden) .worktrees/<name>

→ a git-initialised root needs just two .gitignore entries: .bough/ and worktrees/.

Heads-up: bough create prints 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 .gitignore lines.

Resolvers (internal/cli/layout.go): resolveRepoSrc / worktreesDir / (extended) resolveRegistryPath prefer the new location, fall back to the old, and target the new one for fresh artifacts. resolveMonorepoRoot recognises both worktrees segments.

Test plan

  • Unit: resolveRepoSrc / worktreesDir / resolveRegistryPath / warnIfRootNotGit / resolveMonorepoRoot (new + legacy + fallback cases)
  • go test ./... -race green; golangci-lint run ./... 0 issues; changed files gofumpt-clean
  • Real bough create E2E, new layout (non-git root): checkout → .bough/repos/demo, worktree → worktrees/feat1/demo, registry → .bough/ports.json, warning printed, worktree path emitted on stdout
  • Real bough create E2E, backward-compat: a monorepo with an existing root-level checkout + .worktrees/ reuses both in place (no re-clone, no worktrees/), worktree at .worktrees/feat2

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.
Comment thread internal/cli/remove.go Outdated
runner := gitwt.NewRunner()
for _, repo := range cfg.Repositories {
repoSrc := filepath.Join(monorepoRoot, repo.Name)
repoSrc := resolveRepoSrc(monorepoRoot, repo.Name)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread internal/cli/layout.go
// 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 {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread internal/cli/create.go Outdated
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/")

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread internal/cli/create.go
// 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()

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread internal/cli/create.go
// `.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} {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@ikeikeikeike
ikeikeikeike merged commit 0ef0da7 into main Jul 12, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant