dw-sdlc 0.21.0 → 0.21.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/dist/agentic-config.d.ts +16 -0
  2. package/dist/agentic-config.js +28 -23
  3. package/dist/cannot-run.d.ts +27 -0
  4. package/dist/cannot-run.js +48 -0
  5. package/dist/catalogue.js +2 -1
  6. package/dist/checks/agents-map.js +7 -8
  7. package/dist/checks/check.d.ts +10 -8
  8. package/dist/checks/check.js +9 -7
  9. package/dist/checks/decision-records.js +3 -2
  10. package/dist/checks/file-size-ratchet.js +13 -17
  11. package/dist/checks/index.d.ts +6 -5
  12. package/dist/checks/index.js +10 -9
  13. package/dist/checks/markdown-refs.js +1 -1
  14. package/dist/checks/primitives.js +5 -5
  15. package/dist/checks/routed-docs.js +1 -3
  16. package/dist/checks/skill-sections.js +12 -20
  17. package/dist/checks/tree.js +7 -7
  18. package/dist/cli.d.ts +2 -1
  19. package/dist/cli.js +26 -5
  20. package/dist/commands/check.js +27 -42
  21. package/dist/commands/config.js +22 -27
  22. package/dist/commands/doctor.d.ts +2 -0
  23. package/dist/commands/doctor.js +11 -18
  24. package/dist/commands/gate.js +35 -40
  25. package/dist/commands/lead-start.js +28 -42
  26. package/dist/commands/pr-reviewed.js +41 -59
  27. package/dist/commands/pr-ship.js +71 -89
  28. package/dist/commands/primitives.js +38 -40
  29. package/dist/commands/recap.js +23 -74
  30. package/dist/commands/references-setup.js +28 -76
  31. package/dist/commands/references-sync.js +29 -53
  32. package/dist/commands/setup.d.ts +2 -7
  33. package/dist/commands/setup.js +38 -90
  34. package/dist/commands/sweep.js +46 -157
  35. package/dist/commands/worker-ls.js +15 -31
  36. package/dist/commands/worker-report.js +36 -52
  37. package/dist/commands/worker-start.js +43 -57
  38. package/dist/drift.js +2 -1
  39. package/dist/exec.d.ts +20 -0
  40. package/dist/exec.js +37 -4
  41. package/dist/gate-cache.js +2 -1
  42. package/dist/git.d.ts +32 -0
  43. package/dist/git.js +60 -0
  44. package/dist/json-schema.js +1 -3
  45. package/dist/landed.js +11 -22
  46. package/dist/paused-operation.d.ts +2 -0
  47. package/dist/paused-operation.js +17 -0
  48. package/dist/pins.js +2 -1
  49. package/dist/pr/decide.d.ts +4 -16
  50. package/dist/pr/decide.js +48 -84
  51. package/dist/pr/delta.d.ts +3 -4
  52. package/dist/pr/delta.js +10 -19
  53. package/dist/pr/read.d.ts +27 -30
  54. package/dist/pr/read.js +94 -182
  55. package/dist/pr/reply.js +3 -5
  56. package/dist/pr/route.d.ts +11 -3
  57. package/dist/pr/route.js +5 -5
  58. package/dist/pr/standing.d.ts +138 -0
  59. package/dist/pr/standing.js +181 -0
  60. package/dist/pr/state.d.ts +10 -69
  61. package/dist/pr/state.js +42 -72
  62. package/dist/pr/upstream.d.ts +1 -6
  63. package/dist/pr/upstream.js +2 -2
  64. package/dist/primitives-diff.js +1 -1
  65. package/dist/recap-next.js +34 -47
  66. package/dist/recap-primitives.js +1 -1
  67. package/dist/references-key.d.ts +0 -1
  68. package/dist/references-key.js +3 -4
  69. package/dist/run-command.d.ts +4 -1
  70. package/dist/run-command.js +32 -11
  71. package/dist/setup-gate.d.ts +1 -7
  72. package/dist/setup-rules.js +2 -6
  73. package/dist/setup-writer.d.ts +32 -0
  74. package/dist/setup-writer.js +60 -0
  75. package/dist/shell-line.d.ts +2 -0
  76. package/dist/shell-line.js +5 -0
  77. package/dist/sweep-removal.d.ts +31 -0
  78. package/dist/sweep-removal.js +133 -0
  79. package/dist/sweep-terminals.d.ts +1 -1
  80. package/dist/sweep-terminals.js +21 -57
  81. package/dist/sweep-trees.d.ts +19 -3
  82. package/dist/sweep-trees.js +67 -31
  83. package/dist/worker/herdr.d.ts +67 -23
  84. package/dist/worker/herdr.js +126 -77
  85. package/dist/worker/launch.js +32 -45
  86. package/dist/worker/origin.d.ts +1 -1
  87. package/dist/worker/origin.js +9 -11
  88. package/dist/worker/tabs.js +11 -7
  89. package/dist/working-tree.d.ts +0 -8
  90. package/dist/working-tree.js +6 -22
  91. package/dist/worktree-lock.js +1 -1
  92. package/package.json +1 -1
  93. package/dist/git-env.d.ts +0 -2
  94. package/dist/git-env.js +0 -10
@@ -1,17 +1,18 @@
1
1
  // `dw-sdlc setup` sets a repository and this machine up for the sdlc plugin: the files its USAGE names,
2
2
  // each written only when its content would change, so a run is safe to repeat. Existing content is
3
3
  // merged, never replaced: other keys stay, and a file that is not a JSON object is refused.
4
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { existsSync, readFileSync } from "node:fs";
5
5
  import { homedir } from "node:os";
6
- import { dirname, join, relative } from "node:path";
6
+ import { join, relative } from "node:path";
7
7
  import { pathToFileURL } from "node:url";
8
- import { parseArgs } from "node:util";
9
- import { COMMIT_CONVENTIONS, COMMITTED_FILE, DEFAULT_GUARDRAILS, LOCAL_FILE, SCHEMA_FILE, STRICTNESS, inferConfig, repositoryRoot, resolveConfig, schemaId, validateDocument } from "../agentic-config.js";
10
- import { exec } from "../exec.js";
8
+ import { COMMIT_CONVENTIONS, COMMITTED_FILE, DEFAULT_GUARDRAILS, LOCAL_FILE, SCHEMA_FILE, STRICTNESS, inferConfig, mainCheckout, repositoryRoot, resolveConfig, schemaId, validateDocument } from "../agentic-config.js";
9
+ import { readArgs, rootDir, usageError } from "../cannot-run.js";
10
+ import { gitDirs } from "../git.js";
11
11
  import { isObject, parseObject, render } from "../json-text.js";
12
12
  import { withGate, withPackage } from "../setup-gate.js";
13
13
  import { GLOBAL_DENY, LEASE_RULE, RETIRED_DENY, repositoryDeny } from "../setup-rules.js";
14
14
  import { keptWorker, pinnedWorker } from "../setup-worker.js";
15
+ import { stepLines, Writer } from "../setup-writer.js";
15
16
  export const summary = "adopt the Gate where asked, and write this machine's local config layer, the git excludes and the permission rules";
16
17
  const USAGE = `Usage: dw-sdlc setup [--root <dir>] [--home <dir>]
17
18
  [--convention <preset>] [--force-push deny|ask|lease] [--commit-on-protected deny|allow]
@@ -53,11 +54,6 @@ const PLUGIN = "sdlc";
53
54
  const PACKAGE_LINK = "dw-sdlc";
54
55
  /** The two plugins the sdlc plugin replaced, which the marketplace does not list (ADR 0010). */
55
56
  const RETIRED_PLUGINS = ["guardrails", "references"].map((name) => `${name}@${MARKETPLACE}`);
56
- /** A read-only git command, the one thing the setup runs in the repository. */
57
- function git(root, args) {
58
- const result = exec("git", ["-C", root, ...args]);
59
- return result.status === 0 ? result.stdout.trim() : undefined;
60
- }
61
57
  /** The committed layer when it is valid; an absent or invalid one sets nothing, as the resolver drops it. */
62
58
  function committedConfig(root) {
63
59
  const path = join(root, COMMITTED_FILE);
@@ -188,41 +184,6 @@ function union(existing, additions) {
188
184
  function readJson(path) {
189
185
  return existsSync(path) ? (parseObject(readFileSync(path, "utf8")) ?? "invalid") : undefined;
190
186
  }
191
- class Writer {
192
- steps = [];
193
- dryRun;
194
- constructor(dryRun) {
195
- this.dryRun = dryRun;
196
- }
197
- /** Writes when the text differs from what is on disk; records the outcome under `label`, with what the write does. */
198
- put(label, path, text, detail) {
199
- const current = existsSync(path) ? readFileSync(path, "utf8") : undefined;
200
- if (current === text) {
201
- this.steps.push({ file: label, outcome: "unchanged" });
202
- return;
203
- }
204
- if (!this.dryRun) {
205
- mkdirSync(dirname(path), { recursive: true });
206
- writeFileSync(path, text);
207
- }
208
- this.steps.push(detail === undefined ? { file: label, outcome: "wrote" } : { file: label, outcome: "wrote", detail });
209
- }
210
- refuse(label, detail) {
211
- this.steps.push({ file: label, outcome: "refused", detail });
212
- }
213
- /** The step's file as `change` leaves it: written, unchanged or refused. */
214
- apply(label, path, change) {
215
- if ("refusal" in change) {
216
- this.refuse(label, change.refusal);
217
- }
218
- else {
219
- this.put(label, path, change.text, change.detail);
220
- }
221
- }
222
- byHand(label, detail) {
223
- this.steps.push({ file: label, outcome: "by hand", detail });
224
- }
225
- }
226
187
  /** The enabledPlugins edit settings that still enable a retired plugin need; none where they enable neither. */
227
188
  function enabledPluginsEdit(settings) {
228
189
  const enabled = isObject(settings.enabledPlugins) ? settings.enabledPlugins : {};
@@ -238,19 +199,16 @@ function enabledPluginsEdit(settings) {
238
199
  }
239
200
  /**
240
201
  * Where the repository's files live: the main checkout for a linked worktree, since every worktree reads
241
- * the local config from there when it has none of its own, and Claude Code reads the local settings there.
202
+ * the local config from there when it has none of its own, and Claude Code reads the local settings there;
203
+ * the excludes go to the git dir every worktree shares.
242
204
  */
243
- function mainCheckout(root) {
244
- const common = git(root, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
245
- if (common === undefined) {
246
- return { checkout: root, gitDir: join(root, ".git") };
247
- }
248
- return { checkout: dirname(common), gitDir: common };
205
+ function repositoryFiles(root) {
206
+ return { checkout: mainCheckout(root) ?? root, gitDir: gitDirs(root)?.commonDir ?? join(root, ".git") };
249
207
  }
250
208
  export function setup(options) {
251
209
  const writer = new Writer(options.dryRun);
252
210
  const root = repositoryRoot(options.root);
253
- const { checkout, gitDir } = mainCheckout(root);
211
+ const { checkout, gitDir } = repositoryFiles(root);
254
212
  const claudeDir = options.claudeDir ?? join(options.home, ".claude");
255
213
  // 1. The local config: inference, the convention (the flag's, else the file's pin), the guardrails block
256
214
  // the file holds with the flags' pins. A layer of `$schema` alone tells the guards nothing: not written.
@@ -266,13 +224,13 @@ export function setup(options) {
266
224
  const { worker, detail } = pinnedWorker(keptWorker(existing), options.path ?? process.env.PATH);
267
225
  const local = localConfig(checkout, claudeDir, convention, pinned.guardrails, worker);
268
226
  if (Object.keys(local).length === 1 && (localCurrent === undefined || sameContent(existing, local))) {
269
- writer.steps.push({ file: LOCAL_FILE, outcome: "skipped", detail: "the layer would hold only $schema" });
227
+ writer.skip(LOCAL_FILE, "the layer would hold only $schema");
270
228
  }
271
229
  else if (localCurrent !== undefined && !sameContent(existing, local) && !options.force) {
272
230
  writer.refuse(LOCAL_FILE, "exists with other content; pass --force to overwrite it");
273
231
  }
274
232
  else {
275
- writer.put(LOCAL_FILE, localPath, render(local), detail);
233
+ writer.apply(LOCAL_FILE, localPath, { text: render(local), detail });
276
234
  }
277
235
  }
278
236
  // 2. The exclude entries, appended to whatever the file holds: they keep both local files out of git
@@ -283,11 +241,11 @@ export function setup(options) {
283
241
  const missing = [LOCAL_FILE, ".claude/settings.local.json"].filter((entry) => !lines.includes(entry));
284
242
  const excludeLabel = relative(checkout, excludePath).startsWith("..") ? excludePath : relative(checkout, excludePath);
285
243
  if (missing.length === 0) {
286
- writer.steps.push({ file: excludeLabel, outcome: "unchanged" });
244
+ writer.apply(excludeLabel, excludePath, { text: excludeText });
287
245
  }
288
246
  else {
289
247
  const separator = excludeText === "" || excludeText.endsWith("\n") ? "" : "\n";
290
- writer.put(excludeLabel, excludePath, excludeText + separator + missing.join("\n") + "\n");
248
+ writer.apply(excludeLabel, excludePath, { text: excludeText + separator + missing.join("\n") + "\n" });
291
249
  }
292
250
  // 3. The repository's deny rules, the backstop for the hooks where jq is missing, merged into the main
293
251
  // checkout's local settings.
@@ -301,7 +259,7 @@ export function setup(options) {
301
259
  const permissions = isObject(settings.permissions) ? settings.permissions : {};
302
260
  const deny = union(permissions.deny, repositoryDeny(root)).filter((rule) => !RETIRED_DENY.includes(rule));
303
261
  settings.permissions = { ...permissions, deny };
304
- writer.put(".claude/settings.local.json", localSettingsPath, render(settings));
262
+ writer.apply(".claude/settings.local.json", localSettingsPath, { text: render(settings) });
305
263
  }
306
264
  // 4. The user file, under $CLAUDE_CONFIG_DIR when it is set: the lease exception, "$defaults" kept, and
307
265
  // the irreversible non-git set.
@@ -320,7 +278,7 @@ export function setup(options) {
320
278
  settings.autoMode = { ...autoMode, allow };
321
279
  const permissions = isObject(settings.permissions) ? settings.permissions : {};
322
280
  settings.permissions = { ...permissions, deny: union(permissions.deny, GLOBAL_DENY) };
323
- writer.put(userLabel, userPath, render(settings));
281
+ writer.apply(userLabel, userPath, { text: render(settings) });
324
282
  const edit = enabledPluginsEdit(settings);
325
283
  if (edit !== undefined) {
326
284
  writer.byHand(userLabel, edit);
@@ -341,48 +299,39 @@ export function setup(options) {
341
299
  }
342
300
  return writer.steps;
343
301
  }
344
- function fail(message, code) {
345
- console.error(`guardrails:setup: ${message}`);
346
- process.exit(code);
347
- }
348
302
  export function run(argv) {
349
- let values;
350
- try {
351
- values = parseArgs({
352
- args: argv,
353
- options: {
354
- help: { type: "boolean", short: "h" },
355
- root: { type: "string" },
356
- home: { type: "string" },
357
- convention: { type: "string" },
358
- "force-push": { type: "string" },
359
- "commit-on-protected": { type: "string" },
360
- "add-package": { type: "boolean" },
361
- force: { type: "boolean" },
362
- "dry-run": { type: "boolean" }
363
- }
364
- }).values;
365
- }
366
- catch (error) {
367
- fail(`${error instanceof Error ? error.message : String(error)}\n\n${USAGE}`, 2);
368
- }
303
+ const { values } = readArgs({
304
+ args: argv,
305
+ options: {
306
+ help: { type: "boolean", short: "h" },
307
+ root: { type: "string" },
308
+ home: { type: "string" },
309
+ convention: { type: "string" },
310
+ "force-push": { type: "string" },
311
+ "commit-on-protected": { type: "string" },
312
+ "add-package": { type: "boolean" },
313
+ force: { type: "boolean" },
314
+ "dry-run": { type: "boolean" }
315
+ }
316
+ }, USAGE);
369
317
  if (values.help === true) {
370
318
  console.log(USAGE);
371
319
  return 0;
372
320
  }
373
321
  if (values.convention !== undefined && !isConvention(values.convention)) {
374
- fail(`--convention must be ${COMMIT_CONVENTIONS.join(" or ")}, not '${values.convention}'\n\n${USAGE}`, 2);
322
+ throw usageError(`--convention must be ${COMMIT_CONVENTIONS.join(" or ")}, not '${values.convention}'`, USAGE);
375
323
  }
376
324
  const forcePush = values["force-push"];
377
325
  if (forcePush !== undefined && !isForcePush(forcePush)) {
378
- fail(`--force-push must be ${STRICTNESS.forcePush.join(", ")}, not '${forcePush}'\n\n${USAGE}`, 2);
326
+ throw usageError(`--force-push must be ${STRICTNESS.forcePush.join(", ")}, not '${forcePush}'`, USAGE);
379
327
  }
380
328
  const commitOnProtected = values["commit-on-protected"];
381
329
  if (commitOnProtected !== undefined && !isCommitOnProtected(commitOnProtected)) {
382
- fail(`--commit-on-protected must be ${STRICTNESS.commitOnProtected.join(" or ")}, not '${commitOnProtected}'\n\n${USAGE}`, 2);
330
+ const allowed = STRICTNESS.commitOnProtected.join(" or ");
331
+ throw usageError(`--commit-on-protected must be ${allowed}, not '${commitOnProtected}'`, USAGE);
383
332
  }
384
333
  const steps = setup({
385
- root: values.root ?? process.cwd(),
334
+ root: rootDir(values.root),
386
335
  home: values.home ?? homedir(),
387
336
  claudeDir: values.home === undefined ? process.env.CLAUDE_CONFIG_DIR : undefined,
388
337
  force: values.force === true,
@@ -392,9 +341,8 @@ export function run(argv) {
392
341
  commitOnProtected,
393
342
  addPackage: values["add-package"] === true
394
343
  });
395
- for (const step of steps) {
396
- const verb = step.outcome === "wrote" && values["dry-run"] === true ? "would write" : step.outcome;
397
- console.log(`${verb.padEnd(11)} ${step.file}${step.detail === undefined ? "" : ` — ${step.detail}`}`);
344
+ for (const line of stepLines(steps, values["dry-run"] === true)) {
345
+ console.log(line);
398
346
  }
399
347
  return steps.some((step) => step.outcome === "refused") ? 1 : 0;
400
348
  }
@@ -1,16 +1,15 @@
1
1
  // `dw-sdlc sweep`: removes each linked worktree whose whole set passes and each Landed local branch.
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 { existsSync } from "node:fs";
4
- import { parseArgs } from "node:util";
5
4
  import { findRepository, mainCheckout, resolveConfig } from "../agentic-config.js";
6
- import { exec, SLOW_LIMIT_MS } from "../exec.js";
7
- import { repositoryFreeEnv } from "../git-env.js";
8
- import { isCommitLanded, isLanded } from "../landed.js";
9
- import { shellLine } from "../pr/decide.js";
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 { branchesOf, dirtyFiles, pausedOperation, switchOff, worktreesOf } from "../sweep-trees.js";
13
- import { asked, git } from "../working-tree.js";
5
+ import { CannotRun, readArgs, usageError } from "../cannot-run.js";
6
+ import { SLOW_LIMIT_MS } from "../exec.js";
7
+ import { asked, git } from "../git.js";
8
+ import { isCommitLanded } from "../landed.js";
9
+ import { shellLine } from "../shell-line.js";
10
+ import { apply } from "../sweep-removal.js";
11
+ import { holdersOf, keptIn, lockHolder, outsideWorktrees, readTerminals, stopsOf, sweepingTerminal, whyHeld } from "../sweep-terminals.js";
12
+ import { branchesOf, forcedDelete, heldBranch, switchOff, tipOf, unlockCommand, whyUnclean, worktreesOf } from "../sweep-trees.js";
14
13
  import { lockVerdict } from "../worktree-lock.js";
15
14
  export const summary = "removes Landed branches and clean worktrees whose work has landed, names what it keeps";
16
15
  const USAGE = `Usage: dw-sdlc sweep [--dry-run] [--json]
@@ -26,11 +25,15 @@ config, else the branch origin/HEAD names) and removes, never forcing:
26
25
  It keeps, and names with the reason and the command to run yourself:
27
26
  a worktree that fails the set: the files that hold it with the forced removal, the paused operation, the
28
27
  lock's reason with git worktree unlock, a live Claude Code lock named by its pid, a HEAD that is not Landed;
29
- the default branch, the base branch, a protected branch and a branch a kept worktree has checked out; a
28
+ the default branch, the base branch, a protected branch and a branch a kept worktree has checked out or a
29
+ rebase paused there works on; a
30
30
  Landed branch the main checkout has checked out, clean and with nothing paused, with git switch to the base;
31
31
  a branch whose work is on no commit of the base, with the forced delete.
32
+ It reads each worktree again just before it goes, its Worker stopped: one whose HEAD, branch or lock changed
33
+ since it was judged, or that holds a file or a paused operation by then, stays. A branch goes only while it
34
+ points at the commit judged Landed; one that moved since stays.
32
35
  It never changes anything on a remote: the fetch only writes remote-tracking refs. A removal git refuses is
33
- kept with git's words, and the sweep goes on.
36
+ kept with git's words, one that outlives its time limit with the limit it hit, and the sweep goes on.
34
37
 
35
38
  In herdr ($HC_TERMINAL_ID set) it reads, with hc, the Workers of this session, the terminals whose parent is
36
39
  this one, and every other live terminal. A Worker whose status is none of working, blocked or yet to report
@@ -50,24 +53,13 @@ ends the Sweep with exit 2 before anything is removed.
50
53
 
51
54
  Exit 0 nothing is kept but the default, the base or a protected branch, 1 something else is, 2 when it
52
55
  cannot run: in a linked worktree, with no base to read, when the fetch fails or when hc fails in herdr.`;
53
- function forcedDelete(branch) {
54
- return shellLine(["git", "branch", "-D", branch]);
55
- }
56
- /** The forced removal; git wants --force twice for a worktree still locked. */
57
- function forcedRemoval(path, locked = false) {
58
- return shellLine(["git", "worktree", "remove", "--force", ...(locked ? ["--force"] : []), path]);
59
- }
60
56
  function fetchOrigin(root) {
61
57
  if (asked(root, ["remote", "get-url", "origin"]) === undefined) {
62
58
  return;
63
59
  }
64
- const result = exec("git", ["-C", root, "fetch", "--quiet", "origin"], {
65
- env: repositoryFreeEnv(),
66
- limitMs: SLOW_LIMIT_MS
67
- });
68
- if (result.status !== 0) {
69
- const said = result.stderr.split("\n").find((line) => line.trim() !== "");
70
- throw new CannotRun(`git fetch origin failed: ${said?.trim() ?? `exited ${result.status}`}`, "check the network, then call again");
60
+ const fetched = git(root, ["fetch", "--quiet", "origin"], { limitMs: SLOW_LIMIT_MS });
61
+ if ("error" in fetched) {
62
+ throw new CannotRun(`git fetch origin failed: ${fetched.said}`, "check the network, then call again");
71
63
  }
72
64
  }
73
65
  /** The branch refs/remotes/origin/HEAD names, if it names one of origin. */
@@ -106,22 +98,12 @@ function whyTreeStays(root, baseRef, tree, lock, holders) {
106
98
  const prune = shellLine(["git", "worktree", "prune"]);
107
99
  return {
108
100
  reason: "its folder is gone",
109
- command: lock.state === "stale" ? `${shellLine(["git", "worktree", "unlock", path])} && ${prune}` : prune
101
+ command: lock.state === "stale" ? `${unlockCommand(path)} && ${prune}` : prune
110
102
  };
111
103
  }
112
- const operation = pausedOperation(path);
113
- if (operation !== undefined) {
114
- return { reason: `a ${operation} is paused in it`, command: null };
115
- }
116
- const files = dirtyFiles(path);
117
- if ("error" in files) {
118
- return { reason: files.error, command: null };
119
- }
120
- if (files.length > 0) {
121
- return {
122
- reason: `modified or untracked: ${files.join(", ")}`,
123
- command: forcedRemoval(path, lock.state === "stale")
124
- };
104
+ const unclean = whyUnclean(path, lock.state === "stale");
105
+ if (unclean !== null) {
106
+ return unclean;
125
107
  }
126
108
  if (!isCommitLanded(root, baseRef, tree.head)) {
127
109
  const at = tree.branch === undefined ? `its detached HEAD ${tree.head.slice(0, 9)}` : `its HEAD on ${tree.branch}`;
@@ -144,8 +126,8 @@ function stays(branch, { head, baseBranch, protectedBranches, held }) {
144
126
  if (protectedBranches.includes(branch)) {
145
127
  return standing("a protected branch");
146
128
  }
147
- const path = held.get(branch);
148
- return path === undefined ? null : { reason: `checked out in the worktree at ${path}`, command: null, dev: true };
129
+ const hold = held.get(branch);
130
+ return hold === undefined ? null : { reason: hold.reason, command: null, dev: true };
149
131
  }
150
132
  /** Fetches origin, then sorts each linked worktree, local branch and Worker's terminal into what goes and what stays. */
151
133
  function judge(root, terminals) {
@@ -170,9 +152,10 @@ function judge(root, terminals) {
170
152
  });
171
153
  const staying = verdicts.filter(({ why }) => why !== null).map(({ tree }) => tree);
172
154
  const held = new Map();
173
- for (const { path, branch } of [checkout, ...staying]) {
174
- if (branch !== undefined) {
175
- held.set(branch, path);
155
+ for (const tree of [checkout, ...staying]) {
156
+ const hold = heldBranch(tree);
157
+ if (hold !== undefined) {
158
+ held.set(hold.branch, { path: tree.path, reason: hold.reason });
176
159
  }
177
160
  }
178
161
  const footing = {
@@ -190,12 +173,15 @@ function judge(root, terminals) {
190
173
  }
191
174
  judged.add(name);
192
175
  const why = stays(name, footing);
193
- const off = why?.dev === true && held.get(name) === checkout.path ? switchOff(root, base, name) : null;
176
+ const off = why?.dev === true && held.get(name)?.path === checkout.path ? switchOff(root, base, name) : null;
194
177
  if (why !== null) {
195
178
  kept.push({ kind: "branch", name, ...why, ...off });
179
+ return;
196
180
  }
197
- else if (isLanded(root, base.ref, name)) {
198
- removed.push({ kind: "branch", name, reason: `Landed on ${baseName}` });
181
+ // The tip read once, so the branch goes only while it still points at the commit judged Landed.
182
+ const tip = tipOf(root, name);
183
+ if (tip !== undefined && isCommitLanded(root, base.ref, tip)) {
184
+ removed.push({ kind: "branch", name, reason: `Landed on ${baseName}`, at: tip });
199
185
  }
200
186
  else {
201
187
  const reason = `its work is on no commit of ${baseName} since it forked`;
@@ -221,6 +207,7 @@ function judge(root, terminals) {
221
207
  kind: "worktree",
222
208
  name: tree.path,
223
209
  reason,
210
+ at: tree.head,
224
211
  ...(tree.branch === undefined ? {} : { holds: tree.branch }),
225
212
  ...(lock.state === "stale" ? { stale: lock } : {}),
226
213
  ...(workerLock === undefined ? {} : { workerLock })
@@ -232,86 +219,6 @@ function judge(root, terminals) {
232
219
  branchesOf(root).forEach(judgeBranch);
233
220
  return { base: baseName, removed, kept };
234
221
  }
235
- /** Removes `item`; the item kept when git refuses it, with git's words, or undefined once it is gone. */
236
- function removal(root, item, heldBy, unstopped) {
237
- const refused = (reason, command) => ({
238
- kind: item.kind,
239
- name: item.name,
240
- reason,
241
- command,
242
- dev: true
243
- });
244
- if (item.kind === "terminal") {
245
- const failed = removeTerminal(item);
246
- if (failed === undefined) {
247
- return undefined;
248
- }
249
- if (item.tree !== undefined) {
250
- unstopped.set(item.tree, `its Worker's terminal ${item.name} stays: ${failed}`);
251
- }
252
- return refused(failed, byHand(item));
253
- }
254
- if (item.kind === "worktree") {
255
- const refusedWith = (reason, command) => {
256
- if (item.holds !== undefined) {
257
- heldBy.set(item.holds, item.name);
258
- }
259
- return refused(reason, command);
260
- };
261
- const unstoppable = unstopped.get(item.name);
262
- if (unstoppable !== undefined) {
263
- return refusedWith(unstoppable, null);
264
- }
265
- if (item.workerLock !== undefined) {
266
- const now = lockAfterStop(root, item.name);
267
- if (now.state === "stays") {
268
- return refusedWith(`its Worker's terminal was stopped, but ${now.reason}`, now.command);
269
- }
270
- if (now.state === "stale") {
271
- item.stale = now;
272
- }
273
- delete item.workerLock;
274
- }
275
- if (item.stale !== undefined) {
276
- const lifted = git(root, ["worktree", "unlock", "--", item.name]);
277
- if ("error" in lifted) {
278
- return refusedWith(lifted.error, shellLine(["git", "worktree", "unlock", item.name]));
279
- }
280
- }
281
- const result = git(root, ["worktree", "remove", "--", item.name]);
282
- if (!("error" in result)) {
283
- return undefined;
284
- }
285
- const relocked = item.stale === undefined
286
- ? undefined
287
- : git(root, ["worktree", "lock", "--reason", item.stale.held, "--", item.name]);
288
- const lost = relocked !== undefined && "error" in relocked ? `; its lock could not be put back: ${relocked.error}` : "";
289
- return refusedWith(`${result.error}${lost}`, forcedRemoval(item.name, item.stale !== undefined));
290
- }
291
- const path = heldBy.get(item.name);
292
- if (path !== undefined) {
293
- return refused(`checked out in the worktree at ${path}`, null);
294
- }
295
- const result = git(root, ["branch", "-D", "--", item.name]);
296
- return "error" in result ? refused(result.error, forcedDelete(item.name)) : undefined;
297
- }
298
- /** Removes each item judged to go, in order; a refused worktree keeps the branch it holds as well. */
299
- function apply({ base, removed, kept }, root) {
300
- const gone = [];
301
- const refused = [];
302
- const heldBy = new Map();
303
- const unstopped = new Map();
304
- for (const item of removed) {
305
- const stayed = removal(root, item, heldBy, unstopped);
306
- if (stayed === undefined) {
307
- gone.push(item);
308
- }
309
- else {
310
- refused.push(stayed);
311
- }
312
- }
313
- return { base, removed: gone, kept: [...kept, ...refused] };
314
- }
315
222
  /** The removed items as the Sweep tells them: a stale lock lifted comes just before its worktree. */
316
223
  function shownOf(removed) {
317
224
  return removed.flatMap(({ kind, name, reason, stale, workerLock }) => {
@@ -340,24 +247,19 @@ function linesOf({ dryRun, terminalsRead, removed, kept }) {
340
247
  return [...(out.length === 0 ? ["nothing to sweep"] : out), ...note].join("\n");
341
248
  }
342
249
  function parseCli(argv) {
343
- try {
344
- const { values, positionals } = parseArgs({
345
- args: argv,
346
- allowPositionals: true,
347
- options: {
348
- help: { type: "boolean", short: "h" },
349
- json: { type: "boolean" },
350
- "dry-run": { type: "boolean" }
351
- }
352
- });
353
- if (positionals.length > 0 && values.help !== true) {
354
- return { error: `it takes no argument, not ${JSON.stringify(positionals[0])}` };
250
+ const { values, positionals } = readArgs({
251
+ args: argv,
252
+ allowPositionals: true,
253
+ options: {
254
+ help: { type: "boolean", short: "h" },
255
+ json: { type: "boolean" },
256
+ "dry-run": { type: "boolean" }
355
257
  }
356
- return { help: values.help === true, json: values.json === true, dryRun: values["dry-run"] === true };
357
- }
358
- catch (error) {
359
- return { error: error instanceof Error ? error.message : String(error) };
258
+ }, USAGE);
259
+ if (positionals.length > 0 && values.help !== true) {
260
+ throw usageError(`it takes no argument, not ${JSON.stringify(positionals[0])}`, USAGE);
360
261
  }
262
+ return { help: values.help === true, json: values.json === true, dryRun: values["dry-run"] === true };
361
263
  }
362
264
  function sweep(json, dryRun) {
363
265
  const root = findRepository(process.cwd());
@@ -384,22 +286,9 @@ function sweep(json, dryRun) {
384
286
  }
385
287
  export function run(argv) {
386
288
  const cli = parseCli(argv);
387
- if ("error" in cli) {
388
- process.stderr.write(`dw-sdlc sweep: ${cli.error}\n\n${USAGE}\n`);
389
- return 2;
390
- }
391
289
  if (cli.help) {
392
290
  process.stdout.write(`${USAGE}\n`);
393
291
  return 0;
394
292
  }
395
- try {
396
- return sweep(cli.json, cli.dryRun);
397
- }
398
- catch (error) {
399
- if (error instanceof CannotRun) {
400
- process.stderr.write(`dw-sdlc sweep: ${error.message} — ${error.remedy}\n`);
401
- return 2;
402
- }
403
- throw error;
404
- }
293
+ return sweep(cli.json, cli.dryRun);
405
294
  }
@@ -1,6 +1,6 @@
1
1
  // `dw-sdlc worker ls`: the Workers the calling Lead started, with the state herdr holds them in.
2
- import { parseArgs } from "node:util";
3
- import { describe, lead, refuse, table, workersOf, workingFolders } from "../worker/herdr.js";
2
+ import { readArgs } from "../cannot-run.js";
3
+ import { lead, table, workersOf, workingFolders } from "../worker/herdr.js";
4
4
  export const summary = "list the Workers this Lead started and their states";
5
5
  const USAGE = `Usage: dw-sdlc worker ls [--json]
6
6
 
@@ -17,37 +17,21 @@ with --json.
17
17
 
18
18
  Exit 0 listed, 2 when it cannot run.`;
19
19
  export function run(argv) {
20
- let json;
21
- try {
22
- const { values } = parseArgs({
23
- args: argv,
24
- options: { help: { type: "boolean", short: "h" }, json: { type: "boolean" } }
25
- });
26
- if (values.help === true) {
27
- process.stdout.write(`${USAGE}\n`);
28
- return 0;
29
- }
30
- json = values.json === true;
20
+ const { values } = readArgs({ args: argv, options: { help: { type: "boolean", short: "h" }, json: { type: "boolean" } } }, USAGE);
21
+ if (values.help === true) {
22
+ process.stdout.write(`${USAGE}\n`);
23
+ return 0;
31
24
  }
32
- catch (error) {
33
- process.stderr.write(`dw-sdlc worker ls: ${describe(error)}\n\n${USAGE}\n`);
34
- return 2;
25
+ const workers = workingFolders(workersOf(lead(), true));
26
+ if (values.json === true) {
27
+ process.stdout.write(`${JSON.stringify(workers, null, 2)}\n`);
35
28
  }
36
- try {
37
- const workers = workingFolders(workersOf(lead(), true));
38
- if (json) {
39
- process.stdout.write(`${JSON.stringify(workers, null, 2)}\n`);
40
- }
41
- else if (workers.length === 0) {
42
- process.stdout.write("no Workers\n");
43
- }
44
- else {
45
- const rows = workers.map((worker) => [worker.name, worker.address, worker.state, worker.status, worker.folder]);
46
- process.stdout.write(`${table([["NAME", "ADDRESS", "STATE", "STATUS", "FOLDER"], ...rows])}\n`);
47
- }
48
- return 0;
29
+ else if (workers.length === 0) {
30
+ process.stdout.write("no Workers\n");
49
31
  }
50
- catch (error) {
51
- return refuse("worker ls", error);
32
+ else {
33
+ const rows = workers.map((worker) => [worker.name, worker.address, worker.state, worker.status, worker.folder]);
34
+ process.stdout.write(`${table([["NAME", "ADDRESS", "STATE", "STATUS", "FOLDER"], ...rows])}\n`);
52
35
  }
36
+ return 0;
53
37
  }