Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<repo>/<feature>/`.
- `wspace worktree add <repo> <feature> [<commit-ish>]` — create a git worktree
under `worktrees/<repo>/<feature>/`, branching from the repo's default-branch
baseline (`origin/<default>`) or an explicit `<commit-ish>`. 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 <repo> <feature>` — remove a worktree, then prune and
tidy the now-empty `worktrees/<repo>/` directory.
- `wspace env sync` — copy local environment files from a gitignored `secrets/`
vault into checkouts and worktrees.
- `wspace sync` — alias for `wspace init`.
Expand Down Expand Up @@ -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)
50 changes: 50 additions & 0 deletions docs/adr/0003-worktree-baseline-and-staleness.md
Original file line number Diff line number Diff line change
@@ -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
<branch> <path>`, 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 <repo> <feature> [<commit-ish>]` branches from the
default-branch baseline `origin/<default>` (resolved from `origin/HEAD`) by
default, or from an explicit `<commit-ish>` when given. The git invocation is
`git worktree add --no-track -b <feature> <path> <start-point>`: `--no-track`
avoids wiring the new branch to track `origin/<default>`, which default
`branch.autoSetupMerge` would do for a remote-tracking start point. Upstream
is set later by `git push -u origin <feature>`. 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 `<feature>` already exists locally, `worktree add`
attaches it (`git worktree add --no-track <path> <feature>`) with a warning,
enabling resume of prior-session work.
3. `wspace worktree list --stale` lists linked worktrees whose branch has no
commits beyond `origin/<default>` (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/<repo>/` 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 `<commit-ish>` is given, instead of silently forking from HEAD.
64 changes: 55 additions & 9 deletions src/cli.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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",
Expand Down Expand Up @@ -46,7 +53,7 @@ Usage:
wspace init [--json]
wspace sync [--json]
wspace update [--json]
wspace worktree add <repo> <feature>
wspace worktree add <repo> <feature> [<commit-ish>]
wspace worktree list [--stale] [--json]
wspace worktree remove <repo> <feature>
wspace env sync
Expand Down Expand Up @@ -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 {
Expand All @@ -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 <repo> <feature>");
console.error(
"Usage: wspace worktree add <repo> <feature> [<commit-ish>]",
);
return 2;
}
const repository = manifest.repositories.find((r) => r.name === repoName);
Expand All @@ -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 <commit-ish>",
);
return 2;
}
}
const result = await addWorktree(
g,
repoPath,
worktreePath,
feature,
startPointArg,
);
if (result.code !== 0) {
console.error(result.stderr);
return 1;
Expand Down Expand Up @@ -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;
}
Expand Down
8 changes: 2 additions & 6 deletions src/update.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { join } from "@std/path";
import { join, normalize } from "@std/path";
import type { GitRunner } from "./git.ts";
import {
branchAb,
Expand All @@ -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[],
Expand Down Expand Up @@ -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({
Expand Down
70 changes: 66 additions & 4 deletions src/worktrees.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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<boolean> {
return await hasRef(g, repoPath, `refs/heads/${branch}`);
}

export async function addWorktree(
g: GitRunner,
repoPath: string,
worktreePath: string,
branch: string,
startPoint?: string,
): Promise<GitResult> {
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(
Expand All @@ -78,3 +93,50 @@ export async function removeWorktree(
}
return result;
}

export async function defaultBranchStartPoint(
g: GitRunner,
repoPath: string,
): Promise<string | undefined> {
const branch = await defaultBranch(g, repoPath);
return branch ? `origin/${branch}` : undefined;
}

export async function branchIsAncestor(
g: GitRunner,
repoPath: string,
branch: string,
ref: string,
): Promise<boolean> {
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<WorktreeStaleness> {
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 };
}
Loading
Loading