diff --git a/README.md b/README.md index 2e829a2..be103a6 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,19 @@ Design principles: Untracked workspace content (`repos/`, `worktrees/`) does not mark the root dirty. `--workspace ` updates only the named sub-workspace and leaves the root out. +- `wspace sweep [--dry-run] [--json] [--delete-remote] [--force] [--strict]` — + remove every linked worktree whose branch is already merged, workspace-wide, + including the workspace root's own worktrees. Worktrees are discovered from + `git worktree list`, so no on-disk location convention is imposed (see + ADR-0005). Nothing is deleted until the merge is proven — offline, by tree + equality, an empty diff, or ancestry, which also covers rebase and squash + merges that `git branch -d` refuses. Refusals (`SKIP_NOT_MERGED`, + `SKIP_DIRTY`, `SKIP_DETACHED`, `SKIP_DEFAULT`) are reported rather than fatal, + so the exit code is non-zero only when a mutation actually broke; `--strict` + promotes refusals too. `--force` overrides the merge-proof and clean-tree + gates. Remote branches are kept unless `--delete-remote` is passed. + Fast-forwards are left to `wspace update`, so the usual pairing is + `wspace sweep && wspace update`. See ADR-0006. - `wspace validate` — validate the manifest without touching any repository. - `wspace workspaces [--json]` — list discovered sub-workspaces with repo counts. `check`, `install`, and `update` accept `--workspace ` to scope diff --git a/docs/adr/0006-sweep-merged-worktrees.md b/docs/adr/0006-sweep-merged-worktrees.md new file mode 100644 index 0000000..f87431c --- /dev/null +++ b/docs/adr/0006-sweep-merged-worktrees.md @@ -0,0 +1,116 @@ +# ADR-0006: `wspace sweep` removes merged worktrees, without managing them + +- Status: accepted +- Date: 2026-09 +- Amends: ADR-0005 +- Related: ADR-0002 (conservative update policy), issue + [#153](https://github.com/wazootech/workspace-cli/issues/153) + +## Context + +ADR-0005 removed `wspace worktree add|list|remove` and the `worktreesDirectory` +key, on the grounds that the CLI should not own worktree _management_: worktrees +are managed with raw `git worktree`, and each workspace's `AGENTS.md` owns where +they live. + +That left a real gap. Reclaiming a workspace after a merge is a seven-step +ritual repeated per repository: + +1. merge the PR, +2. `git worktree remove worktrees//` — which on Windows usually + fails with `Filename too long`, +3. hand-purge the leftover directory, +4. `git worktree prune`, +5. `git branch -D `, +6. `git push origin --delete `, +7. `git merge --ff-only origin/main` in the canonical checkout. + +Every one of those steps is a chance to delete the wrong thing, and the +long-path failure in step 2 is not something `git worktree remove` can recover +from on its own. Repeating this by hand across ~40 repositories is how stale +worktrees accumulate. + +A sweep appears to violate ADR-0005. It does not, for one reason: **ADR-0005 +point 6 deliberately kept a read-only `git worktree list --porcelain` guard in +`wspace update`**, justified as _"safety, not management."_ ADR-0005's concern +was CLI ownership of worktree _creation and placement_, not awareness of them. A +sweep lives in exactly that carve-out. + +## Decision + +Add `wspace sweep`: remove every linked worktree in the workspace whose branch +is already merged. + +1. **Discovery comes from git, not from paths.** For each managed repository + (and the workspace root, which is not a manifest entry), run + `git worktree list --porcelain` and treat every entry after the first — the + main worktree — as a candidate. No directory is globbed and no location + convention is imposed or assumed. Branch names are read from git, never + inferred from the worktree leaf name, which routinely differs + (`world-id-hard-cutover`, `feat/frontmatter-fields`). + +2. **A real git checkout is required before any git command runs.** Without this + guard, a non-repo directory in the repositories tree (e.g. a + `repos/worktrees/` directory) makes `git -C worktree list` walk up and + report an _ancestor_ repository's worktrees, leaking root state into a child + repository's sweep. This mirrors the `.git` guard `collectStatus` uses. + +3. **A merge must be proven, offline, before anything is deleted.** The proof + ladder is cheapest-first: tree equality, then an empty diff, then zero + commits ahead of the baseline. These tiers are what authorise cleanup of + rebase and squash merges, whose commits have different SHAs than anything on + the default branch and which `git branch -d` refuses to delete. + +4. **The baseline is `origin/`** when that ref exists, falling back to + the local default branch. This is load-bearing: immediately after a merge the + canonical checkout is usually still _behind_ `origin/`, so proving + against the local ref would refuse cleanup in exactly the case the command + exists for. + +5. **Two gates refuse to act**, each overridable with `--force`: the merge proof + (gate 1) and a clean `git status --porcelain` in the worktree, untracked + files included (gate 2). The default branch and the main worktree are never + targets at all, and detached-HEAD worktrees are skipped because they have no + branch to prove. + +6. **`--delete-remote` is opt-in.** A sweep must never silently delete a remote + ref; local cleanup alone reports `SKIPPED_REMOTE`. + +7. **Refusals are reported, not fatal.** The exit code is non-zero only when a + mutation actually broke (`FAILED`), matching `update`'s conservative-update + tone from ADR-0002 rather than `check`'s stricter contract. `--strict` + promotes refusals for use in scripts. + +8. **`--dry-run` proves but does not mutate or reach the network.** Unlike a + naive preview, it still runs the proof ladder, so it surfaces the refusal a + real run would produce rather than exiting 0 with "proof unavailable". + +9. **When `git worktree remove` fails, the directory is purged by escalating + strategies** (`Deno.remove` recursive, then a Windows verbatim `\\?\` path, + then an empty-source `robocopy /MIR` and a verbatim `rmdir`; `rm -rf` on + POSIX) and the worktree is pruned. The strategy that worked is reported. + Mutations happen in order: remove, prune, `branch -D`, optional remote + delete. + +## Consequences + +- Reclaiming a merged branch becomes one command instead of seven, workspace + wide, and is safe to run unattended: it acts only on proven merges and clean + trees, and degrades to a reported refusal otherwise. +- The CLI still does not create, place, or name worktrees. A workspace remains + free to keep them anywhere, and ADR-0005's removal of + `worktree add|list|remove` and the `worktreesDirectory` key stands. +- The sweep surface is read-mostly discovery plus a narrow, well-gated delete. + This is the "safety, not management" line ADR-0005 drew, now applied to + removal instead of only to `update`'s fast-forward guard. +- `wspace sweep` does not fast-forward the canonical checkout. That stays the + job of `wspace update`, which already does it under ADR-0002, so the two + commands compose instead of overlapping: `wspace sweep && wspace update`. +- Merged branches that never had a worktree are out of scope; the command acts + on linked worktrees, which is where the worktree-isolation pattern leaves its + residue. +- The remote-proof tier (`gh pr view --json state`) is deliberately + **not** implemented. The offline tiers cover the rebase/squash cases that + plain `git branch -d` mishandles, and keeping the common path free of network + calls keeps a workspace-wide sweep fast. Revisit if proof coverage proves + insufficient in practice. diff --git a/skills/wspace/references/COMMANDS.md b/skills/wspace/references/COMMANDS.md index d57bea6..8047d68 100644 --- a/skills/wspace/references/COMMANDS.md +++ b/skills/wspace/references/COMMANDS.md @@ -66,17 +66,18 @@ directory defaults. ## Commands -| Command | Purpose | -| :------------------------------------------------------------------------------------------------------------ | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `wspace check [--json] [--workspace ]` | Read-only baseline check. Reports `CLEAN`, `DIRTY`, `FEATURE_CLEAN`, `DIVERGED`, `UNKNOWN`, `MISSING`, `UNMANAGED` states. A git workspace root is reported first as `(workspace root)`; untracked files there never count as dirty. Omitted when scoped with `--workspace`. | -| `wspace init [--host ] [--owner ] []` | One-time scaffold for an empty directory: writes a fresh `workspace.json` (schema v4) with optional host/owner and seeded shorthand entries, and creates the standard `repos/` directory. Fails closed if any manifest already exists. | -| `wspace install [] [--json] [--workspace ] [--dry-run]` | Clone missing repos, converging in one invocation: each pass re-resolves the tree, so newly detected sub-workspaces bootstrap without reruns. Scoped targets stay single-pass. Fresh clones lack gitignored files and repo setup. `i` is an alias. | -| `wspace add [] [--url ] [--name ] [--as-workspace] [--create] [--visibility ]` | Append a manifest entry: shorthand string, or object via `--url`. `--as-workspace` places the entry in the `workspaces[]` array. Surgical edit (comments preserved). GitHub shorthands probed with `gh`; `--create` makes a missing repo first (default private). Never clones; run `install` after. | -| `wspace remove ` | Delete the manifest entry by effective name. Surgical edit; local checkouts are never deleted. | -| `wspace path [--json]` | Fuzzy-find a workspace directory (repo or sub-workspace). Use in command substitution: `cd "$(wspace path workspace-cli)"`. | -| `wspace update [--json] [--workspace ] [--dry-run]` | Fetch remotes and fast-forward only clean default branches, including the workspace root's own checkout when it is a git repository (reported first; untracked workspace content never marks it dirty). Omitted when scoped with `--workspace`. | -| `wspace workspaces [--json]` | List discovered sub-workspaces with repo counts. | -| `wspace validate` | Validate the manifest without touching any repository. | +| Command | Purpose | +| :------------------------------------------------------------------------------------------------------------ | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `wspace check [--json] [--workspace ]` | Read-only baseline check. Reports `CLEAN`, `DIRTY`, `FEATURE_CLEAN`, `DIVERGED`, `UNKNOWN`, `MISSING`, `UNMANAGED` states. A git workspace root is reported first as `(workspace root)`; untracked files there never count as dirty. Omitted when scoped with `--workspace`. | +| `wspace init [--host ] [--owner ] []` | One-time scaffold for an empty directory: writes a fresh `workspace.json` (schema v4) with optional host/owner and seeded shorthand entries, and creates the standard `repos/` directory. Fails closed if any manifest already exists. | +| `wspace install [] [--json] [--workspace ] [--dry-run]` | Clone missing repos, converging in one invocation: each pass re-resolves the tree, so newly detected sub-workspaces bootstrap without reruns. Scoped targets stay single-pass. Fresh clones lack gitignored files and repo setup. `i` is an alias. | +| `wspace add [] [--url ] [--name ] [--as-workspace] [--create] [--visibility ]` | Append a manifest entry: shorthand string, or object via `--url`. `--as-workspace` places the entry in the `workspaces[]` array. Surgical edit (comments preserved). GitHub shorthands probed with `gh`; `--create` makes a missing repo first (default private). Never clones; run `install` after. | +| `wspace remove ` | Delete the manifest entry by effective name. Surgical edit; local checkouts are never deleted. | +| `wspace path [--json]` | Fuzzy-find a workspace directory (repo or sub-workspace). Use in command substitution: `cd "$(wspace path workspace-cli)"`. | +| `wspace update [--json] [--workspace ] [--dry-run]` | Fetch remotes and fast-forward only clean default branches, including the workspace root's own checkout when it is a git repository (reported first; untracked workspace content never marks it dirty). Omitted when scoped with `--workspace`. | +| `wspace sweep [--dry-run] [--json] [--delete-remote] [--force] [--strict] [--workspace ]` | Remove every linked worktree whose branch is already merged, workspace-wide (including the root's own worktrees). Discovery is via `git worktree list`, not paths, so no location convention is imposed. Deletes only after an offline merge proof (tree equality, empty diff, or ancestry), which covers rebase/squash merges `git branch -d` refuses. Refusals are reported, not fatal: non-zero only on `FAILED`, or on any `SKIP_*` with `--strict`. `--force` overrides the merge-proof and clean-tree gates; remote branches survive without `--delete-remote`. See ADR-0006. | +| `wspace workspaces [--json]` | List discovered sub-workspaces with repo counts. | +| `wspace validate` | Validate the manifest without touching any repository. | All paths resolve relative to the directory containing the manifest. When running raw git, use the absolute `"$PWD/..."` form (e.g. diff --git a/src/cli-options.ts b/src/cli-options.ts index d4a3cd0..0067204 100644 --- a/src/cli-options.ts +++ b/src/cli-options.ts @@ -14,4 +14,10 @@ export interface CliOptions { positional: string[]; workspace?: string; asWorkspace?: boolean; + /** sweep: also delete origin/ after a proven merge. */ + deleteRemote?: boolean; + /** sweep: override the merge-proof and clean-tree gates. */ + force?: boolean; + /** sweep: treat refusals as failures, not just reported rows. */ + strict?: boolean; } diff --git a/src/cli.ts b/src/cli.ts index 10b571d..0da191e 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -15,6 +15,7 @@ import * as initCmd from "./commands/init.ts"; import * as installCmd from "./commands/install.ts"; import * as pathCmd from "./commands/path.ts"; import * as removeCmd from "./commands/remove.ts"; +import * as sweepCmd from "./commands/sweep.ts"; import * as updateCmd from "./commands/update.ts"; import * as validateCmd from "./commands/validate.ts"; import * as workspacesCmd from "./commands/workspaces.ts"; @@ -27,6 +28,7 @@ const COMMANDS = [ "remove", "path", "update", + "sweep", "workspaces", "validate", ]; @@ -49,6 +51,8 @@ wspace check [--json] [--workspace ] wspace remove wspace path [--json] wspace update [--json] [--workspace ] [--dry-run] + wspace sweep [--dry-run] [--json] [--delete-remote] [--force] [--strict] + [--workspace ] wspace workspaces [--json] wspace validate @@ -64,8 +68,17 @@ Options: --json Machine-readable output --dry-run Preview write operations without modifying files or running network calls --workspace Scope command to a specific sub-workspace (by name) + --delete-remote sweep: also delete origin/ (default: keep remotes) + --force sweep: override the merge-proof and clean-tree gates + --strict sweep: exit non-zero on refusals, not only on failures --as-workspace add: place the entry in the workspaces array and require a child manifest +Sweep Command: + sweep Remove every linked worktree whose branch is already + merged, workspace-wide. Discovers worktrees from + git (no on-disk convention) and only deletes after + proving the merge. Refusals are reported, not fatal. + Path Command: path Fuzzy-find a workspace directory (repo, sub-workspace). Use in command substitution: cd "$(wspace path workspace-cli)" @@ -76,7 +89,17 @@ Sub-workspaces: function parseCliArgs(args: string[]): CliOptions { const parsed = parseArgs(args, { - boolean: ["help", "json", "stale", "dry-run", "create", "as-workspace"], + boolean: [ + "help", + "json", + "stale", + "dry-run", + "create", + "as-workspace", + "delete-remote", + "force", + "strict", + ], string: [ "manifest", "workspace", @@ -114,6 +137,9 @@ function parseCliArgs(args: string[]): CliOptions { positional: positional.slice(1), workspace: parsed.workspace, asWorkspace: parsed["as-workspace"] ?? false, + deleteRemote: parsed["delete-remote"] ?? false, + force: parsed.force ?? false, + strict: parsed.strict ?? false, }; } @@ -175,6 +201,8 @@ export async function run(args: string[]): Promise { return await pathCmd.run(opts, resolvedManifest, paths); case "update": return await updateCmd.run(opts, resolvedManifest, paths, g); + case "sweep": + return await sweepCmd.run(opts, resolvedManifest, paths, g); default: return 2; } diff --git a/src/commands/sweep.ts b/src/commands/sweep.ts new file mode 100644 index 0000000..3cb7a20 --- /dev/null +++ b/src/commands/sweep.ts @@ -0,0 +1,33 @@ +import type { GitRunner } from "@/git.ts"; +import type { ManifestPaths } from "@/manifest.ts"; +import type { CliOptions } from "@/shared.ts"; +import { printRows, scopeManifest } from "@/shared.ts"; +import { runSweep } from "@/sweep.ts"; +import { isSweepFailure } from "@/types.ts"; +import type { WorkspaceManifest } from "@/types.ts"; + +export async function run( + opts: CliOptions, + manifest: WorkspaceManifest, + paths: ManifestPaths, + g: GitRunner, +): Promise { + const scoped = scopeManifest(opts, manifest); + const rows = await runSweep(g, scoped, paths, { + dryRun: opts.dryRun, + deleteRemote: opts.deleteRemote, + force: opts.force, + // Scoped runs leave the root out: sweep only what was asked for. + includeRoot: opts.workspace === undefined, + }); + printRows(rows, opts.json); + // Refusals are reported, not fatal (matching `update`); only a mutation that + // actually broke is non-zero. `--strict` promotes refusals too. + const failed = rows.some(isSweepFailure); + // SKIPPED_REMOTE is a successful local cleanup with the remote ref kept by + // design, so it is not a refusal. + const refused = rows.some((row) => + row.kind.startsWith("SKIP_") && row.kind !== "SKIPPED_REMOTE" + ); + return failed || (opts.strict && refused) ? 1 : 0; +} diff --git a/src/git.ts b/src/git.ts index 0cd4043..9b97cc5 100644 --- a/src/git.ts +++ b/src/git.ts @@ -157,3 +157,103 @@ export async function clone( ): Promise { return await g.run(["clone", url, dir]); } + +/** One entry from `git worktree list --porcelain`. */ +export interface WorktreeEntry { + path: string; + head: string; + /** Branch name, or undefined for a detached-HEAD worktree. */ + branch?: string; +} + +/** + * Parse `git worktree list --porcelain`. The first entry is always the main + * worktree; callers treat every later entry as a candidate. Discovery comes + * from git rather than from directory layout so no on-disk convention is + * imposed (see ADR-0005). + */ +export async function worktreeList( + g: GitRunner, + cwd: string, +): Promise { + const result = await g.run(["worktree", "list", "--porcelain"], cwd); + if (result.code !== 0) return []; + const entries: WorktreeEntry[] = []; + let current: WorktreeEntry | undefined; + for (const raw of result.stdout.split("\n")) { + const line = raw.trim(); + if (line === "") { + current = undefined; + continue; + } + if (line.startsWith("worktree ")) { + current = { path: line.slice("worktree ".length), head: "" }; + entries.push(current); + continue; + } + if (!current) continue; + if (line.startsWith("HEAD ")) { + current.head = line.slice("HEAD ".length); + } else if (line.startsWith("branch refs/heads/")) { + current.branch = line.slice("branch refs/heads/".length); + } else if (line === "detached") { + current.branch = undefined; + } + } + return entries; +} + +/** Resolve the tree object a ref points at, or undefined when it does not exist. */ +export async function treeHash( + g: GitRunner, + cwd: string, + ref: string, +): Promise { + const result = await g.run( + ["rev-parse", "--verify", "--quiet", `${ref}^{tree}`], + cwd, + ); + return result.code === 0 && result.stdout ? result.stdout : undefined; +} + +/** True when the two refs have no diff between them. */ +export async function diffQuiet( + g: GitRunner, + cwd: string, + a: string, + b: string, +): Promise { + return (await g.run(["diff", "--quiet", a, b], cwd)).code === 0; +} + +/** Number of commits in a rev-list range, or undefined when unresolvable. */ +export async function revListCount( + g: GitRunner, + cwd: string, + range: string, +): Promise { + const result = await g.run(["rev-list", "--count", range], cwd); + if (result.code !== 0 || !result.stdout) return undefined; + const count = Number(result.stdout.trim()); + return Number.isNaN(count) ? undefined : count; +} + +/** Delete a local branch. `force` uses -D so rewritten merges are removable. */ +export async function deleteBranch( + g: GitRunner, + cwd: string, + branch: string, + force = true, +): Promise { + return (await g.run(["branch", force ? "-D" : "-d", branch], cwd)).code === 0; +} + +/** Delete a remote-tracking branch on the given remote. */ +export async function deleteRemoteBranch( + g: GitRunner, + cwd: string, + remote: string, + branch: string, +): Promise { + return (await g.run(["push", remote, "--delete", branch], cwd)).code === 0; +} diff --git a/src/purge.ts b/src/purge.ts new file mode 100644 index 0000000..d895fe3 --- /dev/null +++ b/src/purge.ts @@ -0,0 +1,122 @@ +/** + * Directory purge used as the fallback when `git worktree remove` fails. + * + * On Windows, removing a worktree whose path exceeds MAX_PATH fails with + * "Filename too long" and leaves the directory (and its `.git` file) behind, + * so the worktree stays registered and the branch stays locked. Git's own + * removal cannot recover from this; the directory has to go first. + * + * Strategies are tried in increasing order of intrusiveness, and the one that + * actually worked is reported so callers and users can see what happened. + */ + +/** Convert a path to a Windows verbatim (`\\?\`) path, bypassing MAX_PATH. */ +function verbatim(path: string): string { + if (path.startsWith("\\\\?\\")) return path; + if (path.startsWith("\\\\")) return `\\\\?\\UNC\\${path.slice(2)}`; + return `\\\\?\\${path}`; +} + +async function exists(path: string): Promise { + try { + await Deno.stat(path); + return true; + } catch { + return false; + } +} + +/** + * Mirror an empty directory over `target` with robocopy, which deletes + * long-path contents the Win32 APIs refuse to touch, then rmdir the shell. + * The empty source is created under the OS temp dir, not beside the target. + */ +async function robocopyPurge(target: string): Promise { + const empty = await Deno.makeTempDir({ prefix: "wspace-purge-" }); + try { + // robocopy exit codes 0-7 are success; 8+ are real failures. + const copy = await new Deno.Command("robocopy", { + args: [ + empty, + target, + "/MIR", + "/NFL", + "/NDL", + "/NJH", + "/NP", + "/R:1", + "/W:1", + ], + stdout: "null", + stderr: "null", + }).output(); + if (copy.success === false && copy.code > 7) return false; + } catch { + return false; + } finally { + try { + await Deno.remove(empty, { recursive: true }); + } catch { + // The empty staging dir is disposable; a leftover temp dir must not + // fail the purge that already succeeded. + } + } + try { + await new Deno.Command("rmdir", { + args: [verbatim(target)], + stdout: "null", + stderr: "null", + }).output(); + } catch { + // Fall through to the existence check below. + } + return !(await exists(target)); +} + +/** + * Delete `dir` and everything under it, returning the strategy that worked. + * Returns undefined when the directory could not be removed, so callers can + * report a failure rather than assume success. + */ +export async function purgeDirectory( + dir: string, +): Promise { + // Strategy 1: a plain recursive delete. On non-Windows platforms, and for + // most Windows paths, this is all that is needed. + try { + await Deno.remove(dir, { recursive: true }); + return "recursive delete"; + } catch { + // Fall through to the long-path strategies. + } + + if (Deno.build.os === "windows") { + // Strategy 2: the verbatim prefix opts out of MAX_PATH normalisation. + try { + await Deno.remove(verbatim(dir), { recursive: true }); + return "verbatim recursive delete"; + } catch { + // Fall through to robocopy. + } + + // Strategy 3: robocopy mirrors the tree away, then rmdir removes the + // now-empty directory through the verbatim path. + if (await robocopyPurge(dir)) { + return "robocopy mirror"; + } + return undefined; + } + + // POSIX last resort: rm -rf tolerates paths Deno refuses to resolve. + try { + const rm = await new Deno.Command("rm", { + args: ["-rf", dir], + stdout: "null", + stderr: "null", + }).output(); + if (rm.success) return "rm -rf"; + } catch { + // Report failure below. + } + return undefined; +} diff --git a/src/sweep.ts b/src/sweep.ts new file mode 100644 index 0000000..010dcff --- /dev/null +++ b/src/sweep.ts @@ -0,0 +1,252 @@ +import type { GitRunner } from "./git.ts"; +import { + deleteBranch, + deleteRemoteBranch, + diffQuiet, + fetch, + hasRef, + isDirty, + revListCount, + treeHash, + type WorktreeEntry, + worktreeList, +} from "./git.ts"; +import { resolveRepositoryPath } from "./manifest-paths.ts"; +import { purgeDirectory } from "./purge.ts"; +import { ROOT_LABEL, type SweepAction } from "./types.ts"; +import type { RepositoryEntry } from "./types.ts"; +import { inspectRepo } from "./repo-inspect.ts"; + +export interface SweepOptions { + dryRun?: boolean; + /** Also delete `origin/`. Off by default: a sweep must never + * silently delete remote refs. */ + deleteRemote?: boolean; + /** Override both safety gates. */ + force?: boolean; + /** Omit the workspace root when scoping to a sub-workspace. */ + includeRoot?: boolean; +} + +/** How a merge was proven, for the human-readable detail column. */ +interface Proof { + detail: string; +} + +/** + * Prove that `branch`'s work is already contained in `baseline`. + * + * The ladder is cheapest-first and entirely offline. Tree equality and an + * empty diff are what authorise cleanup of rebase/squash merges, whose commits + * have different SHAs than anything on the default branch and which + * `git branch -d` refuses to delete. + */ +async function proveMerged( + g: GitRunner, + cwd: string, + baseline: string, + branch: string, +): Promise { + const baselineTree = await treeHash(g, cwd, baseline); + const branchTree = await treeHash(g, cwd, branch); + if (baselineTree !== undefined && baselineTree === branchTree) { + return { detail: `trees identical (${baselineTree.slice(0, 7)})` }; + } + if (await diffQuiet(g, cwd, baseline, branch)) { + return { detail: `no diff against ${baseline}` }; + } + const ahead = await revListCount(g, cwd, `${baseline}..${branch}`); + if (ahead === 0) { + return { detail: `contained in ${baseline}` }; + } + return undefined; +} + +/** + * Choose the baseline a merge is proven against. + * + * This is load-bearing. Right after a merge the canonical checkout is usually + * still behind `origin/`, so proving against the local default ref + * would refuse cleanup in exactly the case the command exists for. + */ +async function resolveBaseline( + g: GitRunner, + cwd: string, + defaultBranchName: string, +): Promise { + const remote = `origin/${defaultBranchName}`; + return await hasRef(g, cwd, `refs/remotes/${remote}`) + ? remote + : defaultBranchName; +} + +/** Remove the worktree, falling back to a directory purge when git refuses. */ +async function removeWorktree( + g: GitRunner, + repoPath: string, + entry: WorktreeEntry, +): Promise { + const result = await g.run(["worktree", "remove", entry.path], repoPath); + if (result.code === 0) return undefined; + const strategy = await purgeDirectory(entry.path); + if (strategy === undefined) { + return result.stderr || "worktree remove failed"; + } + // The registration still exists; prune drops it now the tree is gone. + await g.run(["worktree", "prune"], repoPath); + return undefined; +} + +async function sweepWorktree( + g: GitRunner, + name: string, + repoPath: string, + entry: WorktreeEntry, + baseline: string, + opts: Required>, +): Promise { + const branch = entry.branch; + + if (!branch) { + return { + kind: "SKIP_DETACHED", + name, + worktree: entry.path, + detail: "no branch to prove", + }; + } + // Narrowed to a definite branch, so every row below carries a branch name. + const base = { name, branch, worktree: entry.path }; + + if (!opts.force && await isDirty(g, entry.path)) { + return { + kind: "SKIP_DIRTY", + ...base, + detail: "uncommitted or untracked changes", + }; + } + + const proof = await proveMerged(g, repoPath, baseline, branch); + if (!proof && !opts.force) { + return { + kind: "SKIP_NOT_MERGED", + ...base, + detail: `no merge proof against ${baseline}`, + }; + } + const detail = proof?.detail ?? "forced (no merge proof)"; + + if (opts.dryRun) { + return { kind: "WOULD_REMOVE", ...base, detail }; + } + + const removalError = await removeWorktree(g, repoPath, entry); + if (removalError !== undefined) { + return { kind: "FAILED", ...base, detail: removalError }; + } + + if (!(await deleteBranch(g, repoPath, branch))) { + return { + kind: "FAILED", + ...base, + detail: "worktree removed but branch delete failed", + }; + } + + if (!opts.deleteRemote) { + return { kind: "SKIPPED_REMOTE", ...base, detail }; + } + if (await deleteRemoteBranch(g, repoPath, "origin", branch)) { + return { kind: "REMOVED", ...base, detail }; + } + return { + kind: "FAILED", + ...base, + detail: "local cleanup done but origin/ delete failed", + }; +} + +/** + * Plan or run a sweep for one repository. Returns no rows when the path is not + * a git checkout (the workspace root may legitimately be a plain directory) or + * when it has no candidate worktrees. + */ +async function sweepRepo( + g: GitRunner, + name: string, + repoPath: string, + opts: Required>, +): Promise { + // Guard on a real checkout before running git. Without this, `git -C + // worktree list` on a non-repo directory (e.g. repos/worktrees/) silently + // walks up and reports an ancestor repository's worktrees. + const inspection = await inspectRepo(g, repoPath, { ignoreUntracked: true }); + if (!inspection.isGit) return []; + + const defaultBranchName = inspection.defaultBranch; + if (!defaultBranchName) { + return [{ kind: "SKIP_NO_DEFAULT", name, detail: "no origin/HEAD" }]; + } + + const worktrees = await worktreeList(g, repoPath); + // The first entry is the main worktree (the canonical checkout itself), + // which is never a candidate. + const candidates = worktrees.slice(1); + if (candidates.length === 0) return []; + + // A dry run must not touch the network; a real run needs fresh refs to + // prove against. + if (!opts.dryRun && !(await fetch(g, repoPath))) { + return [{ kind: "FAILED", name, detail: "fetch failed" }]; + } + + const baseline = await resolveBaseline(g, repoPath, defaultBranchName); + const rows: SweepAction[] = []; + for (const entry of candidates) { + if (entry.branch === defaultBranchName) { + // A default branch checked out in a linked worktree is never a target: + // deleting it would rewrite the checkout update fast-forwards. + rows.push({ + kind: "SKIP_DEFAULT", + name, + branch: entry.branch, + worktree: entry.path, + detail: "default branch is never a sweep target", + }); + continue; + } + rows.push(await sweepWorktree(g, name, repoPath, entry, baseline, opts)); + } + return rows; +} + +export async function runSweep( + g: GitRunner, + manifest: { repositories: RepositoryEntry[] }, + paths: { + root: string; + repositoriesDirectory: string; + workspacesDirectory?: string; + }, + { + dryRun = false, + deleteRemote = false, + force = false, + includeRoot = true, + }: SweepOptions = {}, +): Promise { + const opts = { dryRun, force, deleteRemote }; + const rows: SweepAction[] = []; + + // The workspace root carries the manifest's own worktrees, which are not + // reachable through any manifest entry, so it is swept like any other repo. + if (includeRoot) { + rows.push(...await sweepRepo(g, ROOT_LABEL, paths.root, opts)); + } + + for (const repository of manifest.repositories) { + const repoPath = resolveRepositoryPath(repository, paths); + rows.push(...await sweepRepo(g, repository.name, repoPath, opts)); + } + return rows; +} diff --git a/src/types.ts b/src/types.ts index af6358f..cbdf681 100644 --- a/src/types.ts +++ b/src/types.ts @@ -19,6 +19,40 @@ export type UpdateAction = } | { kind: "FAST_FORWARD"; name: string; commits: number }; +/** + * One row of a `wspace sweep` plan or result. A row describes a single + * candidate worktree in a single repository; a repository with no candidate + * worktrees produces no rows at all. + */ +export type SweepAction = + | { + kind: "REMOVED" | "WOULD_REMOVE"; + name: string; + branch: string; + worktree: string; + /** How the merge was proven, or why --force was needed. */ + detail?: string; + } + | { + kind: + | "SKIP_NOT_MERGED" + | "SKIP_DIRTY" + | "SKIP_DETACHED" + | "SKIP_DEFAULT" + | "SKIP_NO_DEFAULT" + | "FAILED" + | "SKIPPED_REMOTE"; + name: string; + branch?: string; + worktree?: string; + detail?: string; + }; + +/** A mutation that broke, as opposed to a refusal to act. */ +export function isSweepFailure(action: SweepAction): boolean { + return action.kind === "FAILED"; +} + export type RepoState = | "MISSING" | "INVALID" diff --git a/tests/sweep_test.ts b/tests/sweep_test.ts new file mode 100644 index 0000000..41f452b --- /dev/null +++ b/tests/sweep_test.ts @@ -0,0 +1,491 @@ +import { assert, assertEquals } from "@std/assert"; +import { exists } from "@std/fs"; +import { join } from "@std/path"; +import { type GitRunner, SystemGit, worktreeList } from "@/git.ts"; +import { run } from "@/cli.ts"; +import { runSweep } from "@/sweep.ts"; + +const g = new SystemGit(); + +async function configure(dir: string): Promise { + await g.run(["config", "user.email", "wspace-test@example.com"], dir); + await g.run(["config", "user.name", "WSC Test"], dir); + await g.run(["config", "commit.gpgsign", "false"], dir); +} + +/** + * Build a repo with a bare origin, a seed checkout on `main`, and a clone at + * `/` whose origin/HEAD points at main. Returns the clone path. + */ +async function makeRepoWithMain(dir: string, name: string): Promise { + const origin = join(dir, `${name}.git`); + const seed = join(dir, `${name}-seed`); + assert((await g.run(["init", "--bare", origin])).code === 0, "init bare"); + assert((await g.run(["init", seed])).code === 0, "init seed"); + await configure(seed); + assert((await g.run(["checkout", "-b", "main"], seed)).code === 0); + await Deno.writeTextFile(join(seed, "a.txt"), "one\n"); + await g.run(["add", "."], seed); + assert((await g.run(["commit", "-m", "seed"], seed)).code === 0); + assert((await g.run(["push", origin, "main"], seed)).code === 0); + assert( + (await g.run(["symbolic-ref", "HEAD", "refs/heads/main"], origin)).code === + 0, + "set origin HEAD", + ); + const work = join(dir, name); + assert((await g.run(["clone", origin, work])).code === 0, "clone"); + await configure(work); + return work; +} + +/** Add a linked worktree on a new branch at `/wt-`. */ +async function addWorktree( + work: string, + dir: string, + branch: string, +): Promise { + const wt = join(dir, `wt-${branch.replaceAll("/", "-")}`); + assert( + (await g.run(["worktree", "add", "-b", branch, wt], work)).code === 0, + `add worktree ${branch}`, + ); + await configure(wt); + return wt; +} + +/** + * Land the worktree's commit on main through the seed repo the way a rebase + * merge does: the same tree, a different SHA. `git branch -d` refuses this, + * so only tree equality can authorise cleanup. + */ +async function rebaseMerge( + dir: string, + name: string, + content: string, +): Promise { + const seed = join(dir, `${name}-seed`); + await Deno.writeTextFile(join(seed, "a.txt"), content); + await g.run(["add", "."], seed); + assert((await g.run(["commit", "-m", "rebase merge"], seed)).code === 0); + assert( + (await g.run(["push", join(dir, `${name}.git`), "main"], seed)).code === 0, + ); +} + +function pathsFor(dir: string) { + return { root: dir, repositoriesDirectory: dir }; +} + +/** + * Git prints worktree paths with forward slashes on Windows while @std/path + * produces backslashes; compare in one form. + */ +function slash(path: string): string { + return path.replaceAll("\\", "/"); +} + +async function removeTempDir(dir: string): Promise { + for (let attempt = 0; attempt < 6; attempt++) { + try { + await Deno.remove(dir, { recursive: true }); + return; + } catch { + await new Promise((resolve) => setTimeout(resolve, 150 * (attempt + 1))); + } + } +} + +Deno.test("sweep --dry-run plans removals and changes nothing", async () => { + const dir = await Deno.makeTempDir(); + try { + const work = await makeRepoWithMain(dir, "a"); + const wt = await addWorktree(work, dir, "feat/merged"); + await Deno.writeTextFile(join(wt, "a.txt"), "feature\n"); + await g.run(["add", "."], wt); + await g.run(["commit", "-m", "feature"], wt); + await rebaseMerge(dir, "a", "feature\n"); + await g.run(["fetch", "--prune"], work); + + const before = await worktreeList(g, work); + const rows = await runSweep( + g, + { repositories: [{ name: "a", url: "u" }] }, + pathsFor(dir), + { dryRun: true }, + ); + assertEquals(rows.length, 1); + assertEquals(rows[0].kind, "WOULD_REMOVE"); + assertEquals(rows[0].name, "a"); + assertEquals(rows[0].branch, "feat/merged"); + assertEquals(slash(rows[0].worktree ?? ""), slash(wt)); + assertEquals( + await worktreeList(g, work), + before, + "dry-run must not remove the worktree", + ); + assert(await exists(wt), "dry-run must leave the tree on disk"); + assert( + (await g.run(["rev-parse", "--verify", "refs/heads/feat/merged"], work)) + .code === 0, + "dry-run must not delete the branch", + ); + } finally { + await removeTempDir(dir); + } +}); + +Deno.test("sweep removes a rebase-merged worktree that branch -d would refuse", async () => { + const dir = await Deno.makeTempDir(); + try { + const work = await makeRepoWithMain(dir, "a"); + const wt = await addWorktree(work, dir, "feat/rebased"); + await Deno.writeTextFile(join(wt, "a.txt"), "feature\n"); + await g.run(["add", "."], wt); + await g.run(["commit", "-m", "feature"], wt); + await rebaseMerge(dir, "a", "feature\n"); + await g.run(["fetch", "--prune"], work); + + const rows = await runSweep( + g, + { repositories: [{ name: "a", url: "u" }] }, + pathsFor(dir), + ); + assertEquals(rows.length, 1); + assertEquals(rows[0].kind, "SKIPPED_REMOTE"); + assertEquals(rows[0].name, "a"); + assertEquals(rows[0].branch, "feat/rebased"); + assertEquals(slash(rows[0].worktree ?? ""), slash(wt)); + assert( + (rows[0].detail ?? "").startsWith("trees identical"), + `expected a tree-equality proof, got: ${rows[0].detail}`, + ); + assertEquals((await worktreeList(g, work)).length, 1, "worktree removed"); + assertEquals(await exists(wt), false, "tree gone from disk"); + // rev-parse --verify exits non-zero (128) for a ref that is gone. + assert( + (await g.run(["rev-parse", "--verify", "refs/heads/feat/rebased"], work)) + .code !== 0, + "local branch deleted", + ); + // The default: remote refs survive. + assert( + (await g.run([ + "rev-parse", + "--verify", + "refs/remotes/origin/feat/rebased", + ], work)).code !== 0, + "remote branch must be kept without --delete-remote", + ); + } finally { + await removeTempDir(dir); + } +}); + +Deno.test("sweep --delete-remote removes the remote branch too", async () => { + const dir = await Deno.makeTempDir(); + try { + const work = await makeRepoWithMain(dir, "a"); + const wt = await addWorktree(work, dir, "feat/pushed"); + await Deno.writeTextFile(join(wt, "a.txt"), "feature\n"); + await g.run(["add", "."], wt); + await g.run(["commit", "-m", "feature"], wt); + assert((await g.run(["push", "origin", "feat/pushed"], wt)).code === 0); + await rebaseMerge(dir, "a", "feature\n"); + await g.run(["fetch", "--prune"], work); + + const rows = await runSweep( + g, + { repositories: [{ name: "a", url: "u" }] }, + pathsFor(dir), + { deleteRemote: true }, + ); + assertEquals(rows[0].kind, "REMOVED"); + assertEquals( + (await g.run(["ls-remote", "--heads", "origin", "feat/pushed"], work)) + .stdout, + "", + "remote branch deleted", + ); + } finally { + await removeTempDir(dir); + } +}); + +Deno.test("sweep refuses an unmerged branch", async () => { + const dir = await Deno.makeTempDir(); + try { + const work = await makeRepoWithMain(dir, "a"); + const wt = await addWorktree(work, dir, "feat/wip"); + await Deno.writeTextFile(join(wt, "a.txt"), "work in progress\n"); + await g.run(["add", "."], wt); + await g.run(["commit", "-m", "wip"], wt); + + const rows = await runSweep( + g, + { repositories: [{ name: "a", url: "u" }] }, + pathsFor(dir), + { dryRun: true }, + ); + assertEquals(rows[0].kind, "SKIP_NOT_MERGED"); + assert(await exists(wt), "unmerged worktree survives"); + } finally { + await removeTempDir(dir); + } +}); + +Deno.test("sweep refuses a merged-but-dirty worktree and untracked files survive", async () => { + const dir = await Deno.makeTempDir(); + try { + const work = await makeRepoWithMain(dir, "a"); + const wt = await addWorktree(work, dir, "feat/dirty"); + await Deno.writeTextFile(join(wt, "a.txt"), "feature\n"); + await g.run(["add", "."], wt); + await g.run(["commit", "-m", "feature"], wt); + await rebaseMerge(dir, "a", "feature\n"); + await g.run(["fetch", "--prune"], work); + // Uncommitted work in the worktree: gate 2 must refuse. + await Deno.writeTextFile(join(wt, "scratch.md"), "notes\n"); + + const rows = await runSweep( + g, + { repositories: [{ name: "a", url: "u" }] }, + pathsFor(dir), + ); + assertEquals(rows[0].kind, "SKIP_DIRTY"); + assert(await exists(join(wt, "scratch.md")), "untracked file survives"); + } finally { + await removeTempDir(dir); + } +}); + +Deno.test("sweep skips a detached-HEAD worktree", async () => { + const dir = await Deno.makeTempDir(); + try { + const work = await makeRepoWithMain(dir, "a"); + const wt = join(dir, "wt-detached"); + assert((await g.run(["worktree", "add", "--detach", wt], work)).code === 0); + + const rows = await runSweep( + g, + { repositories: [{ name: "a", url: "u" }] }, + pathsFor(dir), + ); + assertEquals(rows[0].kind, "SKIP_DETACHED"); + assert(await exists(wt), "detached worktree survives"); + } finally { + await removeTempDir(dir); + } +}); + +Deno.test("sweep never targets the default branch", async () => { + const dir = await Deno.makeTempDir(); + try { + const work = await makeRepoWithMain(dir, "a"); + const wt = join(dir, "wt-main"); + // Move the primary checkout off main so the default branch itself can be + // checked out in a linked worktree. + assert((await g.run(["checkout", "-b", "side"], work)).code === 0); + assert((await g.run(["worktree", "add", wt, "main"], work)).code === 0); + + const rows = await runSweep( + g, + { repositories: [{ name: "a", url: "u" }] }, + pathsFor(dir), + ); + assertEquals(rows[0].kind, "SKIP_DEFAULT"); + assert(await exists(wt), "default-branch worktree survives"); + assert( + (await g.run(["rev-parse", "--verify", "refs/heads/main"], work)).code === + 0, + "default branch survives", + ); + } finally { + await removeTempDir(dir); + } +}); + +Deno.test("sweep --force overrides both safety gates", async () => { + const dir = await Deno.makeTempDir(); + try { + const work = await makeRepoWithMain(dir, "a"); + const wt = await addWorktree(work, dir, "feat/unmerged-dirty"); + // Unmerged and dirty: both gates would refuse. + await Deno.writeTextFile(join(wt, "a.txt"), "unmerged work\n"); + await g.run(["add", "."], wt); + await g.run(["commit", "-m", "unmerged"], wt); + await Deno.writeTextFile(join(wt, "scratch.md"), "notes\n"); + + const rows = await runSweep( + g, + { repositories: [{ name: "a", url: "u" }] }, + pathsFor(dir), + { dryRun: true, force: true }, + ); + assertEquals(rows[0].kind, "WOULD_REMOVE"); + } finally { + await removeTempDir(dir); + } +}); + +Deno.test("sweep includes the workspace root's own worktrees", async () => { + const dir = await Deno.makeTempDir(); + try { + const rootDir = join(dir, "root"); + const work = await makeRepoWithMain(dir, "root"); + // The motivating case: the root repo carries a worktree that no manifest + // entry can reach. + await addWorktree(work, dir, "feat/root-work"); + Deno.renameSync(work, rootDir); + + const rows = await runSweep( + g, + { repositories: [] }, + { root: rootDir, repositoriesDirectory: join(dir, "repos") }, + { dryRun: true }, + ); + assertEquals(rows.length, 1); + assertEquals(rows[0].name, "(workspace root)"); + } finally { + await removeTempDir(dir); + } +}); + +Deno.test("sweep does not leak root worktrees through a non-repo repositories dir", async () => { + const dir = await Deno.makeTempDir(); + try { + // A root repo with a linked worktree... + const rootDir = join(dir, "root"); + const work = await makeRepoWithMain(dir, "root"); + await addWorktree(work, dir, "feat/root-only"); + Deno.renameSync(work, rootDir); + + // ...and a repos/ directory that exists but holds no git repo, mirroring + // the real workspace's repos/worktrees/. A git command run there without a + // .git guard would walk up and report the ROOT repo's worktrees. + await Deno.mkdir(join(dir, "repos", "worktrees"), { recursive: true }); + + const rows = await runSweep( + g, + { repositories: [] }, + { + root: join(dir, "repos", "worktrees"), + repositoriesDirectory: join(dir, "repos"), + }, + { dryRun: true }, + ); + assertEquals(rows, [], "non-repo root must yield no rows at all"); + } finally { + await removeTempDir(dir); + } +}); + +Deno.test("sweep produces no rows for a repository with no linked worktrees", async () => { + const dir = await Deno.makeTempDir(); + try { + await makeRepoWithMain(dir, "a"); + const rows = await runSweep( + g, + { repositories: [{ name: "a", url: "u" }] }, + pathsFor(dir), + { dryRun: true }, + ); + assertEquals(rows, []); + } finally { + await removeTempDir(dir); + } +}); + +Deno.test("sweep exits 0 for refusals and 1 for failures", async () => { + const dir = await Deno.makeTempDir(); + try { + const rootDir = join(dir, "root"); + const work = await makeRepoWithMain(dir, "root"); + await addWorktree(work, dir, "feat/wip2"); + await Deno.writeTextFile( + join(rootDir, "..", "wt-feat-wip2", "a.txt"), + "wip\n", + ); + const wt = join(dir, "wt-feat-wip2"); + await g.run(["add", "."], wt); + await g.run(["commit", "-m", "wip"], wt); + await Deno.writeTextFile( + join(rootDir, "workspace.json"), + JSON.stringify({ + schemaVersion: 4, + workspaceRoot: rootDir, + repositories: [], + }), + ); + await configure(rootDir); + await g.run(["add", "."], rootDir); + await g.run(["commit", "-m", "manifest"], rootDir); + await g.run(["push", "origin", "main"], rootDir); + + const manifestPath = join(rootDir, "workspace.json"); + // An unmerged branch is a refusal, not a failure. + assertEquals(await run(["sweep", "--manifest", manifestPath]), 0); + // --strict promotes refusals to a non-zero exit. + assertEquals( + await run(["sweep", "--strict", "--manifest", manifestPath]), + 1, + ); + // Nothing to sweep at all is still success. + assertEquals( + await run(["sweep", "--dry-run", "--manifest", manifestPath]), + 0, + ); + } finally { + await removeTempDir(dir); + } +}); + +Deno.test("sweep --json emits machine-readable rows", async () => { + const dir = await Deno.makeTempDir(); + try { + const work = await makeRepoWithMain(dir, "a"); + await addWorktree(work, dir, "feat/unmerged3"); + await Deno.writeTextFile( + join(work, "..", "wt-feat-unmerged3", "a.txt"), + "x\n", + ); + const wt = join(dir, "wt-feat-unmerged3"); + await g.run(["add", "."], wt); + await g.run(["commit", "-m", "x"], wt); + + const manifestPath = join(dir, "workspace.json"); + await Deno.writeTextFile( + manifestPath, + JSON.stringify({ + schemaVersion: 4, + workspaceRoot: dir, + repositoriesDirectory: ".", + repositories: [{ name: "a", url: join(dir, "a.git") }], + }), + ); + + const original = console.log; + let output = ""; + // deno-lint-ignore no-explicit-any + console.log = (...args: any[]) => { + output += args.map(String).join(" ") + "\n"; + }; + const code = await run([ + "sweep", + "--json", + "--dry-run", + "--manifest", + manifestPath, + ]); + console.log = original; + + assertEquals(code, 0); + const rows = JSON.parse(output); + assertEquals(rows[0].kind, "SKIP_NOT_MERGED"); + assertEquals(rows[0].branch, "feat/unmerged3"); + } finally { + await removeTempDir(dir); + } +}); + +export type { GitRunner };