dw-sdlc 0.13.0 → 0.14.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/dist/commands/sweep.js +84 -81
- package/dist/sweep-terminals.d.ts +89 -0
- package/dist/sweep-terminals.js +262 -0
- package/dist/sweep-trees.d.ts +17 -0
- package/dist/sweep-trees.js +72 -0
- package/dist/worktree-lock.d.ts +6 -3
- package/dist/worktree-lock.js +2 -1
- package/package.json +1 -1
package/dist/commands/sweep.js
CHANGED
|
@@ -2,13 +2,14 @@
|
|
|
2
2
|
// docs/adr/0015 sets its bounds: it runs from the main checkout, never forces and never writes to a remote.
|
|
3
3
|
import { spawnSync } from "node:child_process";
|
|
4
4
|
import { existsSync } from "node:fs";
|
|
5
|
-
import { join } from "node:path";
|
|
6
5
|
import { parseArgs } from "node:util";
|
|
7
6
|
import { findRepository, mainCheckout, resolveConfig } from "../agentic-config.js";
|
|
8
7
|
import { repositoryFreeEnv } from "../git-env.js";
|
|
9
8
|
import { isCommitLanded, isLanded } from "../landed.js";
|
|
10
9
|
import { shellLine } from "../pr/decide.js";
|
|
11
10
|
import { CannotRun } from "../pr/read.js";
|
|
11
|
+
import { byHand, holdersOf, keptIn, lockAfterStop, lockHolder, outsideWorktrees, readTerminals, removeTerminal, stopsOf, sweepingTerminal, whyHeld } from "../sweep-terminals.js";
|
|
12
|
+
import { dirtyFiles, must, pausedOperation, worktreesOf } from "../sweep-trees.js";
|
|
12
13
|
import { asked, git } from "../working-tree.js";
|
|
13
14
|
import { lockVerdict } from "../worktree-lock.js";
|
|
14
15
|
export const summary = "removes Landed branches and clean worktrees whose work has landed, names what it keeps";
|
|
@@ -30,20 +31,24 @@ It keeps, and names with the reason and the command to run yourself:
|
|
|
30
31
|
It never changes anything on a remote: the fetch only writes remote-tracking refs. A removal git refuses is
|
|
31
32
|
kept with git's words, and the sweep goes on.
|
|
32
33
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
In herdr ($HC_TERMINAL_ID set) it reads, with hc, the Workers of this session, the terminals whose parent is
|
|
35
|
+
this one, and every other live terminal. A Worker whose status is none of working, blocked or yet to report
|
|
36
|
+
is stopped (hc kill) then removed (hc rm) when its worktree goes in the same Sweep or its folder is already
|
|
37
|
+
gone; a worktree is judged whole first, so no Worker is stopped beside work it could still commit. A live
|
|
38
|
+
Claude Code lock on that worktree that names the Worker's own program, the pid hc get reports, does not keep
|
|
39
|
+
it: once the Worker is stopped the lock is read again and lifted only when it is stale then, else the
|
|
40
|
+
worktree stays. A finished Worker of this session is removed. This session's own terminal and another
|
|
41
|
+
session's are never stopped: a worktree one is in stays, named with the terminal's address and place and
|
|
42
|
+
hc kill as the dev's command. Outside herdr no terminal is read, and the output says so. An hc that fails
|
|
43
|
+
ends the Sweep with exit 2 before anything is removed.
|
|
44
|
+
|
|
45
|
+
--dry-run removes and stops nothing and answers the same items. One line an item, or one document with --json:
|
|
46
|
+
{ dryRun, terminalsRead, base, removed: [item], kept: [item] }, an item { kind, name, reason, command? },
|
|
47
|
+
kind branch, worktree, lock (a lifted one) or terminal, name its path or its address, command the one the
|
|
48
|
+
dev would run for a kept item.
|
|
36
49
|
|
|
37
50
|
Exit 0 nothing is kept but the default, the base or a protected branch, 1 something else is, 2 when it
|
|
38
|
-
cannot run: in a linked worktree, with no base to read,
|
|
39
|
-
/** `git <args>` at `root`, or a stop naming its problem. */
|
|
40
|
-
function must(root, args) {
|
|
41
|
-
const result = git(root, args);
|
|
42
|
-
if ("error" in result) {
|
|
43
|
-
throw new CannotRun(result.error, "check the repository with git status");
|
|
44
|
-
}
|
|
45
|
-
return result.out;
|
|
46
|
-
}
|
|
51
|
+
cannot run: in a linked worktree, with no base to read, when the fetch fails or when hc fails in herdr.`;
|
|
47
52
|
function forcedDelete(branch) {
|
|
48
53
|
return shellLine(["git", "branch", "-D", branch]);
|
|
49
54
|
}
|
|
@@ -89,68 +94,16 @@ function branchesOf(root) {
|
|
|
89
94
|
.split("\n")
|
|
90
95
|
.filter((name) => name !== "");
|
|
91
96
|
}
|
|
92
|
-
/** Every worktree of the repository, the main checkout first. */
|
|
93
|
-
function worktreesOf(root) {
|
|
94
|
-
const trees = [];
|
|
95
|
-
let tree;
|
|
96
|
-
for (const line of must(root, ["worktree", "list", "--porcelain"]).split("\n")) {
|
|
97
|
-
if (line.startsWith("worktree ")) {
|
|
98
|
-
tree = { path: line.slice("worktree ".length), head: "", branch: undefined, lock: undefined, prunable: false };
|
|
99
|
-
trees.push(tree);
|
|
100
|
-
}
|
|
101
|
-
else if (tree !== undefined && line.startsWith("HEAD ")) {
|
|
102
|
-
tree.head = line.slice("HEAD ".length);
|
|
103
|
-
}
|
|
104
|
-
else if (tree !== undefined && line.startsWith("branch refs/heads/")) {
|
|
105
|
-
tree.branch = line.slice("branch refs/heads/".length);
|
|
106
|
-
}
|
|
107
|
-
else if (tree !== undefined && (line === "locked" || line.startsWith("locked "))) {
|
|
108
|
-
tree.lock = line.slice("locked".length).trim();
|
|
109
|
-
}
|
|
110
|
-
else if (tree !== undefined && (line === "prunable" || line.startsWith("prunable "))) {
|
|
111
|
-
tree.prunable = true;
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
return trees;
|
|
115
|
-
}
|
|
116
|
-
/** The files `git status` lists in the worktree at `path`, ignored ones aside, or why it cannot be read. */
|
|
117
|
-
function dirtyFiles(path) {
|
|
118
|
-
const args = ["-C", path, "status", "--porcelain=v1", "-z", "--untracked-files=all"];
|
|
119
|
-
const result = spawnSync("git", args, { encoding: "utf8", env: repositoryFreeEnv(), maxBuffer: 64 * 1024 * 1024 });
|
|
120
|
-
if (result.status !== 0) {
|
|
121
|
-
const said = result.stderr.split("\n").find((line) => line.trim() !== "");
|
|
122
|
-
return { error: `git status: ${said?.trim() ?? `exited ${result.status ?? "on a signal"}`}` };
|
|
123
|
-
}
|
|
124
|
-
const files = [];
|
|
125
|
-
const entries = result.stdout.split("\0").filter((entry) => entry !== "");
|
|
126
|
-
for (let index = 0; index < entries.length; index++) {
|
|
127
|
-
const entry = entries[index] ?? "";
|
|
128
|
-
files.push(entry.slice(3));
|
|
129
|
-
if (entry.startsWith("R") || entry.startsWith("C")) {
|
|
130
|
-
index++;
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
return files;
|
|
134
|
-
}
|
|
135
|
-
const OPERATIONS = [
|
|
136
|
-
["rebase-merge", "rebase"],
|
|
137
|
-
["rebase-apply", "rebase or am"],
|
|
138
|
-
["MERGE_HEAD", "merge"],
|
|
139
|
-
["CHERRY_PICK_HEAD", "cherry-pick"],
|
|
140
|
-
["REVERT_HEAD", "revert"],
|
|
141
|
-
["BISECT_LOG", "bisect"]
|
|
142
|
-
];
|
|
143
|
-
/** The operation paused in the worktree at `path`: its git dir holds the state git resumes it from. */
|
|
144
|
-
function pausedOperation(path) {
|
|
145
|
-
const dir = asked(path, ["rev-parse", "--absolute-git-dir"]);
|
|
146
|
-
return dir === undefined ? undefined : OPERATIONS.find(([file]) => existsSync(join(dir, file)))?.[1];
|
|
147
|
-
}
|
|
148
97
|
/** Why the worktree stays, with the dev's command when there is one, or null when its whole set passes. */
|
|
149
|
-
function whyTreeStays(root, baseRef, tree, lock) {
|
|
98
|
+
function whyTreeStays(root, baseRef, tree, lock, holders) {
|
|
150
99
|
const { path } = tree;
|
|
151
100
|
if (lock.state === "stays") {
|
|
152
101
|
return lock;
|
|
153
102
|
}
|
|
103
|
+
const held = whyHeld(holders);
|
|
104
|
+
if (held !== null) {
|
|
105
|
+
return held;
|
|
106
|
+
}
|
|
154
107
|
if (tree.prunable || !existsSync(path)) {
|
|
155
108
|
const prune = shellLine(["git", "worktree", "prune"]);
|
|
156
109
|
return {
|
|
@@ -196,8 +149,8 @@ function stays(branch, { head, baseBranch, protectedBranches, held }) {
|
|
|
196
149
|
const path = held.get(branch);
|
|
197
150
|
return path === undefined ? null : { reason: `checked out in the worktree at ${path}`, command: null, dev: true };
|
|
198
151
|
}
|
|
199
|
-
/** Fetches origin, then sorts each linked worktree
|
|
200
|
-
function judge(root) {
|
|
152
|
+
/** Fetches origin, then sorts each linked worktree, local branch and Worker's terminal into what goes and what stays. */
|
|
153
|
+
function judge(root, terminals) {
|
|
201
154
|
fetchOrigin(root);
|
|
202
155
|
const head = originHead(root);
|
|
203
156
|
const base = baseOf(root, head);
|
|
@@ -206,9 +159,16 @@ function judge(root) {
|
|
|
206
159
|
if (checkout === undefined) {
|
|
207
160
|
throw new CannotRun("git lists no worktree", "check the repository with git worktree list");
|
|
208
161
|
}
|
|
162
|
+
const holders = terminals === undefined
|
|
163
|
+
? new Map()
|
|
164
|
+
: holdersOf(terminals, checkout.path, linked.map(({ path }) => path));
|
|
209
165
|
const verdicts = linked.map((tree) => {
|
|
210
166
|
const lock = lockVerdict(tree.path, tree.lock);
|
|
211
|
-
|
|
167
|
+
const here = holders.get(tree.path) ?? [];
|
|
168
|
+
const pid = lock.state === "stays" ? lock.pid : undefined;
|
|
169
|
+
const workerLock = pid !== undefined && lockHolder(pid, here) !== undefined ? { pid } : undefined;
|
|
170
|
+
const why = whyTreeStays(root, base.ref, tree, workerLock === undefined ? lock : { state: "none" }, here);
|
|
171
|
+
return { tree, lock, here, why, workerLock };
|
|
212
172
|
});
|
|
213
173
|
const staying = verdicts.filter(({ why }) => why !== null).map(({ tree }) => tree);
|
|
214
174
|
const held = new Map();
|
|
@@ -243,18 +203,28 @@ function judge(root) {
|
|
|
243
203
|
kept.push({ kind: "branch", name, reason, command: forcedDelete(name), dev: true });
|
|
244
204
|
}
|
|
245
205
|
};
|
|
246
|
-
|
|
206
|
+
const outside = terminals === undefined
|
|
207
|
+
? { remove: [], keep: [] }
|
|
208
|
+
: outsideWorktrees(terminals, [checkout.path, ...linked.map(({ path }) => path)]);
|
|
209
|
+
removed.push(...outside.remove);
|
|
210
|
+
for (const { name, reason } of outside.keep) {
|
|
211
|
+
kept.push({ kind: "terminal", name, reason, command: null, dev: true });
|
|
212
|
+
}
|
|
213
|
+
for (const { tree, lock, here, why, workerLock } of verdicts) {
|
|
247
214
|
if (why !== null) {
|
|
248
215
|
kept.push({ kind: "worktree", name: tree.path, ...why, dev: true });
|
|
216
|
+
kept.push(...keptIn(here, tree.path).map((item) => ({ kind: "terminal", ...item, dev: true })));
|
|
249
217
|
continue;
|
|
250
218
|
}
|
|
219
|
+
removed.push(...stopsOf(here, tree.path));
|
|
251
220
|
const reason = `clean, its HEAD is Landed on ${baseName}`;
|
|
252
221
|
removed.push({
|
|
253
222
|
kind: "worktree",
|
|
254
223
|
name: tree.path,
|
|
255
224
|
reason,
|
|
256
225
|
...(tree.branch === undefined ? {} : { holds: tree.branch }),
|
|
257
|
-
...(lock.state === "stale" ? { stale: lock } : {})
|
|
226
|
+
...(lock.state === "stale" ? { stale: lock } : {}),
|
|
227
|
+
...(workerLock === undefined ? {} : { workerLock })
|
|
258
228
|
});
|
|
259
229
|
if (tree.branch !== undefined) {
|
|
260
230
|
judgeBranch(tree.branch);
|
|
@@ -264,7 +234,7 @@ function judge(root) {
|
|
|
264
234
|
return { base: baseName, removed, kept };
|
|
265
235
|
}
|
|
266
236
|
/** Removes `item`; the item kept when git refuses it, with git's words, or undefined once it is gone. */
|
|
267
|
-
function removal(root, item, heldBy) {
|
|
237
|
+
function removal(root, item, heldBy, unstopped) {
|
|
268
238
|
const refused = (reason, command) => ({
|
|
269
239
|
kind: item.kind,
|
|
270
240
|
name: item.name,
|
|
@@ -272,6 +242,16 @@ function removal(root, item, heldBy) {
|
|
|
272
242
|
command,
|
|
273
243
|
dev: true
|
|
274
244
|
});
|
|
245
|
+
if (item.kind === "terminal") {
|
|
246
|
+
const failed = removeTerminal(item);
|
|
247
|
+
if (failed === undefined) {
|
|
248
|
+
return undefined;
|
|
249
|
+
}
|
|
250
|
+
if (item.tree !== undefined) {
|
|
251
|
+
unstopped.set(item.tree, `its Worker's terminal ${item.name} stays: ${failed}`);
|
|
252
|
+
}
|
|
253
|
+
return refused(failed, byHand(item));
|
|
254
|
+
}
|
|
275
255
|
if (item.kind === "worktree") {
|
|
276
256
|
const refusedWith = (reason, command) => {
|
|
277
257
|
if (item.holds !== undefined) {
|
|
@@ -279,6 +259,20 @@ function removal(root, item, heldBy) {
|
|
|
279
259
|
}
|
|
280
260
|
return refused(reason, command);
|
|
281
261
|
};
|
|
262
|
+
const unstoppable = unstopped.get(item.name);
|
|
263
|
+
if (unstoppable !== undefined) {
|
|
264
|
+
return refusedWith(unstoppable, null);
|
|
265
|
+
}
|
|
266
|
+
if (item.workerLock !== undefined) {
|
|
267
|
+
const now = lockAfterStop(root, item.name);
|
|
268
|
+
if (now.state === "stays") {
|
|
269
|
+
return refusedWith(`its Worker's terminal was stopped, but ${now.reason}`, now.command);
|
|
270
|
+
}
|
|
271
|
+
if (now.state === "stale") {
|
|
272
|
+
item.stale = now;
|
|
273
|
+
}
|
|
274
|
+
delete item.workerLock;
|
|
275
|
+
}
|
|
282
276
|
if (item.stale !== undefined) {
|
|
283
277
|
const lifted = git(root, ["worktree", "unlock", "--", item.name]);
|
|
284
278
|
if ("error" in lifted) {
|
|
@@ -307,8 +301,9 @@ function apply({ base, removed, kept }, root) {
|
|
|
307
301
|
const gone = [];
|
|
308
302
|
const refused = [];
|
|
309
303
|
const heldBy = new Map();
|
|
304
|
+
const unstopped = new Map();
|
|
310
305
|
for (const item of removed) {
|
|
311
|
-
const stayed = removal(root, item, heldBy);
|
|
306
|
+
const stayed = removal(root, item, heldBy, unstopped);
|
|
312
307
|
if (stayed === undefined) {
|
|
313
308
|
gone.push(item);
|
|
314
309
|
}
|
|
@@ -320,8 +315,12 @@ function apply({ base, removed, kept }, root) {
|
|
|
320
315
|
}
|
|
321
316
|
/** The removed items as the Sweep tells them: a stale lock lifted comes just before its worktree. */
|
|
322
317
|
function shownOf(removed) {
|
|
323
|
-
return removed.flatMap(({ kind, name, reason, stale }) => {
|
|
318
|
+
return removed.flatMap(({ kind, name, reason, stale, workerLock }) => {
|
|
324
319
|
const item = { kind, name, reason };
|
|
320
|
+
if (workerLock !== undefined) {
|
|
321
|
+
const why = `held by its Worker's program, pid ${workerLock.pid}: lifted once the stop leaves it stale`;
|
|
322
|
+
return [{ kind: "lock", name, reason: why }, item];
|
|
323
|
+
}
|
|
325
324
|
return stale === undefined ? [item] : [{ kind: "lock", name, reason: stale.why }, item];
|
|
326
325
|
});
|
|
327
326
|
}
|
|
@@ -331,14 +330,15 @@ function verbOf(kind, dryRun) {
|
|
|
331
330
|
}
|
|
332
331
|
return dryRun ? "would remove" : "removed";
|
|
333
332
|
}
|
|
334
|
-
function linesOf({ dryRun, removed, kept }) {
|
|
333
|
+
function linesOf({ dryRun, terminalsRead, removed, kept }) {
|
|
335
334
|
const out = [
|
|
336
335
|
...removed.map(({ kind, name, reason }) => `${verbOf(kind, dryRun)} ${kind} ${name} — ${reason}`),
|
|
337
336
|
...kept.map(({ kind, name, reason, command }) => {
|
|
338
337
|
return `kept ${kind} ${name} — ${reason}${command === null || command === undefined ? "" : `; to remove it yourself: ${command}`}`;
|
|
339
338
|
})
|
|
340
339
|
];
|
|
341
|
-
|
|
340
|
+
const note = terminalsRead ? [] : ["terminals not read — outside herdr, $HC_TERMINAL_ID is not set"];
|
|
341
|
+
return [...(out.length === 0 ? ["nothing to sweep"] : out), ...note].join("\n");
|
|
342
342
|
}
|
|
343
343
|
function parseCli(argv) {
|
|
344
344
|
try {
|
|
@@ -369,10 +369,13 @@ function sweep(json, dryRun) {
|
|
|
369
369
|
if (main !== undefined) {
|
|
370
370
|
throw new CannotRun(`${root} is a linked worktree`, `run it from the main checkout: ${shellLine(["cd", main])} && dw-sdlc sweep`);
|
|
371
371
|
}
|
|
372
|
-
const
|
|
372
|
+
const own = sweepingTerminal();
|
|
373
|
+
const terminals = own === undefined ? undefined : readTerminals(own);
|
|
374
|
+
const judged = judge(root, terminals);
|
|
373
375
|
const { base, removed, kept } = dryRun ? judged : apply(judged, root);
|
|
374
376
|
const done = {
|
|
375
377
|
dryRun,
|
|
378
|
+
terminalsRead: terminals !== undefined,
|
|
376
379
|
base,
|
|
377
380
|
removed: shownOf(removed),
|
|
378
381
|
kept: kept.map(({ kind, name, reason, command }) => ({ kind, name, reason, command }))
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { type Worker } from "./worker/herdr.ts";
|
|
2
|
+
import { type LockVerdict } from "./worktree-lock.ts";
|
|
3
|
+
/** A live terminal of another session, or the sweeping one's own, with the place herdr shows it at. */
|
|
4
|
+
type Seen = {
|
|
5
|
+
address: string;
|
|
6
|
+
name: string;
|
|
7
|
+
folder: string;
|
|
8
|
+
place: string;
|
|
9
|
+
};
|
|
10
|
+
/** A Worker with the pid of the program `hc get` reports in its terminal, which a Claude Code lock names. */
|
|
11
|
+
export type Located = Worker & {
|
|
12
|
+
pid: number | undefined;
|
|
13
|
+
};
|
|
14
|
+
export type Terminals = {
|
|
15
|
+
own: Seen | undefined;
|
|
16
|
+
workers: Located[];
|
|
17
|
+
others: Seen[];
|
|
18
|
+
};
|
|
19
|
+
/** What holds a worktree: the terminal of this session, another session's, or a Worker of this one. */
|
|
20
|
+
export type Holder = {
|
|
21
|
+
role: "own";
|
|
22
|
+
terminal: Seen;
|
|
23
|
+
} | {
|
|
24
|
+
role: "other";
|
|
25
|
+
terminal: Seen;
|
|
26
|
+
} | {
|
|
27
|
+
role: "worker";
|
|
28
|
+
worker: Located;
|
|
29
|
+
};
|
|
30
|
+
/** The terminal to stop and remove, or only to remove once it has finished. */
|
|
31
|
+
export type TerminalPlan = {
|
|
32
|
+
kind: "terminal";
|
|
33
|
+
name: string;
|
|
34
|
+
reason: string;
|
|
35
|
+
stop: boolean;
|
|
36
|
+
tree?: string;
|
|
37
|
+
};
|
|
38
|
+
/** The address of the terminal the Sweep runs in, or undefined outside herdr. */
|
|
39
|
+
export declare function sweepingTerminal(): string | undefined;
|
|
40
|
+
/**
|
|
41
|
+
* Reads herdr for the sweeping terminal `own`: its Workers, finished ones included, and the other live
|
|
42
|
+
* terminals. An `hc` that fails stops the Sweep, since a terminal it cannot see must not count as absent.
|
|
43
|
+
*/
|
|
44
|
+
export declare function readTerminals(own: string): Terminals;
|
|
45
|
+
/** A Worker whose terminal is live but whose folder is gone, with no worktree of `roots` around it, could commit nothing more. */
|
|
46
|
+
export declare function workerWithoutFolder({ workers }: Terminals, roots: string[]): Worker[];
|
|
47
|
+
/** The terminals in the folder of each of `trees`; the main checkout `main` takes those not in a linked one. */
|
|
48
|
+
export declare function holdersOf({ own, workers, others }: Terminals, main: string, trees: string[]): Map<string, Holder[]>;
|
|
49
|
+
/** How the Sweep says a Worker that is working, blocked or yet to report is, or undefined for one that is done. */
|
|
50
|
+
export declare function atWork({ status }: Worker): string | undefined;
|
|
51
|
+
/** What the terminals in a worktree keep it for, or null when only Workers at the end of their turn are in it. */
|
|
52
|
+
export declare function whyHeld(holders: Holder[]): {
|
|
53
|
+
reason: string;
|
|
54
|
+
command: string | null;
|
|
55
|
+
} | null;
|
|
56
|
+
/** The terminals of the Workers done in a worktree that goes, to stop and remove with it. */
|
|
57
|
+
export declare function stopsOf(holders: Holder[], tree: string): TerminalPlan[];
|
|
58
|
+
/** Stops, if `item` asks, and removes its terminal; the words of the `hc` that refused, or undefined when it is gone. */
|
|
59
|
+
export declare function removeTerminal(item: {
|
|
60
|
+
name: string;
|
|
61
|
+
stop?: boolean;
|
|
62
|
+
}): string | undefined;
|
|
63
|
+
/** A Worker the Sweep keeps, with why. */
|
|
64
|
+
export type KeptWorker = {
|
|
65
|
+
name: string;
|
|
66
|
+
reason: string;
|
|
67
|
+
};
|
|
68
|
+
/** The Workers no worktree of this Sweep decides: a finished one is removed, one whose folder is gone goes under the status rule. */
|
|
69
|
+
export declare function outsideWorktrees(terminals: Terminals, roots: string[]): {
|
|
70
|
+
remove: TerminalPlan[];
|
|
71
|
+
keep: KeptWorker[];
|
|
72
|
+
};
|
|
73
|
+
/** The command that stops or removes the terminal of a planned terminal by hand. */
|
|
74
|
+
export declare function byHand(item: {
|
|
75
|
+
name: string;
|
|
76
|
+
stop?: boolean;
|
|
77
|
+
}): string;
|
|
78
|
+
/** The Worker in `holders` whose program is the process a live lock names, when it may be stopped. */
|
|
79
|
+
export declare function lockHolder(pid: number, holders: Holder[]): Located | undefined;
|
|
80
|
+
/** The lock of the worktree at `path` read again once its Worker is stopped, given the program a moment to end. */
|
|
81
|
+
export declare function lockAfterStop(root: string, path: string): LockVerdict;
|
|
82
|
+
type KeptTerminal = {
|
|
83
|
+
name: string;
|
|
84
|
+
reason: string;
|
|
85
|
+
command: string | null;
|
|
86
|
+
};
|
|
87
|
+
/** The terminals in a worktree that stays which the Sweep lists as kept: Workers, and another session's terminals. */
|
|
88
|
+
export declare function keptIn(holders: Holder[], tree: string): KeptTerminal[];
|
|
89
|
+
export {};
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
// What a Sweep does with herdr terminals: it stops and removes only a Worker of its own whose turn has ended,
|
|
2
|
+
// in a worktree that goes in the same Sweep or is already gone. docs/adr/0015 sets the bounds.
|
|
3
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
4
|
+
import { isObject, parseObject } from "./json-text.js";
|
|
5
|
+
import { shellLine } from "./pr/decide.js";
|
|
6
|
+
import { CannotRun } from "./pr/read.js";
|
|
7
|
+
import { worktreesOf } from "./sweep-trees.js";
|
|
8
|
+
import { call, hcObject, isLive, Refusal, said, workersOf } from "./worker/herdr.js";
|
|
9
|
+
import { lockVerdict } from "./worktree-lock.js";
|
|
10
|
+
/** Statuses of a Worker that is not at the end of its turn, with how the Sweep says it. */
|
|
11
|
+
const AT_WORK = {
|
|
12
|
+
working: "working",
|
|
13
|
+
blocked: "blocked, waiting for the dev",
|
|
14
|
+
unknown: "yet to report"
|
|
15
|
+
};
|
|
16
|
+
/** The address of the terminal the Sweep runs in, or undefined outside herdr. */
|
|
17
|
+
export function sweepingTerminal() {
|
|
18
|
+
const id = process.env.HC_TERMINAL_ID;
|
|
19
|
+
return id === undefined || id === "" ? undefined : id;
|
|
20
|
+
}
|
|
21
|
+
function word(value) {
|
|
22
|
+
return typeof value === "string" && value !== "" ? value : undefined;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Each live terminal with the folder it works in, `hc get`'s foreground. An `hc get` that fails stops the
|
|
26
|
+
* Sweep, since a terminal in a folder it cannot see must not count as absent; one gone since the listing is dropped.
|
|
27
|
+
*/
|
|
28
|
+
function foldersOf(list) {
|
|
29
|
+
return list.flatMap((worker) => {
|
|
30
|
+
if (!isLive(worker)) {
|
|
31
|
+
return [{ ...worker, pid: undefined }];
|
|
32
|
+
}
|
|
33
|
+
const got = call("hc", ["get", worker.address, "--json"]);
|
|
34
|
+
if (got.status === 3) {
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
const parsed = got.status === 0 ? parseObject(got.stdout) : undefined;
|
|
38
|
+
if (parsed === undefined) {
|
|
39
|
+
throw new Refusal(`hc get ${worker.address} failed: ${got.status === 0 ? "its reply is no JSON object" : said(got)}`);
|
|
40
|
+
}
|
|
41
|
+
const { cwd, pid } = isObject(parsed.foreground) ? parsed.foreground : { cwd: undefined, pid: undefined };
|
|
42
|
+
const there = typeof cwd === "string" && cwd !== "" ? { ...worker, folder: cwd } : worker;
|
|
43
|
+
return [{ ...there, pid: typeof pid === "number" ? pid : undefined }];
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
/** The live terminals `hc ls` lists, each with the folder it works in. */
|
|
47
|
+
function liveTerminals() {
|
|
48
|
+
const { terminals } = hcObject(["ls", "--json"]);
|
|
49
|
+
if (!Array.isArray(terminals)) {
|
|
50
|
+
throw new Refusal("hc ls answered no list of terminals", "check hc --version");
|
|
51
|
+
}
|
|
52
|
+
const places = new Map();
|
|
53
|
+
const listed = terminals.flatMap((entry) => {
|
|
54
|
+
const address = isObject(entry) ? word(entry.address) : undefined;
|
|
55
|
+
if (!isObject(entry) || address === undefined || typeof entry.cwd !== "string") {
|
|
56
|
+
throw new Refusal("hc ls answered a terminal without an address or a folder", "check hc --version");
|
|
57
|
+
}
|
|
58
|
+
places.set(address, word(entry.where));
|
|
59
|
+
const name = word(entry.name) ?? address;
|
|
60
|
+
return [{ name, address, state: "live", status: word(entry.status) ?? "unknown", folder: entry.cwd }];
|
|
61
|
+
});
|
|
62
|
+
return foldersOf(listed).map(({ name, address, folder }) => ({
|
|
63
|
+
name,
|
|
64
|
+
address,
|
|
65
|
+
folder,
|
|
66
|
+
place: places.get(address) ?? folder
|
|
67
|
+
}));
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Reads herdr for the sweeping terminal `own`: its Workers, finished ones included, and the other live
|
|
71
|
+
* terminals. An `hc` that fails stops the Sweep, since a terminal it cannot see must not count as absent.
|
|
72
|
+
*/
|
|
73
|
+
export function readTerminals(own) {
|
|
74
|
+
try {
|
|
75
|
+
const workers = foldersOf(workersOf(own, true));
|
|
76
|
+
const live = liveTerminals();
|
|
77
|
+
const addresses = new Set(workers.map(({ address }) => address));
|
|
78
|
+
return {
|
|
79
|
+
own: live.find(({ address }) => address === own),
|
|
80
|
+
workers,
|
|
81
|
+
others: live.filter(({ address }) => address !== own && !addresses.has(address))
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
if (error instanceof Refusal) {
|
|
86
|
+
throw new CannotRun(error.message, error.remedy ?? "check hc, then call again");
|
|
87
|
+
}
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function canonical(path) {
|
|
92
|
+
try {
|
|
93
|
+
return realpathSync(path);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return path;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
function isInside(folder, root) {
|
|
100
|
+
return folder === root || folder.startsWith(root.endsWith("/") ? root : `${root}/`);
|
|
101
|
+
}
|
|
102
|
+
/** The deepest of `roots` whose folder holds `folder`, or undefined. */
|
|
103
|
+
function treeOf(folder, roots) {
|
|
104
|
+
const real = canonical(folder);
|
|
105
|
+
const holding = roots.filter((root) => isInside(folder, root) || isInside(real, canonical(root)));
|
|
106
|
+
return holding.toSorted((a, b) => b.length - a.length)[0];
|
|
107
|
+
}
|
|
108
|
+
/** A Worker whose terminal is live but whose folder is gone, with no worktree of `roots` around it, could commit nothing more. */
|
|
109
|
+
export function workerWithoutFolder({ workers }, roots) {
|
|
110
|
+
const standing = roots.filter((root) => existsSync(root));
|
|
111
|
+
return workers.filter((worker) => isLive(worker) && !existsSync(worker.folder) && treeOf(worker.folder, standing) === undefined);
|
|
112
|
+
}
|
|
113
|
+
/** The terminals in the folder of each of `trees`; the main checkout `main` takes those not in a linked one. */
|
|
114
|
+
export function holdersOf({ own, workers, others }, main, trees) {
|
|
115
|
+
const held = new Map();
|
|
116
|
+
const standing = [main, ...trees].filter((root) => existsSync(root));
|
|
117
|
+
const place = (folder, holder) => {
|
|
118
|
+
const tree = treeOf(folder, standing);
|
|
119
|
+
if (tree !== undefined && tree !== main) {
|
|
120
|
+
held.set(tree, [...(held.get(tree) ?? []), holder]);
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
if (own !== undefined) {
|
|
124
|
+
place(own.folder, { role: "own", terminal: own });
|
|
125
|
+
}
|
|
126
|
+
for (const terminal of others) {
|
|
127
|
+
place(terminal.folder, { role: "other", terminal });
|
|
128
|
+
}
|
|
129
|
+
for (const worker of workers) {
|
|
130
|
+
if (isLive(worker)) {
|
|
131
|
+
place(worker.folder, { role: "worker", worker });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return held;
|
|
135
|
+
}
|
|
136
|
+
/** How the Sweep says a Worker that is working, blocked or yet to report is, or undefined for one that is done. */
|
|
137
|
+
export function atWork({ status }) {
|
|
138
|
+
return AT_WORK[status];
|
|
139
|
+
}
|
|
140
|
+
/** What the terminals in a worktree keep it for, or null when only Workers at the end of their turn are in it. */
|
|
141
|
+
export function whyHeld(holders) {
|
|
142
|
+
const reasons = [];
|
|
143
|
+
const kills = [];
|
|
144
|
+
for (const holder of holders) {
|
|
145
|
+
if (holder.role === "own") {
|
|
146
|
+
reasons.push(`the terminal this Sweep runs in, ${holder.terminal.address}, is in it`);
|
|
147
|
+
}
|
|
148
|
+
else if (holder.role === "other") {
|
|
149
|
+
const { address, place } = holder.terminal;
|
|
150
|
+
reasons.push(`another session's terminal ${address} is in it, at ${place}`);
|
|
151
|
+
kills.push(shellLine(["hc", "kill", address]));
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
const at = atWork(holder.worker);
|
|
155
|
+
if (at !== undefined) {
|
|
156
|
+
reasons.push(`its Worker ${holder.worker.name} (${holder.worker.address}) is ${at}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return reasons.length === 0
|
|
161
|
+
? null
|
|
162
|
+
: { reason: reasons.join("; "), command: kills.length === 0 ? null : kills.join(" && ") };
|
|
163
|
+
}
|
|
164
|
+
/** The terminals of the Workers done in a worktree that goes, to stop and remove with it. */
|
|
165
|
+
export function stopsOf(holders, tree) {
|
|
166
|
+
return holders.flatMap((holder) => holder.role === "worker"
|
|
167
|
+
? [
|
|
168
|
+
{
|
|
169
|
+
kind: "terminal",
|
|
170
|
+
name: holder.worker.address,
|
|
171
|
+
reason: `Worker ${holder.worker.name} is ${holder.worker.status}: stopped, then removed with its worktree`,
|
|
172
|
+
stop: true,
|
|
173
|
+
tree
|
|
174
|
+
}
|
|
175
|
+
]
|
|
176
|
+
: []);
|
|
177
|
+
}
|
|
178
|
+
/** Stops, if `item` asks, and removes its terminal; the words of the `hc` that refused, or undefined when it is gone. */
|
|
179
|
+
export function removeTerminal(item) {
|
|
180
|
+
try {
|
|
181
|
+
if (item.stop === true) {
|
|
182
|
+
const killed = call("hc", ["kill", item.name]);
|
|
183
|
+
// 11: the terminal finished between the read and the stop.
|
|
184
|
+
if (killed.status !== 0 && killed.status !== 11) {
|
|
185
|
+
return `hc kill ${item.name} failed: ${said(killed)}`;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const removed = call("hc", ["rm", item.name]);
|
|
189
|
+
// 3: the terminal is gone already.
|
|
190
|
+
return removed.status === 0 || removed.status === 3 ? undefined : `hc rm ${item.name} failed: ${said(removed)}`;
|
|
191
|
+
}
|
|
192
|
+
catch (error) {
|
|
193
|
+
if (error instanceof Refusal) {
|
|
194
|
+
return error.message;
|
|
195
|
+
}
|
|
196
|
+
throw error;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
/** The Workers no worktree of this Sweep decides: a finished one is removed, one whose folder is gone goes under the status rule. */
|
|
200
|
+
export function outsideWorktrees(terminals, roots) {
|
|
201
|
+
const remove = [];
|
|
202
|
+
const keep = [];
|
|
203
|
+
for (const worker of terminals.workers.filter((one) => !isLive(one))) {
|
|
204
|
+
remove.push({ kind: "terminal", name: worker.address, reason: `Worker ${worker.name} is finished`, stop: false });
|
|
205
|
+
}
|
|
206
|
+
for (const worker of workerWithoutFolder(terminals, roots)) {
|
|
207
|
+
const at = atWork(worker);
|
|
208
|
+
if (at === undefined) {
|
|
209
|
+
const reason = `Worker ${worker.name} is ${worker.status} and its folder ${worker.folder} is gone: stopped, then removed`;
|
|
210
|
+
remove.push({ kind: "terminal", name: worker.address, reason, stop: true });
|
|
211
|
+
}
|
|
212
|
+
else {
|
|
213
|
+
keep.push({ name: worker.address, reason: `Worker ${worker.name} is ${at}, its folder ${worker.folder} is gone` });
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return { remove, keep };
|
|
217
|
+
}
|
|
218
|
+
/** The command that stops or removes the terminal of a planned terminal by hand. */
|
|
219
|
+
export function byHand(item) {
|
|
220
|
+
return item.stop === true
|
|
221
|
+
? `${shellLine(["hc", "kill", item.name])}; ${shellLine(["hc", "rm", item.name])}`
|
|
222
|
+
: shellLine(["hc", "rm", item.name]);
|
|
223
|
+
}
|
|
224
|
+
/** The Worker in `holders` whose program is the process a live lock names, when it may be stopped. */
|
|
225
|
+
export function lockHolder(pid, holders) {
|
|
226
|
+
for (const holder of holders) {
|
|
227
|
+
if (holder.role === "worker" && holder.worker.pid === pid && atWork(holder.worker) === undefined) {
|
|
228
|
+
return holder.worker;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return undefined;
|
|
232
|
+
}
|
|
233
|
+
/** The lock of the worktree at `path` read again once its Worker is stopped, given the program a moment to end. */
|
|
234
|
+
export function lockAfterStop(root, path) {
|
|
235
|
+
let verdict = { state: "none" };
|
|
236
|
+
for (let attempt = 0; attempt < 15; attempt++) {
|
|
237
|
+
verdict = lockVerdict(path, worktreesOf(root).find((tree) => tree.path === path)?.lock);
|
|
238
|
+
if (verdict.state !== "stays") {
|
|
239
|
+
return verdict;
|
|
240
|
+
}
|
|
241
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100);
|
|
242
|
+
}
|
|
243
|
+
return verdict;
|
|
244
|
+
}
|
|
245
|
+
/** The terminals in a worktree that stays which the Sweep lists as kept: Workers, and another session's terminals. */
|
|
246
|
+
export function keptIn(holders, tree) {
|
|
247
|
+
return holders.flatMap((holder) => {
|
|
248
|
+
if (holder.role === "other") {
|
|
249
|
+
const { address, place } = holder.terminal;
|
|
250
|
+
const reason = `another session's terminal, in the worktree at ${tree}, at ${place}`;
|
|
251
|
+
return [{ name: address, reason, command: shellLine(["hc", "kill", address]) }];
|
|
252
|
+
}
|
|
253
|
+
if (holder.role === "worker") {
|
|
254
|
+
const { name, address, status } = holder.worker;
|
|
255
|
+
const at = atWork(holder.worker);
|
|
256
|
+
return [
|
|
257
|
+
{ name: address, reason: `Worker ${name} is ${at ?? `${status}, but its worktree stays`}`, command: null }
|
|
258
|
+
];
|
|
259
|
+
}
|
|
260
|
+
return [];
|
|
261
|
+
});
|
|
262
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** `git <args>` at `root`, or a stop naming its problem. */
|
|
2
|
+
export declare function must(root: string, args: string[]): string;
|
|
3
|
+
export type Tree = {
|
|
4
|
+
path: string;
|
|
5
|
+
head: string;
|
|
6
|
+
branch: string | undefined;
|
|
7
|
+
lock: string | undefined;
|
|
8
|
+
prunable: boolean;
|
|
9
|
+
};
|
|
10
|
+
/** Every worktree of the repository, the main checkout first. */
|
|
11
|
+
export declare function worktreesOf(root: string): Tree[];
|
|
12
|
+
/** The files `git status` lists in the worktree at `path`, ignored ones aside, or why it cannot be read. */
|
|
13
|
+
export declare function dirtyFiles(path: string): string[] | {
|
|
14
|
+
error: string;
|
|
15
|
+
};
|
|
16
|
+
/** The operation paused in the worktree at `path`: its git dir holds the state git resumes it from. */
|
|
17
|
+
export declare function pausedOperation(path: string): string | undefined;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// What a Sweep reads of a repository's linked worktrees: where each lies, what it has checked out, its lock,
|
|
2
|
+
// the files that hold it and the operation paused in it.
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { repositoryFreeEnv } from "./git-env.js";
|
|
7
|
+
import { CannotRun } from "./pr/read.js";
|
|
8
|
+
import { asked, git } from "./working-tree.js";
|
|
9
|
+
/** `git <args>` at `root`, or a stop naming its problem. */
|
|
10
|
+
export function must(root, args) {
|
|
11
|
+
const result = git(root, args);
|
|
12
|
+
if ("error" in result) {
|
|
13
|
+
throw new CannotRun(result.error, "check the repository with git status");
|
|
14
|
+
}
|
|
15
|
+
return result.out;
|
|
16
|
+
}
|
|
17
|
+
/** Every worktree of the repository, the main checkout first. */
|
|
18
|
+
export function worktreesOf(root) {
|
|
19
|
+
const trees = [];
|
|
20
|
+
let tree;
|
|
21
|
+
for (const line of must(root, ["worktree", "list", "--porcelain"]).split("\n")) {
|
|
22
|
+
if (line.startsWith("worktree ")) {
|
|
23
|
+
tree = { path: line.slice("worktree ".length), head: "", branch: undefined, lock: undefined, prunable: false };
|
|
24
|
+
trees.push(tree);
|
|
25
|
+
}
|
|
26
|
+
else if (tree !== undefined && line.startsWith("HEAD ")) {
|
|
27
|
+
tree.head = line.slice("HEAD ".length);
|
|
28
|
+
}
|
|
29
|
+
else if (tree !== undefined && line.startsWith("branch refs/heads/")) {
|
|
30
|
+
tree.branch = line.slice("branch refs/heads/".length);
|
|
31
|
+
}
|
|
32
|
+
else if (tree !== undefined && (line === "locked" || line.startsWith("locked "))) {
|
|
33
|
+
tree.lock = line.slice("locked".length).trim();
|
|
34
|
+
}
|
|
35
|
+
else if (tree !== undefined && (line === "prunable" || line.startsWith("prunable "))) {
|
|
36
|
+
tree.prunable = true;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return trees;
|
|
40
|
+
}
|
|
41
|
+
/** The files `git status` lists in the worktree at `path`, ignored ones aside, or why it cannot be read. */
|
|
42
|
+
export function dirtyFiles(path) {
|
|
43
|
+
const args = ["-C", path, "status", "--porcelain=v1", "-z", "--untracked-files=all"];
|
|
44
|
+
const result = spawnSync("git", args, { encoding: "utf8", env: repositoryFreeEnv(), maxBuffer: 64 * 1024 * 1024 });
|
|
45
|
+
if (result.status !== 0) {
|
|
46
|
+
const said = result.stderr.split("\n").find((line) => line.trim() !== "");
|
|
47
|
+
return { error: `git status: ${said?.trim() ?? `exited ${result.status ?? "on a signal"}`}` };
|
|
48
|
+
}
|
|
49
|
+
const files = [];
|
|
50
|
+
const entries = result.stdout.split("\0").filter((entry) => entry !== "");
|
|
51
|
+
for (let index = 0; index < entries.length; index++) {
|
|
52
|
+
const entry = entries[index] ?? "";
|
|
53
|
+
files.push(entry.slice(3));
|
|
54
|
+
if (entry.startsWith("R") || entry.startsWith("C")) {
|
|
55
|
+
index++;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return files;
|
|
59
|
+
}
|
|
60
|
+
const OPERATIONS = [
|
|
61
|
+
["rebase-merge", "rebase"],
|
|
62
|
+
["rebase-apply", "rebase or am"],
|
|
63
|
+
["MERGE_HEAD", "merge"],
|
|
64
|
+
["CHERRY_PICK_HEAD", "cherry-pick"],
|
|
65
|
+
["REVERT_HEAD", "revert"],
|
|
66
|
+
["BISECT_LOG", "bisect"]
|
|
67
|
+
];
|
|
68
|
+
/** The operation paused in the worktree at `path`: its git dir holds the state git resumes it from. */
|
|
69
|
+
export function pausedOperation(path) {
|
|
70
|
+
const dir = asked(path, ["rev-parse", "--absolute-git-dir"]);
|
|
71
|
+
return dir === undefined ? undefined : OPERATIONS.find(([file]) => existsSync(join(dir, file)))?.[1];
|
|
72
|
+
}
|
package/dist/worktree-lock.d.ts
CHANGED
|
@@ -4,12 +4,15 @@ export type StaleLock = {
|
|
|
4
4
|
held: string;
|
|
5
5
|
why: string;
|
|
6
6
|
};
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
} | StaleLock | {
|
|
7
|
+
/** A lock that keeps the worktree; `pid` is the live process a Claude Code lock names. */
|
|
8
|
+
export type LockStays = {
|
|
10
9
|
state: "stays";
|
|
11
10
|
reason: string;
|
|
12
11
|
command: string | null;
|
|
12
|
+
pid?: number;
|
|
13
13
|
};
|
|
14
|
+
export type LockVerdict = {
|
|
15
|
+
state: "none";
|
|
16
|
+
} | StaleLock | LockStays;
|
|
14
17
|
/** What the lock of the worktree at `path`, held for `reason`, says about whether the Sweep may lift it. */
|
|
15
18
|
export declare function lockVerdict(path: string, reason: string | undefined): LockVerdict;
|
package/dist/worktree-lock.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dw-sdlc",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "The Gate and the agentic config of a repository: the dw-sdlc bin with the Gate, the setup, the doctor, the config check, the Sync and setup of References, and the Ship script that drives a pull request to merged; the resolver of the config's layers, its JSON Schema and the validator that reads it.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|