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.
package/src/device.ts ADDED
@@ -0,0 +1,98 @@
1
+ /**
2
+ * The shared filesystem primitives both layers of the plugin sit on: the tool
3
+ * (`spawn_workspace`'s pre-create device guard) and the strategy (the `cow`
4
+ * create pre-flight). Both need the same device-identity questions answered,
5
+ * so the primitives live here — a module neither layer owns — instead of the
6
+ * strategy importing them from the tool layer, which would invert the
7
+ * layering: the tool composes the strategy; the strategy must not import from
8
+ * it.
9
+ *
10
+ * Policy lives above these primitives: `verifySameDevice` and
11
+ * `verifyCowSameDevice` — which mechanisms the device rule applies to — are
12
+ * tool-layer decisions and stay in `tool.ts`.
13
+ */
14
+ import { stat } from "node:fs/promises";
15
+ import { dirname } from "node:path";
16
+
17
+ /**
18
+ * The device a path's filesystem belongs to, or `undefined` when it cannot be
19
+ * read (a path that does not exist yet, or a permission failure).
20
+ */
21
+ export async function deviceOf(path: string): Promise<number | undefined> {
22
+ try {
23
+ const stats = await stat(path);
24
+ return stats.dev;
25
+ } catch {
26
+ return undefined;
27
+ }
28
+ }
29
+
30
+ /**
31
+ * The actionable `cow` failure for a clone whose target sits on a different
32
+ * filesystem than its source. A reflink cannot cross a device boundary, so the
33
+ * remedy is always to move the target — never to fall back silently.
34
+ *
35
+ * Shared by the tool's pre-create guard and the strategy's own pre-flight so
36
+ * the two name the same cause and the same fix.
37
+ */
38
+ export function crossDeviceError(source: string, target: string): Error {
39
+ return new Error(
40
+ `cow cannot clone ${source} into ${target}: the target is on a different ` +
41
+ "filesystem and a reflink cannot cross devices. Point opencode2's " +
42
+ "`worktree.directory` config or the `targetRoot` plugin option (opencode.json) " +
43
+ "at a directory on the source's filesystem.",
44
+ );
45
+ }
46
+
47
+ /**
48
+ * The one cross-device rule: a `cow` clone may cross no device boundary.
49
+ * `undefined` on either side is unknown, not a mismatch.
50
+ */
51
+ export function assertSameDevice(
52
+ source: string,
53
+ sourceDevice: number | undefined,
54
+ target: string,
55
+ targetDevice: number | undefined,
56
+ ): void {
57
+ if (
58
+ sourceDevice === undefined ||
59
+ targetDevice === undefined ||
60
+ sourceDevice === targetDevice
61
+ ) {
62
+ return;
63
+ }
64
+ throw crossDeviceError(source, target);
65
+ }
66
+
67
+ /**
68
+ * Resolves a target that may not exist yet to the first ancestor whose device
69
+ * can be read, so the strategy's pre-flight checks the filesystem the target
70
+ * will actually land on. The root's parent is itself, so the walk terminates;
71
+ * a fully unreadable chain returns `undefined` and the pre-flight proceeds.
72
+ */
73
+ export async function nearestExistingDevice(
74
+ path: string,
75
+ probeDevice: (path: string) => Promise<number | undefined>,
76
+ ): Promise<number | undefined> {
77
+ let current = path;
78
+ for (;;) {
79
+ const device = await probeDevice(current);
80
+ if (device !== undefined) return device;
81
+ const parent = dirname(current);
82
+ if (parent === current) return undefined;
83
+ current = parent;
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Whether a path exists and is a directory. The live binding for the tool's
89
+ * `isDirectory` seam, alongside `deviceOf`: a path occupied by a file and a
90
+ * path that is not there at all are the same answer — no existing Worktree.
91
+ */
92
+ export async function isDirectory(path: string): Promise<boolean> {
93
+ try {
94
+ return (await stat(path)).isDirectory();
95
+ } catch {
96
+ return false;
97
+ }
98
+ }
package/src/dirty.ts ADDED
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The decision half of `cow`'s `remove`, kept pure — in the style of
3
+ * `entry-kind.ts` — so the refusal table can be tested without a filesystem or
4
+ * a git repository.
5
+ *
6
+ * opencode2's `force` means "proceed despite uncommitted changes", which is not
7
+ * `node:fs` `rm`'s `force` ("ignore a nonexistent path"). The built-in `git`
8
+ * strategy maps it to `git worktree remove --force` and refuses when the
9
+ * worktree is dirty; `cow` must refuse at `force: false` for the same reason,
10
+ * or it silently deletes uncommitted work.
11
+ *
12
+ * A probe that cannot answer (no git metadata, a failing or timed-out `git`, a
13
+ * machine without git) yields `undefined`, which is deliberately treated as
14
+ * dirty: deleting on an unknown is the data loss this guards against.
15
+ */
16
+
17
+ /** The changed paths the probe found, or `undefined` when it could not tell. */
18
+ export type UncommittedChanges = readonly string[] | undefined;
19
+
20
+ /** Whether `remove` may proceed, and why not when it may not. */
21
+ export type RemoveDecision =
22
+ | { readonly remove: true }
23
+ | { readonly remove: false; readonly reason: string };
24
+
25
+ /** How many changed paths the refusal names as examples. */
26
+ const EXAMPLE_PATHS = 3;
27
+
28
+ /**
29
+ * Decides whether `remove` may delete a directory.
30
+ *
31
+ * `force: true` is the user's confirmation and always allows removal.
32
+ * Otherwise a non-empty change list refuses, and so does `undefined` — an
33
+ * unknown dirty state is never safe to delete. Only a probe that positively
34
+ * reported no changes allows removal.
35
+ */
36
+ export function mayRemove(input: {
37
+ readonly force: boolean;
38
+ readonly uncommitted: UncommittedChanges;
39
+ }): RemoveDecision {
40
+ if (input.force) return { remove: true };
41
+ if (input.uncommitted === undefined) {
42
+ return {
43
+ remove: false,
44
+ reason:
45
+ "its uncommitted changes could not be determined (no git metadata, or the git probe failed)",
46
+ };
47
+ }
48
+ if (input.uncommitted.length === 0) return { remove: true };
49
+ const examples = input.uncommitted.slice(0, EXAMPLE_PATHS).join(", ");
50
+ return {
51
+ remove: false,
52
+ reason: `${input.uncommitted.length} uncommitted change(s) (e.g. ${examples})`,
53
+ };
54
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * How a directory entry is materialised by the clone walker, kept a pure
3
+ * decision so it can be tested without a real filesystem.
4
+ *
5
+ * `readdir` reports an entry's type on the filesystems this tool targets, but
6
+ * the POSIX contract allows it to report UNKNOWN (all of `isSymbolicLink`,
7
+ * `isDirectory` and `isFile` false). When that happens `stat` is the
8
+ * authoritative fallback; it must be `lstat`, which never follows a symlink,
9
+ * or a link would be classified by its target.
10
+ */
11
+
12
+ /** How a directory entry is materialised by the clone walker. */
13
+ export type EntryKind = "file" | "directory" | "symlink";
14
+
15
+ /** The slice of a `readdir` Dirent this decision reads. */
16
+ export interface EntryLike {
17
+ isSymbolicLink(): boolean;
18
+ isDirectory(): boolean;
19
+ isFile(): boolean;
20
+ }
21
+
22
+ /** The slice of `lstat` this decision reads; `lstat` never follows a symlink. */
23
+ export interface StatLike {
24
+ isSymbolicLink(): boolean;
25
+ isDirectory(): boolean;
26
+ }
27
+
28
+ export async function entryKind(entry: EntryLike, stat: () => Promise<StatLike>): Promise<EntryKind> {
29
+ if (entry.isSymbolicLink()) return "symlink";
30
+ if (entry.isDirectory()) return "directory";
31
+ if (entry.isFile()) return "file";
32
+ const stats = await stat();
33
+ if (stats.isSymbolicLink()) return "symlink";
34
+ if (stats.isDirectory()) return "directory";
35
+ return "file";
36
+ }
package/src/hooks.ts ADDED
@@ -0,0 +1,216 @@
1
+ /**
2
+ * The post-create hook runner — the impure tail of the `cow` create flow.
3
+ *
4
+ * Commands the plugin option `hooks.postCreate` configures, run against every
5
+ * Worktree the strategy materializes — the tail of the create flow, so every
6
+ * entry path (the HTTP API, the TUI, and the `spawn_workspace` tool, which
7
+ * registers the same strategy object) gets them. Attach never reaches this
8
+ * code: it binds a session to an existing directory and clones nothing.
9
+ *
10
+ * Split out of `strategy.ts` so the strategy is the thin
11
+ * composition layer: it closes over the validated hook list and calls
12
+ * `runPostCreateHooks` after a successful clone; this module owns the
13
+ * mechanics of running the commands and turning a failure into the
14
+ * leave-nothing-behind error.
15
+ */
16
+ import { execFile } from "node:child_process";
17
+ import type { ExecFileOptionsWithStringEncoding } from "node:child_process";
18
+ import { rm } from "node:fs/promises";
19
+ import { resolve } from "node:path";
20
+ import { promisify } from "node:util";
21
+
22
+ const run = promisify(execFile);
23
+
24
+ /**
25
+ * Runs the configured hooks sequentially against a freshly cloned worktree.
26
+ *
27
+ * Each command runs via `sh -c`, with the worktree as its cwd and
28
+ * `COW_WORKTREE_PATH` / `COW_SOURCE_DIRECTORY` (both absolute) in its
29
+ * environment, so a command that needs a per-project value can read one. The
30
+ * first failure aborts creation: the just-created clone is removed — the same
31
+ * leave-nothing-behind contract as the clone's own failure path — and the
32
+ * error names the failed command and its 1-based step.
33
+ */
34
+ export async function runPostCreateHooks(
35
+ postCreate: readonly string[],
36
+ worktree: string,
37
+ sourceDirectory: string,
38
+ ): Promise<void> {
39
+ for (const [index, command] of postCreate.entries()) {
40
+ try {
41
+ await runHookCommand(command, worktree, sourceDirectory);
42
+ } catch (cause) {
43
+ throw await failedCreate(worktree, command, index + 1, postCreate.length, cause);
44
+ }
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Turns a hook failure into the create flow's error, after the same
50
+ * leave-nothing-behind cleanup as the clone's own failure path. The in-place
51
+ * `rm` — not the quarantine of src/removal.ts — is acceptable here:
52
+ * the directory is a seconds-old clone the strategy itself just created, so
53
+ * the provenance is known and there is no audit gap to protect. When the
54
+ * cleanup itself fails, the hook error still wins: the message keeps the
55
+ * command, step, and output, notes the failed cleanup, and names where the
56
+ * remains are.
57
+ */
58
+ async function failedCreate(
59
+ worktree: string,
60
+ command: string,
61
+ step: number,
62
+ total: number,
63
+ cause: unknown,
64
+ ): Promise<Error> {
65
+ const failure = hookFailure(command, step, total, cause);
66
+ try {
67
+ await rm(worktree, { recursive: true, force: true });
68
+ } catch (cleanup) {
69
+ const detail = cleanup instanceof Error ? cleanup.message : String(cleanup);
70
+ return new Error(
71
+ `${failure.message} Removing the worktree also failed (${detail}), so ` +
72
+ `the remains are left at ${worktree}.`,
73
+ { cause },
74
+ );
75
+ }
76
+ return failure;
77
+ }
78
+
79
+ /**
80
+ * How long one post-create command may run before it is killed and counts as a
81
+ * failure, with the same cleanup as any other failure. The primary use case is
82
+ * dependency installation — a cold `corepack use pnpm@latest` or
83
+ * `bun install` can legitimately run for minutes — so the budget is generous;
84
+ * a command that still hangs after five minutes is stuck, not slow.
85
+ */
86
+ const HOOK_TIMEOUT_MS = 300_000;
87
+
88
+ /**
89
+ * How much of a hook's combined output `execFile` may buffer before the
90
+ * command is rejected as an output-limit failure. Node's default, made
91
+ * explicit so the failure message can name the number.
92
+ */
93
+ const HOOK_MAX_BUFFER = 1024 * 1024;
94
+
95
+ /**
96
+ * Runs one hook command. Exported as the seam the timeout test drives with a
97
+ * short explicit timeout — the production path always uses `HOOK_TIMEOUT_MS`
98
+ * through the default parameter, like `probeUncommitted`'s injected seam.
99
+ *
100
+ * The command gets no stdin: the script opens with `exec 0</dev/null`, and
101
+ * `stdio[0] = "ignore"` backs it up on runtimes that honor it (Bun 1.4.2 does
102
+ * not — without the redirect, a hook that reads stdin hangs to the timeout).
103
+ * `worktree` and `sourceDirectory` are resolved to absolute before they reach
104
+ * the cwd or the environment, so the absolute-path contract holds by
105
+ * construction whatever the caller handed over.
106
+ */
107
+ export async function runHookCommand(
108
+ command: string,
109
+ worktree: string,
110
+ sourceDirectory: string,
111
+ timeoutMs: number = HOOK_TIMEOUT_MS,
112
+ ): Promise<void> {
113
+ const worktreePath = resolve(worktree);
114
+ const sourcePath = resolve(sourceDirectory);
115
+ // `execFile`'s types omit `stdio` (its promise overloads assume piped
116
+ // streams), but the runtime accepts it; stdin "ignore" backs up the
117
+ // in-script redirect on runtimes that honor it — Bun 1.4.2 does not.
118
+ const options = {
119
+ cwd: worktreePath,
120
+ encoding: "utf8",
121
+ timeout: timeoutMs,
122
+ maxBuffer: HOOK_MAX_BUFFER,
123
+ stdio: ["ignore", "pipe", "pipe"],
124
+ env: {
125
+ ...process.env,
126
+ COW_WORKTREE_PATH: worktreePath,
127
+ COW_SOURCE_DIRECTORY: sourcePath,
128
+ },
129
+ } as unknown as ExecFileOptionsWithStringEncoding;
130
+ await run("sh", ["-c", `exec 0</dev/null; ${command}`], options);
131
+ }
132
+
133
+ /**
134
+ * What `execFile`'s rejection carries about a failed, signalled, or killed
135
+ * command — the shapes Bun 1.4.2 actually produces (probed at runtime):
136
+ *
137
+ * - exit failure: `code` is the numeric exit status, `signal` absent or null.
138
+ * - signal death: `signal` is the signal name, `code` null, `killed` false.
139
+ * - timeout kill: `killed` true, `signal` "SIGTERM", `code` null.
140
+ * - output past `maxBuffer`: `code` names the limit (Bun:
141
+ * "ERR_CHILD_PROCESS_STDIO_MAXBUFFER"; Node: "ENOBUFS").
142
+ */
143
+ interface CommandFailure {
144
+ readonly killed?: boolean;
145
+ readonly signal?: unknown;
146
+ readonly code?: unknown;
147
+ readonly stdout?: unknown;
148
+ readonly stderr?: unknown;
149
+ }
150
+
151
+ /**
152
+ * The codes `execFile` rejects with when a stream exceeds `maxBuffer`, across
153
+ * the runtimes this plugin runs on.
154
+ */
155
+ const MAXBUFFER_CODES = new Set(["ERR_CHILD_PROCESS_STDIO_MAXBUFFER", "ENOBUFS"]);
156
+
157
+ /**
158
+ * Why a hook command failed, ordered by what the real rejection shapes decide
159
+ * it: an output-limit rejection names itself in `code` before anything else;
160
+ * `killed` separates the timeout kill (which also carries a signal) from a
161
+ * signal death; a numeric `code` is an exit status. The "300s" wording is
162
+ * truthful everywhere this can be produced: `hookFailure` is only reached
163
+ * through `runPostCreateHooks`, which always runs hooks with the
164
+ * `HOOK_TIMEOUT_MS` default — the injected-timeout seam bypasses it.
165
+ */
166
+ function failureReason(failure: CommandFailure): string {
167
+ const code = failure.code;
168
+ if (typeof code === "string" && MAXBUFFER_CODES.has(code)) {
169
+ return "output limit exceeded (1 MiB)";
170
+ }
171
+ if (failure.killed) return "timed out after 300s";
172
+ if (failure.signal != null) return `terminated by ${String(failure.signal)}`;
173
+ if (typeof code === "number") return `exit code ${code}`;
174
+ return `terminated by ${String(code)}`;
175
+ }
176
+
177
+ /**
178
+ * The hook failure: which command, which 1-based step, how it failed, and —
179
+ * because a hook's output is the only record of what a failed setup tried to
180
+ * say — its captured stdout and stderr.
181
+ */
182
+ function hookFailure(
183
+ command: string,
184
+ step: number,
185
+ total: number,
186
+ cause: unknown,
187
+ ): Error {
188
+ const failure = cause as CommandFailure;
189
+ return new Error(
190
+ `post-create hook failed (step ${step} of ${total}): ${command} ` +
191
+ `(${failureReason(failure)})` +
192
+ capturedOutput(failure),
193
+ { cause },
194
+ );
195
+ }
196
+
197
+ /** How much of a failed hook's combined output an error message carries. */
198
+ const CAPTURE_LIMIT = 2048;
199
+
200
+ /**
201
+ * The captured stdout and stderr of a failed hook; absent when both are empty.
202
+ * A hook's output is the only record of what a failed setup tried to say, but
203
+ * a megabyte of it does not belong inside an error message, so past
204
+ * `CAPTURE_LIMIT` only the last chunk is kept, behind a truncation marker.
205
+ */
206
+ function capturedOutput(failure: CommandFailure): string {
207
+ const streams = [failure.stdout, failure.stderr]
208
+ .map((stream) => (typeof stream === "string" ? stream.trim() : ""))
209
+ .filter((stream) => stream !== "");
210
+ if (streams.length === 0) return "";
211
+ const capture = streams.join("\n");
212
+ if (capture.length > CAPTURE_LIMIT) {
213
+ return `\n--- output ---\n…output truncated…\n${capture.slice(-CAPTURE_LIMIT)}`;
214
+ }
215
+ return `\n--- output ---\n${capture}`;
216
+ }
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export { default } from "./plugin";
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Which mechanism produced a Worktree directory.
3
+ *
4
+ * `cow` is a Deep clone (extents shared with the source, ignored files
5
+ * carried); `git` is a Shallow worktree produced by opencode2's built-in
6
+ * strategy. The value is reported to the caller so an agent that relies on
7
+ * ignored state knows whether it actually has it.
8
+ */
9
+ export type Mechanism = "cow" | "git";
@@ -0,0 +1,142 @@
1
+ /**
2
+ * The real macOS `copyfile(3)` binding, isolated from the decision logic in
3
+ * `platform-darwin.ts`.
4
+ *
5
+ * This module is inert on import: importing `bun:ffi` is fine on Linux, and
6
+ * nothing here calls `dlopen`. The library is loaded only when
7
+ * `loadCopyfileLibrary()` is called, which happens only when a Darwin process
8
+ * actually selects this backend. On Linux that call fails, because
9
+ * `/usr/lib/libSystem.B.dylib` does not exist.
10
+ *
11
+ * LIMITATIONS, stated plainly because the real `copyfile(3)` cannot be executed
12
+ * here (only the loader seam can be exercised):
13
+ *
14
+ * - `bun:ffi` does not throw on a failing C call and does not populate
15
+ * `.code`; it returns the raw result and leaves `errno` for the caller. The
16
+ * errno must therefore be read explicitly through `__error()` and mapped to a
17
+ * name. (This behavior was confirmed locally against `libc.so.6`: a failing
18
+ * `open(2)` returned `-1` with no thrown error and no `.code`.)
19
+ * - `COPYFILE_STATE_WAS_CLONED` is a *state* query, not a return bit. With
20
+ * `COPYFILE_CLONE_FORCE`, a 0 return already means the clone happened (the
21
+ * flag fails rather than falls back), so no state handle is needed.
22
+ * - None of this is verified against a real `copyfile(3)`; that requires a
23
+ * human on real APFS hardware.
24
+ */
25
+ import { dlopen, FFIType, read } from "bun:ffi";
26
+ import { DARWIN_CLONE_FLAGS } from "./platform-darwin";
27
+ import type { DarwinCloneSyscall } from "./platform-darwin";
28
+
29
+ /** The `copyfile(3)` / `__error(3)` entry points this binding needs. */
30
+ export interface CopyfileLibrary {
31
+ readonly copyfile: (
32
+ source: string,
33
+ destination: string,
34
+ state: null,
35
+ flags: number,
36
+ ) => number;
37
+ readonly __error: () => number;
38
+ }
39
+
40
+ /**
41
+ * The loader seam, modelled on `dlopen` itself so the real call is exercised by
42
+ * a test with a recording stand-in.
43
+ */
44
+ export type LibraryLoad = (
45
+ path: string,
46
+ definitions: unknown,
47
+ ) => { readonly symbols: unknown };
48
+
49
+ /**
50
+ * The `bun:ffi` definition for libSystem's `copyfile` and its errno accessor
51
+ * (XNU bsd/sys/errno.h: `#define errno (*__error())`).
52
+ */
53
+ const COPYFILE_DEFINITION = {
54
+ copyfile: { args: ["cstring", "cstring", "ptr", "u32"], returns: "i32" },
55
+ __error: { args: [], returns: FFIType.ptr },
56
+ } as const;
57
+
58
+ /**
59
+ * The resolved binding, held for the process lifetime. `cloneFileOnDarwin`
60
+ * resolves the syscall per file, so without this cache `dlopen` would run on
61
+ * every cloned file. libSystem is a loaded image rather than a library that is
62
+ * opened, so this is not a correctness issue — but the handle is also never
63
+ * `dlclose`d on purpose, because `copyfile` is called through a small libc
64
+ * trampoline and unloading libSystem while it is still mapped would be a
65
+ * use-after-free. Caching states that intent instead of leaving it to the
66
+ * refcount.
67
+ */
68
+ let loaded: CopyfileLibrary | undefined;
69
+
70
+ /**
71
+ * Loads the macOS `copyfile(3)` binding out of libSystem, once per process.
72
+ *
73
+ * The `load` seam defaults to the real `dlopen`, so production code loads the
74
+ * library while a test can inject a recording stand-in and inspect the exact
75
+ * call.
76
+ */
77
+ export function loadCopyfileLibrary(
78
+ load: LibraryLoad = dlopen as unknown as LibraryLoad,
79
+ ): CopyfileLibrary {
80
+ loaded ??= load("/usr/lib/libSystem.B.dylib", COPYFILE_DEFINITION).symbols as CopyfileLibrary;
81
+ return loaded;
82
+ }
83
+
84
+ /**
85
+ * Drops the cached binding so the next resolution calls `load` again. The
86
+ * cache itself is process-lifetime by design (see above); the reset exists so
87
+ * a test that injected a stand-in cannot leak it into later tests and make
88
+ * the suite order-dependent.
89
+ */
90
+ export function resetCopyfileLibrary(): void {
91
+ loaded = undefined;
92
+ }
93
+
94
+ /**
95
+ * Darwin errno numbers relevant to a clone. The header is XNU
96
+ * `bsd/sys/errno.h`; unmapped numbers become `ERRNO_<n>` so a code is always
97
+ * present and a real failure never looks like a missing one.
98
+ */
99
+ const ERRNO_NAMES: Readonly<Record<number, string>> = {
100
+ 1: "EPERM",
101
+ 2: "ENOENT",
102
+ 5: "EIO",
103
+ 13: "EACCES",
104
+ 17: "EEXIST",
105
+ 18: "EXDEV",
106
+ 20: "ENOTDIR",
107
+ 22: "EINVAL",
108
+ 25: "ENOTTY",
109
+ 28: "ENOSPC",
110
+ 30: "EROFS",
111
+ 45: "ENOTSUP",
112
+ 62: "ELOOP",
113
+ 63: "ENAMETOOLONG",
114
+ 78: "ENOSYS",
115
+ };
116
+
117
+ function errnoCode(symbols: CopyfileLibrary): string {
118
+ const errno = read.i32(symbols.__error());
119
+ return ERRNO_NAMES[errno] ?? `ERRNO_${errno}`;
120
+ }
121
+
122
+ /**
123
+ * Builds the syscall over an already-loaded library. Clones one regular file
124
+ * with `copyfile(3)`, or throws an `Error` carrying the Darwin errno name in
125
+ * `.code`. The capability predicate matches on those names, so `ENOTSUP` (an
126
+ * unsupported filesystem) is classified without the probe ever seeing the raw
127
+ * syscall.
128
+ */
129
+ export function createDarwinSyscall(
130
+ symbols: CopyfileLibrary,
131
+ ): DarwinCloneSyscall {
132
+ return async (source, destination) => {
133
+ if (symbols.copyfile(source, destination, null, DARWIN_CLONE_FLAGS) === 0) {
134
+ return { cloned: true };
135
+ }
136
+ const error = new Error(
137
+ `copyfile(3) failed on ${source} -> ${destination}`,
138
+ ) as Error & { code: string };
139
+ error.code = errnoCode(symbols);
140
+ throw error;
141
+ };
142
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * The macOS CoW backend: one regular file cloned with `copyfile(3)`.
3
+ *
4
+ * `clonefile(2)` is not used here on purpose. Apple discourages it for
5
+ * directories — it blocks the whole tree for the duration of the call — so a
6
+ * Deep clone must be driven entry by entry, and `copyfile(3)` is the supported
7
+ * recursive primitive.
8
+ *
9
+ * The syscall is a seam (`DarwinCloneSyscall`) so the decision logic in
10
+ * `cloneFileOnDarwin` runs and is unit-tested on any platform. The real binding
11
+ * lives in `platform-darwin-ffi.ts` and is imported only when a Darwin process
12
+ * actually selects this backend.
13
+ */
14
+
15
+ /**
16
+ * Flags passed to `copyfile(3)`, from Apple's `copyfile.h`:
17
+ *
18
+ * - `COPYFILE_CLONE_FORCE` (1<<25) is the fail-loud flag: cloning is required,
19
+ * and an unsupported filesystem returns an error instead of silently
20
+ * producing a byte copy. Apple documents it as equivalent to `(COPYFILE_EXCL
21
+ * | COPYFILE_STAT | COPYFILE_XATTR | COPYFILE_DATA | COPYFILE_NOFOLLOW_SRC)`.
22
+ * - `COPYFILE_ALL` (COPYFILE_ACL|COPYFILE_STAT|COPYFILE_XATTR|COPYFILE_DATA) is
23
+ * added because the clone flag's implied set covers STAT/XATTR/DATA but
24
+ * **not ACL**. Apple's header: "ACLs will not be cloned unless COPYFILE_ACL
25
+ * is also passed"; `COPYFILE_ALL` carries that bit. (STAT is already implied,
26
+ * so the POSIX mode — including the executable bit git reads — is preserved
27
+ * either way.)
28
+ * - `COPYFILE_EXCL`, implied by the clone flag, means the destination must not
29
+ * exist — the same precondition the caller's walker already satisfies.
30
+ *
31
+ * Exported so the flag choice is asserted on any platform, without loading the
32
+ * Darwin library.
33
+ */
34
+ export const COPYFILE_ALL = (1 << 0) | (1 << 1) | (1 << 2) | (1 << 3);
35
+ export const COPYFILE_CLONE_FORCE = 1 << 25;
36
+ /** Implied by COPYFILE_CLONE_FORCE; named so the destination precondition is explicit. */
37
+ export const COPYFILE_EXCL = 1 << 17;
38
+ export const DARWIN_CLONE_FLAGS = COPYFILE_ALL | COPYFILE_CLONE_FORCE;
39
+
40
+ /**
41
+ * True when the clone genuinely shared extents with the source.
42
+ */
43
+ export type CloneOutcome = { readonly cloned: boolean };
44
+
45
+ /** A `copyfile(3)` clone of one regular file. Throws the raw failure. */
46
+ export type DarwinCloneSyscall = (
47
+ source: string,
48
+ destination: string,
49
+ ) => Promise<CloneOutcome>;
50
+
51
+ /**
52
+ * The lazily-loaded binding: `loadCopyfileLibrary` and `createDarwinSyscall`
53
+ * from `platform-darwin-ffi.ts`, whose imports are inert so that merely
54
+ * referencing them costs nothing on Linux.
55
+ */
56
+ export type DarwinFfi = {
57
+ readonly loadCopyfileLibrary: (typeof import("./platform-darwin-ffi"))["loadCopyfileLibrary"];
58
+ readonly createDarwinSyscall: (typeof import("./platform-darwin-ffi"))["createDarwinSyscall"];
59
+ };
60
+
61
+ /**
62
+ * Composes the binding into a syscall: load libSystem, then build the call over
63
+ * it. Exported and injectable for the same reason the syscall itself is — it is
64
+ * the wiring `cloneFileOnDarwin` falls back to, and it must be provable without
65
+ * a Mac. The default performs the lazy import, so the macOS-only module is
66
+ * pulled in only when a Darwin process actually selects this backend.
67
+ */
68
+ export async function darwinSyscall(
69
+ loadFfi: () => Promise<DarwinFfi> = () => import("./platform-darwin-ffi"),
70
+ ): Promise<DarwinCloneSyscall> {
71
+ const ffi = await loadFfi();
72
+ return ffi.createDarwinSyscall(ffi.loadCopyfileLibrary());
73
+ }
74
+
75
+ /**
76
+ * Clones one regular file, or throws.
77
+ *
78
+ * Fail closed: when the syscall reports success without a real clone, the
79
+ * target is discarded and an error is thrown. A Deep clone that quietly became
80
+ * a full copy is the exact failure this backend exists to prevent, so it is
81
+ * never returned as success.
82
+ *
83
+ * The binding is resolved only inside this default, so importing this module is
84
+ * inert on Linux; both tests and the platform seam injection point supply their
85
+ * own syscall instead.
86
+ */
87
+ export async function cloneFileOnDarwin(
88
+ source: string,
89
+ destination: string,
90
+ attempt: DarwinCloneSyscall | undefined = undefined,
91
+ ): Promise<void> {
92
+ const syscall = attempt ?? (await darwinSyscall());
93
+ const outcome = await syscall(source, destination);
94
+ if (outcome.cloned) return;
95
+ await rm(destination);
96
+ throw new Error(
97
+ `copyfile(3) did not clone ${source} onto ${destination}; refusing a full copy`,
98
+ );
99
+ }
100
+
101
+ async function rm(path: string): Promise<void> {
102
+ const { rm: remove } = await import("node:fs/promises");
103
+ await remove(path, { force: true });
104
+ }