agent-coord-mcp 0.26.17 → 0.26.19
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/dist/server.js +5 -4
- package/dist/server.js.map +1 -1
- package/dist/tools/admin.js +8 -2
- package/dist/tools/admin.js.map +1 -1
- package/dist/tools/away.js +45 -3
- package/dist/tools/away.js.map +1 -1
- package/dist/tools/records.js +269 -20
- package/dist/tools/records.js.map +1 -1
- package/dist/tools/rooms.js +2 -2
- package/dist/tools/rooms.js.map +1 -1
- package/dist/tools/shared.js +18 -0
- package/dist/tools/shared.js.map +1 -1
- package/dist/tools/stall.js +28 -1
- package/dist/tools/stall.js.map +1 -1
- package/dist/tools/transport.js +12 -5
- package/dist/tools/transport.js.map +1 -1
- package/dist/tools/work.js +59 -5
- package/dist/tools/work.js.map +1 -1
- package/dist/tools/worktrees.js +245 -3
- package/dist/tools/worktrees.js.map +1 -1
- package/dist/work.js +1 -1
- package/dist/work.js.map +1 -1
- package/hooks/peek-coord.mjs +0 -0
- package/hooks/tmux-pusher.mjs +0 -0
- package/package.json +11 -14
- package/scripts/coord-attention-clock.mjs +0 -0
- package/scripts/coord-node.sh +0 -0
- package/scripts/coord-stall-clock.mjs +0 -0
- package/scripts/coord-token.mjs +0 -0
- package/scripts/probe-tmux-liveness.sh +0 -0
- package/scripts/spawn-agent.sh +0 -0
- package/scripts/stop-agent.sh +0 -0
- package/scripts/typed-record-stats.mjs +0 -0
- package/src/server.ts +11 -3
- package/src/tools/admin.ts +8 -1
- package/src/tools/away.ts +48 -4
- package/src/tools/records.ts +274 -18
- package/src/tools/rooms.ts +2 -1
- package/src/tools/shared.ts +18 -0
- package/src/tools/stall.ts +29 -1
- package/src/tools/transport.ts +12 -4
- package/src/tools/work.ts +70 -4
- package/src/tools/worktrees.ts +267 -3
- package/src/work.ts +4 -0
package/src/tools/work.ts
CHANGED
|
@@ -13,6 +13,8 @@ import {
|
|
|
13
13
|
projectV1ToLanes,
|
|
14
14
|
queueItemsOf,
|
|
15
15
|
renderWorkDoc,
|
|
16
|
+
renderWorkDocForWrite,
|
|
17
|
+
zeroIsUnparsed,
|
|
16
18
|
workDocIssues,
|
|
17
19
|
workDocLegacyWriteIssues,
|
|
18
20
|
LANES_V0_WRITE_ISSUE,
|
|
@@ -55,6 +57,13 @@ export type StoredWorkDoc = {
|
|
|
55
57
|
kind: "queue" | "done" | "board" | "legacy";
|
|
56
58
|
path: string;
|
|
57
59
|
doc: WorkDoc;
|
|
60
|
+
/**
|
|
61
|
+
* The bytes as read. Kept because a COUNT OF ZERO cannot be interpreted
|
|
62
|
+
* without them: `zeroIsUnparsed` needs to know whether the document had
|
|
63
|
+
* content the parser did not understand, and the parsed doc has already
|
|
64
|
+
* discarded that distinction (q-3e7c81a5).
|
|
65
|
+
*/
|
|
66
|
+
source: string;
|
|
58
67
|
};
|
|
59
68
|
|
|
60
69
|
export type StoredFactsDoc = {
|
|
@@ -77,7 +86,7 @@ async function loadDoc(repo: string, f: { kind: WorkFileKind; path: string }): P
|
|
|
77
86
|
const parsed = parseFactsDoc(source);
|
|
78
87
|
return { kind: "facts", path: f.path, source, entries: parsed.entries, issues: parsed.issues };
|
|
79
88
|
}
|
|
80
|
-
return { kind: f.kind, path: f.path, doc: parseWorkDoc(source) };
|
|
89
|
+
return { kind: f.kind, path: f.path, doc: parseWorkDoc(source), source };
|
|
81
90
|
}
|
|
82
91
|
|
|
83
92
|
function docIssues(d: StoredDoc): string[] {
|
|
@@ -97,12 +106,56 @@ function importedSummary(d: StoredDoc) {
|
|
|
97
106
|
};
|
|
98
107
|
}
|
|
99
108
|
const issues = [...workDocIssues(d.doc), ...workDocLegacyWriteIssues(d.doc)];
|
|
100
|
-
|
|
101
|
-
path: d.path,
|
|
102
|
-
kind: d.kind,
|
|
109
|
+
const counts = {
|
|
103
110
|
queue: queueItemsOf(d.doc).length,
|
|
104
111
|
done: doneEntriesOf(d.doc).length,
|
|
105
112
|
board: listWorkBoardOf(d.doc).length,
|
|
113
|
+
};
|
|
114
|
+
// A ZERO THAT CANNOT BE TRUSTED IS NAMED, NOT RETURNED BARE.
|
|
115
|
+
//
|
|
116
|
+
// These three were plain `.length`, so a document the parser did not
|
|
117
|
+
// understand reported the same zero as an empty one. A consumer's dashboard
|
|
118
|
+
// read that and showed a fleet as IDLE on a night it merged 27 PRs; they
|
|
119
|
+
// declined to enable these verbs rather than ship a display they could not
|
|
120
|
+
// trust (q-3e7c81a5).
|
|
121
|
+
//
|
|
122
|
+
// The counts keep their shape and meaning — a reader that only wants numbers
|
|
123
|
+
// is unaffected — and `unparsed` says which of them are unanswered questions
|
|
124
|
+
// rather than answers. The predicate is the SEAM's, shared with `doctor`'s
|
|
125
|
+
// queue-done-loop rather than restated here.
|
|
126
|
+
// ONLY THE AXES THIS DOCUMENT CLAIMS TO CARRY.
|
|
127
|
+
//
|
|
128
|
+
// Caught by the test's own output rather than by review: qualifying all three
|
|
129
|
+
// for every file reported `docs/QUEUE.md` as having unparsed `done` and `board`
|
|
130
|
+
// axes, which is nonsense — a queue document is not expected to hold done
|
|
131
|
+
// entries, so its zero there is not an unanswered question, it is a category
|
|
132
|
+
// error on my part. Scoping the qualification to the document's KIND is the
|
|
133
|
+
// same population discipline the count itself needed: a claim about an axis a
|
|
134
|
+
// file never carried is noise, and noise is what teaches a reader to skip the
|
|
135
|
+
// real one. `legacy` is the single-file BACKLOG.md, which carries both regions.
|
|
136
|
+
const AXES_BY_KIND = {
|
|
137
|
+
queue: ["queue"],
|
|
138
|
+
done: ["done"],
|
|
139
|
+
board: ["board"],
|
|
140
|
+
legacy: ["queue", "done"],
|
|
141
|
+
} as const;
|
|
142
|
+
const unparsed = (AXES_BY_KIND[d.kind] as readonly (keyof typeof counts)[]).filter((axis) =>
|
|
143
|
+
zeroIsUnparsed(counts[axis], d.source),
|
|
144
|
+
);
|
|
145
|
+
return {
|
|
146
|
+
path: d.path,
|
|
147
|
+
kind: d.kind,
|
|
148
|
+
...counts,
|
|
149
|
+
...(unparsed.length
|
|
150
|
+
? {
|
|
151
|
+
unparsed,
|
|
152
|
+
unparsedNote:
|
|
153
|
+
`${d.path} has content the parser did not understand: ${unparsed.join(", ")} parsed to ZERO. ` +
|
|
154
|
+
`That is an unanswered question, not an empty document — do not render it as idle, empty or done. ` +
|
|
155
|
+
`Either the file uses a shape this grammar does not accept, or it is malformed; both need a human, and ` +
|
|
156
|
+
`neither is "nothing there".`,
|
|
157
|
+
}
|
|
158
|
+
: {}),
|
|
106
159
|
...(issues.length ? { issues } : {}),
|
|
107
160
|
};
|
|
108
161
|
}
|
|
@@ -359,6 +412,19 @@ export async function exportWorkTool(args: {
|
|
|
359
412
|
});
|
|
360
413
|
continue;
|
|
361
414
|
}
|
|
415
|
+
// DELIBERATELY THE PURE RENDER, NOT THE STAMPING ONE (q-c50e9b83).
|
|
416
|
+
//
|
|
417
|
+
// I routed this through `renderWorkDocForWrite` first and it broke three
|
|
418
|
+
// pre-existing tests that assert `import → export` writes the real documents
|
|
419
|
+
// back BYTE-IDENTICALLY. They were right and the change was wrong: this is a
|
|
420
|
+
// TRANSPORT, not an authoring path. Its contract is to reproduce a document,
|
|
421
|
+
// and a transport that silently edits content the caller never asked it to
|
|
422
|
+
// touch is a worse defect than the one being fixed — it would also mean
|
|
423
|
+
// exporting into ANOTHER project's documents mutates them.
|
|
424
|
+
//
|
|
425
|
+
// So the primary is scoped to the AUTHORING writer (`land`, via records.ts),
|
|
426
|
+
// and an unstamped item arriving through this path is the backstop's
|
|
427
|
+
// population: it cannot reach `main` without passing a push.
|
|
362
428
|
const rendered = renderWorkDoc(d.doc);
|
|
363
429
|
const current = existsSync(target) ? await fsp.readFile(target, "utf8") : null;
|
|
364
430
|
const declared = scopes.documents.find((s) => s.path === d.path);
|
package/src/tools/worktrees.ts
CHANGED
|
@@ -227,11 +227,58 @@ async function create(a: {
|
|
|
227
227
|
atBase: true,
|
|
228
228
|
behindBy: 0,
|
|
229
229
|
...(a.ephemeral
|
|
230
|
-
? {
|
|
230
|
+
? {
|
|
231
|
+
ephemeral: true,
|
|
232
|
+
// A VERB NAME, NOT A COMMAND LINE. This used to hand back the shell to
|
|
233
|
+
// paste — the tool knew the repo, path and branch and spent the agent's
|
|
234
|
+
// tokens on work it could do itself.
|
|
235
|
+
releaseWith: { verb: "release_worktree", args: { repo, path: target, apply: true } },
|
|
236
|
+
}
|
|
231
237
|
: {}),
|
|
232
238
|
};
|
|
233
239
|
}
|
|
234
240
|
|
|
241
|
+
// ---------- shared: is this tree's work already in the base? ----------
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Are the commits HEAD has beyond `tip` ALREADY LANDED there?
|
|
245
|
+
*
|
|
246
|
+
* `rev-list --count <tip>..HEAD` is pure ANCESTRY, and under squash merge a landed
|
|
247
|
+
* branch's own shas never enter the base's history — so `ahead` stays > 0 FOREVER
|
|
248
|
+
* for work that shipped (q-5c40db91; 20 of the last 20 merges on this repo's main
|
|
249
|
+
* are single-parent). Reading `ahead > 0` as "this tree holds unlanded work" makes
|
|
250
|
+
* the verb whose job is fast-forwarding idle trees classify a FINISHED tree as
|
|
251
|
+
* mid-slice, permanently.
|
|
252
|
+
*
|
|
253
|
+
* MEASURED, and it cost a lane cycle: a `claim` was refused because a worker's tree
|
|
254
|
+
* "reads as 1 commit not on origin/main" while that branch had been squash-merged an
|
|
255
|
+
* hour earlier. Same inverted alarm as the item's headline — landed work reported as
|
|
256
|
+
* unmerged, inviting preservation of work already shipped — inside our own tooling.
|
|
257
|
+
*
|
|
258
|
+
* `git cherry` compares PATCH IDS, so every line marked `-` means every commit's patch
|
|
259
|
+
* is already upstream. It is NOT complete: several commits squashed into one carry a
|
|
260
|
+
* different combined patch id, so a negative is INCONCLUSIVE and is reported as
|
|
261
|
+
* mid-slice exactly as before. Only a positive changes the verdict, which keeps the
|
|
262
|
+
* failure direction the same as today's for everything this cannot prove.
|
|
263
|
+
*/
|
|
264
|
+
function aheadIsLanded(at: string, tip: string): boolean {
|
|
265
|
+
// `git` here THROWS rather than returning null, so the catch is what keeps an
|
|
266
|
+
// UNREADABLE answer out of the landed bucket: not measured is not landed, and
|
|
267
|
+
// the tree stays mid-slice, which is the direction that refuses rather than
|
|
268
|
+
// the direction that tells someone their work is safe to discard.
|
|
269
|
+
let cherry: string;
|
|
270
|
+
try {
|
|
271
|
+
cherry = git(at, ["cherry", tip, "HEAD"]);
|
|
272
|
+
} catch {
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
275
|
+
if (cherry.trim().length === 0) return false;
|
|
276
|
+
return cherry
|
|
277
|
+
.trim()
|
|
278
|
+
.split("\n")
|
|
279
|
+
.every((l) => l.trim().startsWith("-"));
|
|
280
|
+
}
|
|
281
|
+
|
|
235
282
|
// ---------- 1.2 / 1.3: refresh idle trees, never mid-slice ----------
|
|
236
283
|
|
|
237
284
|
export const refreshWorktreesSchema = {
|
|
@@ -264,6 +311,7 @@ export async function refreshWorktreesTool(args: { repo: string; base: string; a
|
|
|
264
311
|
return { ok: false as const, error: `${ref} does not resolve — nothing to fast-forward onto` };
|
|
265
312
|
}
|
|
266
313
|
|
|
314
|
+
|
|
267
315
|
const results = [];
|
|
268
316
|
for (const w of listWorktrees(repo)) {
|
|
269
317
|
const at = w.path;
|
|
@@ -308,7 +356,9 @@ export async function refreshWorktreesTool(args: { repo: string; base: string; a
|
|
|
308
356
|
(dirty
|
|
309
357
|
? " and DIRTY — not fast-forwarded; uncommitted work is what a diff cannot show"
|
|
310
358
|
: ahead !== "0"
|
|
311
|
-
?
|
|
359
|
+
? aheadIsLanded(at, tip)
|
|
360
|
+
? ` and ${ahead} ahead whose patches are ALREADY UPSTREAM — squash-merged, so ancestry will never agree; not fast-forwarded, but nothing here needs preserving`
|
|
361
|
+
: ` and ${ahead} ahead — not fast-forwarded; it has commits ${ref} does not`
|
|
312
362
|
: args.apply
|
|
313
363
|
? ""
|
|
314
364
|
: " — pass apply:true to fast-forward it"),
|
|
@@ -337,7 +387,28 @@ export async function refreshWorktreesTool(args: { repo: string; base: string; a
|
|
|
337
387
|
/* treated as diverged below */
|
|
338
388
|
}
|
|
339
389
|
if (ahead !== "0") {
|
|
340
|
-
|
|
390
|
+
// LANDED, NOT MID-SLICE — the ancestry count cannot tell these apart.
|
|
391
|
+
if (aheadIsLanded(at, tip)) {
|
|
392
|
+
results.push({
|
|
393
|
+
path: w.path,
|
|
394
|
+
action: "landed",
|
|
395
|
+
why:
|
|
396
|
+
`${ahead} commit(s) are not ancestors of ${ref}, but every one of their PATCHES is already upstream — ` +
|
|
397
|
+
`this branch was SQUASH-MERGED and its work has shipped. Ancestry can never say so: a squash writes a new ` +
|
|
398
|
+
`commit, so these shas will read as "not on ${ref}" forever. Nothing here needs preserving; detach to ${ref} ` +
|
|
399
|
+
`(or delete the branch) and this tree is reusable. NOT fast-forwarded automatically — moving a tree off ` +
|
|
400
|
+
`committed work is a decision, not a refresh.`,
|
|
401
|
+
});
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
results.push({
|
|
405
|
+
path: w.path,
|
|
406
|
+
action: "refused",
|
|
407
|
+
why:
|
|
408
|
+
`MID-SLICE: ${ahead} commit(s) not on ${ref}, and their patches are NOT upstream. Refusing rather than --force. ` +
|
|
409
|
+
`(A squash of SEVERAL commits into one changes the combined patch id, so this cannot prove the negative — ` +
|
|
410
|
+
`it reports mid-slice, which is the same direction it always failed in.)`,
|
|
411
|
+
});
|
|
341
412
|
continue;
|
|
342
413
|
}
|
|
343
414
|
if (!args.apply) {
|
|
@@ -353,3 +424,196 @@ export async function refreshWorktreesTool(args: { repo: string; base: string; a
|
|
|
353
424
|
}
|
|
354
425
|
return { ok: true as const, base: ref, tip: tip.slice(0, 8), applied: args.apply === true, trees: results };
|
|
355
426
|
}
|
|
427
|
+
|
|
428
|
+
// ---------- the counterpart to ensure_worktree: give the tree back ----------
|
|
429
|
+
|
|
430
|
+
/*
|
|
431
|
+
* `ensure_worktree` CREATED trees and nothing RELEASED them, so teardown stayed a
|
|
432
|
+
* hand step in five role cards — `git worktree prune` in coord-audit, coord-ci,
|
|
433
|
+
* coord-qa and coord-worker, `git worktree remove` twice in coordinator, which
|
|
434
|
+
* says so itself: "it never removes, so `git worktree remove`/`prune` remain hand
|
|
435
|
+
* steps". Seven commands for one act the bus already had the inputs for.
|
|
436
|
+
*
|
|
437
|
+
* WORSE THAN ABSENT: `ensure_worktree` COMPUTED the teardown command and handed it
|
|
438
|
+
* back as a string for the agent to paste (`removeWith`). The tool knew the repo,
|
|
439
|
+
* the path and the branch, and spent the agent's tokens on shell it could have run.
|
|
440
|
+
* That is the shape David ruled against: "where possible we should hide the
|
|
441
|
+
* plumbing from the agents".
|
|
442
|
+
*
|
|
443
|
+
* THE REFUSALS ARE THE POINT, and they inherit `refresh_worktrees`' direction —
|
|
444
|
+
* report by default, refuse per tree with the reason named, never clobber:
|
|
445
|
+
*
|
|
446
|
+
* - THE PRIMARY CHECKOUT IS NEVER RELEASED. It is the path everyone already has.
|
|
447
|
+
* - A DIRTY TREE IS REFUSED. Uncommitted work is what a diff cannot show and git
|
|
448
|
+
* cannot give back, so this is the one refusal with no override.
|
|
449
|
+
* - A TREE HOLDING UNLANDED COMMITS IS REFUSED — and "unlanded" is decided by
|
|
450
|
+
* `aheadIsLanded` (patch ids), NOT by ancestry. Under squash merge a landed
|
|
451
|
+
* branch's shas never enter the base, so ancestry calls every finished tree
|
|
452
|
+
* mid-slice forever (q-5c40db91). Using ancestry here would refuse to release
|
|
453
|
+
* exactly the trees that are safe to release, which is how the hand step
|
|
454
|
+
* survived: the guard would have been wrong and everyone would have forced it.
|
|
455
|
+
*
|
|
456
|
+
* A NEGATIVE FROM `aheadIsLanded` IS INCONCLUSIVE, NOT A NO. Several commits squashed
|
|
457
|
+
* into one carry a different combined patch id, so it cannot prove landedness for
|
|
458
|
+
* every shape. It therefore refuses and says the check was inconclusive rather than
|
|
459
|
+
* claiming the work is unlanded — the caller lands it, or passes `force:true`, which
|
|
460
|
+
* is recorded in the result so a forced release is findable afterwards.
|
|
461
|
+
*/
|
|
462
|
+
export const releaseWorktreeSchema = {
|
|
463
|
+
repo: z.string().min(1),
|
|
464
|
+
agentId: z.string().min(1).optional(),
|
|
465
|
+
path: z.string().min(1).optional(),
|
|
466
|
+
base: z.string().min(1).optional(),
|
|
467
|
+
apply: z.boolean().optional(),
|
|
468
|
+
force: z.boolean().optional(),
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
export async function releaseWorktreeTool(args: {
|
|
472
|
+
repo: string;
|
|
473
|
+
agentId?: string;
|
|
474
|
+
path?: string;
|
|
475
|
+
base?: string;
|
|
476
|
+
apply?: boolean;
|
|
477
|
+
force?: boolean;
|
|
478
|
+
}) {
|
|
479
|
+
const { repo } = args;
|
|
480
|
+
if (!args.agentId && !args.path) {
|
|
481
|
+
return { ok: false as const, error: "name the tree to release: pass `agentId` or `path`" };
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const trees = listWorktrees(repo);
|
|
485
|
+
const primary = primaryOf(repo) ?? "";
|
|
486
|
+
const target = args.path
|
|
487
|
+
? trees.find((w) => samePath(w.path, args.path as string))
|
|
488
|
+
: trees.find((w) => (w.branch ?? "").includes(args.agentId as string) || w.path.includes(args.agentId as string));
|
|
489
|
+
|
|
490
|
+
if (!target) {
|
|
491
|
+
return {
|
|
492
|
+
ok: false as const,
|
|
493
|
+
error: args.path
|
|
494
|
+
? `no worktree at '${args.path}' — nothing to release`
|
|
495
|
+
: `no worktree found for '${args.agentId}' — nothing to release`,
|
|
496
|
+
// An ABSENCE is reported with what WAS found, so a misaimed lookup is
|
|
497
|
+
// distinguishable from an empty world (the positive-control rule).
|
|
498
|
+
known: trees.map((w) => ({ path: w.path, branch: w.branch })),
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const at = target.path;
|
|
503
|
+
const branch = target.branch;
|
|
504
|
+
|
|
505
|
+
if (samePath(at, primary)) {
|
|
506
|
+
return {
|
|
507
|
+
ok: false as const,
|
|
508
|
+
error: `refusing to release ${at}: that is the PRIMARY checkout, the path everyone already has`,
|
|
509
|
+
path: at,
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
if (isDirty(at)) {
|
|
514
|
+
return {
|
|
515
|
+
ok: false as const,
|
|
516
|
+
error:
|
|
517
|
+
`refusing to release ${at}: the tree is DIRTY. Uncommitted work is what a diff cannot show and ` +
|
|
518
|
+
`git cannot give back, so this refusal has no override — commit it, stash it, or delete it by hand.`,
|
|
519
|
+
path: at,
|
|
520
|
+
branch,
|
|
521
|
+
dirty: true,
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const base = args.base ?? "main";
|
|
526
|
+
const ref = `origin/${base}`;
|
|
527
|
+
let tip: string | null = null;
|
|
528
|
+
try {
|
|
529
|
+
git(repo, ["fetch", "--quiet", "origin", base]);
|
|
530
|
+
} catch {
|
|
531
|
+
/* a stale tip is reported below rather than guessed at */
|
|
532
|
+
}
|
|
533
|
+
try {
|
|
534
|
+
tip = git(repo, ["rev-parse", "--verify", `${ref}^{commit}`]);
|
|
535
|
+
} catch {
|
|
536
|
+
tip = null;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
let ahead = "unknown";
|
|
540
|
+
if (tip) {
|
|
541
|
+
try {
|
|
542
|
+
ahead = git(at, ["rev-list", "--count", `${tip}..HEAD`]);
|
|
543
|
+
} catch {
|
|
544
|
+
/* left unknown — an unreadable count must not read as zero */
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const holdsWork = ahead !== "0";
|
|
549
|
+
const landed = tip && holdsWork ? aheadIsLanded(at, tip) : true;
|
|
550
|
+
|
|
551
|
+
if (!tip) {
|
|
552
|
+
return {
|
|
553
|
+
ok: false as const,
|
|
554
|
+
error: `refusing to release ${at}: ${ref} does not resolve, so whether this tree's work landed cannot be measured`,
|
|
555
|
+
path: at,
|
|
556
|
+
branch,
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
if (holdsWork && !landed && !args.force) {
|
|
561
|
+
return {
|
|
562
|
+
ok: false as const,
|
|
563
|
+
error:
|
|
564
|
+
`refusing to release ${at}: it is ${ahead} commit(s) ahead of ${ref} and the patch-id check was ` +
|
|
565
|
+
`INCONCLUSIVE — not proof the work is unlanded, but not proof it landed either (several commits ` +
|
|
566
|
+
`squashed into one carry a different combined patch id). Land it, or pass force:true, which is recorded.`,
|
|
567
|
+
path: at,
|
|
568
|
+
branch,
|
|
569
|
+
ahead,
|
|
570
|
+
landedCheck: "inconclusive",
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
const plan = {
|
|
575
|
+
path: at,
|
|
576
|
+
branch,
|
|
577
|
+
ahead,
|
|
578
|
+
landedCheck: holdsWork ? (landed ? "landed" : "inconclusive") : "nothing-ahead",
|
|
579
|
+
...(args.force && holdsWork && !landed ? { forced: true } : {}),
|
|
580
|
+
};
|
|
581
|
+
|
|
582
|
+
if (!args.apply) {
|
|
583
|
+
return {
|
|
584
|
+
ok: true as const,
|
|
585
|
+
applied: false,
|
|
586
|
+
would: "remove the worktree and delete its branch",
|
|
587
|
+
...plan,
|
|
588
|
+
note: "reports by default; pass apply:true to remove",
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
try {
|
|
593
|
+
git(repo, ["worktree", "remove", "--force", at]);
|
|
594
|
+
} catch (e) {
|
|
595
|
+
return {
|
|
596
|
+
ok: false as const,
|
|
597
|
+
error: `worktree remove failed: ${String((e as Error).message).split("\n")[0]}`,
|
|
598
|
+
...plan,
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
// PRUNE IS PART OF THE ACT, not a step the caller remembers. Every card that
|
|
602
|
+
// said `git worktree remove` also said `git worktree prune`, which is the
|
|
603
|
+
// clearest evidence they were one operation split across two lines of prose.
|
|
604
|
+
try {
|
|
605
|
+
git(repo, ["worktree", "prune"]);
|
|
606
|
+
} catch {
|
|
607
|
+
/* removal already succeeded; a failed prune is not worth failing the call */
|
|
608
|
+
}
|
|
609
|
+
let branchDeleted = false;
|
|
610
|
+
if (branch) {
|
|
611
|
+
try {
|
|
612
|
+
git(repo, ["branch", "-D", branch.replace(/^refs\/heads\//, "")]);
|
|
613
|
+
branchDeleted = true;
|
|
614
|
+
} catch {
|
|
615
|
+
/* a branch that will not delete is reported, not fatal */
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
return { ok: true as const, applied: true, removed: true, branchDeleted, ...plan };
|
|
619
|
+
}
|