From 073a75dd95f6926189e37b3bc7f73d91eab98628 Mon Sep 17 00:00:00 2001 From: Ethan Davidson <31261035+EthanThatOneKid@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:46:44 -0700 Subject: [PATCH] feat: add wspace sweep to remove merged feature worktrees workspace-wide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reclaiming a merged branch was a seven-step ritual repeated per repository, and step 2 (git worktree remove) usually fails on Windows with "Filename too long", leaving the tree registered and the branch locked. Across ~40 repos that is how stale worktrees accumulate. sweep discovers linked worktrees from `git worktree list --porcelain` rather than from directory layout, so no on-disk convention is imposed, and deletes only after proving the merge offline (tree equality, empty diff, or ancestry). Those tiers are what authorise rebase/squash merges, whose commits have different SHAs than anything on the default branch and which `git branch -d` refuses. The baseline is origin/ when present: right after a merge the canonical checkout is usually still behind it, so proving against the local ref would refuse cleanup in exactly the case this exists for. Refusals are reported rather than fatal, matching update's conservative tone, so the exit code is non-zero only when a mutation actually broke; --strict promotes refusals. Remote branches are kept unless --delete-remote is passed. ADR-0006 records the reconciliation with ADR-0005, which removed worktree management: sweep creates and places nothing, and acts within the same read-only-worktree "safety, not management" carve-out ADR-0005 kept in update. Closes #153 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- README.md | 13 + docs/adr/0006-sweep-merged-worktrees.md | 116 ++++++ skills/wspace/references/COMMANDS.md | 23 +- src/cli-options.ts | 6 + src/cli.ts | 30 +- src/commands/sweep.ts | 33 ++ src/git.ts | 100 +++++ src/purge.ts | 122 ++++++ src/sweep.ts | 252 ++++++++++++ src/types.ts | 34 ++ tests/sweep_test.ts | 491 ++++++++++++++++++++++++ 11 files changed, 1208 insertions(+), 12 deletions(-) create mode 100644 docs/adr/0006-sweep-merged-worktrees.md create mode 100644 src/commands/sweep.ts create mode 100644 src/purge.ts create mode 100644 src/sweep.ts create mode 100644 tests/sweep_test.ts 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 };