dw-sdlc 0.20.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.
- package/README.md +13 -13
- package/config/agentic.config.schema.json +1 -1
- package/dist/agentic-config.d.ts +16 -0
- package/dist/agentic-config.js +28 -23
- package/dist/cannot-run.d.ts +27 -0
- package/dist/cannot-run.js +48 -0
- package/dist/catalogue.js +2 -1
- package/dist/checks/agents-map.js +7 -8
- package/dist/checks/check.d.ts +10 -8
- package/dist/checks/check.js +9 -7
- package/dist/checks/decision-records.js +3 -2
- package/dist/checks/file-size-ratchet.js +13 -17
- package/dist/checks/index.d.ts +6 -5
- package/dist/checks/index.js +10 -9
- package/dist/checks/markdown-refs.js +58 -6
- package/dist/checks/primitives.js +5 -5
- package/dist/checks/routed-docs.js +1 -3
- package/dist/checks/skill-sections.js +12 -20
- package/dist/checks/tree.js +7 -7
- package/dist/cli.d.ts +2 -1
- package/dist/cli.js +26 -5
- package/dist/commands/check.js +27 -42
- package/dist/commands/config.js +22 -27
- package/dist/commands/doctor.d.ts +2 -0
- package/dist/commands/doctor.js +11 -18
- package/dist/commands/gate.js +35 -40
- package/dist/commands/lead-start.js +28 -42
- package/dist/commands/pr-reviewed.js +41 -59
- package/dist/commands/pr-ship.js +72 -87
- package/dist/commands/primitives.js +38 -40
- package/dist/commands/recap.js +23 -74
- package/dist/commands/references-setup.js +28 -76
- package/dist/commands/references-sync.js +29 -53
- package/dist/commands/setup.d.ts +2 -7
- package/dist/commands/setup.js +38 -90
- package/dist/commands/sweep.js +46 -157
- package/dist/commands/worker-ls.js +15 -31
- package/dist/commands/worker-report.js +36 -52
- package/dist/commands/worker-start.js +43 -57
- package/dist/drift.js +2 -1
- package/dist/exec.d.ts +20 -0
- package/dist/exec.js +37 -4
- package/dist/gate-cache.js +2 -1
- package/dist/git.d.ts +32 -0
- package/dist/git.js +60 -0
- package/dist/json-schema.js +1 -3
- package/dist/landed.js +11 -22
- package/dist/paused-operation.d.ts +2 -0
- package/dist/paused-operation.js +17 -0
- package/dist/pins.js +2 -1
- package/dist/pr/decide.d.ts +4 -16
- package/dist/pr/decide.js +48 -83
- package/dist/pr/delta.d.ts +3 -4
- package/dist/pr/delta.js +11 -20
- package/dist/pr/read.d.ts +27 -30
- package/dist/pr/read.js +96 -158
- package/dist/pr/reply.d.ts +34 -0
- package/dist/pr/reply.js +112 -0
- package/dist/pr/route.d.ts +11 -3
- package/dist/pr/route.js +5 -5
- package/dist/pr/standing.d.ts +138 -0
- package/dist/pr/standing.js +181 -0
- package/dist/pr/state.d.ts +14 -69
- package/dist/pr/state.js +75 -148
- package/dist/pr/upstream.d.ts +1 -6
- package/dist/pr/upstream.js +2 -2
- package/dist/primitives-diff.js +1 -1
- package/dist/recap-next.js +34 -47
- package/dist/recap-primitives.js +1 -1
- package/dist/references-key.d.ts +0 -1
- package/dist/references-key.js +3 -4
- package/dist/run-command.d.ts +4 -1
- package/dist/run-command.js +32 -11
- package/dist/setup-gate.d.ts +1 -7
- package/dist/setup-rules.js +2 -6
- package/dist/setup-writer.d.ts +32 -0
- package/dist/setup-writer.js +60 -0
- package/dist/shell-line.d.ts +2 -0
- package/dist/shell-line.js +5 -0
- package/dist/sweep-removal.d.ts +31 -0
- package/dist/sweep-removal.js +133 -0
- package/dist/sweep-terminals.d.ts +1 -1
- package/dist/sweep-terminals.js +21 -57
- package/dist/sweep-trees.d.ts +19 -3
- package/dist/sweep-trees.js +67 -31
- package/dist/worker/herdr.d.ts +67 -23
- package/dist/worker/herdr.js +126 -77
- package/dist/worker/launch.js +32 -45
- package/dist/worker/origin.d.ts +1 -1
- package/dist/worker/origin.js +9 -11
- package/dist/worker/tabs.js +11 -7
- package/dist/working-tree.d.ts +0 -8
- package/dist/working-tree.js +6 -22
- package/dist/worktree-lock.js +1 -1
- package/package.json +1 -1
- package/dist/git-env.d.ts +0 -2
- package/dist/git-env.js +0 -10
package/dist/commands/setup.js
CHANGED
|
@@ -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,
|
|
4
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
|
-
import {
|
|
6
|
+
import { join, relative } from "node:path";
|
|
7
7
|
import { pathToFileURL } from "node:url";
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
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
|
|
244
|
-
|
|
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 } =
|
|
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.
|
|
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.
|
|
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.
|
|
244
|
+
writer.apply(excludeLabel, excludePath, { text: excludeText });
|
|
287
245
|
}
|
|
288
246
|
else {
|
|
289
247
|
const separator = excludeText === "" || excludeText.endsWith("\n") ? "" : "\n";
|
|
290
|
-
writer.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
396
|
-
|
|
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
|
}
|
package/dist/commands/sweep.js
CHANGED
|
@@ -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 {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
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
|
|
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
|
|
65
|
-
|
|
66
|
-
|
|
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" ? `${
|
|
101
|
+
command: lock.state === "stale" ? `${unlockCommand(path)} && ${prune}` : prune
|
|
110
102
|
};
|
|
111
103
|
}
|
|
112
|
-
const
|
|
113
|
-
if (
|
|
114
|
-
return
|
|
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
|
|
148
|
-
return
|
|
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
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
198
|
-
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
-
|
|
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 {
|
|
3
|
-
import {
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
37
|
-
|
|
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
|
-
|
|
51
|
-
|
|
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
|
}
|