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/LICENSE +21 -0
- package/README.md +251 -0
- package/package.json +44 -0
- package/src/capability.ts +140 -0
- package/src/clone.ts +126 -0
- package/src/config.ts +92 -0
- package/src/device.ts +98 -0
- package/src/dirty.ts +54 -0
- package/src/entry-kind.ts +36 -0
- package/src/hooks.ts +216 -0
- package/src/index.ts +1 -0
- package/src/mechanism.ts +9 -0
- package/src/platform-darwin-ffi.ts +142 -0
- package/src/platform-darwin.ts +104 -0
- package/src/platform.ts +71 -0
- package/src/plugin.ts +249 -0
- package/src/removal.ts +306 -0
- package/src/strategy.ts +125 -0
- package/src/tool.ts +482 -0
- package/src/uncommitted.ts +84 -0
- package/strategy-badge.ts +28 -0
- package/tui.tsx +47 -0
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";
|
package/src/mechanism.ts
ADDED
|
@@ -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
|
+
}
|