dw-sdlc 0.12.1 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -16
- package/dist/cli.js +2 -0
- package/dist/commands/gate.js +3 -2
- package/dist/commands/pr-reviewed.d.ts +1 -1
- package/dist/commands/pr-reviewed.js +32 -13
- package/dist/commands/pr-ship.js +35 -8
- package/dist/commands/recap.js +2 -23
- package/dist/commands/sweep.d.ts +2 -0
- package/dist/commands/sweep.js +406 -0
- package/dist/landed.d.ts +7 -0
- package/dist/landed.js +86 -0
- package/dist/pr/decide.d.ts +9 -5
- package/dist/pr/decide.js +36 -9
- package/dist/pr/read.d.ts +2 -0
- package/dist/pr/read.js +12 -8
- package/dist/pr/state.d.ts +32 -6
- package/dist/pr/state.js +57 -16
- package/dist/pr/upstream.d.ts +16 -0
- package/dist/pr/upstream.js +31 -0
- package/dist/run-command.d.ts +1 -1
- package/dist/run-command.js +20 -4
- package/dist/sweep-terminals.d.ts +89 -0
- package/dist/sweep-terminals.js +262 -0
- package/dist/sweep-trees.d.ts +17 -0
- package/dist/sweep-trees.js +72 -0
- package/dist/working-tree.d.ts +2 -0
- package/dist/working-tree.js +5 -0
- package/dist/worktree-lock.d.ts +18 -0
- package/dist/worktree-lock.js +64 -0
- package/package.json +1 -1
|
@@ -0,0 +1,406 @@
|
|
|
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 { parseArgs } from "node:util";
|
|
6
|
+
import { findRepository, mainCheckout, resolveConfig } from "../agentic-config.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 { dirtyFiles, must, pausedOperation, worktreesOf } from "../sweep-trees.js";
|
|
13
|
+
import { asked, git } from "../working-tree.js";
|
|
14
|
+
import { lockVerdict } from "../worktree-lock.js";
|
|
15
|
+
export const summary = "removes Landed branches and clean worktrees whose work has landed, names what it keeps";
|
|
16
|
+
const USAGE = `Usage: dw-sdlc sweep [--dry-run] [--json]
|
|
17
|
+
|
|
18
|
+
Run from the main checkout. Fetches origin, reads the base branch as the guards do (baseBranch of the
|
|
19
|
+
config, else the branch origin/HEAD names) and removes, never forcing:
|
|
20
|
+
every linked worktree, wherever it lies, whose whole set passes: no modified or untracked file (ignored
|
|
21
|
+
files aside), no paused rebase, merge, cherry-pick, revert or bisect, no lock but a stale Claude Code
|
|
22
|
+
one, and its HEAD Landed, on a branch or detached; the branch it had checked out goes right after it. A
|
|
23
|
+
Claude Code lock, "claude session <name> (pid <N> start <time>)", is stale when no process N runs or N
|
|
24
|
+
started at another time; it is lifted just before its worktree goes, and lifted for no worktree that stays;
|
|
25
|
+
every other local branch that is Landed: its work is on the base, as a squash merge leaves it.
|
|
26
|
+
It keeps, and names with the reason and the command to run yourself:
|
|
27
|
+
a worktree that fails the set: the files that hold it with the forced removal, the paused operation, the
|
|
28
|
+
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;
|
|
30
|
+
a branch whose work is on no commit of the base, with the forced delete.
|
|
31
|
+
It never changes anything on a remote: the fetch only writes remote-tracking refs. A removal git refuses is
|
|
32
|
+
kept with git's words, and the sweep goes on.
|
|
33
|
+
|
|
34
|
+
In herdr ($HC_TERMINAL_ID set) it reads, with hc, the Workers of this session, the terminals whose parent is
|
|
35
|
+
this one, and every other live terminal. A Worker whose status is none of working, blocked or yet to report
|
|
36
|
+
is stopped (hc kill) then removed (hc rm) when its worktree goes in the same Sweep or its folder is already
|
|
37
|
+
gone; a worktree is judged whole first, so no Worker is stopped beside work it could still commit. A live
|
|
38
|
+
Claude Code lock on that worktree that names the Worker's own program, the pid hc get reports, does not keep
|
|
39
|
+
it: once the Worker is stopped the lock is read again and lifted only when it is stale then, else the
|
|
40
|
+
worktree stays. A finished Worker of this session is removed. This session's own terminal and another
|
|
41
|
+
session's are never stopped: a worktree one is in stays, named with the terminal's address and place and
|
|
42
|
+
hc kill as the dev's command. Outside herdr no terminal is read, and the output says so. An hc that fails
|
|
43
|
+
ends the Sweep with exit 2 before anything is removed.
|
|
44
|
+
|
|
45
|
+
--dry-run removes and stops nothing and answers the same items. One line an item, or one document with --json:
|
|
46
|
+
{ dryRun, terminalsRead, base, removed: [item], kept: [item] }, an item { kind, name, reason, command? },
|
|
47
|
+
kind branch, worktree, lock (a lifted one) or terminal, name its path or its address, command the one the
|
|
48
|
+
dev would run for a kept item.
|
|
49
|
+
|
|
50
|
+
Exit 0 nothing is kept but the default, the base or a protected branch, 1 something else is, 2 when it
|
|
51
|
+
cannot run: in a linked worktree, with no base to read, when the fetch fails or when hc fails in herdr.`;
|
|
52
|
+
function forcedDelete(branch) {
|
|
53
|
+
return shellLine(["git", "branch", "-D", branch]);
|
|
54
|
+
}
|
|
55
|
+
/** The forced removal; git wants --force twice for a worktree still locked. */
|
|
56
|
+
function forcedRemoval(path, locked = false) {
|
|
57
|
+
return shellLine(["git", "worktree", "remove", "--force", ...(locked ? ["--force"] : []), path]);
|
|
58
|
+
}
|
|
59
|
+
function fetchOrigin(root) {
|
|
60
|
+
if (asked(root, ["remote", "get-url", "origin"]) === undefined) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const env = { ...repositoryFreeEnv(), GIT_TERMINAL_PROMPT: "0" };
|
|
64
|
+
const result = spawnSync("git", ["-C", root, "fetch", "--quiet", "origin"], { encoding: "utf8", env, stdio: "pipe" });
|
|
65
|
+
if (result.status !== 0) {
|
|
66
|
+
const said = result.stderr.split("\n").find((line) => line.trim() !== "");
|
|
67
|
+
throw new CannotRun(`git fetch origin failed: ${said?.trim() ?? `exited ${result.status}`}`, "check the network, then call again");
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/** The branch refs/remotes/origin/HEAD names, if it names one of origin. */
|
|
71
|
+
function originHead(root) {
|
|
72
|
+
const prefix = "refs/remotes/origin/";
|
|
73
|
+
const ref = asked(root, ["symbolic-ref", "-q", "refs/remotes/origin/HEAD"]);
|
|
74
|
+
return ref !== undefined && ref.startsWith(prefix) && ref.length > prefix.length
|
|
75
|
+
? ref.slice(prefix.length)
|
|
76
|
+
: undefined;
|
|
77
|
+
}
|
|
78
|
+
/** The base branch the guard reads: `baseBranch` of the config, under `auto` the branch origin/HEAD names. */
|
|
79
|
+
function baseOf(root, head) {
|
|
80
|
+
const configured = resolveConfig(root).config.baseBranch;
|
|
81
|
+
const name = configured === "" || configured === "auto" ? head : configured;
|
|
82
|
+
if (name === undefined) {
|
|
83
|
+
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");
|
|
84
|
+
}
|
|
85
|
+
const ref = `refs/remotes/origin/${name}`;
|
|
86
|
+
if (asked(root, ["check-ref-format", ref]) === undefined ||
|
|
87
|
+
asked(root, ["rev-parse", "--verify", "-q", `${ref}^{commit}`]) === undefined) {
|
|
88
|
+
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");
|
|
89
|
+
}
|
|
90
|
+
return { name, ref };
|
|
91
|
+
}
|
|
92
|
+
function branchesOf(root) {
|
|
93
|
+
return must(root, ["for-each-ref", "--format=%(refname:lstrip=2)", "refs/heads"])
|
|
94
|
+
.split("\n")
|
|
95
|
+
.filter((name) => name !== "");
|
|
96
|
+
}
|
|
97
|
+
/** Why the worktree stays, with the dev's command when there is one, or null when its whole set passes. */
|
|
98
|
+
function whyTreeStays(root, baseRef, tree, lock, holders) {
|
|
99
|
+
const { path } = tree;
|
|
100
|
+
if (lock.state === "stays") {
|
|
101
|
+
return lock;
|
|
102
|
+
}
|
|
103
|
+
const held = whyHeld(holders);
|
|
104
|
+
if (held !== null) {
|
|
105
|
+
return held;
|
|
106
|
+
}
|
|
107
|
+
if (tree.prunable || !existsSync(path)) {
|
|
108
|
+
const prune = shellLine(["git", "worktree", "prune"]);
|
|
109
|
+
return {
|
|
110
|
+
reason: "its folder is gone",
|
|
111
|
+
command: lock.state === "stale" ? `${shellLine(["git", "worktree", "unlock", path])} && ${prune}` : prune
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
const operation = pausedOperation(path);
|
|
115
|
+
if (operation !== undefined) {
|
|
116
|
+
return { reason: `a ${operation} is paused in it`, command: null };
|
|
117
|
+
}
|
|
118
|
+
const files = dirtyFiles(path);
|
|
119
|
+
if ("error" in files) {
|
|
120
|
+
return { reason: files.error, command: null };
|
|
121
|
+
}
|
|
122
|
+
if (files.length > 0) {
|
|
123
|
+
return {
|
|
124
|
+
reason: `modified or untracked: ${files.join(", ")}`,
|
|
125
|
+
command: forcedRemoval(path, lock.state === "stale")
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
if (!isCommitLanded(root, baseRef, tree.head)) {
|
|
129
|
+
const at = tree.branch === undefined ? `its detached HEAD ${tree.head.slice(0, 9)}` : `its HEAD on ${tree.branch}`;
|
|
130
|
+
return { reason: `${at} is on no commit of ${baseRef.replace("refs/remotes/", "")} since it forked`, command: null };
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
/** A branch kept for what it is, never the dev's to decide. */
|
|
135
|
+
function standing(reason) {
|
|
136
|
+
return { reason, command: null, dev: false };
|
|
137
|
+
}
|
|
138
|
+
/** Why `branch` stays whatever its work, or null when only its work decides. */
|
|
139
|
+
function stays(branch, { head, baseBranch, protectedBranches, held }) {
|
|
140
|
+
if (branch === head) {
|
|
141
|
+
return standing("the default branch");
|
|
142
|
+
}
|
|
143
|
+
if (branch === baseBranch) {
|
|
144
|
+
return standing("the base branch");
|
|
145
|
+
}
|
|
146
|
+
if (protectedBranches.includes(branch)) {
|
|
147
|
+
return standing("a protected branch");
|
|
148
|
+
}
|
|
149
|
+
const path = held.get(branch);
|
|
150
|
+
return path === undefined ? null : { reason: `checked out in the worktree at ${path}`, command: null, dev: true };
|
|
151
|
+
}
|
|
152
|
+
/** Fetches origin, then sorts each linked worktree, local branch and Worker's terminal into what goes and what stays. */
|
|
153
|
+
function judge(root, terminals) {
|
|
154
|
+
fetchOrigin(root);
|
|
155
|
+
const head = originHead(root);
|
|
156
|
+
const base = baseOf(root, head);
|
|
157
|
+
const baseName = `origin/${base.name}`;
|
|
158
|
+
const [checkout, ...linked] = worktreesOf(root);
|
|
159
|
+
if (checkout === undefined) {
|
|
160
|
+
throw new CannotRun("git lists no worktree", "check the repository with git worktree list");
|
|
161
|
+
}
|
|
162
|
+
const holders = terminals === undefined
|
|
163
|
+
? new Map()
|
|
164
|
+
: holdersOf(terminals, checkout.path, linked.map(({ path }) => path));
|
|
165
|
+
const verdicts = linked.map((tree) => {
|
|
166
|
+
const lock = lockVerdict(tree.path, tree.lock);
|
|
167
|
+
const here = holders.get(tree.path) ?? [];
|
|
168
|
+
const pid = lock.state === "stays" ? lock.pid : undefined;
|
|
169
|
+
const workerLock = pid !== undefined && lockHolder(pid, here) !== undefined ? { pid } : undefined;
|
|
170
|
+
const why = whyTreeStays(root, base.ref, tree, workerLock === undefined ? lock : { state: "none" }, here);
|
|
171
|
+
return { tree, lock, here, why, workerLock };
|
|
172
|
+
});
|
|
173
|
+
const staying = verdicts.filter(({ why }) => why !== null).map(({ tree }) => tree);
|
|
174
|
+
const held = new Map();
|
|
175
|
+
for (const { path, branch } of [checkout, ...staying]) {
|
|
176
|
+
if (branch !== undefined) {
|
|
177
|
+
held.set(branch, path);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
const footing = {
|
|
181
|
+
head,
|
|
182
|
+
baseBranch: base.name,
|
|
183
|
+
protectedBranches: resolveConfig(root).config.guardrails.protectedBranches,
|
|
184
|
+
held
|
|
185
|
+
};
|
|
186
|
+
const removed = [];
|
|
187
|
+
const kept = [];
|
|
188
|
+
const judged = new Set();
|
|
189
|
+
const judgeBranch = (name) => {
|
|
190
|
+
if (judged.has(name)) {
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
judged.add(name);
|
|
194
|
+
const why = stays(name, footing);
|
|
195
|
+
if (why !== null) {
|
|
196
|
+
kept.push({ kind: "branch", name, ...why });
|
|
197
|
+
}
|
|
198
|
+
else if (isLanded(root, base.ref, name)) {
|
|
199
|
+
removed.push({ kind: "branch", name, reason: `Landed on ${baseName}` });
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
const reason = `its work is on no commit of ${baseName} since it forked`;
|
|
203
|
+
kept.push({ kind: "branch", name, reason, command: forcedDelete(name), dev: true });
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
const outside = terminals === undefined
|
|
207
|
+
? { remove: [], keep: [] }
|
|
208
|
+
: outsideWorktrees(terminals, [checkout.path, ...linked.map(({ path }) => path)]);
|
|
209
|
+
removed.push(...outside.remove);
|
|
210
|
+
for (const { name, reason } of outside.keep) {
|
|
211
|
+
kept.push({ kind: "terminal", name, reason, command: null, dev: true });
|
|
212
|
+
}
|
|
213
|
+
for (const { tree, lock, here, why, workerLock } of verdicts) {
|
|
214
|
+
if (why !== null) {
|
|
215
|
+
kept.push({ kind: "worktree", name: tree.path, ...why, dev: true });
|
|
216
|
+
kept.push(...keptIn(here, tree.path).map((item) => ({ kind: "terminal", ...item, dev: true })));
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
removed.push(...stopsOf(here, tree.path));
|
|
220
|
+
const reason = `clean, its HEAD is Landed on ${baseName}`;
|
|
221
|
+
removed.push({
|
|
222
|
+
kind: "worktree",
|
|
223
|
+
name: tree.path,
|
|
224
|
+
reason,
|
|
225
|
+
...(tree.branch === undefined ? {} : { holds: tree.branch }),
|
|
226
|
+
...(lock.state === "stale" ? { stale: lock } : {}),
|
|
227
|
+
...(workerLock === undefined ? {} : { workerLock })
|
|
228
|
+
});
|
|
229
|
+
if (tree.branch !== undefined) {
|
|
230
|
+
judgeBranch(tree.branch);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
branchesOf(root).forEach(judgeBranch);
|
|
234
|
+
return { base: baseName, removed, kept };
|
|
235
|
+
}
|
|
236
|
+
/** Removes `item`; the item kept when git refuses it, with git's words, or undefined once it is gone. */
|
|
237
|
+
function removal(root, item, heldBy, unstopped) {
|
|
238
|
+
const refused = (reason, command) => ({
|
|
239
|
+
kind: item.kind,
|
|
240
|
+
name: item.name,
|
|
241
|
+
reason,
|
|
242
|
+
command,
|
|
243
|
+
dev: true
|
|
244
|
+
});
|
|
245
|
+
if (item.kind === "terminal") {
|
|
246
|
+
const failed = removeTerminal(item);
|
|
247
|
+
if (failed === undefined) {
|
|
248
|
+
return undefined;
|
|
249
|
+
}
|
|
250
|
+
if (item.tree !== undefined) {
|
|
251
|
+
unstopped.set(item.tree, `its Worker's terminal ${item.name} stays: ${failed}`);
|
|
252
|
+
}
|
|
253
|
+
return refused(failed, byHand(item));
|
|
254
|
+
}
|
|
255
|
+
if (item.kind === "worktree") {
|
|
256
|
+
const refusedWith = (reason, command) => {
|
|
257
|
+
if (item.holds !== undefined) {
|
|
258
|
+
heldBy.set(item.holds, item.name);
|
|
259
|
+
}
|
|
260
|
+
return refused(reason, command);
|
|
261
|
+
};
|
|
262
|
+
const unstoppable = unstopped.get(item.name);
|
|
263
|
+
if (unstoppable !== undefined) {
|
|
264
|
+
return refusedWith(unstoppable, null);
|
|
265
|
+
}
|
|
266
|
+
if (item.workerLock !== undefined) {
|
|
267
|
+
const now = lockAfterStop(root, item.name);
|
|
268
|
+
if (now.state === "stays") {
|
|
269
|
+
return refusedWith(`its Worker's terminal was stopped, but ${now.reason}`, now.command);
|
|
270
|
+
}
|
|
271
|
+
if (now.state === "stale") {
|
|
272
|
+
item.stale = now;
|
|
273
|
+
}
|
|
274
|
+
delete item.workerLock;
|
|
275
|
+
}
|
|
276
|
+
if (item.stale !== undefined) {
|
|
277
|
+
const lifted = git(root, ["worktree", "unlock", "--", item.name]);
|
|
278
|
+
if ("error" in lifted) {
|
|
279
|
+
return refusedWith(lifted.error, shellLine(["git", "worktree", "unlock", item.name]));
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
const result = git(root, ["worktree", "remove", "--", item.name]);
|
|
283
|
+
if (!("error" in result)) {
|
|
284
|
+
return undefined;
|
|
285
|
+
}
|
|
286
|
+
const relocked = item.stale === undefined
|
|
287
|
+
? undefined
|
|
288
|
+
: git(root, ["worktree", "lock", "--reason", item.stale.held, "--", item.name]);
|
|
289
|
+
const lost = relocked !== undefined && "error" in relocked ? `; its lock could not be put back: ${relocked.error}` : "";
|
|
290
|
+
return refusedWith(`${result.error}${lost}`, forcedRemoval(item.name, item.stale !== undefined));
|
|
291
|
+
}
|
|
292
|
+
const path = heldBy.get(item.name);
|
|
293
|
+
if (path !== undefined) {
|
|
294
|
+
return refused(`checked out in the worktree at ${path}`, null);
|
|
295
|
+
}
|
|
296
|
+
const result = git(root, ["branch", "-D", "--", item.name]);
|
|
297
|
+
return "error" in result ? refused(result.error, forcedDelete(item.name)) : undefined;
|
|
298
|
+
}
|
|
299
|
+
/** Removes each item judged to go, in order; a refused worktree keeps the branch it holds as well. */
|
|
300
|
+
function apply({ base, removed, kept }, root) {
|
|
301
|
+
const gone = [];
|
|
302
|
+
const refused = [];
|
|
303
|
+
const heldBy = new Map();
|
|
304
|
+
const unstopped = new Map();
|
|
305
|
+
for (const item of removed) {
|
|
306
|
+
const stayed = removal(root, item, heldBy, unstopped);
|
|
307
|
+
if (stayed === undefined) {
|
|
308
|
+
gone.push(item);
|
|
309
|
+
}
|
|
310
|
+
else {
|
|
311
|
+
refused.push(stayed);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return { base, removed: gone, kept: [...kept, ...refused] };
|
|
315
|
+
}
|
|
316
|
+
/** The removed items as the Sweep tells them: a stale lock lifted comes just before its worktree. */
|
|
317
|
+
function shownOf(removed) {
|
|
318
|
+
return removed.flatMap(({ kind, name, reason, stale, workerLock }) => {
|
|
319
|
+
const item = { kind, name, reason };
|
|
320
|
+
if (workerLock !== undefined) {
|
|
321
|
+
const why = `held by its Worker's program, pid ${workerLock.pid}: lifted once the stop leaves it stale`;
|
|
322
|
+
return [{ kind: "lock", name, reason: why }, item];
|
|
323
|
+
}
|
|
324
|
+
return stale === undefined ? [item] : [{ kind: "lock", name, reason: stale.why }, item];
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
function verbOf(kind, dryRun) {
|
|
328
|
+
if (kind === "lock") {
|
|
329
|
+
return dryRun ? "would lift" : "lifted";
|
|
330
|
+
}
|
|
331
|
+
return dryRun ? "would remove" : "removed";
|
|
332
|
+
}
|
|
333
|
+
function linesOf({ dryRun, terminalsRead, removed, kept }) {
|
|
334
|
+
const out = [
|
|
335
|
+
...removed.map(({ kind, name, reason }) => `${verbOf(kind, dryRun)} ${kind} ${name} — ${reason}`),
|
|
336
|
+
...kept.map(({ kind, name, reason, command }) => {
|
|
337
|
+
return `kept ${kind} ${name} — ${reason}${command === null || command === undefined ? "" : `; to remove it yourself: ${command}`}`;
|
|
338
|
+
})
|
|
339
|
+
];
|
|
340
|
+
const note = terminalsRead ? [] : ["terminals not read — outside herdr, $HC_TERMINAL_ID is not set"];
|
|
341
|
+
return [...(out.length === 0 ? ["nothing to sweep"] : out), ...note].join("\n");
|
|
342
|
+
}
|
|
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 own = sweepingTerminal();
|
|
373
|
+
const terminals = own === undefined ? undefined : readTerminals(own);
|
|
374
|
+
const judged = judge(root, terminals);
|
|
375
|
+
const { base, removed, kept } = dryRun ? judged : apply(judged, root);
|
|
376
|
+
const done = {
|
|
377
|
+
dryRun,
|
|
378
|
+
terminalsRead: terminals !== undefined,
|
|
379
|
+
base,
|
|
380
|
+
removed: shownOf(removed),
|
|
381
|
+
kept: kept.map(({ kind, name, reason, command }) => ({ kind, name, reason, command }))
|
|
382
|
+
};
|
|
383
|
+
process.stdout.write(`${json ? JSON.stringify(done, null, 2) : linesOf(done)}\n`);
|
|
384
|
+
return kept.some(({ dev }) => dev) ? 1 : 0;
|
|
385
|
+
}
|
|
386
|
+
export function run(argv) {
|
|
387
|
+
const cli = parseCli(argv);
|
|
388
|
+
if ("error" in cli) {
|
|
389
|
+
process.stderr.write(`dw-sdlc sweep: ${cli.error}\n\n${USAGE}\n`);
|
|
390
|
+
return 2;
|
|
391
|
+
}
|
|
392
|
+
if (cli.help) {
|
|
393
|
+
process.stdout.write(`${USAGE}\n`);
|
|
394
|
+
return 0;
|
|
395
|
+
}
|
|
396
|
+
try {
|
|
397
|
+
return sweep(cli.json, cli.dryRun);
|
|
398
|
+
}
|
|
399
|
+
catch (error) {
|
|
400
|
+
if (error instanceof CannotRun) {
|
|
401
|
+
process.stderr.write(`dw-sdlc sweep: ${error.message} — ${error.remedy}\n`);
|
|
402
|
+
return 2;
|
|
403
|
+
}
|
|
404
|
+
throw error;
|
|
405
|
+
}
|
|
406
|
+
}
|
package/dist/landed.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/pr/decide.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
52
|
-
* read. The path names the repository, so the call reads the same from
|
|
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
|
-
/**
|
|
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
|
|
45
|
-
* read. The path names the repository, so the call reads the same from
|
|
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
|
|
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.
|
|
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
|
|
62
|
-
* diff, since the changes after that commit
|
|
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.
|
|
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,6 +18,8 @@ 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.
|