opencode2-cow-worktree 0.3.0 → 0.4.1
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/package.json +1 -1
- package/src/clone.ts +7 -0
- package/src/entry-kind.ts +21 -1
- package/src/plugin.ts +146 -9
- package/src/remove-tool.ts +520 -0
- package/src/tool.ts +155 -13
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode2-cow-worktree",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
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
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
package/src/clone.ts
CHANGED
|
@@ -108,6 +108,13 @@ async function cloneInto(source: string, target: string, skip: string): Promise<
|
|
|
108
108
|
await cloneInto(from, to, skip);
|
|
109
109
|
} else if (kind === "symlink") {
|
|
110
110
|
await symlink(await readlink(from), to);
|
|
111
|
+
} else if (kind === "special") {
|
|
112
|
+
// A socket, FIFO, or device node (e.g. a live daemon socket at a
|
|
113
|
+
// project root) cannot be reflinked — FICLONE fails with EOPNOTSUPP
|
|
114
|
+
// and would abort the whole clone. It is runtime state, not tree
|
|
115
|
+
// content, so the clone omits it; whatever created it in the source
|
|
116
|
+
// creates its own in the clone.
|
|
117
|
+
continue;
|
|
111
118
|
} else {
|
|
112
119
|
await reflinkFile(from, to);
|
|
113
120
|
}
|
package/src/entry-kind.ts
CHANGED
|
@@ -10,27 +10,47 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
/** How a directory entry is materialised by the clone walker. */
|
|
13
|
-
export type EntryKind = "file" | "directory" | "symlink";
|
|
13
|
+
export type EntryKind = "file" | "directory" | "symlink" | "special";
|
|
14
14
|
|
|
15
15
|
/** The slice of a `readdir` Dirent this decision reads. */
|
|
16
16
|
export interface EntryLike {
|
|
17
17
|
isSymbolicLink(): boolean;
|
|
18
18
|
isDirectory(): boolean;
|
|
19
19
|
isFile(): boolean;
|
|
20
|
+
isSocket(): boolean;
|
|
21
|
+
isFIFO(): boolean;
|
|
22
|
+
isBlockDevice(): boolean;
|
|
23
|
+
isCharacterDevice(): boolean;
|
|
20
24
|
}
|
|
21
25
|
|
|
22
26
|
/** The slice of `lstat` this decision reads; `lstat` never follows a symlink. */
|
|
23
27
|
export interface StatLike {
|
|
24
28
|
isSymbolicLink(): boolean;
|
|
25
29
|
isDirectory(): boolean;
|
|
30
|
+
isSocket(): boolean;
|
|
31
|
+
isFIFO(): boolean;
|
|
32
|
+
isBlockDevice(): boolean;
|
|
33
|
+
isCharacterDevice(): boolean;
|
|
26
34
|
}
|
|
27
35
|
|
|
28
36
|
export async function entryKind(entry: EntryLike, stat: () => Promise<StatLike>): Promise<EntryKind> {
|
|
29
37
|
if (entry.isSymbolicLink()) return "symlink";
|
|
30
38
|
if (entry.isDirectory()) return "directory";
|
|
31
39
|
if (entry.isFile()) return "file";
|
|
40
|
+
if (isSpecial(entry)) return "special";
|
|
32
41
|
const stats = await stat();
|
|
33
42
|
if (stats.isSymbolicLink()) return "symlink";
|
|
34
43
|
if (stats.isDirectory()) return "directory";
|
|
44
|
+
if (isSpecial(stats)) return "special";
|
|
35
45
|
return "file";
|
|
36
46
|
}
|
|
47
|
+
|
|
48
|
+
/** Sockets, FIFOs, and devices: runtime artifacts, never tree content. */
|
|
49
|
+
function isSpecial(s: {
|
|
50
|
+
isSocket(): boolean;
|
|
51
|
+
isFIFO(): boolean;
|
|
52
|
+
isBlockDevice(): boolean;
|
|
53
|
+
isCharacterDevice(): boolean;
|
|
54
|
+
}): boolean {
|
|
55
|
+
return s.isSocket() || s.isFIFO() || s.isBlockDevice() || s.isCharacterDevice();
|
|
56
|
+
}
|
package/src/plugin.ts
CHANGED
|
@@ -4,6 +4,12 @@ import { probeCowCapability } from "./capability";
|
|
|
4
4
|
import { fallbackPolicy, postCreateHooks, targetRoot } from "./config";
|
|
5
5
|
import { deviceOf, isDirectory } from "./device";
|
|
6
6
|
import { readMarkerFile, writeMarkerFile } from "./occupancy";
|
|
7
|
+
import { probeUncommitted } from "./uncommitted";
|
|
8
|
+
import { removeWorktree, runGit } from "./remove-tool";
|
|
9
|
+
import type {
|
|
10
|
+
RemoveWorktreeDeps,
|
|
11
|
+
RemoveWorktreeInput,
|
|
12
|
+
} from "./remove-tool";
|
|
7
13
|
import { listCowWorktrees, spawnWorkspace } from "./tool";
|
|
8
14
|
import type {
|
|
9
15
|
FallbackPolicy,
|
|
@@ -78,7 +84,20 @@ const spawnWorkspaceOutput = {
|
|
|
78
84
|
*/
|
|
79
85
|
const listWorktreesInput = {
|
|
80
86
|
type: "object",
|
|
81
|
-
properties: {
|
|
87
|
+
properties: {
|
|
88
|
+
missing: {
|
|
89
|
+
type: "string",
|
|
90
|
+
enum: ["fail", "report", "prune"],
|
|
91
|
+
description:
|
|
92
|
+
"Policy for an inventory row whose directory is gone (stat ENOENT — a dangling " +
|
|
93
|
+
'reference). "fail" (default) keeps the ADR 0003 contract: the list fails loudly. ' +
|
|
94
|
+
'"report" lists the row with missing: true and no createdAt. "prune" de-registers ' +
|
|
95
|
+
"the row through the worktree remove API and drops it from the listing; until " +
|
|
96
|
+
"upstream candidate 8 lands, a gone directory cannot be de-registered, so prune " +
|
|
97
|
+
"fails loudly naming candidate 8. A stat failure that is not ENOENT fails in " +
|
|
98
|
+
"every mode: it is a real anomaly, not a dangling row.",
|
|
99
|
+
},
|
|
100
|
+
},
|
|
82
101
|
additionalProperties: false,
|
|
83
102
|
} as const;
|
|
84
103
|
|
|
@@ -113,10 +132,17 @@ const listWorktreesOutput = {
|
|
|
113
132
|
type: "string",
|
|
114
133
|
description:
|
|
115
134
|
"ISO 8601 timestamp of the directory's birthtime, falling back to its " +
|
|
116
|
-
"mtime when the filesystem reports no birthtime."
|
|
135
|
+
"mtime when the filesystem reports no birthtime. Absent on an entry " +
|
|
136
|
+
"reported with missing: true — there is no directory to stat.",
|
|
137
|
+
},
|
|
138
|
+
missing: {
|
|
139
|
+
type: "boolean",
|
|
140
|
+
description:
|
|
141
|
+
"Present and true only under missing: \"report\", for an inventory row " +
|
|
142
|
+
"whose directory no longer exists.",
|
|
117
143
|
},
|
|
118
144
|
},
|
|
119
|
-
required: ["name", "directory", "strategy"
|
|
145
|
+
required: ["name", "directory", "strategy"],
|
|
120
146
|
additionalProperties: false,
|
|
121
147
|
},
|
|
122
148
|
},
|
|
@@ -125,6 +151,51 @@ const listWorktreesOutput = {
|
|
|
125
151
|
additionalProperties: false,
|
|
126
152
|
} as const;
|
|
127
153
|
|
|
154
|
+
/**
|
|
155
|
+
* `remove_worktree`'s input: exactly one of `name` or `directory` (validated
|
|
156
|
+
* in the execute path — the JSON Schema cannot express "exactly one of" two
|
|
157
|
+
* properties, so the schema leaves both optional and the tool refuses
|
|
158
|
+
* anything else). `force` is the same confirmation the TUI's remove carries:
|
|
159
|
+
* proceed despite unlanded or uncommitted work.
|
|
160
|
+
*/
|
|
161
|
+
const removeWorktreeInput = {
|
|
162
|
+
type: "object",
|
|
163
|
+
properties: {
|
|
164
|
+
name: {
|
|
165
|
+
type: "string",
|
|
166
|
+
description: "The worktree's basename, as list_worktrees reports it.",
|
|
167
|
+
},
|
|
168
|
+
directory: {
|
|
169
|
+
type: "string",
|
|
170
|
+
description: "The worktree directory, as the worktree inventory records it.",
|
|
171
|
+
},
|
|
172
|
+
force: {
|
|
173
|
+
type: "boolean",
|
|
174
|
+
description:
|
|
175
|
+
"Remove even when the worktree holds unlanded commits or uncommitted files.",
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
additionalProperties: false,
|
|
179
|
+
} as const;
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* `remove_worktree`'s declared output, under the same rule as the other two
|
|
183
|
+
* tools: a returned `output` field must have a declared schema. Whether force
|
|
184
|
+
* was used is reported in the text content only — the structured result is
|
|
185
|
+
* the one fact every caller needs, the directory that is gone.
|
|
186
|
+
*/
|
|
187
|
+
const removeWorktreeOutput = {
|
|
188
|
+
type: "object",
|
|
189
|
+
properties: {
|
|
190
|
+
directory: {
|
|
191
|
+
type: "string",
|
|
192
|
+
description: "The removed worktree directory, as the inventory recorded it.",
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
required: ["directory"],
|
|
196
|
+
additionalProperties: false,
|
|
197
|
+
} as const;
|
|
198
|
+
|
|
128
199
|
/**
|
|
129
200
|
* Binds `spawnWorkspace`'s seams to the live opencode2 context. The tool is
|
|
130
201
|
* the layer that owns the strategy choice, so the capability probe and the
|
|
@@ -180,6 +251,22 @@ function liveDeps(
|
|
|
180
251
|
};
|
|
181
252
|
}
|
|
182
253
|
|
|
254
|
+
/**
|
|
255
|
+
* Whether a path exists — the dangling-row pre-check's live binding. ENOENT
|
|
256
|
+
* answers "no"; any other stat failure (a permission problem, an I/O error)
|
|
257
|
+
* propagates, because an unreadable directory is a real anomaly the caller
|
|
258
|
+
* must see, not a dangling row.
|
|
259
|
+
*/
|
|
260
|
+
async function directoryPresent(directory: string): Promise<boolean> {
|
|
261
|
+
try {
|
|
262
|
+
await stat(directory);
|
|
263
|
+
return true;
|
|
264
|
+
} catch (cause) {
|
|
265
|
+
if ((cause as { code?: unknown }).code === "ENOENT") return false;
|
|
266
|
+
throw cause;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
183
270
|
/**
|
|
184
271
|
* The opencode2 plugin module.
|
|
185
272
|
*
|
|
@@ -248,20 +335,29 @@ export default {
|
|
|
248
335
|
"(birthtime, falling back to mtime when the filesystem reports none). Derived from " +
|
|
249
336
|
"opencode2's worktree inventory alone — there is no sessions field, because server " +
|
|
250
337
|
"plugins cannot enumerate sessions (ADR 0003). The list fails rather than skipping " +
|
|
251
|
-
"when an inventory row's directory cannot be read: the inventory is truth."
|
|
338
|
+
"when an inventory row's directory cannot be read: the inventory is truth. Pass " +
|
|
339
|
+
"missing: \"report\" or \"prune\" to opt out for dangling rows (a row whose " +
|
|
340
|
+
"directory is gone); see the missing knob's description for the exact policy.",
|
|
252
341
|
input: listWorktreesInput,
|
|
253
342
|
output: listWorktreesOutput,
|
|
254
343
|
// Same reason as spawn_workspace above: callable by name, not routed
|
|
255
344
|
// through CodeMode.
|
|
256
345
|
options: { codemode: false },
|
|
257
|
-
execute: async () => {
|
|
346
|
+
execute: async (input: { readonly missing?: "fail" | "report" | "prune" }) => {
|
|
258
347
|
const worktrees = await listCowWorktrees({
|
|
259
|
-
// Own minimal deps, not liveDeps: this
|
|
260
|
-
//
|
|
261
|
-
// other seams. (Option validation happens
|
|
262
|
-
// is no validation side effect to dodge
|
|
348
|
+
// Own minimal deps, not liveDeps: this call touches the inventory,
|
|
349
|
+
// one stat per row, and — in prune mode only — the remove API.
|
|
350
|
+
// None of spawn_workspace's other seams. (Option validation happens
|
|
351
|
+
// once in setup, so there is no validation side effect to dodge
|
|
352
|
+
// either way.)
|
|
263
353
|
listWorktrees: () => ctx.worktree.list({ projectID: ctx.location.project.id }),
|
|
264
354
|
statEntry: stat,
|
|
355
|
+
missing: input.missing,
|
|
356
|
+
// The prune seam goes through the same DELETE route every other
|
|
357
|
+
// removal uses, forced: a dangling row's directory is already gone,
|
|
358
|
+
// so the strategy's dirty probe has nothing to protect.
|
|
359
|
+
removeEntry: (directory) =>
|
|
360
|
+
ctx.worktree.remove({ projectID: ctx.location.project.id, directory, force: true }),
|
|
265
361
|
});
|
|
266
362
|
const summary =
|
|
267
363
|
worktrees.length === 0 ? "0 cow worktree(s)" : `${worktrees.length} cow worktree(s):`;
|
|
@@ -271,6 +367,47 @@ export default {
|
|
|
271
367
|
};
|
|
272
368
|
},
|
|
273
369
|
});
|
|
370
|
+
editor.add({
|
|
371
|
+
name: "remove_worktree",
|
|
372
|
+
description:
|
|
373
|
+
"Remove a finished cow worktree so its directory and inventory row are cleaned up " +
|
|
374
|
+
"together — no rm -rf, no hand-editing opencode's SQLite. Takes exactly one of name " +
|
|
375
|
+
"(a basename from list_worktrees) or directory (a row's directory). A guard refuses " +
|
|
376
|
+
"while the worktree still holds unlanded work — commits not on the landing ref " +
|
|
377
|
+
"(origin/HEAD, else main, else master) or uncommitted files — naming the counts; " +
|
|
378
|
+
"an undetectable landing ref also refuses. force: true removes anyway. Only worktrees " +
|
|
379
|
+
"the cow strategy created are removable; rows whose directory is already gone cannot " +
|
|
380
|
+
"be cleared through the API yet (upstream candidate 8) and are refused.",
|
|
381
|
+
input: removeWorktreeInput,
|
|
382
|
+
output: removeWorktreeOutput,
|
|
383
|
+
// Same reason as spawn_workspace above: callable by name, not routed
|
|
384
|
+
// through CodeMode.
|
|
385
|
+
options: { codemode: false },
|
|
386
|
+
execute: async (input: RemoveWorktreeInput) => {
|
|
387
|
+
const result = await removeWorktree(input, {
|
|
388
|
+
listWorktrees: () => ctx.worktree.list({ projectID: ctx.location.project.id }),
|
|
389
|
+
// Force passes through verbatim: on the guard-passed clean path
|
|
390
|
+
// `false` lets the strategy's own dirty probe run (and pass); at
|
|
391
|
+
// `true` the strategy skips it. The quarantine mechanics stay in
|
|
392
|
+
// the strategy.
|
|
393
|
+
removeWorktree: (directory, force) =>
|
|
394
|
+
ctx.worktree.remove({ projectID: ctx.location.project.id, directory, force }),
|
|
395
|
+
directoryExists: directoryPresent,
|
|
396
|
+
runGit,
|
|
397
|
+
probeUncommitted,
|
|
398
|
+
} satisfies RemoveWorktreeDeps);
|
|
399
|
+
// The text is what carries the force story: the structured output is
|
|
400
|
+
// only the directory, so a caller reading `output` alone still learns
|
|
401
|
+
// the one fact that matters — this directory is gone.
|
|
402
|
+
const content =
|
|
403
|
+
`Removed cow worktree ${result.directory}.` +
|
|
404
|
+
(result.forced ? " Force was used: the landed-ness guard was bypassed." : "");
|
|
405
|
+
return {
|
|
406
|
+
output: { directory: result.directory },
|
|
407
|
+
content,
|
|
408
|
+
};
|
|
409
|
+
},
|
|
410
|
+
});
|
|
274
411
|
});
|
|
275
412
|
},
|
|
276
413
|
};
|
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `remove_worktree` tool's decision logic: clean up a finished cow lane —
|
|
3
|
+
* its directory *and* its inventory row together — without `rm -rf` and
|
|
4
|
+
* without hand-editing opencode's SQLite.
|
|
5
|
+
*
|
|
6
|
+
* WHY the landed-ness guard lives here and not in `strategy.remove`: the
|
|
7
|
+
* strategy's `remove` is also what opencode2's TUI drives, and its contract
|
|
8
|
+
* there is only the dirty probe (issue #13). Changing what the TUI's remove
|
|
9
|
+
* button does is not this tool's business, so the "has the lane's work
|
|
10
|
+
* landed?" question is asked one layer up, where only agents are affected.
|
|
11
|
+
* The strategy keeps its own dirty probe; this module adds the commit-level
|
|
12
|
+
* question on top.
|
|
13
|
+
*
|
|
14
|
+
* The allow rule: a worktree may be removed when its HEAD has **landed** — it
|
|
15
|
+
* is an ancestor of the landing ref (`origin/HEAD`, else `main`, else
|
|
16
|
+
* `master`) — or when it is clean *and* holds no commits the landing ref
|
|
17
|
+
* lacks. Anything else refuses, naming exactly what is unlanded, because an
|
|
18
|
+
* agent cleaning up a lane must not destroy the only copy of finished-but-
|
|
19
|
+
* unpushed work. A cow lane is a separate clone whose `origin/*` refs freeze
|
|
20
|
+
* at clone time, so a would-be refusal first runs one `git fetch origin` and
|
|
21
|
+
* re-judges on the fresh refs (issue #16); a pass on stale refs is already
|
|
22
|
+
* sound, so the happy path never fetches, and a failed fetch keeps the
|
|
23
|
+
* refusal while saying the verdict is stale-limited. An undetectable landing
|
|
24
|
+
* ref also refuses: "cannot judge" is not "landed". `force: true` bypasses
|
|
25
|
+
* the guard entirely — the human's confirmation, same meaning the TUI's
|
|
26
|
+
* remove confirmation carries.
|
|
27
|
+
*
|
|
28
|
+
* `force` is then passed through **verbatim** to opencode2's
|
|
29
|
+
* `ctx.worktree.remove`. When the guard passed because the tree is clean and
|
|
30
|
+
* holds no unique commits, `force: false` lets the strategy run its own dirty
|
|
31
|
+
* probe (which passes); at `force: true` the strategy skips it. Existing-
|
|
32
|
+
* clone semantics — the quarantine dance, the identity capture — stay in the
|
|
33
|
+
* strategy (`removal.ts`); none of that is re-implemented here.
|
|
34
|
+
*
|
|
35
|
+
* Identity and membership come from opencode2's worktree inventory (ADR 0003)
|
|
36
|
+
* through the same matcher attach uses (`inventoryEntryFor`): a directory the
|
|
37
|
+
* inventory does not know, or knows under another strategy, is not this
|
|
38
|
+
* tool's to delete. A row whose directory is already gone is refused before
|
|
39
|
+
* the guard: opencode2's DELETE route answers 400 "Worktree directory
|
|
40
|
+
* unavailable" even at `force:true` until upstream candidate 8 lands
|
|
41
|
+
* (`docs/research/upstream-issues.md`), so de-registering a dangling row is
|
|
42
|
+
* impossible through the API and the tool must say so instead of failing with
|
|
43
|
+
* a message that implies `force` would help.
|
|
44
|
+
*
|
|
45
|
+
* Every impure contact — the inventory, the removal, the directory check, git
|
|
46
|
+
* itself — is injected (`RemoveWorktreeDeps`), so the decision table is
|
|
47
|
+
* testable without opencode2 and without a git repository, exactly like
|
|
48
|
+
* `SpawnWorkspaceDeps` in `./tool`.
|
|
49
|
+
*/
|
|
50
|
+
import { execFile } from "node:child_process";
|
|
51
|
+
import { basename } from "node:path";
|
|
52
|
+
import { promisify } from "node:util";
|
|
53
|
+
import type { UncommittedChanges } from "./dirty";
|
|
54
|
+
import { inventoryEntryFor } from "./tool";
|
|
55
|
+
import type { WorktreeInventoryEntry } from "../types/opencode2-worktree";
|
|
56
|
+
|
|
57
|
+
/** How long one git invocation may run before the live binding gives up. */
|
|
58
|
+
const GIT_TIMEOUT_MS = 5_000;
|
|
59
|
+
|
|
60
|
+
const run = promisify(execFile);
|
|
61
|
+
|
|
62
|
+
/** The outcome of one git invocation the guard ran through the seam. */
|
|
63
|
+
export interface GitRun {
|
|
64
|
+
/** The process exit code: 0 means the command answered affirmatively. */
|
|
65
|
+
readonly code: number;
|
|
66
|
+
/** Standard output, when the command produced any. */
|
|
67
|
+
readonly stdout: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The seams `removeWorktree` drives. Injected like `SpawnWorkspaceDeps` so the
|
|
72
|
+
* refusal table is testable without opencode2, a filesystem, or git.
|
|
73
|
+
*/
|
|
74
|
+
export interface RemoveWorktreeDeps {
|
|
75
|
+
/**
|
|
76
|
+
* The worktree inventory (`ctx.worktree.list()`), the same seam attach and
|
|
77
|
+
* `list_worktrees` read. The inventory, not a plugin-owned registry, is the
|
|
78
|
+
* source of truth for "ours" (ADR 0003).
|
|
79
|
+
*/
|
|
80
|
+
readonly listWorktrees: () => Promise<readonly WorktreeInventoryEntry[]>;
|
|
81
|
+
/**
|
|
82
|
+
* Removes a Worktree through opencode2's DELETE route
|
|
83
|
+
* (`ctx.worktree.remove`). `force` is passed verbatim: `false` lets the
|
|
84
|
+
* strategy's dirty probe run and pass on a clean tree, `true` skips it.
|
|
85
|
+
*/
|
|
86
|
+
readonly removeWorktree: (directory: string, force: boolean) => Promise<void>;
|
|
87
|
+
/**
|
|
88
|
+
* Whether the worktree directory exists. The dangling-row pre-check asks
|
|
89
|
+
* exactly one question — is the directory there — before any guard work.
|
|
90
|
+
*/
|
|
91
|
+
readonly directoryExists: (directory: string) => Promise<boolean>;
|
|
92
|
+
/**
|
|
93
|
+
* Runs one git command inside the worktree. The landed-ness guard's only
|
|
94
|
+
* contact with git: `symbolic-ref`, `rev-parse --verify --quiet`,
|
|
95
|
+
* `merge-base --is-ancestor`, `rev-list --count`, and the refusal path's
|
|
96
|
+
* `fetch origin` refresh. A non-zero exit is an answer ("no", "cannot"),
|
|
97
|
+
* never an exception.
|
|
98
|
+
*/
|
|
99
|
+
readonly runGit: (args: string[], cwd: string) => Promise<GitRun>;
|
|
100
|
+
/**
|
|
101
|
+
* Reports a worktree's uncommitted paths, or `undefined` when that cannot
|
|
102
|
+
* be determined. The same probe the strategy's dirty guard uses
|
|
103
|
+
* (`probeUncommitted` from `./uncommitted`); an unknown is treated as
|
|
104
|
+
* dirty, exactly as `mayRemove` treats it.
|
|
105
|
+
*/
|
|
106
|
+
readonly probeUncommitted: (directory: string) => Promise<UncommittedChanges>;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The tool's input, exactly as the JSON Schema declares it. */
|
|
110
|
+
export interface RemoveWorktreeInput {
|
|
111
|
+
/** The worktree's basename, as `list_worktrees` reports it. */
|
|
112
|
+
readonly name?: string;
|
|
113
|
+
/** The worktree directory, as the inventory records it. */
|
|
114
|
+
readonly directory?: string;
|
|
115
|
+
/** Remove even when the worktree holds unlanded or uncommitted work. */
|
|
116
|
+
readonly force?: boolean;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** What a successful removal produced: the directory, and whether force ran. */
|
|
120
|
+
export interface RemoveWorktreeResult {
|
|
121
|
+
/** The removed directory, verbatim as the inventory recorded it. */
|
|
122
|
+
readonly directory: string;
|
|
123
|
+
/** True when `force: true` bypassed the landed-ness guard. */
|
|
124
|
+
readonly forced: boolean;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Removes one cow worktree by name or directory, guarded against destroying
|
|
129
|
+
* unlanded work. See the module doc for the why of each refusal.
|
|
130
|
+
*
|
|
131
|
+
* Order matters: the exactly-one-of selector check comes first (it is about
|
|
132
|
+
* the input, not the world), then the inventory resolution (which decides
|
|
133
|
+
* whether the target is ours at all), then the dangling-row pre-check (which
|
|
134
|
+
* must precede the guard — the guard would run git in a directory that is not
|
|
135
|
+
* there), then the landed-ness guard, and only then the removal itself.
|
|
136
|
+
*/
|
|
137
|
+
export async function removeWorktree(
|
|
138
|
+
input: RemoveWorktreeInput,
|
|
139
|
+
deps: RemoveWorktreeDeps,
|
|
140
|
+
): Promise<RemoveWorktreeResult> {
|
|
141
|
+
const directory = await resolveTarget(input, deps);
|
|
142
|
+
await assertDirectoryPresent(directory, deps);
|
|
143
|
+
const forced = input.force === true;
|
|
144
|
+
if (!forced) await assertLanded(directory, deps);
|
|
145
|
+
await deps.removeWorktree(directory, forced);
|
|
146
|
+
return { directory, forced };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** One of the two selector shapes, after the exactly-one-of check. */
|
|
150
|
+
type Selector =
|
|
151
|
+
| { readonly kind: "directory"; readonly directory: string }
|
|
152
|
+
| { readonly kind: "name"; readonly name: string };
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Reads the input's selector: exactly one of `name`/`directory`, both
|
|
156
|
+
* non-empty. An empty string is not a selector — the same rule attach applies
|
|
157
|
+
* to `name` — so `{ name: "" }` refuses as "neither given" instead of
|
|
158
|
+
* resolving to nothing surprising.
|
|
159
|
+
*/
|
|
160
|
+
function selectorOf(input: RemoveWorktreeInput): Selector {
|
|
161
|
+
const name = typeof input.name === "string" && input.name.length > 0 ? input.name : undefined;
|
|
162
|
+
const directory =
|
|
163
|
+
typeof input.directory === "string" && input.directory.length > 0
|
|
164
|
+
? input.directory
|
|
165
|
+
: undefined;
|
|
166
|
+
if (name !== undefined && directory !== undefined) {
|
|
167
|
+
throw new Error(
|
|
168
|
+
'remove_worktree takes exactly one of "name" or "directory", not both: pass the ' +
|
|
169
|
+
"worktree's basename from list_worktrees, or its directory as the inventory " +
|
|
170
|
+
"records it — both were given.",
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
if (name === undefined && directory === undefined) {
|
|
174
|
+
throw new Error(
|
|
175
|
+
'remove_worktree takes exactly one of "name" or "directory": pass the worktree\'s ' +
|
|
176
|
+
"basename from list_worktrees, or its directory as the inventory records it — " +
|
|
177
|
+
"neither was given.",
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
return name !== undefined
|
|
181
|
+
? { kind: "name", name }
|
|
182
|
+
: { kind: "directory", directory: directory! };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Resolves the input's selector to the directory the inventory records, or
|
|
187
|
+
* refuses: an unknown directory as Foreign, a non-cow row as foreign
|
|
188
|
+
* strategy, an unknown or ambiguous name as a listing/ambiguity error.
|
|
189
|
+
*/
|
|
190
|
+
async function resolveTarget(
|
|
191
|
+
input: RemoveWorktreeInput,
|
|
192
|
+
deps: RemoveWorktreeDeps,
|
|
193
|
+
): Promise<string> {
|
|
194
|
+
const selector = selectorOf(input);
|
|
195
|
+
const entries = await deps.listWorktrees();
|
|
196
|
+
return selector.kind === "directory"
|
|
197
|
+
? resolveByDirectory(entries, selector.directory)
|
|
198
|
+
: resolveByName(entries, selector.name);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* The directory selector: the same identity matcher attach uses
|
|
203
|
+
* (`inventoryEntryFor` — basename, exact string, then realpath/lexical
|
|
204
|
+
* normalization), so a worktree list_worktrees reports is removable under the
|
|
205
|
+
* spelling that report used. The removal targets the inventory-recorded
|
|
206
|
+
* spelling: that is the path opencode2's record holds.
|
|
207
|
+
*/
|
|
208
|
+
async function resolveByDirectory(
|
|
209
|
+
entries: readonly WorktreeInventoryEntry[],
|
|
210
|
+
directory: string,
|
|
211
|
+
): Promise<string> {
|
|
212
|
+
const entry = await inventoryEntryFor(entries, directory);
|
|
213
|
+
if (entry === undefined) throw foreignWorktreeRemovalError(directory);
|
|
214
|
+
if (entry.strategy !== "cow") {
|
|
215
|
+
throw foreignStrategyRemovalError(directory, entry.strategy);
|
|
216
|
+
}
|
|
217
|
+
return entry.directory;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* The name selector: the basename of a `cow` inventory row. Non-cow rows are
|
|
222
|
+
* filtered out first — a name can only ever resolve to a cow worktree, so a
|
|
223
|
+
* `git`-strategy row under the same basename is invisible to name lookups,
|
|
224
|
+
* not a foreign-strategy refusal. Zero matches lists what does exist; more
|
|
225
|
+
* than one names every candidate and asks for the directory.
|
|
226
|
+
*/
|
|
227
|
+
function resolveByName(
|
|
228
|
+
entries: readonly WorktreeInventoryEntry[],
|
|
229
|
+
name: string,
|
|
230
|
+
): string {
|
|
231
|
+
const cow = entries.filter((entry) => entry.strategy === "cow");
|
|
232
|
+
const matches = cow.filter((entry) => basename(entry.directory) === name);
|
|
233
|
+
if (matches.length === 0) throw unknownNameError(name, cow);
|
|
234
|
+
if (matches.length > 1) throw ambiguousNameError(name, matches);
|
|
235
|
+
return matches[0]!.directory;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* The dangling-row pre-check: a row whose directory is gone cannot be
|
|
240
|
+
* de-registered through the API at all — opencode2's DELETE route answers
|
|
241
|
+
* 400 "Worktree directory unavailable" even at `force:true` until upstream
|
|
242
|
+
* candidate 8 lands. Refusing here names the real obstacle; letting the call
|
|
243
|
+
* reach the API would surface that 400 as if `force` had not been tried.
|
|
244
|
+
*/
|
|
245
|
+
async function assertDirectoryPresent(
|
|
246
|
+
directory: string,
|
|
247
|
+
deps: RemoveWorktreeDeps,
|
|
248
|
+
): Promise<void> {
|
|
249
|
+
if (await deps.directoryExists(directory)) return;
|
|
250
|
+
throw danglingRowError(directory);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* The landed-ness guard, applied only when the caller did not force. It
|
|
255
|
+
* refuses while the worktree holds unlanded work and otherwise lets the
|
|
256
|
+
* removal proceed. Every sub-answer is fail-closed: an undetectable landing
|
|
257
|
+
* ref and an undeterminable dirty state both refuse, because "cannot judge"
|
|
258
|
+
* must never mean "safe to delete".
|
|
259
|
+
*/
|
|
260
|
+
async function assertLanded(
|
|
261
|
+
directory: string,
|
|
262
|
+
deps: RemoveWorktreeDeps,
|
|
263
|
+
): Promise<void> {
|
|
264
|
+
const landingRef = await detectLandingRef(directory, deps);
|
|
265
|
+
if (landingRef === undefined) throw landingRefUnknownError(directory);
|
|
266
|
+
let check = await landingCheck(directory, landingRef, deps);
|
|
267
|
+
let refreshFailed = false;
|
|
268
|
+
if (!landed(check)) {
|
|
269
|
+
// A cow lane is a separate clone whose origin/<branch> refs freeze at
|
|
270
|
+
// clone time, so work that landed on the remote afterwards (a merged
|
|
271
|
+
// PR) looks unlanded here (issue #16). Refresh once and re-judge before
|
|
272
|
+
// refusing; a pass on stale refs is already sound, so the fetch only
|
|
273
|
+
// ever runs on the refusal path.
|
|
274
|
+
const fetched = await deps.runGit(["fetch", "origin"], directory);
|
|
275
|
+
if (fetched.code === 0) {
|
|
276
|
+
check = await landingCheck(directory, landingRef, deps);
|
|
277
|
+
} else {
|
|
278
|
+
refreshFailed = true;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
if (landed(check)) return;
|
|
282
|
+
throw unlandedError(directory, landingRef, check, refreshFailed);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** The guard's allow rule over one probe set. */
|
|
286
|
+
function landed(check: LandingCheck): boolean {
|
|
287
|
+
const clean = check.uncommitted !== undefined && check.uncommitted.length === 0;
|
|
288
|
+
return check.landed || (clean && check.uniqueCommits === 0);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** What the guard learned about one worktree's landed-ness. */
|
|
292
|
+
interface LandingCheck {
|
|
293
|
+
/** `git merge-base --is-ancestor HEAD <ref>` exited 0. */
|
|
294
|
+
readonly landed: boolean;
|
|
295
|
+
/** `git rev-list --count <ref>..HEAD`, or `undefined` when it failed. */
|
|
296
|
+
readonly uniqueCommits: number | undefined;
|
|
297
|
+
/** The dirty probe's answer; `undefined` means it could not tell. */
|
|
298
|
+
readonly uncommitted: UncommittedChanges;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Runs the guard's three probes — ancestry, unique-commit count, dirty state. */
|
|
302
|
+
async function landingCheck(
|
|
303
|
+
directory: string,
|
|
304
|
+
landingRef: string,
|
|
305
|
+
deps: RemoveWorktreeDeps,
|
|
306
|
+
): Promise<LandingCheck> {
|
|
307
|
+
const ancestor = await deps.runGit(
|
|
308
|
+
["merge-base", "--is-ancestor", "HEAD", landingRef],
|
|
309
|
+
directory,
|
|
310
|
+
);
|
|
311
|
+
const counted = await deps.runGit(
|
|
312
|
+
["rev-list", "--count", `${landingRef}..HEAD`],
|
|
313
|
+
directory,
|
|
314
|
+
);
|
|
315
|
+
const uncommitted = await deps.probeUncommitted(directory);
|
|
316
|
+
return {
|
|
317
|
+
landed: ancestor.code === 0,
|
|
318
|
+
uniqueCommits: counted.code === 0 ? parseCount(counted.stdout) : undefined,
|
|
319
|
+
uncommitted,
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** A `rev-list --count` answer, or `undefined` when it is not a number. */
|
|
324
|
+
function parseCount(stdout: string): number | undefined {
|
|
325
|
+
const count = Number.parseInt(stdout.trim(), 10);
|
|
326
|
+
return Number.isNaN(count) ? undefined : count;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* The worktree's landing ref: the first of `origin/HEAD` (resolved through
|
|
331
|
+
* `git symbolic-ref`, trimmed to `origin/<branch>`), `main`, and `master`
|
|
332
|
+
* that `git rev-parse --verify --quiet` confirms. `undefined` when none
|
|
333
|
+
* resolves — which refuses, because landed-ness cannot be judged.
|
|
334
|
+
*/
|
|
335
|
+
async function detectLandingRef(
|
|
336
|
+
directory: string,
|
|
337
|
+
deps: RemoveWorktreeDeps,
|
|
338
|
+
): Promise<string | undefined> {
|
|
339
|
+
const fromOriginHead = await originHeadRef(directory, deps);
|
|
340
|
+
if (fromOriginHead !== undefined && (await refResolves(fromOriginHead, directory, deps))) {
|
|
341
|
+
return fromOriginHead;
|
|
342
|
+
}
|
|
343
|
+
for (const candidate of ["main", "master"]) {
|
|
344
|
+
if (await refResolves(candidate, directory, deps)) return candidate;
|
|
345
|
+
}
|
|
346
|
+
return undefined;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** The `origin/<branch>` ref `origin/HEAD` points at, when it answers one. */
|
|
350
|
+
async function originHeadRef(
|
|
351
|
+
directory: string,
|
|
352
|
+
deps: RemoveWorktreeDeps,
|
|
353
|
+
): Promise<string | undefined> {
|
|
354
|
+
const answer = await deps.runGit(
|
|
355
|
+
["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"],
|
|
356
|
+
directory,
|
|
357
|
+
);
|
|
358
|
+
if (answer.code !== 0) return undefined;
|
|
359
|
+
const ref = answer.stdout.trim();
|
|
360
|
+
// A symbolic-ref answer outside refs/remotes/ is not a remote branch this
|
|
361
|
+
// guard can name; treat it as no answer rather than passing it on.
|
|
362
|
+
return ref.startsWith("refs/remotes/") ? ref.slice("refs/remotes/".length) : undefined;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Whether `git rev-parse --verify --quiet <ref>` confirms the ref exists. */
|
|
366
|
+
async function refResolves(
|
|
367
|
+
ref: string,
|
|
368
|
+
directory: string,
|
|
369
|
+
deps: RemoveWorktreeDeps,
|
|
370
|
+
): Promise<boolean> {
|
|
371
|
+
const answer = await deps.runGit(["rev-parse", "--verify", "--quiet", ref], directory);
|
|
372
|
+
return answer.code === 0;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* The unlanded refusal, naming exactly what has not landed. A failed
|
|
377
|
+
* `fetch` refresh is named too, so the operator knows the counts came from
|
|
378
|
+
* the refs frozen at clone time.
|
|
379
|
+
*/
|
|
380
|
+
function unlandedError(
|
|
381
|
+
directory: string,
|
|
382
|
+
landingRef: string,
|
|
383
|
+
check: LandingCheck,
|
|
384
|
+
refreshFailed: boolean,
|
|
385
|
+
): Error {
|
|
386
|
+
const stale =
|
|
387
|
+
"A `git fetch origin` refresh failed, so this verdict used the remote " +
|
|
388
|
+
"refs frozen at clone time. ";
|
|
389
|
+
return new Error(
|
|
390
|
+
`refusing to remove ${directory}: it holds work that has not landed on ` +
|
|
391
|
+
`${landingRef} — ${describeUnlanded(check.uniqueCommits, check.uncommitted)}. ` +
|
|
392
|
+
(refreshFailed ? stale : "") +
|
|
393
|
+
"Re-run with force to remove anyway.",
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** The counts half of the unlanded refusal; unknowns are named as unknown. */
|
|
398
|
+
function describeUnlanded(
|
|
399
|
+
uniqueCommits: number | undefined,
|
|
400
|
+
uncommitted: UncommittedChanges,
|
|
401
|
+
): string {
|
|
402
|
+
const commits =
|
|
403
|
+
uniqueCommits === undefined
|
|
404
|
+
? "an unknown number of unique commit(s) (the git probe failed)"
|
|
405
|
+
: `${uniqueCommits} unique commit(s)`;
|
|
406
|
+
const files =
|
|
407
|
+
uncommitted === undefined
|
|
408
|
+
? "an unknown number of uncommitted file(s) (the probe could not answer)"
|
|
409
|
+
: `${uncommitted.length} uncommitted file(s)`;
|
|
410
|
+
return `${commits} and ${files}`;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** The refusal for an undetectable landing ref; force still overrides. */
|
|
414
|
+
function landingRefUnknownError(directory: string): Error {
|
|
415
|
+
return new Error(
|
|
416
|
+
`refusing to remove ${directory}: the landing ref could not be determined — ` +
|
|
417
|
+
"none of refs/remotes/origin/HEAD (via git symbolic-ref), main, or master " +
|
|
418
|
+
"resolves in this worktree, so landed-ness cannot be judged. " +
|
|
419
|
+
"Re-run with force to remove anyway.",
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** The refusal for a directory the inventory does not know at all. */
|
|
424
|
+
function foreignWorktreeRemovalError(directory: string): Error {
|
|
425
|
+
return new Error(
|
|
426
|
+
`refusing to remove ${directory}: opencode2's worktree inventory has no entry ` +
|
|
427
|
+
"for it, so it is not a worktree this strategy materialized (a Foreign " +
|
|
428
|
+
"worktree). remove_worktree removes only worktrees its cow strategy " +
|
|
429
|
+
"created; if nothing needs what is inside, remove the directory by hand.",
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** The refusal for an inventory entry naming a strategy other than `cow`. */
|
|
434
|
+
function foreignStrategyRemovalError(
|
|
435
|
+
directory: string,
|
|
436
|
+
strategy: string | undefined,
|
|
437
|
+
): Error {
|
|
438
|
+
const described = strategy === undefined ? "no strategy" : `"${strategy}"`;
|
|
439
|
+
return new Error(
|
|
440
|
+
`refusing to remove ${directory}: the worktree inventory records it with ` +
|
|
441
|
+
`${described}, not "cow" — remove_worktree will not remove a Worktree ` +
|
|
442
|
+
"another strategy materialized. Remove it through opencode2 or by hand.",
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* The refusal for a row whose directory is gone: the API cannot clear it
|
|
448
|
+
* until upstream candidate 8 ("Worktree.remove resolves the real path before
|
|
449
|
+
* reading the record") lands, so the refusal names that instead of letting a
|
|
450
|
+
* 400 "Worktree directory unavailable" imply that `force` was not tried.
|
|
451
|
+
*/
|
|
452
|
+
function danglingRowError(directory: string): Error {
|
|
453
|
+
return new Error(
|
|
454
|
+
`refusing to remove ${directory}: the worktree directory no longer exists, so ` +
|
|
455
|
+
"the inventory row is dangling and cannot be de-registered through the API " +
|
|
456
|
+
'until upstream candidate 8 ("Worktree.remove resolves the real path before ' +
|
|
457
|
+
'reading the record") lands — the DELETE route answers 400 "Worktree directory ' +
|
|
458
|
+
"unavailable\" even at force:true. See docs/research/upstream-issues.md; " +
|
|
459
|
+
"until then, clearing the row means editing opencode.db by hand.",
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** The refusal for a name no cow row carries, listing the rows that exist. */
|
|
464
|
+
function unknownNameError(
|
|
465
|
+
name: string,
|
|
466
|
+
cow: readonly WorktreeInventoryEntry[],
|
|
467
|
+
): Error {
|
|
468
|
+
const known =
|
|
469
|
+
cow.length === 0
|
|
470
|
+
? "this location's inventory has no cow worktrees"
|
|
471
|
+
: `known cow worktree(s): ${cow.map((entry) => basename(entry.directory)).join(", ")}`;
|
|
472
|
+
return new Error(
|
|
473
|
+
`no cow worktree named ${JSON.stringify(name)}: ${known}. ` +
|
|
474
|
+
"Run list_worktrees to see this location's cow worktrees.",
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** The refusal for a name several cow rows carry; the directory disambiguates. */
|
|
479
|
+
function ambiguousNameError(
|
|
480
|
+
name: string,
|
|
481
|
+
matches: readonly WorktreeInventoryEntry[],
|
|
482
|
+
): Error {
|
|
483
|
+
return new Error(
|
|
484
|
+
`the name ${JSON.stringify(name)} is ambiguous: ${matches.length} cow worktrees ` +
|
|
485
|
+
`share that basename — ${matches.map((entry) => entry.directory).join(", ")}. ` +
|
|
486
|
+
"Pass the full directory instead.",
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* The live `runGit` binding: one git invocation via `execFile` — no shell, no
|
|
492
|
+
* inherited prompt — with the same hygiene `probeUncommitted` uses. Declared
|
|
493
|
+
* total like the guard's other answers: every failure (non-zero exit,
|
|
494
|
+
* timeout, no git on the machine) resolves to a non-zero code, which each
|
|
495
|
+
* caller reads as "no"/"cannot". An exit code the error object carries is
|
|
496
|
+
* kept so a signal kill and a plain refusal are at least distinguishable in
|
|
497
|
+
* principle; anything unreadable is 1.
|
|
498
|
+
*/
|
|
499
|
+
export async function runGit(args: string[], cwd: string): Promise<GitRun> {
|
|
500
|
+
try {
|
|
501
|
+
const { stdout } = await run("git", args, {
|
|
502
|
+
cwd,
|
|
503
|
+
encoding: "utf8",
|
|
504
|
+
timeout: GIT_TIMEOUT_MS,
|
|
505
|
+
env: { ...process.env, GIT_PAGER: "cat", GIT_EDITOR: "true", CI: "1" },
|
|
506
|
+
});
|
|
507
|
+
return { code: 0, stdout };
|
|
508
|
+
} catch (cause) {
|
|
509
|
+
return { code: exitCodeOf(cause), stdout: "" };
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** The exit code an execFile failure carries, when it carries one. */
|
|
514
|
+
function exitCodeOf(cause: unknown): number {
|
|
515
|
+
if (typeof cause === "object" && cause !== null && "code" in cause) {
|
|
516
|
+
const code = (cause as { code: unknown }).code;
|
|
517
|
+
if (typeof code === "number") return code;
|
|
518
|
+
}
|
|
519
|
+
return 1;
|
|
520
|
+
}
|
package/src/tool.ts
CHANGED
|
@@ -525,8 +525,17 @@ export interface CowWorktreeEntry {
|
|
|
525
525
|
/** The worktree directory, verbatim as the inventory records it. */
|
|
526
526
|
readonly directory: string;
|
|
527
527
|
readonly strategy: "cow";
|
|
528
|
-
/**
|
|
529
|
-
|
|
528
|
+
/**
|
|
529
|
+
* ISO 8601; derived from a directory stat. See `createdAtOf`. Absent on a
|
|
530
|
+
* `missing` entry: with no directory to stat there is no timestamp to derive.
|
|
531
|
+
*/
|
|
532
|
+
readonly createdAt?: string;
|
|
533
|
+
/**
|
|
534
|
+
* Set only under `missing: "report"`: the inventory still lists the row, but
|
|
535
|
+
* its directory is gone (the stat answered ENOENT). The directory stays
|
|
536
|
+
* verbatim from the inventory and `createdAt` is absent.
|
|
537
|
+
*/
|
|
538
|
+
readonly missing?: true;
|
|
530
539
|
}
|
|
531
540
|
|
|
532
541
|
/**
|
|
@@ -548,11 +557,29 @@ export interface ListWorktreesDeps {
|
|
|
548
557
|
*/
|
|
549
558
|
readonly listWorktrees: () => Promise<readonly WorktreeInventoryEntry[]>;
|
|
550
559
|
/**
|
|
551
|
-
* Stats one inventory-listed directory
|
|
552
|
-
* inventory
|
|
553
|
-
*
|
|
560
|
+
* Stats one inventory-listed directory, for `createdAt`. An ENOENT here is a
|
|
561
|
+
* dangling inventory row — a directory deleted out from under the inventory —
|
|
562
|
+
* handled per the `missing` policy. Any other failure is a real anomaly and
|
|
563
|
+
* fails the list in every mode.
|
|
554
564
|
*/
|
|
555
565
|
readonly statEntry: (path: string) => Promise<StatTimes>;
|
|
566
|
+
/**
|
|
567
|
+
* What a dangling inventory row (its directory stats ENOENT) costs the call.
|
|
568
|
+
* `"fail"` — the default — keeps ADR 0003's fail-loud contract. `"report"`
|
|
569
|
+
* lists the row flagged `missing: true`, without `createdAt`. `"prune"`
|
|
570
|
+
* de-registers the row through `removeEntry` and drops it from the listing.
|
|
571
|
+
*/
|
|
572
|
+
readonly missing?: "fail" | "report" | "prune";
|
|
573
|
+
/**
|
|
574
|
+
* De-registers one inventory row; the seam behind `missing: "prune"`, wired
|
|
575
|
+
* by the plugin to `ctx.worktree.remove` with force, so a gone directory is
|
|
576
|
+
* accepted as an already-completed deletion. A rejection is never absorbed:
|
|
577
|
+
* upstream candidate 8 ("Worktree.remove resolves the real path before
|
|
578
|
+
* reading the record", docs/research/upstream-issues.md) makes the delete
|
|
579
|
+
* answer 400 for a gone directory, so the caller must learn the row is
|
|
580
|
+
* still there.
|
|
581
|
+
*/
|
|
582
|
+
readonly removeEntry?: (directory: string) => Promise<void>;
|
|
556
583
|
}
|
|
557
584
|
|
|
558
585
|
/**
|
|
@@ -564,22 +591,47 @@ export interface ListWorktreesDeps {
|
|
|
564
591
|
* for CoW capability: per ADR 0003 the inventory is the source of truth, and
|
|
565
592
|
* the only filesystem contact is one stat per surviving entry, for
|
|
566
593
|
* `createdAt`.
|
|
594
|
+
*
|
|
595
|
+
* A row whose directory stats ENOENT is a dangling reference — deleted out
|
|
596
|
+
* from under the inventory, not a worktree to preserve. The default
|
|
597
|
+
* (`missing: "fail"`) keeps ADR 0003's fail-loud contract; `"report"` returns
|
|
598
|
+
* the row flagged `missing: true` and without `createdAt`; `"prune"`
|
|
599
|
+
* de-registers the row through `removeEntry` and drops it. Any non-ENOENT
|
|
600
|
+
* stat failure fails the list in every mode — that is a real anomaly. The
|
|
601
|
+
* return shape stays the bare listing, so a prune is visible to callers only
|
|
602
|
+
* through `removeEntry`'s effect on the inventory or its rejection.
|
|
567
603
|
*/
|
|
568
604
|
export async function listCowWorktrees(deps: ListWorktreesDeps): Promise<CowWorktreeEntry[]> {
|
|
569
605
|
const entries = await deps.listWorktrees();
|
|
570
606
|
const cow = entries.filter((entry) => entry.strategy === "cow");
|
|
571
|
-
|
|
607
|
+
// Rows are resolved one at a time: prune mode mutates the inventory through
|
|
608
|
+
// `removeEntry`, and concurrent removes racing the same inventory file buy
|
|
609
|
+
// nothing for a listing of a handful of rows.
|
|
610
|
+
const listed: CowWorktreeEntry[] = [];
|
|
611
|
+
for (const entry of cow) {
|
|
612
|
+
const resolved = await cowEntryOf(entry, deps);
|
|
613
|
+
if (resolved !== undefined) listed.push(resolved);
|
|
614
|
+
}
|
|
615
|
+
return listed;
|
|
572
616
|
}
|
|
573
617
|
|
|
574
|
-
/**
|
|
618
|
+
/**
|
|
619
|
+
* Derives one listing entry — the basename, the verbatim directory, and the
|
|
620
|
+
* stat — or `undefined` when the row was pruned. The stat failure itself is
|
|
621
|
+
* classified, not absorbed: ENOENT goes to the `missing` policy, and every
|
|
622
|
+
* other failure propagates untouched, exactly as before the knob existed.
|
|
623
|
+
*/
|
|
575
624
|
async function cowEntryOf(
|
|
576
625
|
entry: WorktreeInventoryEntry,
|
|
577
|
-
|
|
578
|
-
): Promise<CowWorktreeEntry> {
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
626
|
+
deps: ListWorktreesDeps,
|
|
627
|
+
): Promise<CowWorktreeEntry | undefined> {
|
|
628
|
+
let times: StatTimes;
|
|
629
|
+
try {
|
|
630
|
+
times = await deps.statEntry(entry.directory);
|
|
631
|
+
} catch (cause) {
|
|
632
|
+
if (!isEnoent(cause)) throw cause;
|
|
633
|
+
return missingEntryOf(entry, deps);
|
|
634
|
+
}
|
|
583
635
|
return {
|
|
584
636
|
name: basename(entry.directory),
|
|
585
637
|
directory: entry.directory,
|
|
@@ -588,6 +640,96 @@ async function cowEntryOf(
|
|
|
588
640
|
};
|
|
589
641
|
}
|
|
590
642
|
|
|
643
|
+
/**
|
|
644
|
+
* The `missing` policy applied to a dangling row. `"report"` keeps the row,
|
|
645
|
+
* flagged; `"prune"` de-registers it and answers `undefined` so the row leaves
|
|
646
|
+
* the listing; the default `"fail"` throws the shaped dangling-row refusal.
|
|
647
|
+
*/
|
|
648
|
+
async function missingEntryOf(
|
|
649
|
+
entry: WorktreeInventoryEntry,
|
|
650
|
+
deps: ListWorktreesDeps,
|
|
651
|
+
): Promise<CowWorktreeEntry | undefined> {
|
|
652
|
+
switch (deps.missing ?? "fail") {
|
|
653
|
+
case "report":
|
|
654
|
+
return {
|
|
655
|
+
name: basename(entry.directory),
|
|
656
|
+
directory: entry.directory,
|
|
657
|
+
strategy: "cow",
|
|
658
|
+
missing: true,
|
|
659
|
+
};
|
|
660
|
+
case "prune": {
|
|
661
|
+
if (deps.removeEntry === undefined) throw pruneWithoutSeamError();
|
|
662
|
+
try {
|
|
663
|
+
await deps.removeEntry(entry.directory);
|
|
664
|
+
} catch (cause) {
|
|
665
|
+
throw pruneBlockedError(entry.directory, cause);
|
|
666
|
+
}
|
|
667
|
+
return undefined;
|
|
668
|
+
}
|
|
669
|
+
default:
|
|
670
|
+
throw danglingRowError(entry.directory);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* The shaped refusal for a dangling row under the default `"fail"` policy: the
|
|
676
|
+
* raw ENOENT rides as the cause, and the message names the two opt-outs.
|
|
677
|
+
*/
|
|
678
|
+
function danglingRowError(directory: string): Error {
|
|
679
|
+
return new Error(
|
|
680
|
+
`the worktree inventory lists ${directory}, but stat answers ENOENT: the ` +
|
|
681
|
+
"directory was deleted while the inventory still records it — a dangling " +
|
|
682
|
+
"row, and the default fails loudly rather than report an incomplete list " +
|
|
683
|
+
'as complete. Pass missing: "report" to list the row flagged missing, or ' +
|
|
684
|
+
'missing: "prune" to de-register it.',
|
|
685
|
+
);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* The refusal for `missing: "prune"` without a wired `removeEntry`: the policy
|
|
690
|
+
* asked for a de-registration the call has no seam to perform.
|
|
691
|
+
*/
|
|
692
|
+
function pruneWithoutSeamError(): Error {
|
|
693
|
+
return new Error(
|
|
694
|
+
'missing: "prune" was requested but no removeEntry seam is wired, so a ' +
|
|
695
|
+
"dangling inventory row cannot be de-registered.",
|
|
696
|
+
);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* The refusal when the prune itself fails: never swallowed, because the row
|
|
701
|
+
* the caller wanted gone is still in the inventory. The message names the row,
|
|
702
|
+
* the raw failure, and the upstream blocker (candidate 8) that makes a gone
|
|
703
|
+
* directory refuse deletion even at force.
|
|
704
|
+
*/
|
|
705
|
+
function pruneBlockedError(directory: string, cause: unknown): Error {
|
|
706
|
+
const reason = cause instanceof Error ? cause.message : String(cause);
|
|
707
|
+
return new Error(
|
|
708
|
+
`cow could not prune the dangling inventory row ${directory}: the removal ` +
|
|
709
|
+
`failed (${reason}). Upstream candidate 8 ("Worktree.remove resolves the ` +
|
|
710
|
+
"real path before reading the record\", docs/research/upstream-issues.md) " +
|
|
711
|
+
"makes DELETE /api/worktree answer 400 for a gone directory even at " +
|
|
712
|
+
"force:true, so the row cannot be de-registered until that lands. Re-run " +
|
|
713
|
+
'with missing: "report" to see the row flagged instead.',
|
|
714
|
+
{ cause },
|
|
715
|
+
);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* Whether an error is a plain ENOENT — "the path is not there", which for an
|
|
720
|
+
* inventory row means a dangling reference rather than an anomaly. Node's
|
|
721
|
+
* filesystem errors carry the code; anything else is not an absence answer.
|
|
722
|
+
* (Mirrors the classifier in `./removal`, which is module-private there.)
|
|
723
|
+
*/
|
|
724
|
+
function isEnoent(cause: unknown): boolean {
|
|
725
|
+
return (
|
|
726
|
+
typeof cause === "object" &&
|
|
727
|
+
cause !== null &&
|
|
728
|
+
"code" in cause &&
|
|
729
|
+
cause.code === "ENOENT"
|
|
730
|
+
);
|
|
731
|
+
}
|
|
732
|
+
|
|
591
733
|
/**
|
|
592
734
|
* Birthtime first, mtime when the filesystem reports none. btrfs and other
|
|
593
735
|
* filesystems leave birthtime at zero/epoch, where mtime is the
|