opencode2-cow-worktree 0.1.0

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.
@@ -0,0 +1,125 @@
1
+ import { rm } from "node:fs/promises";
2
+ import type { WorktreeDefinition } from "../types/opencode2-worktree";
3
+ import {
4
+ assertSameDevice,
5
+ deviceOf,
6
+ nearestExistingDevice,
7
+ } from "./device";
8
+ import { cloneDirectory, OccupiedTargetError } from "./clone";
9
+ import { mayRemove } from "./dirty";
10
+ import { runPostCreateHooks } from "./hooks";
11
+ import { removeQuarantined } from "./removal";
12
+ import { probeUncommitted } from "./uncommitted";
13
+
14
+ /**
15
+ * Builds the `cow` Strategy: materialize a Worktree as a Deep clone.
16
+ *
17
+ * `create` reflinks the source working directory — ignored files included —
18
+ * into the directory opencode2 has already chosen. It fails loudly rather than
19
+ * falling back: the CoW capability predicate belongs to the caller-facing tool,
20
+ * so a request for `cow` either clones or throws. `list` returns nothing on
21
+ * purpose — opencode2 reads its own records for inventory, so a Strategy is not
22
+ * the source of truth for what exists.
23
+ *
24
+ * opencode2 may hand the strategy a target on a different filesystem (its
25
+ * default worktree parent is under the data directory). A reflink cannot cross
26
+ * a device boundary, so the strategy pre-flights the same device rule the tool
27
+ * applies and throws an actionable error before any directory is created; it
28
+ * does not relocate the target, because a caller-supplied path is not the
29
+ * strategy's to silently override.
30
+ *
31
+ * `postCreate` is the validated `hooks.postCreate` list the plugin's setup
32
+ * passes in. When non-empty, the clone is followed by those commands —
33
+ * sequentially, in the worktree, with the source and worktree paths in the
34
+ * environment — and the first failure removes the clone before the error
35
+ * propagates, so a failed setup never leaves an orphan clone behind. The list
36
+ * closes over the factory call, so the setup-ordering contract ("validate the
37
+ * options before registering the strategy") holds by construction, with no
38
+ * module state to install or reset.
39
+ */
40
+ export function createCowStrategy(
41
+ { postCreate }: { readonly postCreate: readonly string[] },
42
+ ): WorktreeDefinition {
43
+ return {
44
+ id: "cow",
45
+
46
+ async create(input, { signal }) {
47
+ signal.throwIfAborted();
48
+ // Thrown outside the try so the actionable message is not rewrapped as a
49
+ // generic clone failure. `input.directory` is the final target opencode2
50
+ // has assembled; it may not exist yet, so the device of its nearest
51
+ // existing ancestor decides whether a reflink can land there.
52
+ const [sourceDevice, targetDevice] = await Promise.all([
53
+ deviceOf(input.sourceDirectory),
54
+ nearestExistingDevice(input.directory, deviceOf),
55
+ ]);
56
+ assertSameDevice(input.sourceDirectory, sourceDevice, input.directory, targetDevice);
57
+ try {
58
+ await cloneDirectory(input.sourceDirectory, input.directory);
59
+ } catch (cause) {
60
+ // An occupied target was refused before the first write, so nothing in
61
+ // that directory is this call's creation; the leave-nothing-behind
62
+ // rollback must never run over foreign content. The refusal propagates
63
+ // as-is.
64
+ if (cause instanceof OccupiedTargetError) throw cause;
65
+ // Leave nothing behind: cloneDirectory creates the target before it can
66
+ // fail, so a partial tree would otherwise survive a failed create.
67
+ // In-place `rm` — not the quarantine dance of src/removal.ts — is
68
+ // acceptable here: the directory is a seconds-old clone this call itself
69
+ // just created, so the provenance is known and there is no audit gap.
70
+ await rm(input.directory, { recursive: true, force: true });
71
+ throw new Error(`cow strategy failed to clone into ${input.directory}`, {
72
+ cause,
73
+ });
74
+ }
75
+ await runPostCreateHooks(postCreate, input.directory, input.sourceDirectory);
76
+ return { directory: input.directory };
77
+ },
78
+
79
+ async remove(input) {
80
+ // opencode2's `force` is its two-phase confirm protocol — "proceed despite
81
+ // uncommitted changes" — not `rm`'s "ignore a missing path" flag. The
82
+ // built-in git strategy maps it to `git worktree remove --force` and
83
+ // refuses while the worktree is dirty; the TUI turns the refusal into a
84
+ // confirmation and retries with `force: true`. So the probe is skipped
85
+ // entirely once the user has confirmed, and `rm` always receives a literal
86
+ // `force: true` (a directory this call is authorized to delete may
87
+ // legitimately be gone already).
88
+ //
89
+ // At `force: false` a dirty — or unknowable — directory refuses before any
90
+ // filesystem change, so uncommitted work is never silently destroyed
91
+ // (issue #13). `input.force` is authorization for the decision, not a flag
92
+ // for `rm`.
93
+ //
94
+ // Past the guard the deletion is never in-place: the directory's identity
95
+ // is captured, it is renamed to a quarantine sibling, the identity is
96
+ // re-confirmed, and only then is the copy deleted — so a swapped or
97
+ // recycled path can never be deleted in the audited directory's name, and
98
+ // an agent holding a cwd inside stops blocking the path. `removal.ts` owns
99
+ // those mechanics.
100
+ const uncommitted = input.force
101
+ ? undefined
102
+ : await probeUncommitted(input.directory);
103
+ const decision = mayRemove({ force: input.force, uncommitted });
104
+ if (!decision.remove) {
105
+ throw new Error(
106
+ `cow refuses to remove ${input.directory} without force: ${decision.reason}. ` +
107
+ "Re-run with force to delete them.",
108
+ );
109
+ }
110
+ await removeQuarantined(input.directory);
111
+ },
112
+
113
+ async list() {
114
+ return [];
115
+ },
116
+ };
117
+ }
118
+
119
+ /**
120
+ * The `cow` strategy with no post-create hooks — the package's default
121
+ * instance, exported for direct consumers of the Strategy API. The plugin
122
+ * builds its own instance per setup through `createCowStrategy`, closing over
123
+ * the hooks validated from the configured options.
124
+ */
125
+ export const cowStrategy = createCowStrategy({ postCreate: [] });
package/src/tool.ts ADDED
@@ -0,0 +1,482 @@
1
+ import { realpath } from "node:fs/promises";
2
+ import { basename, join, resolve } from "node:path";
3
+ import { assertSameDevice } from "./device";
4
+ import type { CowCapability } from "./capability";
5
+ import type { Mechanism } from "./mechanism";
6
+ import type { WorktreeInventoryEntry } from "../types/opencode2-worktree";
7
+
8
+ /** What a successful `spawnWorkspace` produced. */
9
+ export interface SpawnWorkspaceResult {
10
+ readonly sessionID: string;
11
+ readonly directory: string;
12
+ readonly mechanism: Mechanism;
13
+ /**
14
+ * True when the session was attached to an already-existing Worktree instead
15
+ * of a fresh clone. Absent for a created Worktree: the field's presence is
16
+ * the attach marker, and `mechanism` then reports the mechanism of the
17
+ * directory that was found, not of a clone this call performed.
18
+ */
19
+ readonly attached?: boolean;
20
+ }
21
+
22
+ /** Whether a caller who asked for a CoW clone may be given a Shallow worktree. */
23
+ export type FallbackPolicy = "none" | "git";
24
+
25
+ /**
26
+ * The seams `spawnWorkspace` drives. Each is injected so the decision table is
27
+ * testable without opencode2, and so the fallback ticket (#6) can supply the
28
+ * git strategy and its own config-derived policy without touching this file.
29
+ */
30
+ export interface SpawnWorkspaceDeps {
31
+ /** Answers the CoW capability question for a directory. */
32
+ readonly probe: (directory: string) => Promise<CowCapability>;
33
+ /**
34
+ * Reports the device a path's filesystem belongs to, or `undefined` when it
35
+ * cannot be read. Injected so a cross-device target is testable without a
36
+ * second mount.
37
+ */
38
+ readonly probeDevice: (path: string) => Promise<number | undefined>;
39
+ /**
40
+ * Creates a Worktree with the named Strategy, returning its directory.
41
+ * `parentDirectory` is opencode2's `Worktree.CreateInput.directory`: the
42
+ * **parent** the worktree is created under, not the worktree path itself.
43
+ * opencode2 appends the name, so it is what decides the target device.
44
+ */
45
+ readonly createWorktree: (input: {
46
+ readonly sourceDirectory: string;
47
+ readonly parentDirectory: string;
48
+ readonly strategy: Mechanism;
49
+ readonly name?: string;
50
+ }) => Promise<{ readonly directory: string }>;
51
+ /** Starts a session whose Location is the directory; returns its id. */
52
+ readonly createSession: (directory: string, name?: string) => Promise<string>;
53
+ /** Removes a Worktree this call created. Used only to clean up a failure. */
54
+ readonly removeWorktree: (directory: string) => Promise<void>;
55
+ /**
56
+ * Lists the Worktree inventory opencode2 records for the caller's location —
57
+ * one `{ directory, strategy? }` entry per Worktree (`ctx.worktree.list()`).
58
+ * Attach reads it to decide whether an existing directory is ours, and
59
+ * `listCowWorktrees` lists from it (ADR 0003: the inventory, not a
60
+ * plugin-owned registry, is the source of truth). Injected so the decision
61
+ * is testable without opencode2.
62
+ */
63
+ readonly listWorktrees: () => Promise<readonly WorktreeInventoryEntry[]>;
64
+ /**
65
+ * Whether a path exists and is a directory. Attach consults it twice: the
66
+ * predicted target must already exist before any attach question is asked,
67
+ * and the Deep-clone signature is `<target>/.git` being a *directory*. Used
68
+ * for no other purpose. Injected like `deviceOf` so both checks are testable
69
+ * without a filesystem.
70
+ */
71
+ readonly isDirectory: (path: string) => Promise<boolean>;
72
+ /** Defaults to `"none"`: a request for `cow` is a statement about what you get. */
73
+ readonly fallback?: FallbackPolicy;
74
+ /** Worktree parent. Defaults to a same-filesystem sibling of the source. */
75
+ readonly targetRoot?: string;
76
+ }
77
+
78
+ export interface SpawnWorkspaceInput {
79
+ readonly sourceDirectory: string;
80
+ readonly name?: string;
81
+ }
82
+
83
+ /**
84
+ * Creates one Worktree and starts a session in it, reporting the Mechanism that
85
+ * produced the directory.
86
+ *
87
+ * The CoW capability predicate is consulted on the source directory and decides
88
+ * the strategy. A capability *error* (missing path, permissions, I/O) always
89
+ * fails, even with fallback enabled: it is not the same answer as "not
90
+ * supported", and honouring the fallback there would hide a real problem behind
91
+ * a git worktree. The fallback only applies to a genuine negative.
92
+ *
93
+ * A CoW clone cannot cross a filesystem boundary, so the tool creates the
94
+ * Worktree under a parent on the source's own device — a sibling by default, or
95
+ * the configured target root. When the chosen parent is on a different device
96
+ * the call fails with an explicit device-mismatch error rather than letting a
97
+ * bare `EXDEV` surface as an unexplained clone failure.
98
+ *
99
+ * There is no silent fallback by default. If the probe says unsupported and no
100
+ * fallback is configured, the call throws rather than fabricating a mechanism —
101
+ * the result type has no "none", and a caller must never be told it got a Deep
102
+ * clone it did not get.
103
+ *
104
+ * If the Worktree is created but the session cannot be started, the Worktree is
105
+ * removed before the error propagates, so no directory is orphaned. If creating
106
+ * the Worktree itself fails there is nothing to clean up.
107
+ *
108
+ * Attach: when the call names a Worktree (`name`) whose predicted directory
109
+ * already exists, the call attaches instead of cloning. The prediction is
110
+ * assembled exactly as the create flow assembles it — the same parent, the
111
+ * same name — and the existing directory is attached to only when the
112
+ * inventory records it with `strategy: "cow"` (ADR 0003) **and** it carries the
113
+ * Deep-clone signature (`.git` is a directory). Every other occupant of the
114
+ * predicted path — a `git`-strategy Worktree, a path the inventory does not
115
+ * know (a Foreign worktree), a `cow` row that lost its Deep-clone shape — is
116
+ * refused loudly before any session is started and without touching the
117
+ * filesystem. Attach never consults the capability probe (no clone is
118
+ * attempted) and never removes the existing directory when its own session
119
+ * start fails, because the directory predates the call.
120
+ */
121
+ export async function spawnWorkspace(
122
+ input: SpawnWorkspaceInput,
123
+ deps: SpawnWorkspaceDeps,
124
+ ): Promise<SpawnWorkspaceResult> {
125
+ const name = input.name;
126
+ // A present, non-empty name may hit an existing Worktree; an empty string is
127
+ // not a name and takes the create path exactly as before.
128
+ if (name) {
129
+ assertSimpleName(name);
130
+ const attached = await tryAttach(name, input.sourceDirectory, deps);
131
+ if (attached !== undefined) return attached;
132
+ }
133
+ const capability = await deps.probe(input.sourceDirectory);
134
+ if (capability.status === "error") {
135
+ throw new Error(
136
+ `cannot clone ${input.sourceDirectory}: CoW capability probe failed`,
137
+ { cause: capability.error },
138
+ );
139
+ }
140
+
141
+ const mechanism: Mechanism =
142
+ capability.status === "supported"
143
+ ? "cow"
144
+ : selectFallback(deps.fallback ?? "none", input.sourceDirectory);
145
+
146
+ const parent = predictedParent(input, deps);
147
+ await verifySameDevice(input.sourceDirectory, parent, mechanism, deps.probeDevice);
148
+
149
+ const worktree = await deps.createWorktree({
150
+ sourceDirectory: input.sourceDirectory,
151
+ parentDirectory: parent,
152
+ strategy: mechanism,
153
+ name: input.name,
154
+ });
155
+
156
+ try {
157
+ const sessionID = await deps.createSession(worktree.directory, input.name);
158
+ return { sessionID, directory: worktree.directory, mechanism };
159
+ } catch (cause) {
160
+ // The cleanup is best-effort: it must never replace the failure that
161
+ // triggered it. `removeWorktree` goes through opencode2's DELETE route,
162
+ // which refuses once the directory is already gone, so a successful
163
+ // session-create failure would otherwise surface as a confusing
164
+ // "directory unavailable" instead of the original cause.
165
+ try {
166
+ await deps.removeWorktree(worktree.directory);
167
+ } catch {
168
+ // Leave the tree for the caller to inspect; the real error follows.
169
+ }
170
+ throw new Error(`session start failed in ${worktree.directory}`, { cause });
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Rejects a `cow` parent on a different filesystem than the source before the
176
+ * create is attempted. A reflink cannot cross devices, so this is a distinct,
177
+ * actionable failure and must not surface as a generic clone error. The `git`
178
+ * mechanism does not share extents and is left untouched.
179
+ *
180
+ * An unreadable device on either side is not a mismatch — `probeDevice` returns
181
+ * `undefined` rather than throwing, and the create proceeds to report whatever
182
+ * the filesystem actually does.
183
+ */
184
+ async function verifySameDevice(
185
+ source: string,
186
+ parent: string,
187
+ mechanism: Mechanism,
188
+ probeDevice: SpawnWorkspaceDeps["probeDevice"],
189
+ ): Promise<void> {
190
+ if (mechanism !== "cow") return;
191
+ // A parent that does not exist yet (an injected fake in the unit tests, or a
192
+ // target root opencode2 has not created) is checked as-is: `probeDevice`
193
+ // returns `undefined` and the create proceeds rather than failing on an
194
+ // unreadable device. The strategy owns the nearest-ancestor walk, where the
195
+ // target is a full path opencode2 assembled.
196
+ await verifyCowSameDevice({ source, target: parent, probeDevice });
197
+ }
198
+
199
+ function selectFallback(policy: FallbackPolicy, sourceDirectory: string): Mechanism {
200
+ if (policy === "git") return "git";
201
+ throw new Error(
202
+ `copy-on-write is not supported for ${sourceDirectory} and no fallback is enabled`,
203
+ );
204
+ }
205
+
206
+ /**
207
+ * The `cow` half of the device policy: it checks both sides through the
208
+ * injected `probeDevice` seam and applies the shared `assertSameDevice` rule.
209
+ * The primitives live in `./device`; only the decision lives here.
210
+ */
211
+ export async function verifyCowSameDevice(input: {
212
+ readonly source: string;
213
+ readonly target: string;
214
+ readonly probeDevice: (path: string) => Promise<number | undefined>;
215
+ }): Promise<void> {
216
+ const [sourceDevice, targetDevice] = await Promise.all([
217
+ input.probeDevice(input.source),
218
+ input.probeDevice(input.target),
219
+ ]);
220
+ assertSameDevice(input.source, sourceDevice, input.target, targetDevice);
221
+ }
222
+
223
+ /**
224
+ * The parent directory a worktree of this input is assembled under: the
225
+ * configured target root, or a sibling of the source. The create flow passes
226
+ * it to opencode2 as `Worktree.CreateInput.directory`, and attach predicts
227
+ * `<parent>/<name>` under it — one helper so both halves of the tool assemble
228
+ * the parent identically. See the assembly contract pinned in
229
+ * `types/opencode2-worktree.d.ts`.
230
+ */
231
+ function predictedParent(
232
+ input: SpawnWorkspaceInput,
233
+ deps: SpawnWorkspaceDeps,
234
+ ): string {
235
+ return deps.targetRoot ?? join(input.sourceDirectory, "..");
236
+ }
237
+
238
+ /**
239
+ * Rejects a `name` the prediction cannot treat as one directory name, before
240
+ * any existence probe or inventory read: a separator would address a nested
241
+ * path the attach flow never predicted, and `.`/`..` do not name a directory.
242
+ */
243
+ function assertSimpleName(name: string): void {
244
+ if (name === "." || name === ".." || /[\\/]/.test(name)) {
245
+ throw new Error(
246
+ `invalid worktree name ${JSON.stringify(name)}: only simple directory names are ` +
247
+ "accepted — no \"/\" or \"\\\", never \".\" or \"..\". The worktree parent is " +
248
+ "chosen for you; pass the plain name.",
249
+ );
250
+ }
251
+ }
252
+
253
+ /**
254
+ * The attach half of a named `spawnWorkspace`: when the predicted directory
255
+ * already exists, decide whether the call may attach to it. Returns `undefined`
256
+ * when the predicted path is free, handing control back to the create flow
257
+ * unchanged.
258
+ *
259
+ * The prediction reuses the create flow's parent — the configured target root,
260
+ * or a sibling of the source — so a path that exists here is exactly a path
261
+ * `createWorktree` would collide with. The check runs before the capability
262
+ * probe on purpose: attach attempts no clone, so the source's CoW capability
263
+ * is not its question to ask.
264
+ */
265
+ async function tryAttach(
266
+ name: string,
267
+ sourceDirectory: string,
268
+ deps: SpawnWorkspaceDeps,
269
+ ): Promise<SpawnWorkspaceResult | undefined> {
270
+ const target = join(predictedParent({ sourceDirectory }, deps), name);
271
+ if (!(await deps.isDirectory(target))) return undefined;
272
+ // TOCTOU by construction: the existence check, inventory read, and Deep-clone check are separate reads, and every interleaving fails closed.
273
+ return attachToExisting(name, target, deps);
274
+ }
275
+
276
+ /**
277
+ * The attach decision for a path that already exists. The inventory is the
278
+ * source of truth for "ours" (ADR 0003): an entry recorded with `cow` plus the
279
+ * Deep-clone signature (`.git` is a directory) means the `cow` strategy
280
+ * materialized this directory, so a new session may bind to it. Every other
281
+ * answer is refused loudly — before any session is created and with no
282
+ * filesystem change — because a Foreign worktree, another strategy's Worktree,
283
+ * or a `cow` row that lost its Deep-clone shape is not this tool's to attach
284
+ * to.
285
+ */
286
+ async function attachToExisting(
287
+ name: string,
288
+ target: string,
289
+ deps: SpawnWorkspaceDeps,
290
+ ): Promise<SpawnWorkspaceResult> {
291
+ const entry = await inventoryEntryFor(await deps.listWorktrees(), target);
292
+ if (entry === undefined) throw foreignWorktreeError(target);
293
+ if (entry.strategy !== "cow") {
294
+ throw foreignStrategyError(target, entry.strategy);
295
+ }
296
+ if (!(await deps.isDirectory(join(target, ".git")))) {
297
+ throw notDeepCloneError(target);
298
+ }
299
+ try {
300
+ const sessionID = await deps.createSession(target, name);
301
+ return { sessionID, directory: target, mechanism: "cow", attached: true };
302
+ } catch (cause) {
303
+ // No removeWorktree here, unlike the create flow: the directory existed
304
+ // before this call, so a failed session start must leave it untouched.
305
+ throw new Error(`session start failed in existing worktree ${target}`, { cause });
306
+ }
307
+ }
308
+
309
+ /**
310
+ * The inventory entry recorded for a directory, when the inventory knows it.
311
+ *
312
+ * Identity is not an exact-string comparison: the inventory may record the
313
+ * same directory through a different spelling (a symlinked ancestor, a `..`
314
+ * segment), and an exact-string miss would refuse a real cow worktree as
315
+ * Foreign. Rows are narrowed by basename first — cheap, and shared with the
316
+ * prediction — then matched exactly, and finally by resolving both paths:
317
+ * `realpath` when both resolve, lexical normalization when it cannot. A
318
+ * comparison that establishes no identity leaves the entry unfound, so an
319
+ * unknown path still refuses as Foreign.
320
+ *
321
+ * Exported as the one directory-identity matcher: the TUI badge
322
+ * (`strategy-badge.ts`) asks the same question of the same inventory rows, and
323
+ * a badge that matched by exact string only would go dark for a symlinked
324
+ * location the tool happily attaches to.
325
+ */
326
+ export async function inventoryEntryFor(
327
+ entries: readonly WorktreeInventoryEntry[],
328
+ directory: string,
329
+ ): Promise<WorktreeInventoryEntry | undefined> {
330
+ const leaf = basename(directory);
331
+ const candidates = entries.filter((entry) => basename(entry.directory) === leaf);
332
+ for (const candidate of candidates) {
333
+ if (candidate.directory === directory) return candidate;
334
+ if (await sameDirectory(candidate.directory, directory)) return candidate;
335
+ }
336
+ return undefined;
337
+ }
338
+
339
+ /**
340
+ * Whether two paths name the same directory. `realpath` is authoritative when
341
+ * both sides resolve; when either cannot be read (a row for a path that no
342
+ * longer exists, a unit-test fake), identity falls back to lexical
343
+ * normalization. A false answer is "no entry" — never a weaker match.
344
+ */
345
+ async function sameDirectory(a: string, b: string): Promise<boolean> {
346
+ try {
347
+ const [realA, realB] = await Promise.all([realpath(a), realpath(b)]);
348
+ return realA === realB;
349
+ } catch {
350
+ return resolve(a) === resolve(b);
351
+ }
352
+ }
353
+
354
+ /** The refusal for a path the worktree inventory does not know at all. */
355
+ function foreignWorktreeError(target: string): Error {
356
+ return new Error(
357
+ `refusing to attach to ${target}: the path exists but opencode2's worktree ` +
358
+ "inventory has no entry for it, so it is not a worktree this strategy " +
359
+ "materialized (a Foreign worktree). spawn_workspace attaches only to " +
360
+ "worktrees its cow strategy created; pick another name or remove the " +
361
+ "directory.",
362
+ );
363
+ }
364
+
365
+ /** The refusal for an inventory entry naming a strategy other than `cow`. */
366
+ function foreignStrategyError(target: string, strategy: string | undefined): Error {
367
+ return new Error(
368
+ `refusing to attach to ${target}: the worktree inventory records it with ` +
369
+ `${describeStrategy(strategy)}, not "cow" — spawn_workspace will not attach ` +
370
+ "to a Worktree another strategy materialized. Remove it through opencode2 " +
371
+ "or pick another name.",
372
+ );
373
+ }
374
+
375
+ /**
376
+ * The refusal for a `cow` inventory row whose `.git` is not a directory: whatever
377
+ * sits at the path now, it is not the Deep clone this strategy's create leaves
378
+ * behind, so the row is stale or the directory was replaced.
379
+ */
380
+ function notDeepCloneError(target: string): Error {
381
+ return new Error(
382
+ `refusing to attach to ${target}: the worktree inventory records strategy ` +
383
+ `"cow", but ${join(target, ".git")} is not a directory, so the directory does ` +
384
+ "not have the Deep-clone signature a cow worktree is created with. Refresh " +
385
+ "the inventory or remove the directory.",
386
+ );
387
+ }
388
+
389
+ /** The strategy as a refusal names it; the checkout root is listed with none. */
390
+ function describeStrategy(strategy: string | undefined): string {
391
+ return strategy === undefined ? "no strategy" : `"${strategy}"`;
392
+ }
393
+
394
+ // ---------------------------------------------------------------------------
395
+ // list_worktrees (ADR 0003)
396
+ //
397
+ // A read-only listing of the Location's CoW worktrees, derived entirely from
398
+ // opencode2's worktree inventory: no plugin-owned registry, no git commands,
399
+ // and no filesystem contact beyond one stat per surviving entry.
400
+ // ---------------------------------------------------------------------------
401
+
402
+ /** One CoW worktree as the `list_worktrees` tool reports it. */
403
+ export interface CowWorktreeEntry {
404
+ /** The worktree directory's basename. */
405
+ readonly name: string;
406
+ /** The worktree directory, verbatim as the inventory records it. */
407
+ readonly directory: string;
408
+ readonly strategy: "cow";
409
+ /** ISO 8601; derived from a directory stat. See `createdAtOf`. */
410
+ readonly createdAt: string;
411
+ }
412
+
413
+ /**
414
+ * The two stat fields `createdAt` is derived from, kept structural so tests
415
+ * can inject a plain object and the live binding passes node's `Stats`
416
+ * unchanged.
417
+ */
418
+ export interface StatTimes {
419
+ readonly birthtimeMs: number;
420
+ readonly mtimeMs: number;
421
+ }
422
+
423
+ /** The seams `listCowWorktrees` reads. Injected like `SpawnWorkspaceDeps`. */
424
+ export interface ListWorktreesDeps {
425
+ /**
426
+ * The worktree inventory (`ctx.worktree.list()`), the same seam the attach
427
+ * path reads. The inventory, not a plugin-owned registry, is the source of
428
+ * truth (ADR 0003).
429
+ */
430
+ readonly listWorktrees: () => Promise<readonly WorktreeInventoryEntry[]>;
431
+ /**
432
+ * Stats one inventory-listed directory. A failure here is not absorbed: the
433
+ * inventory is truth, so an unreadable row is a real anomaly and the list
434
+ * fails loudly instead of silently dropping the entry.
435
+ */
436
+ readonly statEntry: (path: string) => Promise<StatTimes>;
437
+ }
438
+
439
+ /**
440
+ * Lists the Location's CoW worktrees for the `list_worktrees` tool.
441
+ *
442
+ * Only inventory rows recorded with `strategy: "cow"` are listed — the
443
+ * strategy-less checkout-root row and other strategies' Worktrees are not
444
+ * directories this plugin materialized. Nothing here consults git or probes
445
+ * for CoW capability: per ADR 0003 the inventory is the source of truth, and
446
+ * the only filesystem contact is one stat per surviving entry, for
447
+ * `createdAt`.
448
+ */
449
+ export async function listCowWorktrees(deps: ListWorktreesDeps): Promise<CowWorktreeEntry[]> {
450
+ const entries = await deps.listWorktrees();
451
+ const cow = entries.filter((entry) => entry.strategy === "cow");
452
+ return Promise.all(cow.map((entry) => cowEntryOf(entry, deps.statEntry)));
453
+ }
454
+
455
+ /** Derives one listing entry: the basename, the verbatim directory, and the stat. */
456
+ async function cowEntryOf(
457
+ entry: WorktreeInventoryEntry,
458
+ statEntry: ListWorktreesDeps["statEntry"],
459
+ ): Promise<CowWorktreeEntry> {
460
+ // Fail loud, by design: the inventory is truth, and a row it lists but whose
461
+ // directory cannot be stat'd is a real anomaly. Skipping it would report an
462
+ // incomplete list as a complete one.
463
+ const times = await statEntry(entry.directory);
464
+ return {
465
+ name: basename(entry.directory),
466
+ directory: entry.directory,
467
+ strategy: "cow",
468
+ createdAt: createdAtOf(times),
469
+ };
470
+ }
471
+
472
+ /**
473
+ * Birthtime first, mtime when the filesystem reports none. btrfs and other
474
+ * filesystems leave birthtime at zero/epoch, where mtime is the
475
+ * later-explanatory time — when the directory's content was last written.
476
+ * Zero is unambiguous: no filesystem reports the epoch as a real creation
477
+ * time.
478
+ */
479
+ function createdAtOf(times: StatTimes): string {
480
+ const ms = times.birthtimeMs > 0 ? times.birthtimeMs : times.mtimeMs;
481
+ return new Date(ms).toISOString();
482
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * The impure half of `cow`'s remove guard: the `git status --porcelain` probe
3
+ * that reports a worktree's uncommitted changes, or the deliberate "unknown".
4
+ *
5
+ * Split out of `strategy.ts` so it sits beside the pure decision
6
+ * it feeds — `dirty.ts`'s `mayRemove`, which stays free of I/O. This module
7
+ * owns the subprocess: `execFile` runs `git` directly, no shell, and every
8
+ * failure mode (no metadata, non-zero exit, timeout, no git on the machine)
9
+ * is reported as `undefined`, which the decision treats as dirty. A probe
10
+ * that fails is never "clean".
11
+ */
12
+ import { execFile } from "node:child_process";
13
+ import { stat } from "node:fs/promises";
14
+ import { join } from "node:path";
15
+ import { promisify } from "node:util";
16
+ import type { UncommittedChanges } from "./dirty";
17
+
18
+ /** How long `git status` may take before the probe gives up and reports unknown. */
19
+ const GIT_TIMEOUT_MS = 5_000;
20
+
21
+ const run = promisify(execFile);
22
+
23
+ /**
24
+ * Reports the paths a worktree has uncommitted changes in, or `undefined` when
25
+ * that cannot be determined. A probe that fails is never "clean".
26
+ *
27
+ * The metadata gate keeps the probe off a non-repository directory: without
28
+ * `.git` (a file, as in a linked worktree, or a directory, as in a clone) no
29
+ * porcelain status exists, so the answer is unknown and `remove` refuses. A
30
+ * directory that is gone answers `undefined` too — `stat` following a symlink
31
+ * is intentional, because a symlinked worktree still owns git metadata.
32
+ *
33
+ * Injected through a module-level `spyOn` seam in the tests, like `deviceOf`:
34
+ * `bun:test` can replace an export the module itself calls. The call is
35
+ * deliberately unqualified and outside destructuring so that replacement
36
+ * takes effect.
37
+ *
38
+ * `execFile` runs `git` directly — no shell, no inherited prompt — and CI=1
39
+ * suppresses any credential or editor interaction; the 5s timeout bounds a
40
+ * git that hangs on a lock. A pager needs a tty and is never started by
41
+ * `status --porcelain` here. Any error, non-zero exit, or timeout is unknown.
42
+ */
43
+ export async function probeUncommitted(
44
+ directory: string,
45
+ ): Promise<UncommittedChanges> {
46
+ if (!(await hasGitMetadata(directory))) return undefined;
47
+ try {
48
+ const { stdout } = await run(
49
+ "git",
50
+ ["-C", directory, "status", "--porcelain"],
51
+ {
52
+ encoding: "utf8",
53
+ timeout: GIT_TIMEOUT_MS,
54
+ env: { ...process.env, GIT_PAGER: "cat", GIT_EDITOR: "true", CI: "1" },
55
+ },
56
+ );
57
+ return porcelainPaths(stdout);
58
+ } catch {
59
+ return undefined;
60
+ }
61
+ }
62
+
63
+ async function hasGitMetadata(directory: string): Promise<boolean> {
64
+ try {
65
+ await stat(join(directory, ".git"));
66
+ return true;
67
+ } catch {
68
+ return false;
69
+ }
70
+ }
71
+
72
+ /**
73
+ * The changed paths in `git status --porcelain` output. The status and the
74
+ * path are separated by the first space; a rename's `old -> new` is kept
75
+ * whole, because both ends name something the user would lose.
76
+ */
77
+ function porcelainPaths(stdout: string): UncommittedChanges {
78
+ return stdout
79
+ .split("\n")
80
+ .map((line) => line.trim())
81
+ .filter((line) => line.length > 0)
82
+ .map((line) => line.slice(line.indexOf(" ") + 1).trim())
83
+ .filter((path) => path.length > 0);
84
+ }