opencode2-cow-worktree 0.2.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -148,7 +148,11 @@ clone (default `"none"`):
148
148
  - `"none"`: a request for `cow` produces a Deep clone or fails. Never a
149
149
  shallow worktree.
150
150
  - `"git"`: on a non-CoW filesystem the tool may build a regular `git`
151
- worktree instead and report `mechanism: "git"`.
151
+ worktree instead and report `mechanism: "git"`. This works on opencode2
152
+ builds whose worktree create still accepts a `strategy` request
153
+ (2.0.2-era). From the projectID-era API onward (20260915 nightlies,
154
+ v2.0.3+) the create always runs the selected strategy and ignores the
155
+ field, so the non-CoW refusal surfaces and the fallback cannot engage.
152
156
 
153
157
  **`targetRoot`** — where `spawn_workspace` places the worktree. Unset (the
154
158
  default) means a sibling of the source, on the source's filesystem by
@@ -237,8 +241,10 @@ regardless of `fallback`. Only `spawn_workspace` consults the policy.
237
241
  - **"the target is on a different filesystem"** — set `worktree.directory` as
238
242
  shown above, or point `targetRoot` at the source's filesystem.
239
243
  - **`cow` fails on an ext4 or tmpfs project** — expected: that filesystem
240
- cannot clone. Use the `git` fallback for tool calls, or let the project use
241
- the built-in strategy.
244
+ cannot clone. On opencode2 builds from the projectID era (20260915
245
+ nightlies, v2.0.3+) the `git` fallback cannot be requested through the
246
+ create API, so the refusal is final; on 2.0.2-era builds the `fallback:
247
+ "git"` option produces a regular git worktree for tool calls.
242
248
  - **The plugin is stuck on an old version** — opencode2 caches the package
243
249
  under `~/.cache/opencode/node_modules/`. Remove the plugin's cache
244
250
  directory and restart: `rm -rf ~/.cache/opencode/node_modules/opencode2-cow-worktree`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode2-cow-worktree",
3
- "version": "0.2.0",
3
+ "version": "0.4.1",
4
4
  "description": "Copy-on-write worktree strategy for opencode2: a Deep clone of the whole working directory, ignored files included, so parallel agents start ready to run",
5
5
  "type": "module",
6
6
  "exports": {
package/src/clone.ts CHANGED
@@ -108,6 +108,13 @@ async function cloneInto(source: string, target: string, skip: string): Promise<
108
108
  await cloneInto(from, to, skip);
109
109
  } else if (kind === "symlink") {
110
110
  await symlink(await readlink(from), to);
111
+ } else if (kind === "special") {
112
+ // A socket, FIFO, or device node (e.g. a live daemon socket at a
113
+ // project root) cannot be reflinked — FICLONE fails with EOPNOTSUPP
114
+ // and would abort the whole clone. It is runtime state, not tree
115
+ // content, so the clone omits it; whatever created it in the source
116
+ // creates its own in the clone.
117
+ continue;
111
118
  } else {
112
119
  await reflinkFile(from, to);
113
120
  }
package/src/entry-kind.ts CHANGED
@@ -10,27 +10,47 @@
10
10
  */
11
11
 
12
12
  /** How a directory entry is materialised by the clone walker. */
13
- export type EntryKind = "file" | "directory" | "symlink";
13
+ export type EntryKind = "file" | "directory" | "symlink" | "special";
14
14
 
15
15
  /** The slice of a `readdir` Dirent this decision reads. */
16
16
  export interface EntryLike {
17
17
  isSymbolicLink(): boolean;
18
18
  isDirectory(): boolean;
19
19
  isFile(): boolean;
20
+ isSocket(): boolean;
21
+ isFIFO(): boolean;
22
+ isBlockDevice(): boolean;
23
+ isCharacterDevice(): boolean;
20
24
  }
21
25
 
22
26
  /** The slice of `lstat` this decision reads; `lstat` never follows a symlink. */
23
27
  export interface StatLike {
24
28
  isSymbolicLink(): boolean;
25
29
  isDirectory(): boolean;
30
+ isSocket(): boolean;
31
+ isFIFO(): boolean;
32
+ isBlockDevice(): boolean;
33
+ isCharacterDevice(): boolean;
26
34
  }
27
35
 
28
36
  export async function entryKind(entry: EntryLike, stat: () => Promise<StatLike>): Promise<EntryKind> {
29
37
  if (entry.isSymbolicLink()) return "symlink";
30
38
  if (entry.isDirectory()) return "directory";
31
39
  if (entry.isFile()) return "file";
40
+ if (isSpecial(entry)) return "special";
32
41
  const stats = await stat();
33
42
  if (stats.isSymbolicLink()) return "symlink";
34
43
  if (stats.isDirectory()) return "directory";
44
+ if (isSpecial(stats)) return "special";
35
45
  return "file";
36
46
  }
47
+
48
+ /** Sockets, FIFOs, and devices: runtime artifacts, never tree content. */
49
+ function isSpecial(s: {
50
+ isSocket(): boolean;
51
+ isFIFO(): boolean;
52
+ isBlockDevice(): boolean;
53
+ isCharacterDevice(): boolean;
54
+ }): boolean {
55
+ return s.isSocket() || s.isFIFO() || s.isBlockDevice() || s.isCharacterDevice();
56
+ }
package/src/plugin.ts CHANGED
@@ -4,6 +4,12 @@ import { probeCowCapability } from "./capability";
4
4
  import { fallbackPolicy, postCreateHooks, targetRoot } from "./config";
5
5
  import { deviceOf, isDirectory } from "./device";
6
6
  import { readMarkerFile, writeMarkerFile } from "./occupancy";
7
+ import { probeUncommitted } from "./uncommitted";
8
+ import { removeWorktree, runGit } from "./remove-tool";
9
+ import type {
10
+ RemoveWorktreeDeps,
11
+ RemoveWorktreeInput,
12
+ } from "./remove-tool";
7
13
  import { listCowWorktrees, spawnWorkspace } from "./tool";
8
14
  import type {
9
15
  FallbackPolicy,
@@ -78,7 +84,20 @@ const spawnWorkspaceOutput = {
78
84
  */
79
85
  const listWorktreesInput = {
80
86
  type: "object",
81
- properties: {},
87
+ properties: {
88
+ missing: {
89
+ type: "string",
90
+ enum: ["fail", "report", "prune"],
91
+ description:
92
+ "Policy for an inventory row whose directory is gone (stat ENOENT — a dangling " +
93
+ 'reference). "fail" (default) keeps the ADR 0003 contract: the list fails loudly. ' +
94
+ '"report" lists the row with missing: true and no createdAt. "prune" de-registers ' +
95
+ "the row through the worktree remove API and drops it from the listing; until " +
96
+ "upstream candidate 8 lands, a gone directory cannot be de-registered, so prune " +
97
+ "fails loudly naming candidate 8. A stat failure that is not ENOENT fails in " +
98
+ "every mode: it is a real anomaly, not a dangling row.",
99
+ },
100
+ },
82
101
  additionalProperties: false,
83
102
  } as const;
84
103
 
@@ -113,10 +132,17 @@ const listWorktreesOutput = {
113
132
  type: "string",
114
133
  description:
115
134
  "ISO 8601 timestamp of the directory's birthtime, falling back to its " +
116
- "mtime when the filesystem reports no birthtime.",
135
+ "mtime when the filesystem reports no birthtime. Absent on an entry " +
136
+ "reported with missing: true — there is no directory to stat.",
137
+ },
138
+ missing: {
139
+ type: "boolean",
140
+ description:
141
+ "Present and true only under missing: \"report\", for an inventory row " +
142
+ "whose directory no longer exists.",
117
143
  },
118
144
  },
119
- required: ["name", "directory", "strategy", "createdAt"],
145
+ required: ["name", "directory", "strategy"],
120
146
  additionalProperties: false,
121
147
  },
122
148
  },
@@ -125,6 +151,51 @@ const listWorktreesOutput = {
125
151
  additionalProperties: false,
126
152
  } as const;
127
153
 
154
+ /**
155
+ * `remove_worktree`'s input: exactly one of `name` or `directory` (validated
156
+ * in the execute path — the JSON Schema cannot express "exactly one of" two
157
+ * properties, so the schema leaves both optional and the tool refuses
158
+ * anything else). `force` is the same confirmation the TUI's remove carries:
159
+ * proceed despite unlanded or uncommitted work.
160
+ */
161
+ const removeWorktreeInput = {
162
+ type: "object",
163
+ properties: {
164
+ name: {
165
+ type: "string",
166
+ description: "The worktree's basename, as list_worktrees reports it.",
167
+ },
168
+ directory: {
169
+ type: "string",
170
+ description: "The worktree directory, as the worktree inventory records it.",
171
+ },
172
+ force: {
173
+ type: "boolean",
174
+ description:
175
+ "Remove even when the worktree holds unlanded commits or uncommitted files.",
176
+ },
177
+ },
178
+ additionalProperties: false,
179
+ } as const;
180
+
181
+ /**
182
+ * `remove_worktree`'s declared output, under the same rule as the other two
183
+ * tools: a returned `output` field must have a declared schema. Whether force
184
+ * was used is reported in the text content only — the structured result is
185
+ * the one fact every caller needs, the directory that is gone.
186
+ */
187
+ const removeWorktreeOutput = {
188
+ type: "object",
189
+ properties: {
190
+ directory: {
191
+ type: "string",
192
+ description: "The removed worktree directory, as the inventory recorded it.",
193
+ },
194
+ },
195
+ required: ["directory"],
196
+ additionalProperties: false,
197
+ } as const;
198
+
128
199
  /**
129
200
  * Binds `spawnWorkspace`'s seams to the live opencode2 context. The tool is
130
201
  * the layer that owns the strategy choice, so the capability probe and the
@@ -146,21 +217,27 @@ function liveDeps(
146
217
  return {
147
218
  probe: probeCowCapability,
148
219
  probeDevice: deviceOf,
149
- listWorktrees: () => ctx.worktree.list(),
220
+ // The projectID-era API (20260915 nightlies onward) requires the project
221
+ // id on every worktree call and no longer accepts a `location` query, so
222
+ // all three seams derive it from the plugin's own location context.
223
+ listWorktrees: () => ctx.worktree.list({ projectID: ctx.location.project.id }),
150
224
  isDirectory,
151
225
  createWorktree: (input) =>
152
226
  ctx.worktree.create({
153
- strategy: input.strategy,
154
- name: input.name,
155
- location: { directory: input.sourceDirectory },
227
+ projectID: ctx.location.project.id,
228
+ from: input.sourceDirectory,
156
229
  directory: input.parentDirectory,
230
+ name: input.name,
231
+ // Ignored by projectID-era binaries (the selected strategy wins);
232
+ // honored by the 2.0.2-era API, where the git fallback needs it.
233
+ strategy: input.strategy,
157
234
  }),
158
235
  createSession: async (directory, name) => {
159
236
  const session = await ctx.session.create({ title: name, location: { directory } });
160
237
  return session.id;
161
238
  },
162
239
  removeWorktree: (directory) =>
163
- ctx.worktree.remove({ directory, force: true }),
240
+ ctx.worktree.remove({ projectID: ctx.location.project.id, directory, force: true }),
164
241
  // `ctx.session.get` exists on the v2 runtime but is untyped in the
165
242
  // installed beta; types/opencode2-worktree.d.ts declares the verified
166
243
  // `{ sessionID }` shape and the structural record slice the occupancy
@@ -174,14 +251,32 @@ function liveDeps(
174
251
  };
175
252
  }
176
253
 
254
+ /**
255
+ * Whether a path exists — the dangling-row pre-check's live binding. ENOENT
256
+ * answers "no"; any other stat failure (a permission problem, an I/O error)
257
+ * propagates, because an unreadable directory is a real anomaly the caller
258
+ * must see, not a dangling row.
259
+ */
260
+ async function directoryPresent(directory: string): Promise<boolean> {
261
+ try {
262
+ await stat(directory);
263
+ return true;
264
+ } catch (cause) {
265
+ if ((cause as { code?: unknown }).code === "ENOENT") return false;
266
+ throw cause;
267
+ }
268
+ }
269
+
177
270
  /**
178
271
  * The opencode2 plugin module.
179
272
  *
180
273
  * `setup` registers the `cow` Strategy through the worktree seam, and the
181
274
  * `spawn_workspace` and `list_worktrees` tools through the tool seam.
182
- * Registering the Strategy also selects it as the Location default; opencode2's
183
- * registry still lets a caller name the built-in `git` strategy explicitly, so
184
- * this module does not touch that behavior.
275
+ * Registering the Strategy also selects it as the default; the projectID-era
276
+ * create API has no per-request strategy field, so the selected strategy is
277
+ * what every create uses. The create call still carries `strategy` for
278
+ * 2.0.2-era binaries, where it is what lets the tool's opt-in `git` fallback
279
+ * name the built-in git strategy.
185
280
  */
186
281
  export default {
187
282
  id: "opencode2-cow-worktree",
@@ -240,20 +335,29 @@ export default {
240
335
  "(birthtime, falling back to mtime when the filesystem reports none). Derived from " +
241
336
  "opencode2's worktree inventory alone — there is no sessions field, because server " +
242
337
  "plugins cannot enumerate sessions (ADR 0003). The list fails rather than skipping " +
243
- "when an inventory row's directory cannot be read: the inventory is truth.",
338
+ "when an inventory row's directory cannot be read: the inventory is truth. Pass " +
339
+ "missing: \"report\" or \"prune\" to opt out for dangling rows (a row whose " +
340
+ "directory is gone); see the missing knob's description for the exact policy.",
244
341
  input: listWorktreesInput,
245
342
  output: listWorktreesOutput,
246
343
  // Same reason as spawn_workspace above: callable by name, not routed
247
344
  // through CodeMode.
248
345
  options: { codemode: false },
249
- execute: async () => {
346
+ execute: async (input: { readonly missing?: "fail" | "report" | "prune" }) => {
250
347
  const worktrees = await listCowWorktrees({
251
- // Own minimal deps, not liveDeps: this read-only call touches only
252
- // the inventory and one stat per row, none of spawn_workspace's
253
- // other seams. (Option validation happens once in setup, so there
254
- // is no validation side effect to dodge either way.)
255
- listWorktrees: () => ctx.worktree.list(),
348
+ // Own minimal deps, not liveDeps: this call touches the inventory,
349
+ // one stat per row, and — in prune mode only — the remove API.
350
+ // None of spawn_workspace's other seams. (Option validation happens
351
+ // once in setup, so there is no validation side effect to dodge
352
+ // either way.)
353
+ listWorktrees: () => ctx.worktree.list({ projectID: ctx.location.project.id }),
256
354
  statEntry: stat,
355
+ missing: input.missing,
356
+ // The prune seam goes through the same DELETE route every other
357
+ // removal uses, forced: a dangling row's directory is already gone,
358
+ // so the strategy's dirty probe has nothing to protect.
359
+ removeEntry: (directory) =>
360
+ ctx.worktree.remove({ projectID: ctx.location.project.id, directory, force: true }),
257
361
  });
258
362
  const summary =
259
363
  worktrees.length === 0 ? "0 cow worktree(s)" : `${worktrees.length} cow worktree(s):`;
@@ -263,6 +367,47 @@ export default {
263
367
  };
264
368
  },
265
369
  });
370
+ editor.add({
371
+ name: "remove_worktree",
372
+ description:
373
+ "Remove a finished cow worktree so its directory and inventory row are cleaned up " +
374
+ "together — no rm -rf, no hand-editing opencode's SQLite. Takes exactly one of name " +
375
+ "(a basename from list_worktrees) or directory (a row's directory). A guard refuses " +
376
+ "while the worktree still holds unlanded work — commits not on the landing ref " +
377
+ "(origin/HEAD, else main, else master) or uncommitted files — naming the counts; " +
378
+ "an undetectable landing ref also refuses. force: true removes anyway. Only worktrees " +
379
+ "the cow strategy created are removable; rows whose directory is already gone cannot " +
380
+ "be cleared through the API yet (upstream candidate 8) and are refused.",
381
+ input: removeWorktreeInput,
382
+ output: removeWorktreeOutput,
383
+ // Same reason as spawn_workspace above: callable by name, not routed
384
+ // through CodeMode.
385
+ options: { codemode: false },
386
+ execute: async (input: RemoveWorktreeInput) => {
387
+ const result = await removeWorktree(input, {
388
+ listWorktrees: () => ctx.worktree.list({ projectID: ctx.location.project.id }),
389
+ // Force passes through verbatim: on the guard-passed clean path
390
+ // `false` lets the strategy's own dirty probe run (and pass); at
391
+ // `true` the strategy skips it. The quarantine mechanics stay in
392
+ // the strategy.
393
+ removeWorktree: (directory, force) =>
394
+ ctx.worktree.remove({ projectID: ctx.location.project.id, directory, force }),
395
+ directoryExists: directoryPresent,
396
+ runGit,
397
+ probeUncommitted,
398
+ } satisfies RemoveWorktreeDeps);
399
+ // The text is what carries the force story: the structured output is
400
+ // only the directory, so a caller reading `output` alone still learns
401
+ // the one fact that matters — this directory is gone.
402
+ const content =
403
+ `Removed cow worktree ${result.directory}.` +
404
+ (result.forced ? " Force was used: the landed-ness guard was bypassed." : "");
405
+ return {
406
+ output: { directory: result.directory },
407
+ content,
408
+ };
409
+ },
410
+ });
266
411
  });
267
412
  },
268
413
  };
@@ -0,0 +1,520 @@
1
+ /**
2
+ * The `remove_worktree` tool's decision logic: clean up a finished cow lane —
3
+ * its directory *and* its inventory row together — without `rm -rf` and
4
+ * without hand-editing opencode's SQLite.
5
+ *
6
+ * WHY the landed-ness guard lives here and not in `strategy.remove`: the
7
+ * strategy's `remove` is also what opencode2's TUI drives, and its contract
8
+ * there is only the dirty probe (issue #13). Changing what the TUI's remove
9
+ * button does is not this tool's business, so the "has the lane's work
10
+ * landed?" question is asked one layer up, where only agents are affected.
11
+ * The strategy keeps its own dirty probe; this module adds the commit-level
12
+ * question on top.
13
+ *
14
+ * The allow rule: a worktree may be removed when its HEAD has **landed** — it
15
+ * is an ancestor of the landing ref (`origin/HEAD`, else `main`, else
16
+ * `master`) — or when it is clean *and* holds no commits the landing ref
17
+ * lacks. Anything else refuses, naming exactly what is unlanded, because an
18
+ * agent cleaning up a lane must not destroy the only copy of finished-but-
19
+ * unpushed work. A cow lane is a separate clone whose `origin/*` refs freeze
20
+ * at clone time, so a would-be refusal first runs one `git fetch origin` and
21
+ * re-judges on the fresh refs (issue #16); a pass on stale refs is already
22
+ * sound, so the happy path never fetches, and a failed fetch keeps the
23
+ * refusal while saying the verdict is stale-limited. An undetectable landing
24
+ * ref also refuses: "cannot judge" is not "landed". `force: true` bypasses
25
+ * the guard entirely — the human's confirmation, same meaning the TUI's
26
+ * remove confirmation carries.
27
+ *
28
+ * `force` is then passed through **verbatim** to opencode2's
29
+ * `ctx.worktree.remove`. When the guard passed because the tree is clean and
30
+ * holds no unique commits, `force: false` lets the strategy run its own dirty
31
+ * probe (which passes); at `force: true` the strategy skips it. Existing-
32
+ * clone semantics — the quarantine dance, the identity capture — stay in the
33
+ * strategy (`removal.ts`); none of that is re-implemented here.
34
+ *
35
+ * Identity and membership come from opencode2's worktree inventory (ADR 0003)
36
+ * through the same matcher attach uses (`inventoryEntryFor`): a directory the
37
+ * inventory does not know, or knows under another strategy, is not this
38
+ * tool's to delete. A row whose directory is already gone is refused before
39
+ * the guard: opencode2's DELETE route answers 400 "Worktree directory
40
+ * unavailable" even at `force:true` until upstream candidate 8 lands
41
+ * (`docs/research/upstream-issues.md`), so de-registering a dangling row is
42
+ * impossible through the API and the tool must say so instead of failing with
43
+ * a message that implies `force` would help.
44
+ *
45
+ * Every impure contact — the inventory, the removal, the directory check, git
46
+ * itself — is injected (`RemoveWorktreeDeps`), so the decision table is
47
+ * testable without opencode2 and without a git repository, exactly like
48
+ * `SpawnWorkspaceDeps` in `./tool`.
49
+ */
50
+ import { execFile } from "node:child_process";
51
+ import { basename } from "node:path";
52
+ import { promisify } from "node:util";
53
+ import type { UncommittedChanges } from "./dirty";
54
+ import { inventoryEntryFor } from "./tool";
55
+ import type { WorktreeInventoryEntry } from "../types/opencode2-worktree";
56
+
57
+ /** How long one git invocation may run before the live binding gives up. */
58
+ const GIT_TIMEOUT_MS = 5_000;
59
+
60
+ const run = promisify(execFile);
61
+
62
+ /** The outcome of one git invocation the guard ran through the seam. */
63
+ export interface GitRun {
64
+ /** The process exit code: 0 means the command answered affirmatively. */
65
+ readonly code: number;
66
+ /** Standard output, when the command produced any. */
67
+ readonly stdout: string;
68
+ }
69
+
70
+ /**
71
+ * The seams `removeWorktree` drives. Injected like `SpawnWorkspaceDeps` so the
72
+ * refusal table is testable without opencode2, a filesystem, or git.
73
+ */
74
+ export interface RemoveWorktreeDeps {
75
+ /**
76
+ * The worktree inventory (`ctx.worktree.list()`), the same seam attach and
77
+ * `list_worktrees` read. The inventory, not a plugin-owned registry, is the
78
+ * source of truth for "ours" (ADR 0003).
79
+ */
80
+ readonly listWorktrees: () => Promise<readonly WorktreeInventoryEntry[]>;
81
+ /**
82
+ * Removes a Worktree through opencode2's DELETE route
83
+ * (`ctx.worktree.remove`). `force` is passed verbatim: `false` lets the
84
+ * strategy's dirty probe run and pass on a clean tree, `true` skips it.
85
+ */
86
+ readonly removeWorktree: (directory: string, force: boolean) => Promise<void>;
87
+ /**
88
+ * Whether the worktree directory exists. The dangling-row pre-check asks
89
+ * exactly one question — is the directory there — before any guard work.
90
+ */
91
+ readonly directoryExists: (directory: string) => Promise<boolean>;
92
+ /**
93
+ * Runs one git command inside the worktree. The landed-ness guard's only
94
+ * contact with git: `symbolic-ref`, `rev-parse --verify --quiet`,
95
+ * `merge-base --is-ancestor`, `rev-list --count`, and the refusal path's
96
+ * `fetch origin` refresh. A non-zero exit is an answer ("no", "cannot"),
97
+ * never an exception.
98
+ */
99
+ readonly runGit: (args: string[], cwd: string) => Promise<GitRun>;
100
+ /**
101
+ * Reports a worktree's uncommitted paths, or `undefined` when that cannot
102
+ * be determined. The same probe the strategy's dirty guard uses
103
+ * (`probeUncommitted` from `./uncommitted`); an unknown is treated as
104
+ * dirty, exactly as `mayRemove` treats it.
105
+ */
106
+ readonly probeUncommitted: (directory: string) => Promise<UncommittedChanges>;
107
+ }
108
+
109
+ /** The tool's input, exactly as the JSON Schema declares it. */
110
+ export interface RemoveWorktreeInput {
111
+ /** The worktree's basename, as `list_worktrees` reports it. */
112
+ readonly name?: string;
113
+ /** The worktree directory, as the inventory records it. */
114
+ readonly directory?: string;
115
+ /** Remove even when the worktree holds unlanded or uncommitted work. */
116
+ readonly force?: boolean;
117
+ }
118
+
119
+ /** What a successful removal produced: the directory, and whether force ran. */
120
+ export interface RemoveWorktreeResult {
121
+ /** The removed directory, verbatim as the inventory recorded it. */
122
+ readonly directory: string;
123
+ /** True when `force: true` bypassed the landed-ness guard. */
124
+ readonly forced: boolean;
125
+ }
126
+
127
+ /**
128
+ * Removes one cow worktree by name or directory, guarded against destroying
129
+ * unlanded work. See the module doc for the why of each refusal.
130
+ *
131
+ * Order matters: the exactly-one-of selector check comes first (it is about
132
+ * the input, not the world), then the inventory resolution (which decides
133
+ * whether the target is ours at all), then the dangling-row pre-check (which
134
+ * must precede the guard — the guard would run git in a directory that is not
135
+ * there), then the landed-ness guard, and only then the removal itself.
136
+ */
137
+ export async function removeWorktree(
138
+ input: RemoveWorktreeInput,
139
+ deps: RemoveWorktreeDeps,
140
+ ): Promise<RemoveWorktreeResult> {
141
+ const directory = await resolveTarget(input, deps);
142
+ await assertDirectoryPresent(directory, deps);
143
+ const forced = input.force === true;
144
+ if (!forced) await assertLanded(directory, deps);
145
+ await deps.removeWorktree(directory, forced);
146
+ return { directory, forced };
147
+ }
148
+
149
+ /** One of the two selector shapes, after the exactly-one-of check. */
150
+ type Selector =
151
+ | { readonly kind: "directory"; readonly directory: string }
152
+ | { readonly kind: "name"; readonly name: string };
153
+
154
+ /**
155
+ * Reads the input's selector: exactly one of `name`/`directory`, both
156
+ * non-empty. An empty string is not a selector — the same rule attach applies
157
+ * to `name` — so `{ name: "" }` refuses as "neither given" instead of
158
+ * resolving to nothing surprising.
159
+ */
160
+ function selectorOf(input: RemoveWorktreeInput): Selector {
161
+ const name = typeof input.name === "string" && input.name.length > 0 ? input.name : undefined;
162
+ const directory =
163
+ typeof input.directory === "string" && input.directory.length > 0
164
+ ? input.directory
165
+ : undefined;
166
+ if (name !== undefined && directory !== undefined) {
167
+ throw new Error(
168
+ 'remove_worktree takes exactly one of "name" or "directory", not both: pass the ' +
169
+ "worktree's basename from list_worktrees, or its directory as the inventory " +
170
+ "records it — both were given.",
171
+ );
172
+ }
173
+ if (name === undefined && directory === undefined) {
174
+ throw new Error(
175
+ 'remove_worktree takes exactly one of "name" or "directory": pass the worktree\'s ' +
176
+ "basename from list_worktrees, or its directory as the inventory records it — " +
177
+ "neither was given.",
178
+ );
179
+ }
180
+ return name !== undefined
181
+ ? { kind: "name", name }
182
+ : { kind: "directory", directory: directory! };
183
+ }
184
+
185
+ /**
186
+ * Resolves the input's selector to the directory the inventory records, or
187
+ * refuses: an unknown directory as Foreign, a non-cow row as foreign
188
+ * strategy, an unknown or ambiguous name as a listing/ambiguity error.
189
+ */
190
+ async function resolveTarget(
191
+ input: RemoveWorktreeInput,
192
+ deps: RemoveWorktreeDeps,
193
+ ): Promise<string> {
194
+ const selector = selectorOf(input);
195
+ const entries = await deps.listWorktrees();
196
+ return selector.kind === "directory"
197
+ ? resolveByDirectory(entries, selector.directory)
198
+ : resolveByName(entries, selector.name);
199
+ }
200
+
201
+ /**
202
+ * The directory selector: the same identity matcher attach uses
203
+ * (`inventoryEntryFor` — basename, exact string, then realpath/lexical
204
+ * normalization), so a worktree list_worktrees reports is removable under the
205
+ * spelling that report used. The removal targets the inventory-recorded
206
+ * spelling: that is the path opencode2's record holds.
207
+ */
208
+ async function resolveByDirectory(
209
+ entries: readonly WorktreeInventoryEntry[],
210
+ directory: string,
211
+ ): Promise<string> {
212
+ const entry = await inventoryEntryFor(entries, directory);
213
+ if (entry === undefined) throw foreignWorktreeRemovalError(directory);
214
+ if (entry.strategy !== "cow") {
215
+ throw foreignStrategyRemovalError(directory, entry.strategy);
216
+ }
217
+ return entry.directory;
218
+ }
219
+
220
+ /**
221
+ * The name selector: the basename of a `cow` inventory row. Non-cow rows are
222
+ * filtered out first — a name can only ever resolve to a cow worktree, so a
223
+ * `git`-strategy row under the same basename is invisible to name lookups,
224
+ * not a foreign-strategy refusal. Zero matches lists what does exist; more
225
+ * than one names every candidate and asks for the directory.
226
+ */
227
+ function resolveByName(
228
+ entries: readonly WorktreeInventoryEntry[],
229
+ name: string,
230
+ ): string {
231
+ const cow = entries.filter((entry) => entry.strategy === "cow");
232
+ const matches = cow.filter((entry) => basename(entry.directory) === name);
233
+ if (matches.length === 0) throw unknownNameError(name, cow);
234
+ if (matches.length > 1) throw ambiguousNameError(name, matches);
235
+ return matches[0]!.directory;
236
+ }
237
+
238
+ /**
239
+ * The dangling-row pre-check: a row whose directory is gone cannot be
240
+ * de-registered through the API at all — opencode2's DELETE route answers
241
+ * 400 "Worktree directory unavailable" even at `force:true` until upstream
242
+ * candidate 8 lands. Refusing here names the real obstacle; letting the call
243
+ * reach the API would surface that 400 as if `force` had not been tried.
244
+ */
245
+ async function assertDirectoryPresent(
246
+ directory: string,
247
+ deps: RemoveWorktreeDeps,
248
+ ): Promise<void> {
249
+ if (await deps.directoryExists(directory)) return;
250
+ throw danglingRowError(directory);
251
+ }
252
+
253
+ /**
254
+ * The landed-ness guard, applied only when the caller did not force. It
255
+ * refuses while the worktree holds unlanded work and otherwise lets the
256
+ * removal proceed. Every sub-answer is fail-closed: an undetectable landing
257
+ * ref and an undeterminable dirty state both refuse, because "cannot judge"
258
+ * must never mean "safe to delete".
259
+ */
260
+ async function assertLanded(
261
+ directory: string,
262
+ deps: RemoveWorktreeDeps,
263
+ ): Promise<void> {
264
+ const landingRef = await detectLandingRef(directory, deps);
265
+ if (landingRef === undefined) throw landingRefUnknownError(directory);
266
+ let check = await landingCheck(directory, landingRef, deps);
267
+ let refreshFailed = false;
268
+ if (!landed(check)) {
269
+ // A cow lane is a separate clone whose origin/<branch> refs freeze at
270
+ // clone time, so work that landed on the remote afterwards (a merged
271
+ // PR) looks unlanded here (issue #16). Refresh once and re-judge before
272
+ // refusing; a pass on stale refs is already sound, so the fetch only
273
+ // ever runs on the refusal path.
274
+ const fetched = await deps.runGit(["fetch", "origin"], directory);
275
+ if (fetched.code === 0) {
276
+ check = await landingCheck(directory, landingRef, deps);
277
+ } else {
278
+ refreshFailed = true;
279
+ }
280
+ }
281
+ if (landed(check)) return;
282
+ throw unlandedError(directory, landingRef, check, refreshFailed);
283
+ }
284
+
285
+ /** The guard's allow rule over one probe set. */
286
+ function landed(check: LandingCheck): boolean {
287
+ const clean = check.uncommitted !== undefined && check.uncommitted.length === 0;
288
+ return check.landed || (clean && check.uniqueCommits === 0);
289
+ }
290
+
291
+ /** What the guard learned about one worktree's landed-ness. */
292
+ interface LandingCheck {
293
+ /** `git merge-base --is-ancestor HEAD <ref>` exited 0. */
294
+ readonly landed: boolean;
295
+ /** `git rev-list --count <ref>..HEAD`, or `undefined` when it failed. */
296
+ readonly uniqueCommits: number | undefined;
297
+ /** The dirty probe's answer; `undefined` means it could not tell. */
298
+ readonly uncommitted: UncommittedChanges;
299
+ }
300
+
301
+ /** Runs the guard's three probes — ancestry, unique-commit count, dirty state. */
302
+ async function landingCheck(
303
+ directory: string,
304
+ landingRef: string,
305
+ deps: RemoveWorktreeDeps,
306
+ ): Promise<LandingCheck> {
307
+ const ancestor = await deps.runGit(
308
+ ["merge-base", "--is-ancestor", "HEAD", landingRef],
309
+ directory,
310
+ );
311
+ const counted = await deps.runGit(
312
+ ["rev-list", "--count", `${landingRef}..HEAD`],
313
+ directory,
314
+ );
315
+ const uncommitted = await deps.probeUncommitted(directory);
316
+ return {
317
+ landed: ancestor.code === 0,
318
+ uniqueCommits: counted.code === 0 ? parseCount(counted.stdout) : undefined,
319
+ uncommitted,
320
+ };
321
+ }
322
+
323
+ /** A `rev-list --count` answer, or `undefined` when it is not a number. */
324
+ function parseCount(stdout: string): number | undefined {
325
+ const count = Number.parseInt(stdout.trim(), 10);
326
+ return Number.isNaN(count) ? undefined : count;
327
+ }
328
+
329
+ /**
330
+ * The worktree's landing ref: the first of `origin/HEAD` (resolved through
331
+ * `git symbolic-ref`, trimmed to `origin/<branch>`), `main`, and `master`
332
+ * that `git rev-parse --verify --quiet` confirms. `undefined` when none
333
+ * resolves — which refuses, because landed-ness cannot be judged.
334
+ */
335
+ async function detectLandingRef(
336
+ directory: string,
337
+ deps: RemoveWorktreeDeps,
338
+ ): Promise<string | undefined> {
339
+ const fromOriginHead = await originHeadRef(directory, deps);
340
+ if (fromOriginHead !== undefined && (await refResolves(fromOriginHead, directory, deps))) {
341
+ return fromOriginHead;
342
+ }
343
+ for (const candidate of ["main", "master"]) {
344
+ if (await refResolves(candidate, directory, deps)) return candidate;
345
+ }
346
+ return undefined;
347
+ }
348
+
349
+ /** The `origin/<branch>` ref `origin/HEAD` points at, when it answers one. */
350
+ async function originHeadRef(
351
+ directory: string,
352
+ deps: RemoveWorktreeDeps,
353
+ ): Promise<string | undefined> {
354
+ const answer = await deps.runGit(
355
+ ["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"],
356
+ directory,
357
+ );
358
+ if (answer.code !== 0) return undefined;
359
+ const ref = answer.stdout.trim();
360
+ // A symbolic-ref answer outside refs/remotes/ is not a remote branch this
361
+ // guard can name; treat it as no answer rather than passing it on.
362
+ return ref.startsWith("refs/remotes/") ? ref.slice("refs/remotes/".length) : undefined;
363
+ }
364
+
365
+ /** Whether `git rev-parse --verify --quiet <ref>` confirms the ref exists. */
366
+ async function refResolves(
367
+ ref: string,
368
+ directory: string,
369
+ deps: RemoveWorktreeDeps,
370
+ ): Promise<boolean> {
371
+ const answer = await deps.runGit(["rev-parse", "--verify", "--quiet", ref], directory);
372
+ return answer.code === 0;
373
+ }
374
+
375
+ /**
376
+ * The unlanded refusal, naming exactly what has not landed. A failed
377
+ * `fetch` refresh is named too, so the operator knows the counts came from
378
+ * the refs frozen at clone time.
379
+ */
380
+ function unlandedError(
381
+ directory: string,
382
+ landingRef: string,
383
+ check: LandingCheck,
384
+ refreshFailed: boolean,
385
+ ): Error {
386
+ const stale =
387
+ "A `git fetch origin` refresh failed, so this verdict used the remote " +
388
+ "refs frozen at clone time. ";
389
+ return new Error(
390
+ `refusing to remove ${directory}: it holds work that has not landed on ` +
391
+ `${landingRef} — ${describeUnlanded(check.uniqueCommits, check.uncommitted)}. ` +
392
+ (refreshFailed ? stale : "") +
393
+ "Re-run with force to remove anyway.",
394
+ );
395
+ }
396
+
397
+ /** The counts half of the unlanded refusal; unknowns are named as unknown. */
398
+ function describeUnlanded(
399
+ uniqueCommits: number | undefined,
400
+ uncommitted: UncommittedChanges,
401
+ ): string {
402
+ const commits =
403
+ uniqueCommits === undefined
404
+ ? "an unknown number of unique commit(s) (the git probe failed)"
405
+ : `${uniqueCommits} unique commit(s)`;
406
+ const files =
407
+ uncommitted === undefined
408
+ ? "an unknown number of uncommitted file(s) (the probe could not answer)"
409
+ : `${uncommitted.length} uncommitted file(s)`;
410
+ return `${commits} and ${files}`;
411
+ }
412
+
413
+ /** The refusal for an undetectable landing ref; force still overrides. */
414
+ function landingRefUnknownError(directory: string): Error {
415
+ return new Error(
416
+ `refusing to remove ${directory}: the landing ref could not be determined — ` +
417
+ "none of refs/remotes/origin/HEAD (via git symbolic-ref), main, or master " +
418
+ "resolves in this worktree, so landed-ness cannot be judged. " +
419
+ "Re-run with force to remove anyway.",
420
+ );
421
+ }
422
+
423
+ /** The refusal for a directory the inventory does not know at all. */
424
+ function foreignWorktreeRemovalError(directory: string): Error {
425
+ return new Error(
426
+ `refusing to remove ${directory}: opencode2's worktree inventory has no entry ` +
427
+ "for it, so it is not a worktree this strategy materialized (a Foreign " +
428
+ "worktree). remove_worktree removes only worktrees its cow strategy " +
429
+ "created; if nothing needs what is inside, remove the directory by hand.",
430
+ );
431
+ }
432
+
433
+ /** The refusal for an inventory entry naming a strategy other than `cow`. */
434
+ function foreignStrategyRemovalError(
435
+ directory: string,
436
+ strategy: string | undefined,
437
+ ): Error {
438
+ const described = strategy === undefined ? "no strategy" : `"${strategy}"`;
439
+ return new Error(
440
+ `refusing to remove ${directory}: the worktree inventory records it with ` +
441
+ `${described}, not "cow" — remove_worktree will not remove a Worktree ` +
442
+ "another strategy materialized. Remove it through opencode2 or by hand.",
443
+ );
444
+ }
445
+
446
+ /**
447
+ * The refusal for a row whose directory is gone: the API cannot clear it
448
+ * until upstream candidate 8 ("Worktree.remove resolves the real path before
449
+ * reading the record") lands, so the refusal names that instead of letting a
450
+ * 400 "Worktree directory unavailable" imply that `force` was not tried.
451
+ */
452
+ function danglingRowError(directory: string): Error {
453
+ return new Error(
454
+ `refusing to remove ${directory}: the worktree directory no longer exists, so ` +
455
+ "the inventory row is dangling and cannot be de-registered through the API " +
456
+ 'until upstream candidate 8 ("Worktree.remove resolves the real path before ' +
457
+ 'reading the record") lands — the DELETE route answers 400 "Worktree directory ' +
458
+ "unavailable\" even at force:true. See docs/research/upstream-issues.md; " +
459
+ "until then, clearing the row means editing opencode.db by hand.",
460
+ );
461
+ }
462
+
463
+ /** The refusal for a name no cow row carries, listing the rows that exist. */
464
+ function unknownNameError(
465
+ name: string,
466
+ cow: readonly WorktreeInventoryEntry[],
467
+ ): Error {
468
+ const known =
469
+ cow.length === 0
470
+ ? "this location's inventory has no cow worktrees"
471
+ : `known cow worktree(s): ${cow.map((entry) => basename(entry.directory)).join(", ")}`;
472
+ return new Error(
473
+ `no cow worktree named ${JSON.stringify(name)}: ${known}. ` +
474
+ "Run list_worktrees to see this location's cow worktrees.",
475
+ );
476
+ }
477
+
478
+ /** The refusal for a name several cow rows carry; the directory disambiguates. */
479
+ function ambiguousNameError(
480
+ name: string,
481
+ matches: readonly WorktreeInventoryEntry[],
482
+ ): Error {
483
+ return new Error(
484
+ `the name ${JSON.stringify(name)} is ambiguous: ${matches.length} cow worktrees ` +
485
+ `share that basename — ${matches.map((entry) => entry.directory).join(", ")}. ` +
486
+ "Pass the full directory instead.",
487
+ );
488
+ }
489
+
490
+ /**
491
+ * The live `runGit` binding: one git invocation via `execFile` — no shell, no
492
+ * inherited prompt — with the same hygiene `probeUncommitted` uses. Declared
493
+ * total like the guard's other answers: every failure (non-zero exit,
494
+ * timeout, no git on the machine) resolves to a non-zero code, which each
495
+ * caller reads as "no"/"cannot". An exit code the error object carries is
496
+ * kept so a signal kill and a plain refusal are at least distinguishable in
497
+ * principle; anything unreadable is 1.
498
+ */
499
+ export async function runGit(args: string[], cwd: string): Promise<GitRun> {
500
+ try {
501
+ const { stdout } = await run("git", args, {
502
+ cwd,
503
+ encoding: "utf8",
504
+ timeout: GIT_TIMEOUT_MS,
505
+ env: { ...process.env, GIT_PAGER: "cat", GIT_EDITOR: "true", CI: "1" },
506
+ });
507
+ return { code: 0, stdout };
508
+ } catch (cause) {
509
+ return { code: exitCodeOf(cause), stdout: "" };
510
+ }
511
+ }
512
+
513
+ /** The exit code an execFile failure carries, when it carries one. */
514
+ function exitCodeOf(cause: unknown): number {
515
+ if (typeof cause === "object" && cause !== null && "code" in cause) {
516
+ const code = (cause as { code: unknown }).code;
517
+ if (typeof code === "number") return code;
518
+ }
519
+ return 1;
520
+ }
package/src/tool.ts CHANGED
@@ -525,8 +525,17 @@ export interface CowWorktreeEntry {
525
525
  /** The worktree directory, verbatim as the inventory records it. */
526
526
  readonly directory: string;
527
527
  readonly strategy: "cow";
528
- /** ISO 8601; derived from a directory stat. See `createdAtOf`. */
529
- readonly createdAt: string;
528
+ /**
529
+ * ISO 8601; derived from a directory stat. See `createdAtOf`. Absent on a
530
+ * `missing` entry: with no directory to stat there is no timestamp to derive.
531
+ */
532
+ readonly createdAt?: string;
533
+ /**
534
+ * Set only under `missing: "report"`: the inventory still lists the row, but
535
+ * its directory is gone (the stat answered ENOENT). The directory stays
536
+ * verbatim from the inventory and `createdAt` is absent.
537
+ */
538
+ readonly missing?: true;
530
539
  }
531
540
 
532
541
  /**
@@ -548,11 +557,29 @@ export interface ListWorktreesDeps {
548
557
  */
549
558
  readonly listWorktrees: () => Promise<readonly WorktreeInventoryEntry[]>;
550
559
  /**
551
- * Stats one inventory-listed directory. A failure here is not absorbed: the
552
- * inventory is truth, so an unreadable row is a real anomaly and the list
553
- * fails loudly instead of silently dropping the entry.
560
+ * Stats one inventory-listed directory, for `createdAt`. An ENOENT here is a
561
+ * dangling inventory row — a directory deleted out from under the inventory —
562
+ * handled per the `missing` policy. Any other failure is a real anomaly and
563
+ * fails the list in every mode.
554
564
  */
555
565
  readonly statEntry: (path: string) => Promise<StatTimes>;
566
+ /**
567
+ * What a dangling inventory row (its directory stats ENOENT) costs the call.
568
+ * `"fail"` — the default — keeps ADR 0003's fail-loud contract. `"report"`
569
+ * lists the row flagged `missing: true`, without `createdAt`. `"prune"`
570
+ * de-registers the row through `removeEntry` and drops it from the listing.
571
+ */
572
+ readonly missing?: "fail" | "report" | "prune";
573
+ /**
574
+ * De-registers one inventory row; the seam behind `missing: "prune"`, wired
575
+ * by the plugin to `ctx.worktree.remove` with force, so a gone directory is
576
+ * accepted as an already-completed deletion. A rejection is never absorbed:
577
+ * upstream candidate 8 ("Worktree.remove resolves the real path before
578
+ * reading the record", docs/research/upstream-issues.md) makes the delete
579
+ * answer 400 for a gone directory, so the caller must learn the row is
580
+ * still there.
581
+ */
582
+ readonly removeEntry?: (directory: string) => Promise<void>;
556
583
  }
557
584
 
558
585
  /**
@@ -564,22 +591,47 @@ export interface ListWorktreesDeps {
564
591
  * for CoW capability: per ADR 0003 the inventory is the source of truth, and
565
592
  * the only filesystem contact is one stat per surviving entry, for
566
593
  * `createdAt`.
594
+ *
595
+ * A row whose directory stats ENOENT is a dangling reference — deleted out
596
+ * from under the inventory, not a worktree to preserve. The default
597
+ * (`missing: "fail"`) keeps ADR 0003's fail-loud contract; `"report"` returns
598
+ * the row flagged `missing: true` and without `createdAt`; `"prune"`
599
+ * de-registers the row through `removeEntry` and drops it. Any non-ENOENT
600
+ * stat failure fails the list in every mode — that is a real anomaly. The
601
+ * return shape stays the bare listing, so a prune is visible to callers only
602
+ * through `removeEntry`'s effect on the inventory or its rejection.
567
603
  */
568
604
  export async function listCowWorktrees(deps: ListWorktreesDeps): Promise<CowWorktreeEntry[]> {
569
605
  const entries = await deps.listWorktrees();
570
606
  const cow = entries.filter((entry) => entry.strategy === "cow");
571
- return Promise.all(cow.map((entry) => cowEntryOf(entry, deps.statEntry)));
607
+ // Rows are resolved one at a time: prune mode mutates the inventory through
608
+ // `removeEntry`, and concurrent removes racing the same inventory file buy
609
+ // nothing for a listing of a handful of rows.
610
+ const listed: CowWorktreeEntry[] = [];
611
+ for (const entry of cow) {
612
+ const resolved = await cowEntryOf(entry, deps);
613
+ if (resolved !== undefined) listed.push(resolved);
614
+ }
615
+ return listed;
572
616
  }
573
617
 
574
- /** Derives one listing entry: the basename, the verbatim directory, and the stat. */
618
+ /**
619
+ * Derives one listing entry — the basename, the verbatim directory, and the
620
+ * stat — or `undefined` when the row was pruned. The stat failure itself is
621
+ * classified, not absorbed: ENOENT goes to the `missing` policy, and every
622
+ * other failure propagates untouched, exactly as before the knob existed.
623
+ */
575
624
  async function cowEntryOf(
576
625
  entry: WorktreeInventoryEntry,
577
- statEntry: ListWorktreesDeps["statEntry"],
578
- ): Promise<CowWorktreeEntry> {
579
- // Fail loud, by design: the inventory is truth, and a row it lists but whose
580
- // directory cannot be stat'd is a real anomaly. Skipping it would report an
581
- // incomplete list as a complete one.
582
- const times = await statEntry(entry.directory);
626
+ deps: ListWorktreesDeps,
627
+ ): Promise<CowWorktreeEntry | undefined> {
628
+ let times: StatTimes;
629
+ try {
630
+ times = await deps.statEntry(entry.directory);
631
+ } catch (cause) {
632
+ if (!isEnoent(cause)) throw cause;
633
+ return missingEntryOf(entry, deps);
634
+ }
583
635
  return {
584
636
  name: basename(entry.directory),
585
637
  directory: entry.directory,
@@ -588,6 +640,96 @@ async function cowEntryOf(
588
640
  };
589
641
  }
590
642
 
643
+ /**
644
+ * The `missing` policy applied to a dangling row. `"report"` keeps the row,
645
+ * flagged; `"prune"` de-registers it and answers `undefined` so the row leaves
646
+ * the listing; the default `"fail"` throws the shaped dangling-row refusal.
647
+ */
648
+ async function missingEntryOf(
649
+ entry: WorktreeInventoryEntry,
650
+ deps: ListWorktreesDeps,
651
+ ): Promise<CowWorktreeEntry | undefined> {
652
+ switch (deps.missing ?? "fail") {
653
+ case "report":
654
+ return {
655
+ name: basename(entry.directory),
656
+ directory: entry.directory,
657
+ strategy: "cow",
658
+ missing: true,
659
+ };
660
+ case "prune": {
661
+ if (deps.removeEntry === undefined) throw pruneWithoutSeamError();
662
+ try {
663
+ await deps.removeEntry(entry.directory);
664
+ } catch (cause) {
665
+ throw pruneBlockedError(entry.directory, cause);
666
+ }
667
+ return undefined;
668
+ }
669
+ default:
670
+ throw danglingRowError(entry.directory);
671
+ }
672
+ }
673
+
674
+ /**
675
+ * The shaped refusal for a dangling row under the default `"fail"` policy: the
676
+ * raw ENOENT rides as the cause, and the message names the two opt-outs.
677
+ */
678
+ function danglingRowError(directory: string): Error {
679
+ return new Error(
680
+ `the worktree inventory lists ${directory}, but stat answers ENOENT: the ` +
681
+ "directory was deleted while the inventory still records it — a dangling " +
682
+ "row, and the default fails loudly rather than report an incomplete list " +
683
+ 'as complete. Pass missing: "report" to list the row flagged missing, or ' +
684
+ 'missing: "prune" to de-register it.',
685
+ );
686
+ }
687
+
688
+ /**
689
+ * The refusal for `missing: "prune"` without a wired `removeEntry`: the policy
690
+ * asked for a de-registration the call has no seam to perform.
691
+ */
692
+ function pruneWithoutSeamError(): Error {
693
+ return new Error(
694
+ 'missing: "prune" was requested but no removeEntry seam is wired, so a ' +
695
+ "dangling inventory row cannot be de-registered.",
696
+ );
697
+ }
698
+
699
+ /**
700
+ * The refusal when the prune itself fails: never swallowed, because the row
701
+ * the caller wanted gone is still in the inventory. The message names the row,
702
+ * the raw failure, and the upstream blocker (candidate 8) that makes a gone
703
+ * directory refuse deletion even at force.
704
+ */
705
+ function pruneBlockedError(directory: string, cause: unknown): Error {
706
+ const reason = cause instanceof Error ? cause.message : String(cause);
707
+ return new Error(
708
+ `cow could not prune the dangling inventory row ${directory}: the removal ` +
709
+ `failed (${reason}). Upstream candidate 8 ("Worktree.remove resolves the ` +
710
+ "real path before reading the record\", docs/research/upstream-issues.md) " +
711
+ "makes DELETE /api/worktree answer 400 for a gone directory even at " +
712
+ "force:true, so the row cannot be de-registered until that lands. Re-run " +
713
+ 'with missing: "report" to see the row flagged instead.',
714
+ { cause },
715
+ );
716
+ }
717
+
718
+ /**
719
+ * Whether an error is a plain ENOENT — "the path is not there", which for an
720
+ * inventory row means a dangling reference rather than an anomaly. Node's
721
+ * filesystem errors carry the code; anything else is not an absence answer.
722
+ * (Mirrors the classifier in `./removal`, which is module-private there.)
723
+ */
724
+ function isEnoent(cause: unknown): boolean {
725
+ return (
726
+ typeof cause === "object" &&
727
+ cause !== null &&
728
+ "code" in cause &&
729
+ cause.code === "ENOENT"
730
+ );
731
+ }
732
+
591
733
  /**
592
734
  * Birthtime first, mtime when the filesystem reports none. btrfs and other
593
735
  * filesystems leave birthtime at zero/epoch, where mtime is the