From 9a86deb401224d307b0ceb3fc70ed613140c12f0 Mon Sep 17 00:00:00 2001 From: Ethan Davidson <31261035+EthanThatOneKid@users.noreply.github.com> Date: Sat, 8 Aug 2026 18:08:52 -0700 Subject: [PATCH 1/2] docs: record explicit worktree baseline and staleness ADR --- README.md | 12 ++++- .../0003-worktree-baseline-and-staleness.md | 50 +++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 docs/adr/0003-worktree-baseline-and-staleness.md diff --git a/README.md b/README.md index 665c9c5..4f4de5e 100644 --- a/README.md +++ b/README.md @@ -28,8 +28,15 @@ Design principles: - `wspace init` — clone missing repositories from the manifest. Prints a warning that fresh clones lack gitignored files and repo-specific setup. - `wspace update` — fetch remotes and fast-forward only clean default branches. -- `wspace worktree add|list|remove` — create, list, and remove git worktrees - under `worktrees///`. +- `wspace worktree add []` — create a git worktree + under `worktrees///`, branching from the repo's default-branch + baseline (`origin/`) or an explicit ``. Attaches an + existing branch of the same name with a warning. +- `wspace worktree list [--stale] [--json]` — list worktrees across all + repositories. `--stale` filters to linked worktrees whose branch is fully + merged into the default branch (or missing), i.e. safe removal candidates. +- `wspace worktree remove ` — remove a worktree, then prune and + tidy the now-empty `worktrees//` directory. - `wspace env sync` — copy local environment files from a gitignored `secrets/` vault into checkouts and worktrees. - `wspace sync` — alias for `wspace init`. @@ -64,3 +71,4 @@ integration tests against real local git repositories). - [ADR-0001: Git-native worktrees](docs/adr/0001-git-native-worktrees.md) - [ADR-0002: Conservative update policy](docs/adr/0002-conservative-update-policy.md) +- [ADR-0003: Explicit worktree baseline and merged-branch staleness](docs/adr/0003-worktree-baseline-and-staleness.md) diff --git a/docs/adr/0003-worktree-baseline-and-staleness.md b/docs/adr/0003-worktree-baseline-and-staleness.md new file mode 100644 index 0000000..9f826d9 --- /dev/null +++ b/docs/adr/0003-worktree-baseline-and-staleness.md @@ -0,0 +1,50 @@ +# ADR-0003: Explicit worktree baseline and merged-branch staleness + +- Status: accepted +- Date: 2026-08 + +## Context + +`wspace worktree add` created feature branches with +`git worktree add --track -b + `, forking from the current HEAD of +the `repos/` checkout. When that checkout sat on a stale or unrelated branch, +features silently forked from the wrong baseline. `--track` was also a no-op: +the start point was a local branch, so git set up no upstream. Separately, +`wspace worktree list --stale` listed detached worktrees, conflating a detached +HEAD with a worktree that is safe to delete. + +## Decision + +1. `wspace worktree add []` branches from the + default-branch baseline `origin/` (resolved from `origin/HEAD`) by + default, or from an explicit `` when given. The git invocation is + `git worktree add --no-track -b `: `--no-track` + avoids wiring the new branch to track `origin/`, which default + `branch.autoSetupMerge` would do for a remote-tracking start point. Upstream + is set later by `git push -u origin `. No fetch is performed; + `wspace update` remains the refresh step, matching git's own behavior of + branching from the last-fetched refs. +2. When a branch named `` already exists locally, `worktree add` + attaches it (`git worktree add --no-track `) with a warning, + enabling resume of prior-session work. +3. `wspace worktree list --stale` lists linked worktrees whose branch has no + commits beyond `origin/` (fully merged or not yet diverged, tested + with `git merge-base --is-ancestor`) or whose branch ref is missing. The main + worktree, bare entries, and detached worktrees are excluded; detached + worktrees remain visible in the unfiltered `list`. Each stale row carries a + `reason` (`merged` | `branch-missing`). +4. After a successful `wspace worktree remove`, the now-empty + `worktrees//` parent directory is removed (best-effort). + +## Consequences + +- Feature branches always fork from the last-fetched remote baseline, even when + the `repos/` checkout is on a feature branch or detached. +- The staleness test matches git's own "merged" notion (`git branch --merged`): + a worktree whose branch has no unique commits is a safe deletion candidate, + which includes a freshly created worktree that has not yet diverged. +- `--stale` becomes an actionable cleanup list rather than a proxy for detached + HEADs. +- `worktree add` fails loudly when no default branch can be resolved and no + explicit `` is given, instead of silently forking from HEAD. From 478318d018adcab5c793d1fcc6af39304be3e3f6 Mon Sep 17 00:00:00 2001 From: Ethan Davidson <31261035+EthanThatOneKid@users.noreply.github.com> Date: Sat, 8 Aug 2026 18:21:44 -0700 Subject: [PATCH 2/2] feat: branch worktrees from default baseline and flag stale ones --- src/cli.ts | 64 ++++++++++++++++++++++---- src/update.ts | 8 +--- src/worktrees.ts | 70 +++++++++++++++++++++++++++-- tests/integration_test.ts | 94 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 217 insertions(+), 19 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 7defb1b..0f7ed0d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,7 +1,7 @@ import { join, resolve } from "@std/path"; import { parseArgs } from "@std/cli/parse-args"; import { syncEnv } from "./env.ts"; -import { clone } from "./git.ts"; +import { clone, defaultBranch } from "./git.ts"; import type { GitRunner } from "./git.ts"; import { SystemGit } from "./git.ts"; import { @@ -15,7 +15,14 @@ import type { ManifestPaths } from "./manifest.ts"; import { collectStatus, hasErrors } from "./status.ts"; import type { WorkspaceManifest } from "./types.ts"; import { runUpdate } from "./update.ts"; -import { addWorktree, listWorktrees, removeWorktree } from "./worktrees.ts"; +import { + addWorktree, + branchExists, + defaultBranchStartPoint, + listWorktrees, + removeWorktree, + staleness, +} from "./worktrees.ts"; const COMMANDS = [ "check", @@ -46,7 +53,7 @@ Usage: wspace init [--json] wspace sync [--json] wspace update [--json] - wspace worktree add + wspace worktree add [] wspace worktree list [--stale] [--json] wspace worktree remove wspace env sync @@ -98,23 +105,36 @@ async function runWorktree( branch?: string; bare: boolean; detached: boolean; + stale?: boolean; + reason?: string; }[] = []; for (const repository of manifest.repositories) { const repoPath = resolveRepositoryPath(repository, paths); if (!(await exists(repoPath))) { continue; } + const defaultName = opts.stale + ? await defaultBranch(g, repoPath) + : undefined; for (const wt of await listWorktrees(g, repoPath)) { - rows.push({ + const row: (typeof rows)[number] = { repo: repository.name, path: wt.path, branch: wt.branch, bare: wt.bare, detached: wt.detached, - }); + }; + if (opts.stale) { + const s = await staleness(g, repoPath, wt, defaultName); + row.stale = s.stale; + if (s.reason) { + row.reason = s.reason; + } + } + rows.push(row); } } - const filtered = opts.stale ? rows.filter((r) => r.detached) : rows; + const filtered = opts.stale ? rows.filter((r) => r.stale) : rows; if (opts.json) { console.log(JSON.stringify(filtered, null, 2)); } else { @@ -123,9 +143,11 @@ async function runWorktree( return 0; } case "add": { - const [repoName, feature] = opts.positional; + const [repoName, feature, startPoint] = opts.positional; if (!repoName || !feature) { - console.error("Usage: wspace worktree add "); + console.error( + "Usage: wspace worktree add []", + ); return 2; } const repository = manifest.repositories.find((r) => r.name === repoName); @@ -139,7 +161,28 @@ async function runWorktree( return 2; } const worktreePath = join(paths.worktreesDirectory, repoName, feature); - const result = await addWorktree(g, repoPath, worktreePath, feature); + const reattach = await branchExists(g, repoPath, feature); + let startPointArg: string | undefined = startPoint; + if (reattach) { + console.warn( + `Branch ${feature} already exists; attaching existing branch`, + ); + } else { + startPointArg ??= await defaultBranchStartPoint(g, repoPath); + if (!startPointArg) { + console.error( + "Cannot resolve a default-branch baseline (no origin/HEAD); pass an explicit ", + ); + return 2; + } + } + const result = await addWorktree( + g, + repoPath, + worktreePath, + feature, + startPointArg, + ); if (result.code !== 0) { console.error(result.stderr); return 1; @@ -169,6 +212,9 @@ async function runWorktree( console.error(result.stderr); return 1; } + await Deno.remove(join(paths.worktreesDirectory, repoName), { + recursive: false, + }).catch(() => {}); console.log(`Removed worktree ${worktreePath}`); return 0; } diff --git a/src/update.ts b/src/update.ts index 7515d34..572d049 100644 --- a/src/update.ts +++ b/src/update.ts @@ -1,4 +1,4 @@ -import { join } from "@std/path"; +import { join, normalize } from "@std/path"; import type { GitRunner } from "./git.ts"; import { branchAb, @@ -13,10 +13,6 @@ import type { ManifestPaths } from "./manifest.ts"; import type { RepositoryEntry, UpdateAction } from "./types.ts"; import { listWorktrees } from "./worktrees.ts"; -function normalizePath(path: string): string { - return path.replaceAll("\\", "/").replace(/\/+$/, ""); -} - export async function planUpdate( g: GitRunner, repositories: RepositoryEntry[], @@ -65,7 +61,7 @@ export async function planUpdate( const worktrees = await listWorktrees(g, repoPath); const linkedWorktrees = worktrees.filter( - (w) => normalizePath(w.path) !== normalizePath(repoPath), + (w) => normalize(w.path) !== normalize(repoPath), ); if (linkedWorktrees.some((w) => w.branch === defaultBranchName)) { actions.push({ diff --git a/src/worktrees.ts b/src/worktrees.ts index 1266a29..4cba253 100644 --- a/src/worktrees.ts +++ b/src/worktrees.ts @@ -1,3 +1,5 @@ +import { normalize } from "@std/path"; +import { defaultBranch, hasRef } from "./git.ts"; import type { GitResult, GitRunner } from "./git.ts"; import type { Worktree } from "./types.ts"; @@ -55,16 +57,29 @@ export async function listWorktrees( return result.code === 0 ? parseWorktreesPorcelain(result.stdout) : []; } +export async function branchExists( + g: GitRunner, + repoPath: string, + branch: string, +): Promise { + return await hasRef(g, repoPath, `refs/heads/${branch}`); +} + export async function addWorktree( g: GitRunner, repoPath: string, worktreePath: string, branch: string, + startPoint?: string, ): Promise { - return await g.run( - ["worktree", "add", "--track", "-b", branch, worktreePath], - repoPath, - ); + if (await branchExists(g, repoPath, branch)) { + return await g.run(["worktree", "add", worktreePath, branch], repoPath); + } + const args = ["worktree", "add", "--no-track", "-b", branch, worktreePath]; + if (startPoint) { + args.push(startPoint); + } + return await g.run(args, repoPath); } export async function removeWorktree( @@ -78,3 +93,50 @@ export async function removeWorktree( } return result; } + +export async function defaultBranchStartPoint( + g: GitRunner, + repoPath: string, +): Promise { + const branch = await defaultBranch(g, repoPath); + return branch ? `origin/${branch}` : undefined; +} + +export async function branchIsAncestor( + g: GitRunner, + repoPath: string, + branch: string, + ref: string, +): Promise { + return (await g.run(["merge-base", "--is-ancestor", branch, ref], repoPath)) + .code === 0; +} + +export interface WorktreeStaleness { + stale: boolean; + reason?: "merged" | "branch-missing"; +} + +export async function staleness( + g: GitRunner, + repoPath: string, + wt: Worktree, + defaultBranchName: string | undefined, +): Promise { + if (normalize(wt.path) === normalize(repoPath)) { + return { stale: false }; + } + if (!defaultBranchName || wt.bare || wt.detached || !wt.branch) { + return { stale: false }; + } + if (!(await branchExists(g, repoPath, wt.branch))) { + return { stale: true, reason: "branch-missing" }; + } + const merged = await branchIsAncestor( + g, + repoPath, + wt.branch, + `origin/${defaultBranchName}`, + ); + return merged ? { stale: true, reason: "merged" } : { stale: false }; +} diff --git a/tests/integration_test.ts b/tests/integration_test.ts index 16b7b54..1bfa732 100644 --- a/tests/integration_test.ts +++ b/tests/integration_test.ts @@ -16,6 +16,7 @@ import { addWorktree, listWorktrees, removeWorktree, + staleness, } from "../src/worktrees.ts"; const g = new SystemGit(); @@ -207,6 +208,99 @@ Deno.test("worktree add/list/remove round trip", async () => { } }); +Deno.test("worktree add attaches an existing branch", async () => { + const dir = await Deno.makeTempDir(); + try { + const work = await makeRepoWithMain(dir, "a"); + assertEquals((await g.run(["branch", "feature-x"], work)).code, 0); + const worktreePath = join(dir, "worktrees", "a", "feature-x"); + assertEquals( + (await addWorktree(g, work, worktreePath, "feature-x")).code, + 0, + ); + const worktrees = await listWorktrees(g, work); + assert( + worktrees.some((w) => + w.path.replaceAll("\\", "/") === worktreePath.replaceAll("\\", "/") + ), + "worktree for existing branch not found", + ); + } finally { + await Deno.remove(dir, { recursive: true }); + } +}); + +Deno.test("worktree add branches from an explicit start point", async () => { + const dir = await Deno.makeTempDir(); + try { + const work = await makeRepoWithMain(dir, "a"); + await seedCommitOnOrigin(dir, "a", "two\n"); + assertEquals((await g.run(["fetch", "--prune"], work)).code, 0); + assertEquals( + (await g.run(["checkout", "-b", "base-branch"], work)).code, + 0, + ); + + const worktreePath = join(dir, "worktrees", "a", "feature-x"); + assertEquals( + (await addWorktree(g, work, worktreePath, "feature-x", "origin/main")) + .code, + 0, + ); + + const head = (await g.run(["rev-parse", "HEAD"], worktreePath)).stdout; + const originMain = (await g.run(["rev-parse", "origin/main"], work)).stdout; + const baseBranch = (await g.run(["rev-parse", "base-branch"], work)).stdout; + assertEquals(head, originMain); + assert(head !== baseBranch, "forked from HEAD instead of the start point"); + } finally { + await Deno.remove(dir, { recursive: true }); + } +}); + +Deno.test("staleness flags merged branches and never the main worktree", async () => { + const dir = await Deno.makeTempDir(); + try { + const work = await makeRepoWithMain(dir, "a"); + const worktreePath = join(dir, "worktrees", "a", "feature-x"); + assertEquals( + (await addWorktree(g, work, worktreePath, "feature-x", "origin/main")) + .code, + 0, + ); + const worktrees = await listWorktrees(g, work); + const created = worktrees.find((w) => + w.path.replaceAll("\\", "/") === worktreePath.replaceAll("\\", "/") + ); + assert(created, "worktree not found"); + + assertEquals( + await staleness(g, work, created, "main"), + { stale: true, reason: "merged" }, + ); + + await Deno.writeTextFile(join(worktreePath, "a.txt"), "feature\n"); + assertEquals((await g.run(["add", "."], worktreePath)).code, 0); + await configure(worktreePath); + assertEquals( + (await g.run(["commit", "-m", "feature work"], worktreePath)).code, + 0, + ); + assertEquals( + await staleness(g, work, created, "main"), + { stale: false }, + ); + + const main = worktrees.find((w) => + w.path.replaceAll("\\", "/") === work.replaceAll("\\", "/") + ); + assert(main, "main worktree not found"); + assertEquals(await staleness(g, work, main, "main"), { stale: false }); + } finally { + await Deno.remove(dir, { recursive: true }); + } +}); + Deno.test("update skips when default branch is checked out in a worktree", async () => { const dir = await Deno.makeTempDir(); try {