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,71 @@
1
+ /**
2
+ * The platform seam: which syscall fulfils a CoW clone.
3
+ *
4
+ * On Linux the primitive is a reflink (`COPYFILE_FICLONE_FORCE`). On macOS it
5
+ * is `copyfile(3)` with `COPYFILE_CLONE_FORCE`, because libuv never reached
6
+ * `clonefile(2)` on Darwin even on APFS — under Node the reflink call fails
7
+ * with `ENOSYS` unconditionally. `cloneFile` and `isDarwin` are the only
8
+ * platform-specific things the rest of the code touches.
9
+ */
10
+
11
+ /**
12
+ * The CoW clone operation for one regular file: shares the target's extents
13
+ * with `source`, or throws. Never falls back to a byte copy.
14
+ */
15
+ export type CloneFile = (source: string, target: string) => Promise<void>;
16
+
17
+ /**
18
+ * True on Darwin. Injectable into `cloneFile` so the platform dispatch is
19
+ * exercised from a Linux test.
20
+ */
21
+ export type PlatformCheck = () => boolean;
22
+
23
+ /** True when the current process is running on macOS. */
24
+ export function isDarwin(): boolean {
25
+ return process.platform === "darwin";
26
+ }
27
+
28
+ const defaultIsDarwin: PlatformCheck = isDarwin;
29
+
30
+ /**
31
+ * Clones one regular file using this platform's syscall, or throws.
32
+ *
33
+ * Dispatch is by `platform` so the Darwin branch is reachable from tests on
34
+ * Linux. On Linux this is exactly the previous behavior:
35
+ * `COPYFILE_FICLONE_FORCE` on the same paths.
36
+ */
37
+ export async function cloneFile(
38
+ source: string,
39
+ target: string,
40
+ platform: PlatformCheck = defaultIsDarwin,
41
+ ): Promise<void> {
42
+ if (platform()) return cloneOnDarwin(source, target);
43
+ return cloneOnLinux(source, target);
44
+ }
45
+
46
+ /**
47
+ * The Linux primitive: a forced, create-only reflink, which fails rather than
48
+ * byte-copying and refuses an existing destination instead of overwriting it.
49
+ * `COPYFILE_EXCL` is Darwin parity — `COPYFILE_CLONE_FORCE` implies it there
50
+ * (see `platform-darwin.ts`) — and correct on its own: the clone walker only
51
+ * ever writes fresh names, so an occupied destination is a bug to surface,
52
+ * not bytes to replace.
53
+ */
54
+ export async function cloneOnLinux(source: string, target: string): Promise<void> {
55
+ const { constants } = await import("node:fs");
56
+ const { copyFile } = await import("node:fs/promises");
57
+ await copyFile(
58
+ source,
59
+ target,
60
+ constants.COPYFILE_FICLONE_FORCE | constants.COPYFILE_EXCL,
61
+ );
62
+ }
63
+
64
+ /**
65
+ * The Darwin backend, loaded only when selected. The dynamic import keeps the
66
+ * macOS module — and its `bun:ffi` `dlopen` — out of the Linux process.
67
+ */
68
+ async function cloneOnDarwin(source: string, target: string): Promise<void> {
69
+ const { cloneFileOnDarwin } = await import("./platform-darwin");
70
+ await cloneFileOnDarwin(source, target);
71
+ }
package/src/plugin.ts ADDED
@@ -0,0 +1,249 @@
1
+ import type { Context } from "@opencode-ai/plugin";
2
+ import { stat } from "node:fs/promises";
3
+ import { probeCowCapability } from "./capability";
4
+ import { fallbackPolicy, postCreateHooks, targetRoot } from "./config";
5
+ import { deviceOf, isDirectory } from "./device";
6
+ import { listCowWorktrees, spawnWorkspace } from "./tool";
7
+ import type {
8
+ FallbackPolicy,
9
+ SpawnWorkspaceDeps,
10
+ SpawnWorkspaceInput,
11
+ } from "./tool";
12
+ import { createCowStrategy } from "./strategy";
13
+
14
+ /**
15
+ * The tool's input schema, as the v2 plugin API expects a JSON Schema. Kept a
16
+ * plain object because the installed plugin package is a v1 build; the running
17
+ * v2 binary decodes it.
18
+ */
19
+ const spawnWorkspaceInput = {
20
+ type: "object",
21
+ properties: {
22
+ sourceDirectory: {
23
+ type: "string",
24
+ description: "The project directory to clone or branch from.",
25
+ },
26
+ name: {
27
+ type: "string",
28
+ description: "Optional name for the worktree and its session.",
29
+ },
30
+ },
31
+ required: ["sourceDirectory"],
32
+ additionalProperties: false,
33
+ } as const;
34
+
35
+ /**
36
+ * The tool's declared output, as a JSON Schema. A tool that returns an `output`
37
+ * field must declare its schema: opencode2 treats an undeclared output as a
38
+ * defect ("Tool result declared output without an output schema"), not as a
39
+ * recoverable error. The reported mechanism is part of the contract, so the
40
+ * structured result is declared rather than flattened into text.
41
+ */
42
+ const spawnWorkspaceOutput = {
43
+ type: "object",
44
+ properties: {
45
+ sessionID: {
46
+ type: "string",
47
+ description: "The session started in the created directory.",
48
+ },
49
+ directory: {
50
+ type: "string",
51
+ description: "The working directory that was produced.",
52
+ },
53
+ mechanism: {
54
+ type: "string",
55
+ enum: ["cow", "git"],
56
+ description: "The mechanism that produced the directory.",
57
+ },
58
+ attached: {
59
+ type: "boolean",
60
+ description:
61
+ "True when the session was attached to an existing worktree instead of a new clone.",
62
+ },
63
+ },
64
+ required: ["sessionID", "directory", "mechanism"],
65
+ additionalProperties: false,
66
+ } as const;
67
+
68
+ /**
69
+ * `list_worktrees` takes no input. The empty object keeps the shape the tool
70
+ * seam expects (a JSON Schema object) while declaring that no properties
71
+ * exist.
72
+ */
73
+ const listWorktreesInput = {
74
+ type: "object",
75
+ properties: {},
76
+ additionalProperties: false,
77
+ } as const;
78
+
79
+ /**
80
+ * `list_worktrees`' declared output, with the same rule as
81
+ * `spawn_workspace`'s: a tool that returns an `output` field must declare its
82
+ * schema. The four entry fields are exactly what `listCowWorktrees` derives.
83
+ */
84
+ const listWorktreesOutput = {
85
+ type: "object",
86
+ properties: {
87
+ worktrees: {
88
+ type: "array",
89
+ description: "The location's cow worktrees, in inventory order.",
90
+ items: {
91
+ type: "object",
92
+ properties: {
93
+ name: {
94
+ type: "string",
95
+ description: "The worktree directory's basename.",
96
+ },
97
+ directory: {
98
+ type: "string",
99
+ description: "The worktree directory, as the worktree inventory records it.",
100
+ },
101
+ strategy: {
102
+ type: "string",
103
+ enum: ["cow"],
104
+ description: "Only cow worktrees are listed.",
105
+ },
106
+ createdAt: {
107
+ type: "string",
108
+ description:
109
+ "ISO 8601 timestamp of the directory's birthtime, falling back to its " +
110
+ "mtime when the filesystem reports no birthtime.",
111
+ },
112
+ },
113
+ required: ["name", "directory", "strategy", "createdAt"],
114
+ additionalProperties: false,
115
+ },
116
+ },
117
+ },
118
+ required: ["worktrees"],
119
+ additionalProperties: false,
120
+ } as const;
121
+
122
+ /**
123
+ * Binds `spawnWorkspace`'s seams to the live opencode2 context. The tool is
124
+ * the layer that owns the strategy choice, so the capability probe and the
125
+ * worktree/session APIs meet here and nowhere else.
126
+ *
127
+ * The fallback policy and the Worktree target root arrive already validated —
128
+ * `setup` read them from the plugin's options once and passes the values in.
129
+ * The fallback defaults to `"none"`: a request for `cow` is a statement about
130
+ * what the caller gets, so the tool never produces a Shallow worktree unless
131
+ * the opt-in was set. The target root defaults to unset, which makes the tool
132
+ * place the Worktree beside the source — the same-filesystem parent a CoW
133
+ * clone requires.
134
+ */
135
+ function liveDeps(
136
+ ctx: Context,
137
+ fallback: FallbackPolicy,
138
+ worktreeRoot: string | undefined,
139
+ ): SpawnWorkspaceDeps {
140
+ return {
141
+ probe: probeCowCapability,
142
+ probeDevice: deviceOf,
143
+ listWorktrees: () => ctx.worktree.list(),
144
+ isDirectory,
145
+ createWorktree: (input) =>
146
+ ctx.worktree.create({
147
+ strategy: input.strategy,
148
+ name: input.name,
149
+ location: { directory: input.sourceDirectory },
150
+ directory: input.parentDirectory,
151
+ }),
152
+ createSession: async (directory, name) => {
153
+ const session = await ctx.session.create({ title: name, location: { directory } });
154
+ return session.id;
155
+ },
156
+ removeWorktree: (directory) =>
157
+ ctx.worktree.remove({ directory, force: true }),
158
+ fallback,
159
+ targetRoot: worktreeRoot,
160
+ };
161
+ }
162
+
163
+ /**
164
+ * The opencode2 plugin module.
165
+ *
166
+ * `setup` registers the `cow` Strategy through the worktree seam, and the
167
+ * `spawn_workspace` and `list_worktrees` tools through the tool seam.
168
+ * Registering the Strategy also selects it as the Location default; opencode2's
169
+ * registry still lets a caller name the built-in `git` strategy explicitly, so
170
+ * this module does not touch that behavior.
171
+ */
172
+ export default {
173
+ id: "opencode2-cow-worktree",
174
+ async setup(ctx: Context): Promise<void> {
175
+ // Every plugin option is validated exactly once, here, before anything is
176
+ // registered: a misconfiguration must fail the plugin load, not surface
177
+ // inside the first tool call or halfway through a create. The validated
178
+ // values close over into the strategy and the tool bindings below, so
179
+ // the tool path never re-reads — or re-throws on — `ctx.options`.
180
+ const hooks = postCreateHooks(ctx.options);
181
+ const fallback = fallbackPolicy(ctx.options);
182
+ const worktreeRoot = targetRoot(ctx.options);
183
+ await ctx.worktree.transform((editor) => {
184
+ editor.add(createCowStrategy({ postCreate: hooks }));
185
+ });
186
+ // `?.`: the installed plugin package is a v1 build whose Context has no
187
+ // tool domain; the v2 binary always provides it. Optional chaining keeps
188
+ // setup usable with a context that predates the tool seam.
189
+ await ctx.tool?.transform((editor) => {
190
+ editor.add({
191
+ name: "spawn_workspace",
192
+ description:
193
+ "Create a worktree and start a session in it, reporting the mechanism that produced the directory. " +
194
+ "When a worktree with the requested name already exists and was produced by the cow strategy, the " +
195
+ "session is attached to it instead of creating a new directory; anything else at that name is refused.",
196
+ input: spawnWorkspaceInput,
197
+ output: spawnWorkspaceOutput,
198
+ // A tool defaults into CodeMode, which advertises it to the model only
199
+ // through `execute`. This tool must be callable by name, so it is kept
200
+ // on the provider's native tool list.
201
+ options: { codemode: false },
202
+ execute: async (input: SpawnWorkspaceInput) => {
203
+ const result = await spawnWorkspace(input, liveDeps(ctx, fallback, worktreeRoot));
204
+ // The text is what tells attach from create: on attach `mechanism`
205
+ // reports the found directory's mechanism, and the caller must never
206
+ // read that as a fresh clone having happened.
207
+ const content = result.attached
208
+ ? `Attached to existing cow worktree at ${result.directory} (session ${result.sessionID}); no new worktree was created.`
209
+ : `Created ${result.mechanism} worktree at ${result.directory} (session ${result.sessionID}).`;
210
+ return {
211
+ output: result,
212
+ content,
213
+ };
214
+ },
215
+ });
216
+ editor.add({
217
+ name: "list_worktrees",
218
+ description:
219
+ "List this location's cow worktrees: each entry carries the directory's basename as " +
220
+ "name, the directory, the strategy, and createdAt from a stat of the directory " +
221
+ "(birthtime, falling back to mtime when the filesystem reports none). Derived from " +
222
+ "opencode2's worktree inventory alone — there is no sessions field, because server " +
223
+ "plugins cannot enumerate sessions (ADR 0003). The list fails rather than skipping " +
224
+ "when an inventory row's directory cannot be read: the inventory is truth.",
225
+ input: listWorktreesInput,
226
+ output: listWorktreesOutput,
227
+ // Same reason as spawn_workspace above: callable by name, not routed
228
+ // through CodeMode.
229
+ options: { codemode: false },
230
+ execute: async () => {
231
+ const worktrees = await listCowWorktrees({
232
+ // Own minimal deps, not liveDeps: this read-only call touches only
233
+ // the inventory and one stat per row, none of spawn_workspace's
234
+ // other seams. (Option validation happens once in setup, so there
235
+ // is no validation side effect to dodge either way.)
236
+ listWorktrees: () => ctx.worktree.list(),
237
+ statEntry: stat,
238
+ });
239
+ const summary =
240
+ worktrees.length === 0 ? "0 cow worktree(s)" : `${worktrees.length} cow worktree(s):`;
241
+ return {
242
+ output: { worktrees },
243
+ content: `${summary}\n${JSON.stringify(worktrees, null, 2)}`,
244
+ };
245
+ },
246
+ });
247
+ });
248
+ },
249
+ };
package/src/removal.ts ADDED
@@ -0,0 +1,306 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { readdir, rename, rm, stat } from "node:fs/promises";
3
+ import type { Dirent, Stats } from "node:fs";
4
+ import { basename, dirname, join } from "node:path";
5
+
6
+ /**
7
+ * The mechanics half of `cow`'s `remove`: identity-captured, quarantine-renamed
8
+ * deletion.
9
+ *
10
+ * `rm` deletes whatever occupies a path now, not the directory that was audited.
11
+ * Between the dirty guard's probe and the delete, the path can be swapped or
12
+ * recycled — a rename racing the removal, a create taking the freed name — and
13
+ * an in-place `rm` would destroy the newcomer in the audited worktree's name.
14
+ * So the directory is first stat'd (dev + inode), renamed to a sibling
15
+ * quarantine name, and the quarantine path is re-stat'd: only when the identity
16
+ * still matches the capture is the copy deleted. A mismatch aborts loudly and
17
+ * moves the directory back. The original path is never deleted in place; at
18
+ * every point before the identity is re-confirmed, a failure leaves it intact.
19
+ *
20
+ * The rename is also what keeps an agent holding a cwd inside the worktree from
21
+ * blocking the removal: the path is vacated immediately even though its former
22
+ * contents cannot be fully unlinked until that process lets go.
23
+ *
24
+ * The slow tail — dependency directories like `node_modules` — is deleted
25
+ * asynchronously in-process (the server is long-lived) after the removal
26
+ * returns, with failures logged, never thrown: a deletion the caller is no
27
+ * longer waiting on must not turn a reported success into a late error.
28
+ *
29
+ * A leftover `.cow-removing-…` directory means a deletion failed midway (the
30
+ * thrown or logged error names it): it holds the remains of a removed worktree
31
+ * and nothing else, and is safe to delete by hand once no process is using it.
32
+ */
33
+
34
+ /** The filesystem identity a directory is pinned by: device + inode. */
35
+ interface DirectoryIdentity {
36
+ readonly dev: number;
37
+ readonly ino: number;
38
+ }
39
+
40
+ /**
41
+ * The dependency-directory class removed by the background tail, recognised by
42
+ * name at the quarantine's top level. Everything nested inside a deferred one
43
+ * goes with it — the tail deletes with the same recursive mechanics as the
44
+ * in-band pass.
45
+ */
46
+ const HEAVY_DIRECTORY = "node_modules";
47
+
48
+ /** How much randomness separates a quarantine name from any other sibling. */
49
+ const QUARANTINE_RANDOM_BYTES = 12;
50
+
51
+ /**
52
+ * Removes a directory whose identity has been captured and re-confirmed, per
53
+ * the module doc. Resolves once the original path is vacated and the in-band
54
+ * deletion finished; any remaining dependency directories are the background
55
+ * tail's business.
56
+ *
57
+ * A missing directory is a completed removal, not an error: opencode2's
58
+ * `remove` is idempotent the same way `rm`'s `force` was.
59
+ */
60
+ export async function removeQuarantined(directory: string): Promise<void> {
61
+ const captured = await captureIdentity(directory);
62
+ if (captured === undefined) return;
63
+ const quarantine = quarantineTarget(directory);
64
+ try {
65
+ await renamePath(directory, quarantine);
66
+ } catch (cause) {
67
+ throw new Error(
68
+ `cow cannot remove ${directory}: moving it aside to ${quarantine} failed, ` +
69
+ "so the original directory was left untouched.",
70
+ { cause },
71
+ );
72
+ }
73
+ await verifyQuarantined(captured, directory, quarantine);
74
+ await deleteInBand(quarantine);
75
+ }
76
+
77
+ /**
78
+ * The dev + inode of the directory a path names, or `undefined` when the path
79
+ * does not exist. Any other stat failure is real corruption (a file where a
80
+ * directory component belongs, a permission problem) and propagates: deleting
81
+ * on an unreadable path is how the wrong thing gets destroyed.
82
+ */
83
+ async function captureIdentity(
84
+ directory: string,
85
+ ): Promise<DirectoryIdentity | undefined> {
86
+ try {
87
+ const stats = await statDirectory(directory);
88
+ return { dev: stats.dev, ino: stats.ino };
89
+ } catch (cause) {
90
+ if (isMissing(cause)) return undefined;
91
+ throw cause;
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Re-validates the quarantine path against the capture before anything is
97
+ * deleted. A mismatch — or a quarantine path that cannot be read at all — means
98
+ * what sits there is not the audited directory, so it is moved back to the
99
+ * original path and the operation aborts loudly. When even the move-back fails,
100
+ * both failures are chained: the files are stranded at the quarantine path and
101
+ * the error must say so.
102
+ */
103
+ async function verifyQuarantined(
104
+ captured: DirectoryIdentity,
105
+ directory: string,
106
+ quarantine: string,
107
+ ): Promise<void> {
108
+ if (await identityMatches(quarantine, captured)) return;
109
+ try {
110
+ await renamePath(quarantine, directory);
111
+ } catch (cause) {
112
+ throw new Error(
113
+ `cow cannot remove ${directory}: the quarantine path ${quarantine} does ` +
114
+ "not hold the directory whose identity was captured, and moving the " +
115
+ `worktree back to ${directory} failed. Nothing was deleted; the files ` +
116
+ `remain at ${quarantine}.`,
117
+ { cause },
118
+ );
119
+ }
120
+ throw new Error(
121
+ `cow cannot remove ${directory}: the quarantine path ${quarantine} does ` +
122
+ "not hold the directory whose identity was captured, so deleting it " +
123
+ "could destroy files that were never audited. The worktree was moved " +
124
+ `back to ${directory}; nothing was deleted.`,
125
+ );
126
+ }
127
+
128
+ /**
129
+ * Whether the path still holds the captured directory. An unreadable path is a
130
+ * mismatch, never a pass: only a positive identity match allows deletion.
131
+ */
132
+ async function identityMatches(
133
+ path: string,
134
+ captured: DirectoryIdentity,
135
+ ): Promise<boolean> {
136
+ try {
137
+ const stats = await statDirectory(path);
138
+ return stats.dev === captured.dev && stats.ino === captured.ino;
139
+ } catch {
140
+ return false;
141
+ }
142
+ }
143
+
144
+ /**
145
+ * The in-band deletion: everything in the quarantine copy except dependency
146
+ * directories, which are handed to the background tail. With no tail work the
147
+ * quarantine directory is removed here too, so an ordinary removal leaves no
148
+ * trace behind. A child — or the listing, or the quarantine copy itself —
149
+ * that cannot be deleted fails the removal with the quarantine path in the
150
+ * message: the original path is already vacated, so the remains and their
151
+ * meaning are the one thing the caller must learn.
152
+ */
153
+ async function deleteInBand(quarantine: string): Promise<void> {
154
+ const heavy: string[] = [];
155
+ let entries: Dirent[];
156
+ try {
157
+ entries = await readDirectory(quarantine);
158
+ } catch (cause) {
159
+ throw new Error(
160
+ `cow could not finish removing the worktree: reading the quarantine ` +
161
+ `copy ${quarantine} failed, so remains are left at ${quarantine}. ` +
162
+ "The original worktree path is gone; the remains hold nothing else " +
163
+ "and are safe to delete by hand.",
164
+ { cause },
165
+ );
166
+ }
167
+ for (const entry of entries) {
168
+ const child = join(quarantine, entry.name);
169
+ if (entry.name === HEAVY_DIRECTORY && entry.isDirectory()) {
170
+ heavy.push(child);
171
+ continue;
172
+ }
173
+ await removeChild(child, quarantine);
174
+ }
175
+ if (heavy.length > 0) {
176
+ void deleteHeavyTail(quarantine, heavy);
177
+ return;
178
+ }
179
+ try {
180
+ await removeTree(quarantine);
181
+ } catch (cause) {
182
+ throw new Error(
183
+ `cow could not finish removing the worktree: deleting the quarantine ` +
184
+ `copy ${quarantine} failed, so remains are left at ${quarantine}. ` +
185
+ "The original worktree path is gone; the remains hold nothing else " +
186
+ "and are safe to delete by hand.",
187
+ { cause },
188
+ );
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Deletes one in-band child, converting a failure into the removal's error.
194
+ * Logged as well as thrown: the thrown error may reach the caller compressed,
195
+ * while the log carries the record for whoever inspects the server.
196
+ */
197
+ async function removeChild(child: string, quarantine: string): Promise<void> {
198
+ try {
199
+ await removeTree(child);
200
+ } catch (cause) {
201
+ const error = new Error(
202
+ `cow could not finish removing the worktree: deleting ${child} inside ` +
203
+ `the quarantine copy failed, so remains are left at ${quarantine}. ` +
204
+ "The original worktree path is gone; the remains hold nothing else " +
205
+ "and are safe to delete by hand.",
206
+ { cause },
207
+ );
208
+ logFailure(error.message);
209
+ throw error;
210
+ }
211
+ }
212
+
213
+ /**
214
+ * The background tail: deletes the deferred dependency directories and then the
215
+ * quarantine shell, logging every failure and never throwing — the caller that
216
+ * triggered this has already been told the removal succeeded.
217
+ */
218
+ export async function deleteHeavyTail(
219
+ quarantine: string,
220
+ heavy: readonly string[],
221
+ ): Promise<void> {
222
+ for (const directory of heavy) {
223
+ try {
224
+ await removeTree(directory);
225
+ } catch (cause) {
226
+ logFailure(
227
+ `cow could not delete the dependency directory ${directory} while ` +
228
+ `finishing a removal: ${describeCause(cause)}. Its files remain ` +
229
+ "there and are safe to delete by hand.",
230
+ );
231
+ }
232
+ }
233
+ try {
234
+ await removeTree(quarantine);
235
+ } catch (cause) {
236
+ logFailure(
237
+ `cow could not delete the quarantine directory ${quarantine} while ` +
238
+ `finishing a removal: ${describeCause(cause)}. Whatever remains ` +
239
+ "there is safe to delete by hand.",
240
+ );
241
+ }
242
+ }
243
+
244
+ /** Where a failed tail is recorded: the server's own log. */
245
+ function logFailure(message: string): void {
246
+ console.error(message);
247
+ }
248
+
249
+ /** The human-readable half of a logged cause. */
250
+ function describeCause(cause: unknown): string {
251
+ return cause instanceof Error ? cause.message : String(cause);
252
+ }
253
+
254
+ /**
255
+ * The sibling quarantine path: same parent (so the rename never crosses a
256
+ * device), hidden dot-name with the `.cow-removing-` prefix and a random
257
+ * suffix. The prefix keeps the name out of the worktree namespace callers
258
+ * choose, and the randomness keeps concurrent removals from colliding with
259
+ * each other or with a directory a caller could predict.
260
+ */
261
+ function quarantineTarget(directory: string): string {
262
+ const name = basename(directory);
263
+ const random = randomBytes(QUARANTINE_RANDOM_BYTES).toString("hex");
264
+ return join(dirname(directory), `.cow-removing-${name}-${random}`);
265
+ }
266
+
267
+ /**
268
+ * Whether an error is a plain ENOENT — "the path is not there", which for a
269
+ * removal means the work is already done. Node's filesystem errors carry the
270
+ * code; anything else is not an absence answer.
271
+ */
272
+ function isMissing(cause: unknown): boolean {
273
+ return (
274
+ typeof cause === "object" &&
275
+ cause !== null &&
276
+ "code" in cause &&
277
+ cause.code === "ENOENT"
278
+ );
279
+ }
280
+
281
+ // The filesystem seams below are module-level exports so `bun:test`'s `spyOn`
282
+ // can replace what this module calls — the same pattern as `probeUncommitted`
283
+ // and `deviceOf`. The calls are deliberately unqualified.
284
+
285
+ /** `stat`, following symlinks: a symlinked worktree still owns its contents. */
286
+ export async function statDirectory(path: string): Promise<Stats> {
287
+ return stat(path);
288
+ }
289
+
290
+ /** `readdir`, listing the quarantine copy for the in-band deletion pass. */
291
+ export async function readDirectory(path: string): Promise<Dirent[]> {
292
+ return readdir(path, { withFileTypes: true });
293
+ }
294
+
295
+ /** `rename`, the quarantine step and, on a mismatch, the way back. */
296
+ export async function renamePath(from: string, to: string): Promise<void> {
297
+ await rename(from, to);
298
+ }
299
+
300
+ /**
301
+ * The deletion mechanics: `rm` with a literal `force: true`, because a path
302
+ * this function is authorized to delete may legitimately be gone already.
303
+ */
304
+ export async function removeTree(path: string): Promise<void> {
305
+ await rm(path, { recursive: true, force: true });
306
+ }