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/strategy.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { rm } from "node:fs/promises";
|
|
2
|
+
import type { WorktreeDefinition } from "../types/opencode2-worktree";
|
|
3
|
+
import {
|
|
4
|
+
assertSameDevice,
|
|
5
|
+
deviceOf,
|
|
6
|
+
nearestExistingDevice,
|
|
7
|
+
} from "./device";
|
|
8
|
+
import { cloneDirectory, OccupiedTargetError } from "./clone";
|
|
9
|
+
import { mayRemove } from "./dirty";
|
|
10
|
+
import { runPostCreateHooks } from "./hooks";
|
|
11
|
+
import { removeQuarantined } from "./removal";
|
|
12
|
+
import { probeUncommitted } from "./uncommitted";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Builds the `cow` Strategy: materialize a Worktree as a Deep clone.
|
|
16
|
+
*
|
|
17
|
+
* `create` reflinks the source working directory — ignored files included —
|
|
18
|
+
* into the directory opencode2 has already chosen. It fails loudly rather than
|
|
19
|
+
* falling back: the CoW capability predicate belongs to the caller-facing tool,
|
|
20
|
+
* so a request for `cow` either clones or throws. `list` returns nothing on
|
|
21
|
+
* purpose — opencode2 reads its own records for inventory, so a Strategy is not
|
|
22
|
+
* the source of truth for what exists.
|
|
23
|
+
*
|
|
24
|
+
* opencode2 may hand the strategy a target on a different filesystem (its
|
|
25
|
+
* default worktree parent is under the data directory). A reflink cannot cross
|
|
26
|
+
* a device boundary, so the strategy pre-flights the same device rule the tool
|
|
27
|
+
* applies and throws an actionable error before any directory is created; it
|
|
28
|
+
* does not relocate the target, because a caller-supplied path is not the
|
|
29
|
+
* strategy's to silently override.
|
|
30
|
+
*
|
|
31
|
+
* `postCreate` is the validated `hooks.postCreate` list the plugin's setup
|
|
32
|
+
* passes in. When non-empty, the clone is followed by those commands —
|
|
33
|
+
* sequentially, in the worktree, with the source and worktree paths in the
|
|
34
|
+
* environment — and the first failure removes the clone before the error
|
|
35
|
+
* propagates, so a failed setup never leaves an orphan clone behind. The list
|
|
36
|
+
* closes over the factory call, so the setup-ordering contract ("validate the
|
|
37
|
+
* options before registering the strategy") holds by construction, with no
|
|
38
|
+
* module state to install or reset.
|
|
39
|
+
*/
|
|
40
|
+
export function createCowStrategy(
|
|
41
|
+
{ postCreate }: { readonly postCreate: readonly string[] },
|
|
42
|
+
): WorktreeDefinition {
|
|
43
|
+
return {
|
|
44
|
+
id: "cow",
|
|
45
|
+
|
|
46
|
+
async create(input, { signal }) {
|
|
47
|
+
signal.throwIfAborted();
|
|
48
|
+
// Thrown outside the try so the actionable message is not rewrapped as a
|
|
49
|
+
// generic clone failure. `input.directory` is the final target opencode2
|
|
50
|
+
// has assembled; it may not exist yet, so the device of its nearest
|
|
51
|
+
// existing ancestor decides whether a reflink can land there.
|
|
52
|
+
const [sourceDevice, targetDevice] = await Promise.all([
|
|
53
|
+
deviceOf(input.sourceDirectory),
|
|
54
|
+
nearestExistingDevice(input.directory, deviceOf),
|
|
55
|
+
]);
|
|
56
|
+
assertSameDevice(input.sourceDirectory, sourceDevice, input.directory, targetDevice);
|
|
57
|
+
try {
|
|
58
|
+
await cloneDirectory(input.sourceDirectory, input.directory);
|
|
59
|
+
} catch (cause) {
|
|
60
|
+
// An occupied target was refused before the first write, so nothing in
|
|
61
|
+
// that directory is this call's creation; the leave-nothing-behind
|
|
62
|
+
// rollback must never run over foreign content. The refusal propagates
|
|
63
|
+
// as-is.
|
|
64
|
+
if (cause instanceof OccupiedTargetError) throw cause;
|
|
65
|
+
// Leave nothing behind: cloneDirectory creates the target before it can
|
|
66
|
+
// fail, so a partial tree would otherwise survive a failed create.
|
|
67
|
+
// In-place `rm` — not the quarantine dance of src/removal.ts — is
|
|
68
|
+
// acceptable here: the directory is a seconds-old clone this call itself
|
|
69
|
+
// just created, so the provenance is known and there is no audit gap.
|
|
70
|
+
await rm(input.directory, { recursive: true, force: true });
|
|
71
|
+
throw new Error(`cow strategy failed to clone into ${input.directory}`, {
|
|
72
|
+
cause,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
await runPostCreateHooks(postCreate, input.directory, input.sourceDirectory);
|
|
76
|
+
return { directory: input.directory };
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
async remove(input) {
|
|
80
|
+
// opencode2's `force` is its two-phase confirm protocol — "proceed despite
|
|
81
|
+
// uncommitted changes" — not `rm`'s "ignore a missing path" flag. The
|
|
82
|
+
// built-in git strategy maps it to `git worktree remove --force` and
|
|
83
|
+
// refuses while the worktree is dirty; the TUI turns the refusal into a
|
|
84
|
+
// confirmation and retries with `force: true`. So the probe is skipped
|
|
85
|
+
// entirely once the user has confirmed, and `rm` always receives a literal
|
|
86
|
+
// `force: true` (a directory this call is authorized to delete may
|
|
87
|
+
// legitimately be gone already).
|
|
88
|
+
//
|
|
89
|
+
// At `force: false` a dirty — or unknowable — directory refuses before any
|
|
90
|
+
// filesystem change, so uncommitted work is never silently destroyed
|
|
91
|
+
// (issue #13). `input.force` is authorization for the decision, not a flag
|
|
92
|
+
// for `rm`.
|
|
93
|
+
//
|
|
94
|
+
// Past the guard the deletion is never in-place: the directory's identity
|
|
95
|
+
// is captured, it is renamed to a quarantine sibling, the identity is
|
|
96
|
+
// re-confirmed, and only then is the copy deleted — so a swapped or
|
|
97
|
+
// recycled path can never be deleted in the audited directory's name, and
|
|
98
|
+
// an agent holding a cwd inside stops blocking the path. `removal.ts` owns
|
|
99
|
+
// those mechanics.
|
|
100
|
+
const uncommitted = input.force
|
|
101
|
+
? undefined
|
|
102
|
+
: await probeUncommitted(input.directory);
|
|
103
|
+
const decision = mayRemove({ force: input.force, uncommitted });
|
|
104
|
+
if (!decision.remove) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
`cow refuses to remove ${input.directory} without force: ${decision.reason}. ` +
|
|
107
|
+
"Re-run with force to delete them.",
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
await removeQuarantined(input.directory);
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
async list() {
|
|
114
|
+
return [];
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The `cow` strategy with no post-create hooks — the package's default
|
|
121
|
+
* instance, exported for direct consumers of the Strategy API. The plugin
|
|
122
|
+
* builds its own instance per setup through `createCowStrategy`, closing over
|
|
123
|
+
* the hooks validated from the configured options.
|
|
124
|
+
*/
|
|
125
|
+
export const cowStrategy = createCowStrategy({ postCreate: [] });
|
package/src/tool.ts
ADDED
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
import { realpath } from "node:fs/promises";
|
|
2
|
+
import { basename, join, resolve } from "node:path";
|
|
3
|
+
import { assertSameDevice } from "./device";
|
|
4
|
+
import type { CowCapability } from "./capability";
|
|
5
|
+
import type { Mechanism } from "./mechanism";
|
|
6
|
+
import type { WorktreeInventoryEntry } from "../types/opencode2-worktree";
|
|
7
|
+
|
|
8
|
+
/** What a successful `spawnWorkspace` produced. */
|
|
9
|
+
export interface SpawnWorkspaceResult {
|
|
10
|
+
readonly sessionID: string;
|
|
11
|
+
readonly directory: string;
|
|
12
|
+
readonly mechanism: Mechanism;
|
|
13
|
+
/**
|
|
14
|
+
* True when the session was attached to an already-existing Worktree instead
|
|
15
|
+
* of a fresh clone. Absent for a created Worktree: the field's presence is
|
|
16
|
+
* the attach marker, and `mechanism` then reports the mechanism of the
|
|
17
|
+
* directory that was found, not of a clone this call performed.
|
|
18
|
+
*/
|
|
19
|
+
readonly attached?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Whether a caller who asked for a CoW clone may be given a Shallow worktree. */
|
|
23
|
+
export type FallbackPolicy = "none" | "git";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The seams `spawnWorkspace` drives. Each is injected so the decision table is
|
|
27
|
+
* testable without opencode2, and so the fallback ticket (#6) can supply the
|
|
28
|
+
* git strategy and its own config-derived policy without touching this file.
|
|
29
|
+
*/
|
|
30
|
+
export interface SpawnWorkspaceDeps {
|
|
31
|
+
/** Answers the CoW capability question for a directory. */
|
|
32
|
+
readonly probe: (directory: string) => Promise<CowCapability>;
|
|
33
|
+
/**
|
|
34
|
+
* Reports the device a path's filesystem belongs to, or `undefined` when it
|
|
35
|
+
* cannot be read. Injected so a cross-device target is testable without a
|
|
36
|
+
* second mount.
|
|
37
|
+
*/
|
|
38
|
+
readonly probeDevice: (path: string) => Promise<number | undefined>;
|
|
39
|
+
/**
|
|
40
|
+
* Creates a Worktree with the named Strategy, returning its directory.
|
|
41
|
+
* `parentDirectory` is opencode2's `Worktree.CreateInput.directory`: the
|
|
42
|
+
* **parent** the worktree is created under, not the worktree path itself.
|
|
43
|
+
* opencode2 appends the name, so it is what decides the target device.
|
|
44
|
+
*/
|
|
45
|
+
readonly createWorktree: (input: {
|
|
46
|
+
readonly sourceDirectory: string;
|
|
47
|
+
readonly parentDirectory: string;
|
|
48
|
+
readonly strategy: Mechanism;
|
|
49
|
+
readonly name?: string;
|
|
50
|
+
}) => Promise<{ readonly directory: string }>;
|
|
51
|
+
/** Starts a session whose Location is the directory; returns its id. */
|
|
52
|
+
readonly createSession: (directory: string, name?: string) => Promise<string>;
|
|
53
|
+
/** Removes a Worktree this call created. Used only to clean up a failure. */
|
|
54
|
+
readonly removeWorktree: (directory: string) => Promise<void>;
|
|
55
|
+
/**
|
|
56
|
+
* Lists the Worktree inventory opencode2 records for the caller's location —
|
|
57
|
+
* one `{ directory, strategy? }` entry per Worktree (`ctx.worktree.list()`).
|
|
58
|
+
* Attach reads it to decide whether an existing directory is ours, and
|
|
59
|
+
* `listCowWorktrees` lists from it (ADR 0003: the inventory, not a
|
|
60
|
+
* plugin-owned registry, is the source of truth). Injected so the decision
|
|
61
|
+
* is testable without opencode2.
|
|
62
|
+
*/
|
|
63
|
+
readonly listWorktrees: () => Promise<readonly WorktreeInventoryEntry[]>;
|
|
64
|
+
/**
|
|
65
|
+
* Whether a path exists and is a directory. Attach consults it twice: the
|
|
66
|
+
* predicted target must already exist before any attach question is asked,
|
|
67
|
+
* and the Deep-clone signature is `<target>/.git` being a *directory*. Used
|
|
68
|
+
* for no other purpose. Injected like `deviceOf` so both checks are testable
|
|
69
|
+
* without a filesystem.
|
|
70
|
+
*/
|
|
71
|
+
readonly isDirectory: (path: string) => Promise<boolean>;
|
|
72
|
+
/** Defaults to `"none"`: a request for `cow` is a statement about what you get. */
|
|
73
|
+
readonly fallback?: FallbackPolicy;
|
|
74
|
+
/** Worktree parent. Defaults to a same-filesystem sibling of the source. */
|
|
75
|
+
readonly targetRoot?: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface SpawnWorkspaceInput {
|
|
79
|
+
readonly sourceDirectory: string;
|
|
80
|
+
readonly name?: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Creates one Worktree and starts a session in it, reporting the Mechanism that
|
|
85
|
+
* produced the directory.
|
|
86
|
+
*
|
|
87
|
+
* The CoW capability predicate is consulted on the source directory and decides
|
|
88
|
+
* the strategy. A capability *error* (missing path, permissions, I/O) always
|
|
89
|
+
* fails, even with fallback enabled: it is not the same answer as "not
|
|
90
|
+
* supported", and honouring the fallback there would hide a real problem behind
|
|
91
|
+
* a git worktree. The fallback only applies to a genuine negative.
|
|
92
|
+
*
|
|
93
|
+
* A CoW clone cannot cross a filesystem boundary, so the tool creates the
|
|
94
|
+
* Worktree under a parent on the source's own device — a sibling by default, or
|
|
95
|
+
* the configured target root. When the chosen parent is on a different device
|
|
96
|
+
* the call fails with an explicit device-mismatch error rather than letting a
|
|
97
|
+
* bare `EXDEV` surface as an unexplained clone failure.
|
|
98
|
+
*
|
|
99
|
+
* There is no silent fallback by default. If the probe says unsupported and no
|
|
100
|
+
* fallback is configured, the call throws rather than fabricating a mechanism —
|
|
101
|
+
* the result type has no "none", and a caller must never be told it got a Deep
|
|
102
|
+
* clone it did not get.
|
|
103
|
+
*
|
|
104
|
+
* If the Worktree is created but the session cannot be started, the Worktree is
|
|
105
|
+
* removed before the error propagates, so no directory is orphaned. If creating
|
|
106
|
+
* the Worktree itself fails there is nothing to clean up.
|
|
107
|
+
*
|
|
108
|
+
* Attach: when the call names a Worktree (`name`) whose predicted directory
|
|
109
|
+
* already exists, the call attaches instead of cloning. The prediction is
|
|
110
|
+
* assembled exactly as the create flow assembles it — the same parent, the
|
|
111
|
+
* same name — and the existing directory is attached to only when the
|
|
112
|
+
* inventory records it with `strategy: "cow"` (ADR 0003) **and** it carries the
|
|
113
|
+
* Deep-clone signature (`.git` is a directory). Every other occupant of the
|
|
114
|
+
* predicted path — a `git`-strategy Worktree, a path the inventory does not
|
|
115
|
+
* know (a Foreign worktree), a `cow` row that lost its Deep-clone shape — is
|
|
116
|
+
* refused loudly before any session is started and without touching the
|
|
117
|
+
* filesystem. Attach never consults the capability probe (no clone is
|
|
118
|
+
* attempted) and never removes the existing directory when its own session
|
|
119
|
+
* start fails, because the directory predates the call.
|
|
120
|
+
*/
|
|
121
|
+
export async function spawnWorkspace(
|
|
122
|
+
input: SpawnWorkspaceInput,
|
|
123
|
+
deps: SpawnWorkspaceDeps,
|
|
124
|
+
): Promise<SpawnWorkspaceResult> {
|
|
125
|
+
const name = input.name;
|
|
126
|
+
// A present, non-empty name may hit an existing Worktree; an empty string is
|
|
127
|
+
// not a name and takes the create path exactly as before.
|
|
128
|
+
if (name) {
|
|
129
|
+
assertSimpleName(name);
|
|
130
|
+
const attached = await tryAttach(name, input.sourceDirectory, deps);
|
|
131
|
+
if (attached !== undefined) return attached;
|
|
132
|
+
}
|
|
133
|
+
const capability = await deps.probe(input.sourceDirectory);
|
|
134
|
+
if (capability.status === "error") {
|
|
135
|
+
throw new Error(
|
|
136
|
+
`cannot clone ${input.sourceDirectory}: CoW capability probe failed`,
|
|
137
|
+
{ cause: capability.error },
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const mechanism: Mechanism =
|
|
142
|
+
capability.status === "supported"
|
|
143
|
+
? "cow"
|
|
144
|
+
: selectFallback(deps.fallback ?? "none", input.sourceDirectory);
|
|
145
|
+
|
|
146
|
+
const parent = predictedParent(input, deps);
|
|
147
|
+
await verifySameDevice(input.sourceDirectory, parent, mechanism, deps.probeDevice);
|
|
148
|
+
|
|
149
|
+
const worktree = await deps.createWorktree({
|
|
150
|
+
sourceDirectory: input.sourceDirectory,
|
|
151
|
+
parentDirectory: parent,
|
|
152
|
+
strategy: mechanism,
|
|
153
|
+
name: input.name,
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
const sessionID = await deps.createSession(worktree.directory, input.name);
|
|
158
|
+
return { sessionID, directory: worktree.directory, mechanism };
|
|
159
|
+
} catch (cause) {
|
|
160
|
+
// The cleanup is best-effort: it must never replace the failure that
|
|
161
|
+
// triggered it. `removeWorktree` goes through opencode2's DELETE route,
|
|
162
|
+
// which refuses once the directory is already gone, so a successful
|
|
163
|
+
// session-create failure would otherwise surface as a confusing
|
|
164
|
+
// "directory unavailable" instead of the original cause.
|
|
165
|
+
try {
|
|
166
|
+
await deps.removeWorktree(worktree.directory);
|
|
167
|
+
} catch {
|
|
168
|
+
// Leave the tree for the caller to inspect; the real error follows.
|
|
169
|
+
}
|
|
170
|
+
throw new Error(`session start failed in ${worktree.directory}`, { cause });
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Rejects a `cow` parent on a different filesystem than the source before the
|
|
176
|
+
* create is attempted. A reflink cannot cross devices, so this is a distinct,
|
|
177
|
+
* actionable failure and must not surface as a generic clone error. The `git`
|
|
178
|
+
* mechanism does not share extents and is left untouched.
|
|
179
|
+
*
|
|
180
|
+
* An unreadable device on either side is not a mismatch — `probeDevice` returns
|
|
181
|
+
* `undefined` rather than throwing, and the create proceeds to report whatever
|
|
182
|
+
* the filesystem actually does.
|
|
183
|
+
*/
|
|
184
|
+
async function verifySameDevice(
|
|
185
|
+
source: string,
|
|
186
|
+
parent: string,
|
|
187
|
+
mechanism: Mechanism,
|
|
188
|
+
probeDevice: SpawnWorkspaceDeps["probeDevice"],
|
|
189
|
+
): Promise<void> {
|
|
190
|
+
if (mechanism !== "cow") return;
|
|
191
|
+
// A parent that does not exist yet (an injected fake in the unit tests, or a
|
|
192
|
+
// target root opencode2 has not created) is checked as-is: `probeDevice`
|
|
193
|
+
// returns `undefined` and the create proceeds rather than failing on an
|
|
194
|
+
// unreadable device. The strategy owns the nearest-ancestor walk, where the
|
|
195
|
+
// target is a full path opencode2 assembled.
|
|
196
|
+
await verifyCowSameDevice({ source, target: parent, probeDevice });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function selectFallback(policy: FallbackPolicy, sourceDirectory: string): Mechanism {
|
|
200
|
+
if (policy === "git") return "git";
|
|
201
|
+
throw new Error(
|
|
202
|
+
`copy-on-write is not supported for ${sourceDirectory} and no fallback is enabled`,
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* The `cow` half of the device policy: it checks both sides through the
|
|
208
|
+
* injected `probeDevice` seam and applies the shared `assertSameDevice` rule.
|
|
209
|
+
* The primitives live in `./device`; only the decision lives here.
|
|
210
|
+
*/
|
|
211
|
+
export async function verifyCowSameDevice(input: {
|
|
212
|
+
readonly source: string;
|
|
213
|
+
readonly target: string;
|
|
214
|
+
readonly probeDevice: (path: string) => Promise<number | undefined>;
|
|
215
|
+
}): Promise<void> {
|
|
216
|
+
const [sourceDevice, targetDevice] = await Promise.all([
|
|
217
|
+
input.probeDevice(input.source),
|
|
218
|
+
input.probeDevice(input.target),
|
|
219
|
+
]);
|
|
220
|
+
assertSameDevice(input.source, sourceDevice, input.target, targetDevice);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The parent directory a worktree of this input is assembled under: the
|
|
225
|
+
* configured target root, or a sibling of the source. The create flow passes
|
|
226
|
+
* it to opencode2 as `Worktree.CreateInput.directory`, and attach predicts
|
|
227
|
+
* `<parent>/<name>` under it — one helper so both halves of the tool assemble
|
|
228
|
+
* the parent identically. See the assembly contract pinned in
|
|
229
|
+
* `types/opencode2-worktree.d.ts`.
|
|
230
|
+
*/
|
|
231
|
+
function predictedParent(
|
|
232
|
+
input: SpawnWorkspaceInput,
|
|
233
|
+
deps: SpawnWorkspaceDeps,
|
|
234
|
+
): string {
|
|
235
|
+
return deps.targetRoot ?? join(input.sourceDirectory, "..");
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Rejects a `name` the prediction cannot treat as one directory name, before
|
|
240
|
+
* any existence probe or inventory read: a separator would address a nested
|
|
241
|
+
* path the attach flow never predicted, and `.`/`..` do not name a directory.
|
|
242
|
+
*/
|
|
243
|
+
function assertSimpleName(name: string): void {
|
|
244
|
+
if (name === "." || name === ".." || /[\\/]/.test(name)) {
|
|
245
|
+
throw new Error(
|
|
246
|
+
`invalid worktree name ${JSON.stringify(name)}: only simple directory names are ` +
|
|
247
|
+
"accepted — no \"/\" or \"\\\", never \".\" or \"..\". The worktree parent is " +
|
|
248
|
+
"chosen for you; pass the plain name.",
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* The attach half of a named `spawnWorkspace`: when the predicted directory
|
|
255
|
+
* already exists, decide whether the call may attach to it. Returns `undefined`
|
|
256
|
+
* when the predicted path is free, handing control back to the create flow
|
|
257
|
+
* unchanged.
|
|
258
|
+
*
|
|
259
|
+
* The prediction reuses the create flow's parent — the configured target root,
|
|
260
|
+
* or a sibling of the source — so a path that exists here is exactly a path
|
|
261
|
+
* `createWorktree` would collide with. The check runs before the capability
|
|
262
|
+
* probe on purpose: attach attempts no clone, so the source's CoW capability
|
|
263
|
+
* is not its question to ask.
|
|
264
|
+
*/
|
|
265
|
+
async function tryAttach(
|
|
266
|
+
name: string,
|
|
267
|
+
sourceDirectory: string,
|
|
268
|
+
deps: SpawnWorkspaceDeps,
|
|
269
|
+
): Promise<SpawnWorkspaceResult | undefined> {
|
|
270
|
+
const target = join(predictedParent({ sourceDirectory }, deps), name);
|
|
271
|
+
if (!(await deps.isDirectory(target))) return undefined;
|
|
272
|
+
// TOCTOU by construction: the existence check, inventory read, and Deep-clone check are separate reads, and every interleaving fails closed.
|
|
273
|
+
return attachToExisting(name, target, deps);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* The attach decision for a path that already exists. The inventory is the
|
|
278
|
+
* source of truth for "ours" (ADR 0003): an entry recorded with `cow` plus the
|
|
279
|
+
* Deep-clone signature (`.git` is a directory) means the `cow` strategy
|
|
280
|
+
* materialized this directory, so a new session may bind to it. Every other
|
|
281
|
+
* answer is refused loudly — before any session is created and with no
|
|
282
|
+
* filesystem change — because a Foreign worktree, another strategy's Worktree,
|
|
283
|
+
* or a `cow` row that lost its Deep-clone shape is not this tool's to attach
|
|
284
|
+
* to.
|
|
285
|
+
*/
|
|
286
|
+
async function attachToExisting(
|
|
287
|
+
name: string,
|
|
288
|
+
target: string,
|
|
289
|
+
deps: SpawnWorkspaceDeps,
|
|
290
|
+
): Promise<SpawnWorkspaceResult> {
|
|
291
|
+
const entry = await inventoryEntryFor(await deps.listWorktrees(), target);
|
|
292
|
+
if (entry === undefined) throw foreignWorktreeError(target);
|
|
293
|
+
if (entry.strategy !== "cow") {
|
|
294
|
+
throw foreignStrategyError(target, entry.strategy);
|
|
295
|
+
}
|
|
296
|
+
if (!(await deps.isDirectory(join(target, ".git")))) {
|
|
297
|
+
throw notDeepCloneError(target);
|
|
298
|
+
}
|
|
299
|
+
try {
|
|
300
|
+
const sessionID = await deps.createSession(target, name);
|
|
301
|
+
return { sessionID, directory: target, mechanism: "cow", attached: true };
|
|
302
|
+
} catch (cause) {
|
|
303
|
+
// No removeWorktree here, unlike the create flow: the directory existed
|
|
304
|
+
// before this call, so a failed session start must leave it untouched.
|
|
305
|
+
throw new Error(`session start failed in existing worktree ${target}`, { cause });
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* The inventory entry recorded for a directory, when the inventory knows it.
|
|
311
|
+
*
|
|
312
|
+
* Identity is not an exact-string comparison: the inventory may record the
|
|
313
|
+
* same directory through a different spelling (a symlinked ancestor, a `..`
|
|
314
|
+
* segment), and an exact-string miss would refuse a real cow worktree as
|
|
315
|
+
* Foreign. Rows are narrowed by basename first — cheap, and shared with the
|
|
316
|
+
* prediction — then matched exactly, and finally by resolving both paths:
|
|
317
|
+
* `realpath` when both resolve, lexical normalization when it cannot. A
|
|
318
|
+
* comparison that establishes no identity leaves the entry unfound, so an
|
|
319
|
+
* unknown path still refuses as Foreign.
|
|
320
|
+
*
|
|
321
|
+
* Exported as the one directory-identity matcher: the TUI badge
|
|
322
|
+
* (`strategy-badge.ts`) asks the same question of the same inventory rows, and
|
|
323
|
+
* a badge that matched by exact string only would go dark for a symlinked
|
|
324
|
+
* location the tool happily attaches to.
|
|
325
|
+
*/
|
|
326
|
+
export async function inventoryEntryFor(
|
|
327
|
+
entries: readonly WorktreeInventoryEntry[],
|
|
328
|
+
directory: string,
|
|
329
|
+
): Promise<WorktreeInventoryEntry | undefined> {
|
|
330
|
+
const leaf = basename(directory);
|
|
331
|
+
const candidates = entries.filter((entry) => basename(entry.directory) === leaf);
|
|
332
|
+
for (const candidate of candidates) {
|
|
333
|
+
if (candidate.directory === directory) return candidate;
|
|
334
|
+
if (await sameDirectory(candidate.directory, directory)) return candidate;
|
|
335
|
+
}
|
|
336
|
+
return undefined;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Whether two paths name the same directory. `realpath` is authoritative when
|
|
341
|
+
* both sides resolve; when either cannot be read (a row for a path that no
|
|
342
|
+
* longer exists, a unit-test fake), identity falls back to lexical
|
|
343
|
+
* normalization. A false answer is "no entry" — never a weaker match.
|
|
344
|
+
*/
|
|
345
|
+
async function sameDirectory(a: string, b: string): Promise<boolean> {
|
|
346
|
+
try {
|
|
347
|
+
const [realA, realB] = await Promise.all([realpath(a), realpath(b)]);
|
|
348
|
+
return realA === realB;
|
|
349
|
+
} catch {
|
|
350
|
+
return resolve(a) === resolve(b);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** The refusal for a path the worktree inventory does not know at all. */
|
|
355
|
+
function foreignWorktreeError(target: string): Error {
|
|
356
|
+
return new Error(
|
|
357
|
+
`refusing to attach to ${target}: the path exists but opencode2's worktree ` +
|
|
358
|
+
"inventory has no entry for it, so it is not a worktree this strategy " +
|
|
359
|
+
"materialized (a Foreign worktree). spawn_workspace attaches only to " +
|
|
360
|
+
"worktrees its cow strategy created; pick another name or remove the " +
|
|
361
|
+
"directory.",
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** The refusal for an inventory entry naming a strategy other than `cow`. */
|
|
366
|
+
function foreignStrategyError(target: string, strategy: string | undefined): Error {
|
|
367
|
+
return new Error(
|
|
368
|
+
`refusing to attach to ${target}: the worktree inventory records it with ` +
|
|
369
|
+
`${describeStrategy(strategy)}, not "cow" — spawn_workspace will not attach ` +
|
|
370
|
+
"to a Worktree another strategy materialized. Remove it through opencode2 " +
|
|
371
|
+
"or pick another name.",
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* The refusal for a `cow` inventory row whose `.git` is not a directory: whatever
|
|
377
|
+
* sits at the path now, it is not the Deep clone this strategy's create leaves
|
|
378
|
+
* behind, so the row is stale or the directory was replaced.
|
|
379
|
+
*/
|
|
380
|
+
function notDeepCloneError(target: string): Error {
|
|
381
|
+
return new Error(
|
|
382
|
+
`refusing to attach to ${target}: the worktree inventory records strategy ` +
|
|
383
|
+
`"cow", but ${join(target, ".git")} is not a directory, so the directory does ` +
|
|
384
|
+
"not have the Deep-clone signature a cow worktree is created with. Refresh " +
|
|
385
|
+
"the inventory or remove the directory.",
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** The strategy as a refusal names it; the checkout root is listed with none. */
|
|
390
|
+
function describeStrategy(strategy: string | undefined): string {
|
|
391
|
+
return strategy === undefined ? "no strategy" : `"${strategy}"`;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// ---------------------------------------------------------------------------
|
|
395
|
+
// list_worktrees (ADR 0003)
|
|
396
|
+
//
|
|
397
|
+
// A read-only listing of the Location's CoW worktrees, derived entirely from
|
|
398
|
+
// opencode2's worktree inventory: no plugin-owned registry, no git commands,
|
|
399
|
+
// and no filesystem contact beyond one stat per surviving entry.
|
|
400
|
+
// ---------------------------------------------------------------------------
|
|
401
|
+
|
|
402
|
+
/** One CoW worktree as the `list_worktrees` tool reports it. */
|
|
403
|
+
export interface CowWorktreeEntry {
|
|
404
|
+
/** The worktree directory's basename. */
|
|
405
|
+
readonly name: string;
|
|
406
|
+
/** The worktree directory, verbatim as the inventory records it. */
|
|
407
|
+
readonly directory: string;
|
|
408
|
+
readonly strategy: "cow";
|
|
409
|
+
/** ISO 8601; derived from a directory stat. See `createdAtOf`. */
|
|
410
|
+
readonly createdAt: string;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* The two stat fields `createdAt` is derived from, kept structural so tests
|
|
415
|
+
* can inject a plain object and the live binding passes node's `Stats`
|
|
416
|
+
* unchanged.
|
|
417
|
+
*/
|
|
418
|
+
export interface StatTimes {
|
|
419
|
+
readonly birthtimeMs: number;
|
|
420
|
+
readonly mtimeMs: number;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** The seams `listCowWorktrees` reads. Injected like `SpawnWorkspaceDeps`. */
|
|
424
|
+
export interface ListWorktreesDeps {
|
|
425
|
+
/**
|
|
426
|
+
* The worktree inventory (`ctx.worktree.list()`), the same seam the attach
|
|
427
|
+
* path reads. The inventory, not a plugin-owned registry, is the source of
|
|
428
|
+
* truth (ADR 0003).
|
|
429
|
+
*/
|
|
430
|
+
readonly listWorktrees: () => Promise<readonly WorktreeInventoryEntry[]>;
|
|
431
|
+
/**
|
|
432
|
+
* Stats one inventory-listed directory. A failure here is not absorbed: the
|
|
433
|
+
* inventory is truth, so an unreadable row is a real anomaly and the list
|
|
434
|
+
* fails loudly instead of silently dropping the entry.
|
|
435
|
+
*/
|
|
436
|
+
readonly statEntry: (path: string) => Promise<StatTimes>;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Lists the Location's CoW worktrees for the `list_worktrees` tool.
|
|
441
|
+
*
|
|
442
|
+
* Only inventory rows recorded with `strategy: "cow"` are listed — the
|
|
443
|
+
* strategy-less checkout-root row and other strategies' Worktrees are not
|
|
444
|
+
* directories this plugin materialized. Nothing here consults git or probes
|
|
445
|
+
* for CoW capability: per ADR 0003 the inventory is the source of truth, and
|
|
446
|
+
* the only filesystem contact is one stat per surviving entry, for
|
|
447
|
+
* `createdAt`.
|
|
448
|
+
*/
|
|
449
|
+
export async function listCowWorktrees(deps: ListWorktreesDeps): Promise<CowWorktreeEntry[]> {
|
|
450
|
+
const entries = await deps.listWorktrees();
|
|
451
|
+
const cow = entries.filter((entry) => entry.strategy === "cow");
|
|
452
|
+
return Promise.all(cow.map((entry) => cowEntryOf(entry, deps.statEntry)));
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** Derives one listing entry: the basename, the verbatim directory, and the stat. */
|
|
456
|
+
async function cowEntryOf(
|
|
457
|
+
entry: WorktreeInventoryEntry,
|
|
458
|
+
statEntry: ListWorktreesDeps["statEntry"],
|
|
459
|
+
): Promise<CowWorktreeEntry> {
|
|
460
|
+
// Fail loud, by design: the inventory is truth, and a row it lists but whose
|
|
461
|
+
// directory cannot be stat'd is a real anomaly. Skipping it would report an
|
|
462
|
+
// incomplete list as a complete one.
|
|
463
|
+
const times = await statEntry(entry.directory);
|
|
464
|
+
return {
|
|
465
|
+
name: basename(entry.directory),
|
|
466
|
+
directory: entry.directory,
|
|
467
|
+
strategy: "cow",
|
|
468
|
+
createdAt: createdAtOf(times),
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Birthtime first, mtime when the filesystem reports none. btrfs and other
|
|
474
|
+
* filesystems leave birthtime at zero/epoch, where mtime is the
|
|
475
|
+
* later-explanatory time — when the directory's content was last written.
|
|
476
|
+
* Zero is unambiguous: no filesystem reports the epoch as a real creation
|
|
477
|
+
* time.
|
|
478
|
+
*/
|
|
479
|
+
function createdAtOf(times: StatTimes): string {
|
|
480
|
+
const ms = times.birthtimeMs > 0 ? times.birthtimeMs : times.mtimeMs;
|
|
481
|
+
return new Date(ms).toISOString();
|
|
482
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The impure half of `cow`'s remove guard: the `git status --porcelain` probe
|
|
3
|
+
* that reports a worktree's uncommitted changes, or the deliberate "unknown".
|
|
4
|
+
*
|
|
5
|
+
* Split out of `strategy.ts` so it sits beside the pure decision
|
|
6
|
+
* it feeds — `dirty.ts`'s `mayRemove`, which stays free of I/O. This module
|
|
7
|
+
* owns the subprocess: `execFile` runs `git` directly, no shell, and every
|
|
8
|
+
* failure mode (no metadata, non-zero exit, timeout, no git on the machine)
|
|
9
|
+
* is reported as `undefined`, which the decision treats as dirty. A probe
|
|
10
|
+
* that fails is never "clean".
|
|
11
|
+
*/
|
|
12
|
+
import { execFile } from "node:child_process";
|
|
13
|
+
import { stat } from "node:fs/promises";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { promisify } from "node:util";
|
|
16
|
+
import type { UncommittedChanges } from "./dirty";
|
|
17
|
+
|
|
18
|
+
/** How long `git status` may take before the probe gives up and reports unknown. */
|
|
19
|
+
const GIT_TIMEOUT_MS = 5_000;
|
|
20
|
+
|
|
21
|
+
const run = promisify(execFile);
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Reports the paths a worktree has uncommitted changes in, or `undefined` when
|
|
25
|
+
* that cannot be determined. A probe that fails is never "clean".
|
|
26
|
+
*
|
|
27
|
+
* The metadata gate keeps the probe off a non-repository directory: without
|
|
28
|
+
* `.git` (a file, as in a linked worktree, or a directory, as in a clone) no
|
|
29
|
+
* porcelain status exists, so the answer is unknown and `remove` refuses. A
|
|
30
|
+
* directory that is gone answers `undefined` too — `stat` following a symlink
|
|
31
|
+
* is intentional, because a symlinked worktree still owns git metadata.
|
|
32
|
+
*
|
|
33
|
+
* Injected through a module-level `spyOn` seam in the tests, like `deviceOf`:
|
|
34
|
+
* `bun:test` can replace an export the module itself calls. The call is
|
|
35
|
+
* deliberately unqualified and outside destructuring so that replacement
|
|
36
|
+
* takes effect.
|
|
37
|
+
*
|
|
38
|
+
* `execFile` runs `git` directly — no shell, no inherited prompt — and CI=1
|
|
39
|
+
* suppresses any credential or editor interaction; the 5s timeout bounds a
|
|
40
|
+
* git that hangs on a lock. A pager needs a tty and is never started by
|
|
41
|
+
* `status --porcelain` here. Any error, non-zero exit, or timeout is unknown.
|
|
42
|
+
*/
|
|
43
|
+
export async function probeUncommitted(
|
|
44
|
+
directory: string,
|
|
45
|
+
): Promise<UncommittedChanges> {
|
|
46
|
+
if (!(await hasGitMetadata(directory))) return undefined;
|
|
47
|
+
try {
|
|
48
|
+
const { stdout } = await run(
|
|
49
|
+
"git",
|
|
50
|
+
["-C", directory, "status", "--porcelain"],
|
|
51
|
+
{
|
|
52
|
+
encoding: "utf8",
|
|
53
|
+
timeout: GIT_TIMEOUT_MS,
|
|
54
|
+
env: { ...process.env, GIT_PAGER: "cat", GIT_EDITOR: "true", CI: "1" },
|
|
55
|
+
},
|
|
56
|
+
);
|
|
57
|
+
return porcelainPaths(stdout);
|
|
58
|
+
} catch {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function hasGitMetadata(directory: string): Promise<boolean> {
|
|
64
|
+
try {
|
|
65
|
+
await stat(join(directory, ".git"));
|
|
66
|
+
return true;
|
|
67
|
+
} catch {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The changed paths in `git status --porcelain` output. The status and the
|
|
74
|
+
* path are separated by the first space; a rename's `old -> new` is kept
|
|
75
|
+
* whole, because both ends name something the user would lose.
|
|
76
|
+
*/
|
|
77
|
+
function porcelainPaths(stdout: string): UncommittedChanges {
|
|
78
|
+
return stdout
|
|
79
|
+
.split("\n")
|
|
80
|
+
.map((line) => line.trim())
|
|
81
|
+
.filter((line) => line.length > 0)
|
|
82
|
+
.map((line) => line.slice(line.indexOf(" ") + 1).trim())
|
|
83
|
+
.filter((path) => path.length > 0);
|
|
84
|
+
}
|