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/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
|
+
}
|