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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Rodrigo Belem
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,251 @@
1
+ # opencode2-cow-worktree
2
+
3
+ A copy-on-write worktree strategy for [opencode2](https://opencode.ai). Each
4
+ agent gets a complete, independent copy of the project: tracked files,
5
+ `node_modules`, build caches, local env files, all of it, cloned in
6
+ milliseconds at almost no disk cost. Where a `git worktree` carries tracked
7
+ files only, a **Deep clone** here carries everything, so the agent can run the
8
+ test suite immediately.
9
+
10
+ In daily use, and published to npm. Install from a local checkout (form A
11
+ below) or from the npm registry.
12
+
13
+ ## Requirements
14
+
15
+ - opencode2's server runs on **Bun**. The macOS backend needs Bun (`bun:ffi`);
16
+ it does not work under Node.
17
+ - Linux: **btrfs**, or XFS with reflink enabled. macOS: **APFS**.
18
+ - The worktree directory must be on the **same filesystem** as the project. A
19
+ reflink cannot cross a device boundary, and the plugin fails loudly rather
20
+ than degrading to a full copy. Configure it as shown below; the default
21
+ location opencode2 picks is usually on a different filesystem.
22
+
23
+ ## Install
24
+
25
+ **What installing changes.** Registering the plugin makes `cow` opencode2's
26
+ default worktree strategy everywhere — TUI, API, and tool calls. There is no
27
+ capability gate on that default: on a filesystem that cannot reflink (ext4,
28
+ tmpfs), worktree creation fails loudly until you remove the plugin. Install
29
+ it only on machines whose projects meet the Requirements above. opencode2
30
+ Desktop cannot select plugin strategies today, so it ignores the plugin
31
+ entirely (`docs/research/desktop-strategy-hardcode.md`).
32
+
33
+ Two forms. Pick one. Having both makes opencode2 load the tree twice, and the
34
+ duplicate load fails.
35
+
36
+ ### Form A: directory discovery
37
+
38
+ 1. Clone this repository somewhere permanent, e.g. `/path/to/opencode2-cow-worktree`.
39
+ 2. Create the plugin directory and its `node_modules`:
40
+
41
+ ```sh
42
+ mkdir -p ~/.config/opencode/plugins/opencode2-cow-worktree/node_modules
43
+ ```
44
+
45
+ 3. Create two one-line seam files in the plugin directory. The first loads
46
+ the server plugin; the second loads its TUI half, which shows a small
47
+ `cow` marker in the sidebar footer while a session runs in a cow
48
+ worktree:
49
+
50
+ ```ts
51
+ // ~/.config/opencode/plugins/opencode2-cow-worktree/index.ts
52
+ export { default } from "opencode2-cow-worktree";
53
+ ```
54
+
55
+ ```tsx
56
+ // ~/.config/opencode/plugins/opencode2-cow-worktree/tui.tsx
57
+ export { default } from "opencode2-cow-worktree/tui";
58
+ ```
59
+
60
+ The server works without the second file; skip it if you do not want the
61
+ marker.
62
+
63
+ 4. Symlink the checkout into `node_modules` so the bare specifiers resolve:
64
+
65
+ ```sh
66
+ ln -sfn /path/to/opencode2-cow-worktree \
67
+ ~/.config/opencode/plugins/opencode2-cow-worktree/node_modules/opencode2-cow-worktree
68
+ ```
69
+
70
+ Because opencode2's runtime is Bun and the symlink points at the working tree,
71
+ tracked edits are live with no build step.
72
+
73
+ This form runs with default options. To set options, use form B.
74
+
75
+ ### Form B: the `plugins` array (required for options)
76
+
77
+ Point a `plugins` entry at the checkout itself — no symlink, no seam files:
78
+
79
+ ```json
80
+ {
81
+ "plugins": [
82
+ {
83
+ "package": "/path/to/opencode2-cow-worktree",
84
+ "options": {
85
+ "hooks": { "postCreate": ["corepack use pnpm@latest"] }
86
+ }
87
+ }
88
+ ]
89
+ }
90
+ ```
91
+
92
+ `hooks`, `fallback`, and `targetRoot` are all optional; anything omitted takes
93
+ its default. Each is described below.
94
+
95
+ ### Verify
96
+
97
+ ```sh
98
+ bun scripts/dogfood-install-check.ts
99
+ ```
100
+
101
+ This boots a throwaway server against the installed plugin and asserts that it
102
+ activates (`GET /api/plugin` reports `state.status: "active"`) and that a
103
+ worktree create with no `strategy` field produces a Deep clone, which proves
104
+ `cow` became the default strategy.
105
+
106
+ Two gotchas when checking by hand: `GET /api/plugin` does not await
107
+ activation, so a list taken right after boot can look empty — resolve
108
+ `POST /api/plugin/await-activation` first. And if the plugin is present both as
109
+ a discovered directory and in the `plugins` array, one of the two loads fails
110
+ with `Plugin failed to load`; remove one of the declarations.
111
+
112
+ ## Configure
113
+
114
+ ### `worktree.directory` — set this first
115
+
116
+ opencode2's default worktree parent lives under its data directory, which is
117
+ often on a different filesystem from your projects. Point it inside the
118
+ project's own filesystem:
119
+
120
+ ```json
121
+ { "worktree": { "directory": ".opencode/worktrees" } }
122
+ ```
123
+
124
+ A relative value resolves against the project checkout, which puts every clone
125
+ on the source's filesystem by construction. An absolute value is used as-is
126
+ and must be on the same filesystem as each project. Without this, `cow`
127
+ creates fail on most setups while the built-in `git` strategy keeps working.
128
+
129
+ ### Plugin `options`
130
+
131
+ All three options are validated when the plugin loads. A malformed value fails
132
+ the plugin load with a message naming the option; it never degrades silently.
133
+
134
+ **`fallback`** — what `spawn_workspace` does when the source filesystem cannot
135
+ clone (default `"none"`):
136
+
137
+ - `"none"`: a request for `cow` produces a Deep clone or fails. Never a
138
+ shallow worktree.
139
+ - `"git"`: on a non-CoW filesystem the tool may build a regular `git`
140
+ worktree instead and report `mechanism: "git"`.
141
+
142
+ **`targetRoot`** — where `spawn_workspace` places the worktree. Unset (the
143
+ default) means a sibling of the source, on the source's filesystem by
144
+ construction. A path is used verbatim and must share the source's filesystem
145
+ for `cow`.
146
+
147
+ **`hooks.postCreate`** — commands run at the end of every `cow` create,
148
+ whatever started it (HTTP API, TUI, `spawn_workspace`):
149
+
150
+ ```json
151
+ {
152
+ "plugins": [
153
+ {
154
+ "package": "/path/to/opencode2-cow-worktree",
155
+ "options": {
156
+ "hooks": {
157
+ "postCreate": [
158
+ "corepack use pnpm@latest",
159
+ "cp $COW_SOURCE_DIRECTORY/.env.local ."
160
+ ]
161
+ }
162
+ }
163
+ }
164
+ ]
165
+ }
166
+ ```
167
+
168
+ Commands run sequentially via `sh -c` in the new worktree, with
169
+ `COW_WORKTREE_PATH` and `COW_SOURCE_DIRECTORY` (absolute) in the environment.
170
+ A five-minute timeout applies per command; stdin is detached, so a command
171
+ that waits on input fails instead of hanging. The first failure removes the
172
+ just-created clone (no orphan directory) and the error names the failed
173
+ command, its 1-based step, and its captured output.
174
+
175
+ Hooks are your own configuration and run with full shell rights inside the
176
+ new worktree: treat the list like a shell script you wrote.
177
+
178
+ **Per-project values**: opencode2 merges plugin `options` from a project-level
179
+ `opencode.json` the same way as the global one, so a project can declare its
180
+ own `hooks.postCreate` (or `fallback`/`targetRoot`) and every other project
181
+ keeps the global default.
182
+
183
+ ## Using it
184
+
185
+ Registering the plugin makes `cow` the **default strategy**: a worktree create
186
+ from the TUI, the API, or a tool call materializes a Deep clone. Passing
187
+ `strategy: "git"` explicitly still selects opencode2's built-in strategy.
188
+
189
+ **`spawn_workspace`** (for agents): creates a worktree and starts a session in
190
+ it, returning `{ sessionID, directory, mechanism, attached }`. `mechanism`
191
+ tells a `cow` Deep clone from a `git` shallow worktree, so an agent that
192
+ relies on ignored files knows whether it has them.
193
+
194
+ If the requested name already belongs to a cow worktree, the call attaches: a
195
+ new session binds to the existing directory and `attached: true` comes back —
196
+ nothing is cloned. Attach only happens for worktrees this strategy
197
+ materialized; anything else already at that path (a `git` worktree, an unknown
198
+ directory) is refused before anything changes.
199
+
200
+ A create whose target path already exists is refused before the first write:
201
+ `cow` never merges into, or deletes, a directory it did not create. Resolve
202
+ the path and re-run.
203
+
204
+ **`list_worktrees`**: lists the location's cow worktrees — `name` (the
205
+ directory basename), `directory`, `strategy`, and `createdAt`. Derived from
206
+ opencode2's inventory alone; it has no session information.
207
+
208
+ **Removal**: the strategy refuses to delete a worktree with uncommitted
209
+ changes unless you confirm with force — and if it cannot tell (no git
210
+ metadata, a failed probe), it refuses too. Past that guard the directory is
211
+ renamed to a sibling `.cow-removing-<name>-<random>` and deleted from there,
212
+ so an agent holding a working directory inside does not block the removal.
213
+ `node_modules`-class directories are deleted in the background right after;
214
+ a `.cow-removing-…` sibling that lingers means a deletion failed midway and
215
+ its error was logged — the remains hold nothing else and are safe to delete
216
+ by hand once no process is using them.
217
+
218
+ The fallback policy is tool-only: `POST /api/worktree {strategy: "cow"}` calls
219
+ the strategy directly, which always fails loudly on a non-CoW source
220
+ regardless of `fallback`. Only `spawn_workspace` consults the policy.
221
+
222
+ ## Troubleshooting
223
+
224
+ - **"the target is on a different filesystem"** — set `worktree.directory` as
225
+ shown above, or point `targetRoot` at the source's filesystem.
226
+ - **`cow` fails on an ext4 or tmpfs project** — expected: that filesystem
227
+ cannot clone. Use the `git` fallback for tool calls, or let the project use
228
+ the built-in strategy.
229
+ - **Plugin looks absent right after boot** — resolve
230
+ `POST /api/plugin/await-activation` before reading `GET /api/plugin`.
231
+ - **`Plugin failed to load`** — the plugin is declared twice (discovered
232
+ directory plus `plugins` array). Keep one.
233
+ - **A `.cow-removing-…` directory that will not go away** — a background
234
+ deletion failed; the server log names the cause. Delete it by hand once no
235
+ agent holds a directory inside it.
236
+
237
+ ## Development and verification
238
+
239
+ The unit suite (`bun test`), typecheck (`bun run typecheck`), coverage gate
240
+ (`bun run test:coverage`), and the e2e harness (`bun scripts/e2e/harness.ts`)
241
+ are described in [`docs/development.md`](docs/development.md), along with the
242
+ recorded live runs and the parallel-lane tooling this repository is developed
243
+ with.
244
+
245
+ Terms the output uses: a **Workspace** is a logical handle, a **Location** is
246
+ where a session runs, and a **Worktree** is a directory materialized by a
247
+ **Strategy** — more in [CONTEXT.md](CONTEXT.md).
248
+
249
+ ## License
250
+
251
+ MIT
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "opencode2-cow-worktree",
3
+ "version": "0.1.0",
4
+ "description": "Copy-on-write worktree strategy for opencode2: a Deep clone of the whole working directory, ignored files included, so parallel agents start ready to run",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./src/index.ts",
8
+ "./tui": "./tui.tsx"
9
+ },
10
+ "files": [
11
+ "src",
12
+ "tui.tsx",
13
+ "strategy-badge.ts",
14
+ "README.md",
15
+ "LICENSE"
16
+ ],
17
+ "scripts": {
18
+ "test": "bun test",
19
+ "test:coverage": "bun test --coverage --coverage-reporter=lcov && bun scripts/check-coverage.ts",
20
+ "typecheck": "tsc --noEmit"
21
+ },
22
+ "keywords": [
23
+ "opencode",
24
+ "opencode2",
25
+ "plugin",
26
+ "worktree",
27
+ "copy-on-write",
28
+ "reflink",
29
+ "ficlone",
30
+ "agents"
31
+ ],
32
+ "license": "MIT",
33
+ "devDependencies": {
34
+ "@opencode-ai/plugin": "^0.0.0-beta-17639",
35
+ "@opentui/core": "0.5.11",
36
+ "@opentui/solid": "0.5.11",
37
+ "@types/bun": "latest",
38
+ "solid-js": "1.9.15",
39
+ "typescript": "^5.9.2"
40
+ },
41
+ "peerDependencies": {
42
+ "@opencode-ai/plugin": ">=0.0.0-beta-17639"
43
+ }
44
+ }
@@ -0,0 +1,140 @@
1
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { reflinkFile } from "./clone";
4
+
5
+ /**
6
+ * The outcome of a CoW capability probe.
7
+ *
8
+ * - `supported`: a clone attempt succeeded.
9
+ * - `unsupported`: the clone attempt failed in a way that definitively means
10
+ * the filesystem cannot satisfy a CoW clone.
11
+ * - `error`: any other failure — permissions, a missing path, I/O. Never
12
+ * collapsed into `unsupported`.
13
+ */
14
+ export type CowCapability =
15
+ | { readonly status: "supported" }
16
+ | { readonly status: "unsupported" }
17
+ | { readonly status: "error"; readonly error: Error };
18
+
19
+ /**
20
+ * Performs one clone of `source` onto `destination`, throwing on failure.
21
+ * Injectable so tests can drive the unsupported and error branches without a
22
+ * filesystem that lacks CoW support.
23
+ */
24
+ export type CowCloneAttempt = (
25
+ source: string,
26
+ destination: string,
27
+ ) => Promise<void>;
28
+
29
+ /**
30
+ * Default clone operation: `reflinkFile`, which is the platform's forced CoW
31
+ * clone — `COPYFILE_FICLONE_FORCE` on Linux, `copyfile(3)` with
32
+ * `COPYFILE_CLONE_FORCE` on macOS. Both fail instead of silently degrading to a
33
+ * byte copy, which is the semantics this predicate needs; a best-try flag would
34
+ * report `supported` on a filesystem without CoW support.
35
+ */
36
+ const cloneWithReflink: CowCloneAttempt = reflinkFile;
37
+
38
+ const PROBE_PREFIX = ".opencode2-cow-capability-";
39
+
40
+ /** Failures that definitively mean the clone operation is not supported. */
41
+ const NOT_SUPPORTED_CODES = new Set([
42
+ "EOPNOTSUPP",
43
+ "ENOTSUP",
44
+ "ENOTTY",
45
+ "EINVAL",
46
+ "EXDEV",
47
+ "ENOSYS",
48
+ ]);
49
+
50
+ const cache = new Map<string, Promise<CowCapability>>();
51
+
52
+ /**
53
+ * Answers whether `directory`'s filesystem can satisfy a CoW clone, by
54
+ * attempting one of a small temporary file inside that directory. Never throws:
55
+ * a failure is reported as `unsupported` or `error`. Terminal verdicts
56
+ * (`supported`, `unsupported`) are cached per directory; an `error` verdict is
57
+ * deliberately not — it is the one class defined as transient (permissions, a
58
+ * missing path, an I/O hiccup), so caching it would disable this directory
59
+ * until the server restarted over one bad moment. Concurrent first callers
60
+ * share the one in-flight probe, and only its terminal verdict is kept.
61
+ */
62
+ export function probeCowCapability(
63
+ directory: string,
64
+ attempt: CowCloneAttempt = cloneWithReflink,
65
+ ): Promise<CowCapability> {
66
+ const cached = cache.get(directory);
67
+ if (cached !== undefined) return cached;
68
+ const pending = probe(directory, attempt);
69
+ // Stored before the first await so concurrent callers share this probe
70
+ // instead of each starting their own. When it settles on `error`, the entry
71
+ // is dropped: this call and every caller sharing the probe still receive
72
+ // the verdict, but the next call probes again instead of replaying a
73
+ // transient failure forever. The identity check means the eviction can only
74
+ // remove this probe's own entry, never a verdict a newer probe already
75
+ // replaced it with.
76
+ cache.set(directory, pending);
77
+ void pending.then((verdict) => {
78
+ if (verdict.status === "error" && cache.get(directory) === pending) {
79
+ cache.delete(directory);
80
+ }
81
+ });
82
+ return pending;
83
+ }
84
+
85
+ async function probe(
86
+ directory: string,
87
+ attempt: CowCloneAttempt,
88
+ ): Promise<CowCapability> {
89
+ try {
90
+ return await attemptClone(directory, attempt);
91
+ } catch (error) {
92
+ return { status: "error", error: toError(error) };
93
+ }
94
+ }
95
+
96
+ async function attemptClone(
97
+ directory: string,
98
+ attempt: CowCloneAttempt,
99
+ ): Promise<CowCapability> {
100
+ const scratch = await mkdtemp(join(directory, PROBE_PREFIX));
101
+ try {
102
+ const source = join(scratch, "source");
103
+ await writeFile(source, "opencode2-cow-worktree");
104
+ await attempt(source, join(scratch, "clone"));
105
+ return { status: "supported" };
106
+ } catch (error) {
107
+ return classifyCloneFailure(error);
108
+ } finally {
109
+ // The cleanup is best-effort and runs in its own guard: the scratch is
110
+ // dot-prefixed, tiny, and inside the probed directory, so a failed `rm`
111
+ // (say, EACCES on a suddenly read-only parent) is not a capability fact
112
+ // and must never mask the verdict above — least of all by turning a
113
+ // `supported` answer into an `error`.
114
+ try {
115
+ await rm(scratch, { recursive: true, force: true });
116
+ } catch {
117
+ // A leftover scratch is harmless; the verdict stands as reported.
118
+ }
119
+ }
120
+ }
121
+
122
+ function classifyCloneFailure(error: unknown): CowCapability {
123
+ const code = errorCode(error);
124
+ if (code !== undefined && NOT_SUPPORTED_CODES.has(code)) {
125
+ return { status: "unsupported" };
126
+ }
127
+ return { status: "error", error: toError(error) };
128
+ }
129
+
130
+ function errorCode(error: unknown): string | undefined {
131
+ if (typeof error === "object" && error !== null && "code" in error) {
132
+ const { code } = error as { code?: unknown };
133
+ if (typeof code === "string") return code;
134
+ }
135
+ return undefined;
136
+ }
137
+
138
+ function toError(error: unknown): Error {
139
+ return error instanceof Error ? error : new Error(String(error));
140
+ }
package/src/clone.ts ADDED
@@ -0,0 +1,126 @@
1
+ import {
2
+ lstat,
3
+ mkdir,
4
+ readdir,
5
+ readlink,
6
+ symlink,
7
+ } from "node:fs/promises";
8
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
9
+ import { entryKind } from "./entry-kind";
10
+ import { cloneFile } from "./platform";
11
+
12
+ /**
13
+ * CoW-clone one regular file, sharing its extents with `source`.
14
+ *
15
+ * Dispatches to the platform backend: `COPYFILE_FICLONE_FORCE` on Linux, and
16
+ * `copyfile(3)` with `COPYFILE_CLONE_FORCE` on macOS. Both fail instead of
17
+ * falling back to a full byte copy; the error is propagated, never swallowed.
18
+ * A filesystem that cannot share extents must surface as a failure, not as a
19
+ * correct-looking clone made by the wrong mechanism.
20
+ */
21
+ export async function reflinkFile(source: string, target: string): Promise<void> {
22
+ await cloneFile(source, target);
23
+ }
24
+
25
+ /**
26
+ * Deep clone `source` into `target`: recursively reflink every regular file and
27
+ * recreate every directory and symbolic link, `.git` included.
28
+ *
29
+ * Symbolic links are recreated, never followed, so a link in the clone is a
30
+ * link. Hard links are reflinked like any other file. The target directory is
31
+ * created if missing. `.git` is walked like any other directory — the clone
32
+ * gets a genuine standalone metadata directory and no dependency on the
33
+ * source's object store. Throws when reflinking is unavailable; there is no
34
+ * copy fallback.
35
+ *
36
+ * A target inside the source is legitimate — opencode2 resolves a relative
37
+ * `worktree.directory` against the project checkout — so the target subtree is
38
+ * skipped rather than copied into itself. A target that contains the source is
39
+ * rejected: writing the clone over the tree being walked has no coherent
40
+ * meaning.
41
+ *
42
+ * An occupied target is refused before the first filesystem write: `cow`
43
+ * never writes into, or deletes, bytes it did not create. `mkdir` would
44
+ * happily merge into an existing directory and per-file clones would
45
+ * overwrite its files, and a caller's leave-nothing-behind rollback would
46
+ * then run its `rm` over content this call never made — so a pre-existing
47
+ * path (any type, a `lstat` that never follows symlinks) is always the
48
+ * caller's mistake to resolve, and it surfaces here as a refusal naming the
49
+ * path. An entry path whose occupancy cannot even be determined fails closed:
50
+ * it is an error, never an "absent".
51
+ */
52
+ export async function cloneDirectory(source: string, target: string): Promise<void> {
53
+ const from = resolve(source);
54
+ const to = resolve(target);
55
+
56
+ if (from === to) {
57
+ throw new Error(`cannot clone ${from} into itself`);
58
+ }
59
+ if (isInside(from, to)) {
60
+ throw new Error(`cannot clone ${from} into ${to}: the target contains the source`);
61
+ }
62
+ if (await entryExists(to)) {
63
+ throw new OccupiedTargetError(`cannot clone into ${to}: it already exists`);
64
+ }
65
+
66
+ await mkdir(target, { recursive: true });
67
+ await cloneInto(from, to, to);
68
+ }
69
+
70
+ /**
71
+ * True when anything — directory, file, symbolic link — occupies `path`.
72
+ * `lstat` never follows a symlink, so a link at the path counts as occupied
73
+ * without consulting what it points to. An error that is not a plain ENOENT
74
+ * says the occupancy is unknowable, and is rethrown: treating it as absence
75
+ * would let the clone merge into a path no one could inspect.
76
+ */
77
+ async function entryExists(path: string): Promise<boolean> {
78
+ try {
79
+ await lstat(path);
80
+ return true;
81
+ } catch (error) {
82
+ const code = (error as NodeJS.ErrnoException).code;
83
+ if (code === "ENOENT") return false;
84
+ throw error;
85
+ }
86
+ }
87
+
88
+ /**
89
+ * The target of a `cloneDirectory` call was already occupied. Its own class so
90
+ * a caller's leave-nothing-behind rollback can tell "refused before the first
91
+ * write, nothing here is ours" apart from a mid-clone failure, and leave the
92
+ * foreign content untouched instead of `rm`-ing it.
93
+ */
94
+ export class OccupiedTargetError extends Error {}
95
+
96
+ async function cloneInto(source: string, target: string, skip: string): Promise<void> {
97
+ for (const entry of await readdir(source, { withFileTypes: true })) {
98
+ const from = join(source, entry.name);
99
+ // The target (when it lives inside the source) must not be copied: it is
100
+ // being populated by this very walk, so descending into it clones the
101
+ // clone into itself without bound. Everything else — including the
102
+ // target's ancestors and their other children — is copied normally.
103
+ if (from === skip) continue;
104
+ const to = join(target, entry.name);
105
+ const kind = await entryKind(entry, () => lstat(from));
106
+ if (kind === "directory") {
107
+ await mkdir(to);
108
+ await cloneInto(from, to, skip);
109
+ } else if (kind === "symlink") {
110
+ await symlink(await readlink(from), to);
111
+ } else {
112
+ await reflinkFile(from, to);
113
+ }
114
+ }
115
+ }
116
+
117
+ /**
118
+ * True when `child` lies inside `parent`, compared by path components rather
119
+ * than by string prefix: `/a/bc` is not inside `/a/b`, and a child literally
120
+ * named `..foo` is not an escape. `relative` yields exactly that check, and an
121
+ * absolute result means a different root and therefore not contained.
122
+ */
123
+ function isInside(child: string, parent: string): boolean {
124
+ const rel = relative(parent, child);
125
+ return rel !== "" && rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
126
+ }
package/src/config.ts ADDED
@@ -0,0 +1,92 @@
1
+ import type { FallbackPolicy } from "./tool";
2
+
3
+ const ACCEPTED: readonly FallbackPolicy[] = ["none", "git"];
4
+
5
+ /**
6
+ * Reads the fallback policy from the plugin's free-form `options`.
7
+ *
8
+ * Absent means disabled (`"none"`): the fallback is opt-in, so a caller who did
9
+ * not ask for it never silently gets a Shallow worktree. An unrecognized value
10
+ * throws rather than degrading to `"none"` — a misconfiguration must not change
11
+ * the mechanism a caller gets without saying so.
12
+ */
13
+ export function fallbackPolicy(
14
+ options: Record<string, unknown> | undefined,
15
+ ): FallbackPolicy {
16
+ const value = options?.fallback;
17
+ if (value === undefined) return "none";
18
+ if (isFallbackPolicy(value)) return value;
19
+ throw new Error(
20
+ `invalid plugin option "fallback": expected one of ${ACCEPTED.join(", ")}, got ${JSON.stringify(value)}`,
21
+ );
22
+ }
23
+
24
+ function isFallbackPolicy(value: unknown): value is FallbackPolicy {
25
+ return (ACCEPTED as readonly unknown[]).includes(value);
26
+ }
27
+
28
+ const HOOKS = "hooks";
29
+
30
+ /**
31
+ * Reads the post-create hooks from the plugin's free-form `options`, shaped
32
+ * `hooks: { postCreate: string[] }`.
33
+ *
34
+ * Absent — the option, the `hooks` object, or the `postCreate` list — means no
35
+ * hooks: a caller who did not ask for them gets exactly the create behavior
36
+ * they had before. A value that is not an array of non-empty command strings
37
+ * throws rather than being ignored: a misconfiguration must not silently skip
38
+ * setup the user believes runs on every clone.
39
+ */
40
+ export function postCreateHooks(
41
+ options: Record<string, unknown> | undefined,
42
+ ): readonly string[] {
43
+ const hooks = options?.[HOOKS];
44
+ if (hooks === undefined) return [];
45
+ if (!isHooksObject(hooks)) {
46
+ throw new Error(
47
+ `invalid plugin option "${HOOKS}": expected an object with a "postCreate" command list, got ${JSON.stringify(hooks)}`,
48
+ );
49
+ }
50
+ const commands = (hooks as { postCreate?: unknown }).postCreate;
51
+ if (commands === undefined) return [];
52
+ if (!isCommandList(commands)) {
53
+ throw new Error(
54
+ `invalid plugin option "${HOOKS}.postCreate": expected an array of non-empty command strings, got ${JSON.stringify(commands)}`,
55
+ );
56
+ }
57
+ return commands;
58
+ }
59
+
60
+ function isHooksObject(value: unknown): boolean {
61
+ return typeof value === "object" && value !== null && !Array.isArray(value);
62
+ }
63
+
64
+ function isCommandList(value: unknown): value is string[] {
65
+ return (
66
+ Array.isArray(value) &&
67
+ value.every((command) => typeof command === "string" && command.trim() !== "")
68
+ );
69
+ }
70
+
71
+ const TARGET_ROOT = "targetRoot";
72
+
73
+ /**
74
+ * Reads the Worktree target root from the plugin's free-form `options`.
75
+ *
76
+ * Absent means the tool picks a parent on the source's own filesystem (a sibling
77
+ * of the source) — the same-device default a CoW clone requires. A configured
78
+ * value is used verbatim, even when it names a different filesystem, because
79
+ * that is precisely the case the tool must diagnose instead of hiding. A
80
+ * non-string or empty value throws rather than degrading to the default: a
81
+ * misconfiguration must not silently relocate every clone.
82
+ */
83
+ export function targetRoot(
84
+ options: Record<string, unknown> | undefined,
85
+ ): string | undefined {
86
+ const value = options?.[TARGET_ROOT];
87
+ if (value === undefined) return undefined;
88
+ if (typeof value === "string" && value.trim() !== "") return value;
89
+ throw new Error(
90
+ `invalid plugin option "${TARGET_ROOT}": expected a non-empty path string, got ${JSON.stringify(value)}`,
91
+ );
92
+ }