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.
@@ -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
- --dry-run removes nothing and answers the same items. One line an item, or one document with --json:
34
- { dryRun, base, removed: [item], kept: [item] }, an item { kind, name, reason, command? }, kind branch, worktree
35
- or lock (a lifted one), name its path, command the one the dev would run for a kept item.
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, or when the fetch fails.`;
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 and local branch into what goes and what stays. */
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
- return { tree, lock, why: whyTreeStays(root, base.ref, tree, lock) };
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
- for (const { tree, lock, why } of verdicts) {
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
- return out.length === 0 ? "nothing to sweep" : out.join("\n");
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 judged = judge(root);
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
+ }
@@ -4,12 +4,15 @@ export type StaleLock = {
4
4
  held: string;
5
5
  why: string;
6
6
  };
7
- export type LockVerdict = {
8
- state: "none";
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;
@@ -58,6 +58,7 @@ export function lockVerdict(path, reason) {
58
58
  return {
59
59
  state: "stays",
60
60
  reason: `locked by a live Claude Code session: pid ${pid} is running (${reason})`,
61
- command: null
61
+ command: null,
62
+ pid
62
63
  };
63
64
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dw-sdlc",
3
- "version": "0.13.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": {