dw-sdlc 0.12.0 → 0.13.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.
@@ -0,0 +1,403 @@
1
+ // `dw-sdlc sweep`: removes each linked worktree whose whole set passes and each Landed local branch.
2
+ // docs/adr/0015 sets its bounds: it runs from the main checkout, never forces and never writes to a remote.
3
+ import { spawnSync } from "node:child_process";
4
+ import { existsSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { parseArgs } from "node:util";
7
+ import { findRepository, mainCheckout, resolveConfig } from "../agentic-config.js";
8
+ import { repositoryFreeEnv } from "../git-env.js";
9
+ import { isCommitLanded, isLanded } from "../landed.js";
10
+ import { shellLine } from "../pr/decide.js";
11
+ import { CannotRun } from "../pr/read.js";
12
+ import { asked, git } from "../working-tree.js";
13
+ import { lockVerdict } from "../worktree-lock.js";
14
+ export const summary = "removes Landed branches and clean worktrees whose work has landed, names what it keeps";
15
+ const USAGE = `Usage: dw-sdlc sweep [--dry-run] [--json]
16
+
17
+ Run from the main checkout. Fetches origin, reads the base branch as the guards do (baseBranch of the
18
+ config, else the branch origin/HEAD names) and removes, never forcing:
19
+ every linked worktree, wherever it lies, whose whole set passes: no modified or untracked file (ignored
20
+ files aside), no paused rebase, merge, cherry-pick, revert or bisect, no lock but a stale Claude Code
21
+ one, and its HEAD Landed, on a branch or detached; the branch it had checked out goes right after it. A
22
+ Claude Code lock, "claude session <name> (pid <N> start <time>)", is stale when no process N runs or N
23
+ started at another time; it is lifted just before its worktree goes, and lifted for no worktree that stays;
24
+ every other local branch that is Landed: its work is on the base, as a squash merge leaves it.
25
+ It keeps, and names with the reason and the command to run yourself:
26
+ a worktree that fails the set: the files that hold it with the forced removal, the paused operation, the
27
+ lock's reason with git worktree unlock, a live Claude Code lock named by its pid, a HEAD that is not Landed;
28
+ the default branch, the base branch, a protected branch and a branch a kept worktree has checked out;
29
+ a branch whose work is on no commit of the base, with the forced delete.
30
+ It never changes anything on a remote: the fetch only writes remote-tracking refs. A removal git refuses is
31
+ kept with git's words, and the sweep goes on.
32
+
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.
36
+
37
+ 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
+ }
47
+ function forcedDelete(branch) {
48
+ return shellLine(["git", "branch", "-D", branch]);
49
+ }
50
+ /** The forced removal; git wants --force twice for a worktree still locked. */
51
+ function forcedRemoval(path, locked = false) {
52
+ return shellLine(["git", "worktree", "remove", "--force", ...(locked ? ["--force"] : []), path]);
53
+ }
54
+ function fetchOrigin(root) {
55
+ if (asked(root, ["remote", "get-url", "origin"]) === undefined) {
56
+ return;
57
+ }
58
+ const env = { ...repositoryFreeEnv(), GIT_TERMINAL_PROMPT: "0" };
59
+ const result = spawnSync("git", ["-C", root, "fetch", "--quiet", "origin"], { encoding: "utf8", env, stdio: "pipe" });
60
+ if (result.status !== 0) {
61
+ const said = result.stderr.split("\n").find((line) => line.trim() !== "");
62
+ throw new CannotRun(`git fetch origin failed: ${said?.trim() ?? `exited ${result.status}`}`, "check the network, then call again");
63
+ }
64
+ }
65
+ /** The branch refs/remotes/origin/HEAD names, if it names one of origin. */
66
+ function originHead(root) {
67
+ const prefix = "refs/remotes/origin/";
68
+ const ref = asked(root, ["symbolic-ref", "-q", "refs/remotes/origin/HEAD"]);
69
+ return ref !== undefined && ref.startsWith(prefix) && ref.length > prefix.length
70
+ ? ref.slice(prefix.length)
71
+ : undefined;
72
+ }
73
+ /** The base branch the guard reads: `baseBranch` of the config, under `auto` the branch origin/HEAD names. */
74
+ function baseOf(root, head) {
75
+ const configured = resolveConfig(root).config.baseBranch;
76
+ const name = configured === "" || configured === "auto" ? head : configured;
77
+ if (name === undefined) {
78
+ throw new CannotRun("no base branch is known to check the branches against: origin/HEAD names no branch of origin", "run git remote set-head origin --auto, or set baseBranch in .ai/agentic.config.json");
79
+ }
80
+ const ref = `refs/remotes/origin/${name}`;
81
+ if (asked(root, ["check-ref-format", ref]) === undefined ||
82
+ asked(root, ["rev-parse", "--verify", "-q", `${ref}^{commit}`]) === undefined) {
83
+ throw new CannotRun(`the base branch origin/${name} is not here to check the branches against`, "run git fetch origin, or fix baseBranch in .ai/agentic.config.json");
84
+ }
85
+ return { name, ref };
86
+ }
87
+ function branchesOf(root) {
88
+ return must(root, ["for-each-ref", "--format=%(refname:lstrip=2)", "refs/heads"])
89
+ .split("\n")
90
+ .filter((name) => name !== "");
91
+ }
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
+ /** 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) {
150
+ const { path } = tree;
151
+ if (lock.state === "stays") {
152
+ return lock;
153
+ }
154
+ if (tree.prunable || !existsSync(path)) {
155
+ const prune = shellLine(["git", "worktree", "prune"]);
156
+ return {
157
+ reason: "its folder is gone",
158
+ command: lock.state === "stale" ? `${shellLine(["git", "worktree", "unlock", path])} && ${prune}` : prune
159
+ };
160
+ }
161
+ const operation = pausedOperation(path);
162
+ if (operation !== undefined) {
163
+ return { reason: `a ${operation} is paused in it`, command: null };
164
+ }
165
+ const files = dirtyFiles(path);
166
+ if ("error" in files) {
167
+ return { reason: files.error, command: null };
168
+ }
169
+ if (files.length > 0) {
170
+ return {
171
+ reason: `modified or untracked: ${files.join(", ")}`,
172
+ command: forcedRemoval(path, lock.state === "stale")
173
+ };
174
+ }
175
+ if (!isCommitLanded(root, baseRef, tree.head)) {
176
+ const at = tree.branch === undefined ? `its detached HEAD ${tree.head.slice(0, 9)}` : `its HEAD on ${tree.branch}`;
177
+ return { reason: `${at} is on no commit of ${baseRef.replace("refs/remotes/", "")} since it forked`, command: null };
178
+ }
179
+ return null;
180
+ }
181
+ /** A branch kept for what it is, never the dev's to decide. */
182
+ function standing(reason) {
183
+ return { reason, command: null, dev: false };
184
+ }
185
+ /** Why `branch` stays whatever its work, or null when only its work decides. */
186
+ function stays(branch, { head, baseBranch, protectedBranches, held }) {
187
+ if (branch === head) {
188
+ return standing("the default branch");
189
+ }
190
+ if (branch === baseBranch) {
191
+ return standing("the base branch");
192
+ }
193
+ if (protectedBranches.includes(branch)) {
194
+ return standing("a protected branch");
195
+ }
196
+ const path = held.get(branch);
197
+ return path === undefined ? null : { reason: `checked out in the worktree at ${path}`, command: null, dev: true };
198
+ }
199
+ /** Fetches origin, then sorts each linked worktree and local branch into what goes and what stays. */
200
+ function judge(root) {
201
+ fetchOrigin(root);
202
+ const head = originHead(root);
203
+ const base = baseOf(root, head);
204
+ const baseName = `origin/${base.name}`;
205
+ const [checkout, ...linked] = worktreesOf(root);
206
+ if (checkout === undefined) {
207
+ throw new CannotRun("git lists no worktree", "check the repository with git worktree list");
208
+ }
209
+ const verdicts = linked.map((tree) => {
210
+ const lock = lockVerdict(tree.path, tree.lock);
211
+ return { tree, lock, why: whyTreeStays(root, base.ref, tree, lock) };
212
+ });
213
+ const staying = verdicts.filter(({ why }) => why !== null).map(({ tree }) => tree);
214
+ const held = new Map();
215
+ for (const { path, branch } of [checkout, ...staying]) {
216
+ if (branch !== undefined) {
217
+ held.set(branch, path);
218
+ }
219
+ }
220
+ const footing = {
221
+ head,
222
+ baseBranch: base.name,
223
+ protectedBranches: resolveConfig(root).config.guardrails.protectedBranches,
224
+ held
225
+ };
226
+ const removed = [];
227
+ const kept = [];
228
+ const judged = new Set();
229
+ const judgeBranch = (name) => {
230
+ if (judged.has(name)) {
231
+ return;
232
+ }
233
+ judged.add(name);
234
+ const why = stays(name, footing);
235
+ if (why !== null) {
236
+ kept.push({ kind: "branch", name, ...why });
237
+ }
238
+ else if (isLanded(root, base.ref, name)) {
239
+ removed.push({ kind: "branch", name, reason: `Landed on ${baseName}` });
240
+ }
241
+ else {
242
+ const reason = `its work is on no commit of ${baseName} since it forked`;
243
+ kept.push({ kind: "branch", name, reason, command: forcedDelete(name), dev: true });
244
+ }
245
+ };
246
+ for (const { tree, lock, why } of verdicts) {
247
+ if (why !== null) {
248
+ kept.push({ kind: "worktree", name: tree.path, ...why, dev: true });
249
+ continue;
250
+ }
251
+ const reason = `clean, its HEAD is Landed on ${baseName}`;
252
+ removed.push({
253
+ kind: "worktree",
254
+ name: tree.path,
255
+ reason,
256
+ ...(tree.branch === undefined ? {} : { holds: tree.branch }),
257
+ ...(lock.state === "stale" ? { stale: lock } : {})
258
+ });
259
+ if (tree.branch !== undefined) {
260
+ judgeBranch(tree.branch);
261
+ }
262
+ }
263
+ branchesOf(root).forEach(judgeBranch);
264
+ return { base: baseName, removed, kept };
265
+ }
266
+ /** 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) {
268
+ const refused = (reason, command) => ({
269
+ kind: item.kind,
270
+ name: item.name,
271
+ reason,
272
+ command,
273
+ dev: true
274
+ });
275
+ if (item.kind === "worktree") {
276
+ const refusedWith = (reason, command) => {
277
+ if (item.holds !== undefined) {
278
+ heldBy.set(item.holds, item.name);
279
+ }
280
+ return refused(reason, command);
281
+ };
282
+ if (item.stale !== undefined) {
283
+ const lifted = git(root, ["worktree", "unlock", "--", item.name]);
284
+ if ("error" in lifted) {
285
+ return refusedWith(lifted.error, shellLine(["git", "worktree", "unlock", item.name]));
286
+ }
287
+ }
288
+ const result = git(root, ["worktree", "remove", "--", item.name]);
289
+ if (!("error" in result)) {
290
+ return undefined;
291
+ }
292
+ const relocked = item.stale === undefined
293
+ ? undefined
294
+ : git(root, ["worktree", "lock", "--reason", item.stale.held, "--", item.name]);
295
+ const lost = relocked !== undefined && "error" in relocked ? `; its lock could not be put back: ${relocked.error}` : "";
296
+ return refusedWith(`${result.error}${lost}`, forcedRemoval(item.name, item.stale !== undefined));
297
+ }
298
+ const path = heldBy.get(item.name);
299
+ if (path !== undefined) {
300
+ return refused(`checked out in the worktree at ${path}`, null);
301
+ }
302
+ const result = git(root, ["branch", "-D", "--", item.name]);
303
+ return "error" in result ? refused(result.error, forcedDelete(item.name)) : undefined;
304
+ }
305
+ /** Removes each item judged to go, in order; a refused worktree keeps the branch it holds as well. */
306
+ function apply({ base, removed, kept }, root) {
307
+ const gone = [];
308
+ const refused = [];
309
+ const heldBy = new Map();
310
+ for (const item of removed) {
311
+ const stayed = removal(root, item, heldBy);
312
+ if (stayed === undefined) {
313
+ gone.push(item);
314
+ }
315
+ else {
316
+ refused.push(stayed);
317
+ }
318
+ }
319
+ return { base, removed: gone, kept: [...kept, ...refused] };
320
+ }
321
+ /** The removed items as the Sweep tells them: a stale lock lifted comes just before its worktree. */
322
+ function shownOf(removed) {
323
+ return removed.flatMap(({ kind, name, reason, stale }) => {
324
+ const item = { kind, name, reason };
325
+ return stale === undefined ? [item] : [{ kind: "lock", name, reason: stale.why }, item];
326
+ });
327
+ }
328
+ function verbOf(kind, dryRun) {
329
+ if (kind === "lock") {
330
+ return dryRun ? "would lift" : "lifted";
331
+ }
332
+ return dryRun ? "would remove" : "removed";
333
+ }
334
+ function linesOf({ dryRun, removed, kept }) {
335
+ const out = [
336
+ ...removed.map(({ kind, name, reason }) => `${verbOf(kind, dryRun)} ${kind} ${name} — ${reason}`),
337
+ ...kept.map(({ kind, name, reason, command }) => {
338
+ return `kept ${kind} ${name} — ${reason}${command === null || command === undefined ? "" : `; to remove it yourself: ${command}`}`;
339
+ })
340
+ ];
341
+ return out.length === 0 ? "nothing to sweep" : out.join("\n");
342
+ }
343
+ function parseCli(argv) {
344
+ try {
345
+ const { values, positionals } = parseArgs({
346
+ args: argv,
347
+ allowPositionals: true,
348
+ options: {
349
+ help: { type: "boolean", short: "h" },
350
+ json: { type: "boolean" },
351
+ "dry-run": { type: "boolean" }
352
+ }
353
+ });
354
+ if (positionals.length > 0 && values.help !== true) {
355
+ return { error: `it takes no argument, not ${JSON.stringify(positionals[0])}` };
356
+ }
357
+ return { help: values.help === true, json: values.json === true, dryRun: values["dry-run"] === true };
358
+ }
359
+ catch (error) {
360
+ return { error: error instanceof Error ? error.message : String(error) };
361
+ }
362
+ }
363
+ function sweep(json, dryRun) {
364
+ const root = findRepository(process.cwd());
365
+ if (root === undefined) {
366
+ throw new CannotRun(`no git repository holds ${process.cwd()}`, "run it from the main checkout");
367
+ }
368
+ const main = mainCheckout(root);
369
+ if (main !== undefined) {
370
+ throw new CannotRun(`${root} is a linked worktree`, `run it from the main checkout: ${shellLine(["cd", main])} && dw-sdlc sweep`);
371
+ }
372
+ const judged = judge(root);
373
+ const { base, removed, kept } = dryRun ? judged : apply(judged, root);
374
+ const done = {
375
+ dryRun,
376
+ base,
377
+ removed: shownOf(removed),
378
+ kept: kept.map(({ kind, name, reason, command }) => ({ kind, name, reason, command }))
379
+ };
380
+ process.stdout.write(`${json ? JSON.stringify(done, null, 2) : linesOf(done)}\n`);
381
+ return kept.some(({ dev }) => dev) ? 1 : 0;
382
+ }
383
+ export function run(argv) {
384
+ const cli = parseCli(argv);
385
+ if ("error" in cli) {
386
+ process.stderr.write(`dw-sdlc sweep: ${cli.error}\n\n${USAGE}\n`);
387
+ return 2;
388
+ }
389
+ if (cli.help) {
390
+ process.stdout.write(`${USAGE}\n`);
391
+ return 0;
392
+ }
393
+ try {
394
+ return sweep(cli.json, cli.dryRun);
395
+ }
396
+ catch (error) {
397
+ if (error instanceof CannotRun) {
398
+ process.stderr.write(`dw-sdlc sweep: ${error.message} — ${error.remedy}\n`);
399
+ return 2;
400
+ }
401
+ throw error;
402
+ }
403
+ }
@@ -0,0 +1,7 @@
1
+ /** Whether the work of the local `branch` is on `baseRef`; see `isCommitLanded`. */
2
+ export declare function isLanded(root: string, baseRef: string, branch: string): boolean;
3
+ /**
4
+ * Whether the work of the commit `tip` is on `baseRef`: it is on it, or a base commit since the fork holds
5
+ * its tree or the verbatim patch-id of its whole diff, as a squash merge leaves them.
6
+ */
7
+ export declare function isCommitLanded(root: string, baseRef: string, tip: string): boolean;
package/dist/landed.js ADDED
@@ -0,0 +1,86 @@
1
+ // Whether a local branch is Landed: its work is on the base, as the guard's `guard_landed`
2
+ // (plugins/sdlc/hooks/lib-git.sh) judges it. The guard keeps its own bash version; docs/adr/0015 says why there
3
+ // are two, and the shared table of scenarios, plugins/sdlc/test/landed-scenarios.ts, holds them to one meaning.
4
+ import { spawnSync } from "node:child_process";
5
+ import { repositoryFreeEnv } from "./git-env.js";
6
+ /** The base commits read, newest first: a branch that forked further back is kept rather than read for seconds. */
7
+ const SCAN = 5000;
8
+ /** Only local objects: a partial clone's missing blob is no reason to ask its remote. */
9
+ function localEnv() {
10
+ return { ...repositoryFreeEnv(), GIT_NO_LAZY_FETCH: "1" };
11
+ }
12
+ function git(root, args, input) {
13
+ const result = spawnSync("git", ["-C", root, ...args], {
14
+ encoding: "utf8",
15
+ env: localEnv(),
16
+ input,
17
+ maxBuffer: 64 * 1024 * 1024
18
+ });
19
+ return { status: result.status ?? 1, stdout: result.stdout ?? "" };
20
+ }
21
+ /** What `git <args>` prints, or undefined when it exits non-zero. */
22
+ function answer(root, args, input) {
23
+ const ran = git(root, args, input);
24
+ return ran.status === 0 ? ran.stdout.trim() : undefined;
25
+ }
26
+ /** The patch-id of the whole diff from `from` to `to`; undefined for an empty diff or one git could not finish. */
27
+ function patchIdOf(root, from, to) {
28
+ const diff = git(root, ["diff-tree", "-p", "--binary", "--no-renames", from, to]);
29
+ if (diff.status !== 0) {
30
+ return undefined;
31
+ }
32
+ const id = answer(root, ["patch-id", "--verbatim"], diff.stdout)?.split(" ")[0];
33
+ return id === undefined || id === "" ? undefined : id;
34
+ }
35
+ /** Whether a base commit in `range`, merges aside, has `patch` for the patch-id of its diff. */
36
+ function baseHoldsPatch(root, range, patch) {
37
+ // The diffs stream from git to git, never into this process: SCAN commits can print far past a buffer.
38
+ // pipefail: a diff cut short by a missing object fails the scan instead of naming part of the work.
39
+ const pipeline = [
40
+ "set -o pipefail",
41
+ [
42
+ 'git -C "$1" rev-list --no-merges --max-count="$2" "$3"',
43
+ 'git -C "$1" diff-tree --stdin -p --binary --no-renames 2>/dev/null',
44
+ "git patch-id --verbatim"
45
+ ].join(" | ")
46
+ ].join("; ");
47
+ const result = spawnSync("bash", ["-c", pipeline, "bash", root, String(SCAN), range], {
48
+ encoding: "utf8",
49
+ env: localEnv(),
50
+ maxBuffer: 64 * 1024 * 1024
51
+ });
52
+ if (result.status !== 0) {
53
+ return false;
54
+ }
55
+ return result.stdout.split("\n").some((line) => line.split(" ")[0] === patch);
56
+ }
57
+ /** Whether the work of the local `branch` is on `baseRef`; see `isCommitLanded`. */
58
+ export function isLanded(root, baseRef, branch) {
59
+ const ref = `refs/heads/${branch}`;
60
+ if (answer(root, ["check-ref-format", ref]) === undefined) {
61
+ return false;
62
+ }
63
+ const tip = answer(root, ["rev-parse", "--verify", "-q", `${ref}^{commit}`]);
64
+ return tip !== undefined && isCommitLanded(root, baseRef, tip);
65
+ }
66
+ /**
67
+ * Whether the work of the commit `tip` is on `baseRef`: it is on it, or a base commit since the fork holds
68
+ * its tree or the verbatim patch-id of its whole diff, as a squash merge leaves them.
69
+ */
70
+ export function isCommitLanded(root, baseRef, tip) {
71
+ if (git(root, ["merge-base", "--is-ancestor", tip, baseRef]).status === 0) {
72
+ return true;
73
+ }
74
+ const fork = answer(root, ["merge-base", tip, baseRef]);
75
+ const tree = answer(root, ["rev-parse", `${tip}^{tree}`]);
76
+ if (fork === undefined || tree === undefined || tree === answer(root, ["rev-parse", `${fork}^{tree}`])) {
77
+ return false;
78
+ }
79
+ const range = `${fork}..${baseRef}`;
80
+ const trees = answer(root, ["log", "--format=%T", `--max-count=${SCAN}`, range]) ?? "";
81
+ if (trees.split("\n").includes(tree)) {
82
+ return true;
83
+ }
84
+ const patch = patchIdOf(root, fork, tip);
85
+ return patch !== undefined && baseHoldsPatch(root, range, patch);
86
+ }
@@ -3,7 +3,7 @@ import { type PrState, REVIEW } from "./state.ts";
3
3
  export { REVIEW };
4
4
  export type Status = "act" | "wait" | "park" | "done";
5
5
  export type Actor = "script" | "agent" | "dev";
6
- export type Action = "done" | "stop" | "wait" | "fix-conflicts" | "update-branch" | "fix-ci" | "address-review" | "review" | "mark-reviewed" | "unverified" | "ci-stuck" | "awaiting-reply" | "mark-ready" | "awaiting-author" | "awaiting-approval" | "ready-to-merge" | "merge";
6
+ export type Action = "done" | "stop" | "wait" | "fix-conflicts" | "update-branch" | "fix-ci" | "address-review" | "review" | "review-limit" | "mark-reviewed" | "unverified" | "ci-stuck" | "awaiting-reply" | "mark-ready" | "awaiting-author" | "awaiting-approval" | "ready-to-merge" | "merge";
7
7
  /** The Actions the script performs itself, each one gh command. */
8
8
  export type ScriptAction = "update-branch" | "mark-ready" | "mark-reviewed" | "merge";
9
9
  /**
@@ -43,15 +43,19 @@ export type Answer = Blocker & {
43
43
  recheckAfter?: number;
44
44
  blockers: Blocker[];
45
45
  };
46
- /** Its one state: a failure or a pending status would turn the pull request unstable. */
46
+ /**
47
+ * The state of the review status a clean or skipped Review leaves; the read mark's is failure, on a
48
+ * head the fix then leaves behind. A pending status would turn the pull request unstable.
49
+ */
47
50
  export declare const REVIEW_STATE = "success";
48
51
  /** The words as one POSIX shell command line, each word that needs it single-quoted. */
49
52
  export declare function shellLine(words: string[]): string;
50
53
  /**
51
- * The gh arguments that post the `dw-sdlc/review` success on the head, `description` saying how it was
52
- * read. The path names the repository, so the call reads the same from any checkout.
54
+ * The gh arguments that post `dw-sdlc/review` on the head, a success unless `state` says otherwise,
55
+ * `description` saying how it was read. The path names the repository, so the call reads the same from
56
+ * any checkout.
53
57
  */
54
- export declare function reviewStatusArgs(pr: PrState, description: string): string[];
58
+ export declare function reviewStatusArgs(pr: PrState, description: string, state?: string): string[];
55
59
  export declare function isScriptAction(action: Action): action is ScriptAction;
56
60
  /**
57
61
  * The gh arguments of a script Action, `-R` naming the repository when `named`. The update merges the
package/dist/pr/decide.js CHANGED
@@ -12,9 +12,18 @@ export function declaredPolicy({ merge, review, reviewCommand }) {
12
12
  const GRACE_SECONDS = 120;
13
13
  /** How long CI may stay pending before it parks as stuck, from the first start among the pending checks. */
14
14
  const BUDGET_MINUTES = 40;
15
+ /**
16
+ * How many Reviews that each left a Finding open, with no success between them, the script lets run: the
17
+ * next call parks the pull request for the dev. A constant, like the depth rules, so no repository turns
18
+ * the loop back on.
19
+ */
20
+ const REVIEW_LIMIT = 3;
15
21
  /** The prefix of the contexts the script posts itself, as `dw-sdlc/review`: never CI. */
16
22
  const OWN_PREFIX = "dw-sdlc/";
17
- /** Its one state: a failure or a pending status would turn the pull request unstable. */
23
+ /**
24
+ * The state of the review status a clean or skipped Review leaves; the read mark's is failure, on a
25
+ * head the fix then leaves behind. A pending status would turn the pull request unstable.
26
+ */
18
27
  export const REVIEW_STATE = "success";
19
28
  /** The words as one POSIX shell command line, each word that needs it single-quoted. */
20
29
  export function shellLine(words) {
@@ -41,11 +50,12 @@ function listed({ id, path, line, author, body }) {
41
50
  return { id, path, line, author, body };
42
51
  }
43
52
  /**
44
- * The gh arguments that post the `dw-sdlc/review` success on the head, `description` saying how it was
45
- * read. The path names the repository, so the call reads the same from any checkout.
53
+ * The gh arguments that post `dw-sdlc/review` on the head, a success unless `state` says otherwise,
54
+ * `description` saying how it was read. The path names the repository, so the call reads the same from
55
+ * any checkout.
46
56
  */
47
- export function reviewStatusArgs(pr, description) {
48
- const fields = { state: REVIEW_STATE, context: REVIEW, target_url: pr.url, description };
57
+ export function reviewStatusArgs(pr, description, state = REVIEW_STATE) {
58
+ const fields = { state, context: REVIEW, target_url: pr.url, description };
49
59
  const flags = Object.entries(fields).flatMap(([key, value]) => ["-f", `${key}=${value}`]);
50
60
  return ["api", `repos/${pr.repo}/statuses/${pr.head}`, "--method", "POST", "--silent", ...flags];
51
61
  }
@@ -55,14 +65,15 @@ function flagOverCarry(pr, { review }) {
55
65
  }
56
66
  /** The reviewed commit whose success the head carries, if it does and the dev named no depth. */
57
67
  function carriedFrom(pr, policy) {
58
- return pr.carried && !flagOverCarry(pr, policy) ? (pr.lastReviewed?.commit ?? null) : null;
68
+ return pr.carried && !flagOverCarry(pr, policy) ? (pr.lastRead?.commit ?? null) : null;
59
69
  }
60
70
  /**
61
- * The commit the Review reads from: the last reviewed one. Over a carry the dev's depth reads the whole
62
- * diff, since the changes after that commit are only the base's.
71
+ * The commit the Review reads from: the last one a Review read, clean or not, so the Review after a fix
72
+ * reads the fix. Over a carry the dev's depth reads the whole diff, since the changes after that commit
73
+ * are only the base's.
63
74
  */
64
75
  function sinceOf(pr, policy) {
65
- return flagOverCarry(pr, policy) ? null : (pr.lastReviewed?.commit ?? null);
76
+ return flagOverCarry(pr, policy) ? null : (pr.lastRead?.commit ?? null);
66
77
  }
67
78
  /** The depth a Review reads the head at: the dev's or the one `pr.review` sets, else the one the rules route. */
68
79
  function depthOf(pr, { review }) {
@@ -87,6 +98,18 @@ function markedDescription(pr, policy) {
87
98
  }
88
99
  return `${OFF_PREFIX} ${depth.why}`;
89
100
  }
101
+ /** The park at the limit: the dev accepts the head as it stands, or names a depth for one more Review. */
102
+ function reviewLimit(pr) {
103
+ const { count, since } = pr.marks;
104
+ const from = since === null ? "the start of the pull request" : `the success on ${since.slice(0, 7)}`;
105
+ return {
106
+ status: "park",
107
+ action: "review-limit",
108
+ actor: "dev",
109
+ reason: `${count} Reviews since ${from} left a Finding open: accepting the head is the dev's`,
110
+ command: shellLine(["dw-sdlc", "pr", "reviewed", String(pr.number), "-R", pr.repo, "--head", pr.head, "--accept"])
111
+ };
112
+ }
90
113
  /** A script Action's gh arguments; `repo` is `-R` and the repository when the call names it, else empty. */
91
114
  const HOST = {
92
115
  "update-branch": (pr, repo) => ["pr", "update-branch", String(pr.number), ...repo],
@@ -192,6 +215,10 @@ const ROWS = [
192
215
  return { ...head, action: "mark-reviewed", actor: "script", details: after };
193
216
  }
194
217
  const { depth, source } = depthOf(pr, policy);
218
+ // The dev's own depth buys one Review past the limit; a Review under off needs none.
219
+ if (depth !== "off" && source !== "flag" && pr.marks.count >= REVIEW_LIMIT) {
220
+ return reviewLimit(pr);
221
+ }
195
222
  const byScript = depth === "off";
196
223
  return {
197
224
  ...head,
package/dist/pr/read.d.ts CHANGED
@@ -18,13 +18,21 @@ export declare function gh(args: string[]): {
18
18
  stdout: string;
19
19
  stderr: string;
20
20
  };
21
+ /** The remedy for a pull request `command`, as `pr ship`, cannot find by itself: name it in full. */
22
+ export declare function nameItRemedy(command: string): string;
21
23
  /**
22
24
  * The state of the pull request `named`; `command`, as `pr ship`, names the call in a remedy. With
23
25
  * `sized`, as `pr.review: auto` needs, the read sizes the changes a Review would read.
24
26
  */
25
27
  export declare function read(named: Named, command: string, sized: boolean): PrState;
26
- /** The state of the current branch's pull request, open or not, or null when gh finds none. */
27
- export declare function readBranch(sized: boolean): PrState | null;
28
+ /**
29
+ * The current branch's pull request, open or not, or null. gh tries `@{push}`, else the local name, which
30
+ * misses a branch pushed under another name; then `upstreamName` is asked, and `byUpstream` says so.
31
+ */
32
+ export declare function readBranch(sized: boolean, upstreamName: string | undefined): {
33
+ pr: PrState;
34
+ byUpstream: boolean;
35
+ } | null;
28
36
  /** The default branch of the repository gh resolves from the current directory. */
29
37
  export declare function defaultBranch(): string;
30
38
  /** The time the decision reads: DW_SDLC_NOW, an ISO 8601 time a test sets, else the system clock. */