dw-sdlc 0.12.1 → 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.
- 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 +403 -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/working-tree.d.ts +2 -0
- package/dist/working-tree.js +5 -0
- package/dist/worktree-lock.d.ts +15 -0
- package/dist/worktree-lock.js +63 -0
- package/package.json +1 -1
package/dist/pr/read.js
CHANGED
|
@@ -64,6 +64,10 @@ function parseOrCannotRun(parse) {
|
|
|
64
64
|
throw error;
|
|
65
65
|
}
|
|
66
66
|
}
|
|
67
|
+
/** The remedy for a pull request `command`, as `pr ship`, cannot find by itself: name it in full. */
|
|
68
|
+
export function nameItRemedy(command) {
|
|
69
|
+
return `name it: dw-sdlc ${command} <n> -R owner/name`;
|
|
70
|
+
}
|
|
67
71
|
/** The pull request named in full, or the one `gh pr view` resolves from what is given. */
|
|
68
72
|
function targetOf({ number, repo }, command) {
|
|
69
73
|
if (number !== undefined && repo !== undefined) {
|
|
@@ -72,8 +76,7 @@ function targetOf({ number, repo }, command) {
|
|
|
72
76
|
const named = [...(number === undefined ? [] : [String(number)]), ...(repo === undefined ? [] : ["-R", repo])];
|
|
73
77
|
const view = gh(["pr", "view", ...named, "--json", "url"]);
|
|
74
78
|
if (view.status !== 0) {
|
|
75
|
-
|
|
76
|
-
throw new CannotRun(`gh pr view found no pull request: ${firstLine(view.stderr)}`, remedy);
|
|
79
|
+
throw new CannotRun(`gh pr view found no pull request: ${firstLine(view.stderr)}`, nameItRemedy(command));
|
|
77
80
|
}
|
|
78
81
|
return parseOrCannotRun(() => parseView(view.stdout));
|
|
79
82
|
}
|
|
@@ -185,25 +188,26 @@ function ownChanges(pr, commit) {
|
|
|
185
188
|
return parseOrCannotRun(() => changesOf(reply));
|
|
186
189
|
}
|
|
187
190
|
/**
|
|
188
|
-
* `pr` and whether its head carries the last
|
|
189
|
-
* same own changes at both.
|
|
191
|
+
* `pr` and whether its head carries the success of the last commit a Review read: a clean read, only
|
|
192
|
+
* merges since it, and the same own changes at both. A read mark never carries. The compares run only
|
|
193
|
+
* when a Review awaits the head.
|
|
190
194
|
*/
|
|
191
195
|
function withCarried(pr) {
|
|
192
|
-
const last = pr.
|
|
193
|
-
if (last === null || !last.mergesOnly || pr.state !== "OPEN" || !awaitsReview(pr)) {
|
|
196
|
+
const last = pr.lastRead;
|
|
197
|
+
if (last === null || !last.clean || !last.mergesOnly || pr.state !== "OPEN" || !awaitsReview(pr)) {
|
|
194
198
|
return { ...pr, carried: false };
|
|
195
199
|
}
|
|
196
200
|
return { ...pr, carried: sameChanges(ownChanges(pr, last.commit), ownChanges(pr, pr.head)) };
|
|
197
201
|
}
|
|
198
202
|
/**
|
|
199
203
|
* `pr` and, when `sized` and a Review awaits a head that carries none, the files the Review would read:
|
|
200
|
-
* the compare from the last
|
|
204
|
+
* the compare from the last commit a Review read, else from the base, to the head.
|
|
201
205
|
*/
|
|
202
206
|
function withChanged(pr, sized) {
|
|
203
207
|
if (!sized || pr.carried || pr.state !== "OPEN" || !awaitsReview(pr)) {
|
|
204
208
|
return { ...pr, changed: null };
|
|
205
209
|
}
|
|
206
|
-
const from = pr.
|
|
210
|
+
const from = pr.lastRead?.commit ?? pr.baseOid;
|
|
207
211
|
const reply = compared(pr, from, pr.head, `the compare of ${from.slice(0, 7)}...${pr.head.slice(0, 7)}`);
|
|
208
212
|
return { ...pr, changed: parseOrCannotRun(() => changedOf(reply)) };
|
|
209
213
|
}
|
package/dist/pr/state.d.ts
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
export declare const REVIEW = "dw-sdlc/review";
|
|
3
3
|
/** How the description of a success the script posts under off begins: no one read that head. */
|
|
4
4
|
export declare const OFF_PREFIX = "off:";
|
|
5
|
+
/**
|
|
6
|
+
* The read mark: the review status in this state and with this description says a Review read that
|
|
7
|
+
* head and left a Finding open.
|
|
8
|
+
*/
|
|
9
|
+
export declare const MARK_STATE = "failure";
|
|
10
|
+
export declare const READ_MARK = "read, findings open";
|
|
11
|
+
/** How the description of the success the dev's `--accept` posts reads: the head, taken with its Findings open. */
|
|
12
|
+
export declare const ACCEPTED = "accepted by the dev, findings open";
|
|
5
13
|
export declare const QUERY = "query($owner: String!, $name: String!, $number: Int!) {\n repository(owner: $owner, name: $name) {\n pullRequest(number: $number) {\n url number state isDraft mergeable mergeStateStatus reviewDecision headRefOid headRefName baseRefName baseRefOid\n baseRef { target { oid } } viewerDidAuthor mergeCommit { oid }\n reviewThreads(first: 100) { nodes { id isResolved isOutdated path line\n comments(first: 1) { nodes { author { login } body } }\n latest: comments(last: 1) { nodes { viewerDidAuthor } } } }\n commits(last: 1) { nodes { commit { committedDate statusCheckRollup {\n contexts(first: 50) { nodes {\n ... on CheckRun { name status conclusion detailsUrl startedAt isRequired(pullRequestNumber: $number) }\n ... on StatusContext { context state targetUrl createdAt isRequired(pullRequestNumber: $number) } } } } } } }\n history: commits(last: 100) { nodes { commit { oid parents { totalCount } status { context(name: \"dw-sdlc/review\") { state description } } } } }\n }\n }\n}";
|
|
6
14
|
export type Bucket = "pass" | "fail" | "pending" | "skipping";
|
|
7
15
|
export type CiCheck = {
|
|
@@ -51,12 +59,17 @@ export type PrState = {
|
|
|
51
59
|
unresolvedThreads: Thread[];
|
|
52
60
|
/** The oids of the pull request's last 100 commits, oldest first, the head last. */
|
|
53
61
|
commits: string[];
|
|
54
|
-
/**
|
|
55
|
-
|
|
56
|
-
|
|
62
|
+
/**
|
|
63
|
+
* The last commit before the head a Review read, of the last 100: a review success not posted under
|
|
64
|
+
* off, or a read mark.
|
|
65
|
+
*/
|
|
66
|
+
lastRead: LastRead | null;
|
|
67
|
+
/** The read marks since the last review success of any kind, of the last 100 commits. */
|
|
68
|
+
marks: ReadMarks;
|
|
69
|
+
/** Whether the head carries the success of `lastRead`: the read decides it from two compares. */
|
|
57
70
|
carried: boolean;
|
|
58
71
|
/**
|
|
59
|
-
* The files a Review would read, from `
|
|
72
|
+
* The files a Review would read, from `lastRead` or the base to the head, read only for `pr.review:
|
|
60
73
|
* auto` to route on: `unsized` when the compare cannot list them all, null when not read.
|
|
61
74
|
*/
|
|
62
75
|
changed: ChangedFile[] | "unsized" | null;
|
|
@@ -75,11 +88,24 @@ export type ChangedFile = {
|
|
|
75
88
|
previous: string | null;
|
|
76
89
|
lines: number | null;
|
|
77
90
|
};
|
|
78
|
-
/**
|
|
79
|
-
|
|
91
|
+
/**
|
|
92
|
+
* A commit a Review read; `clean` when that Review left a success on it, not a read mark; and whether
|
|
93
|
+
* every commit after it, the head included, is a merge.
|
|
94
|
+
*/
|
|
95
|
+
export type LastRead = {
|
|
80
96
|
commit: string;
|
|
97
|
+
clean: boolean;
|
|
81
98
|
mergesOnly: boolean;
|
|
82
99
|
};
|
|
100
|
+
/**
|
|
101
|
+
* The Reviews that left a Finding open since the last review success, a success under off and the dev's
|
|
102
|
+
* acceptance included: `count` their read marks, the head's own among them, and `since` that success's
|
|
103
|
+
* commit, null when none of the last 100 holds one.
|
|
104
|
+
*/
|
|
105
|
+
export type ReadMarks = {
|
|
106
|
+
count: number;
|
|
107
|
+
since: string | null;
|
|
108
|
+
};
|
|
83
109
|
/** A reply that is not the shape the query asks for; the message names the field. */
|
|
84
110
|
export declare class ReplyError extends Error {
|
|
85
111
|
}
|
package/dist/pr/state.js
CHANGED
|
@@ -4,6 +4,14 @@
|
|
|
4
4
|
export const REVIEW = "dw-sdlc/review";
|
|
5
5
|
/** How the description of a success the script posts under off begins: no one read that head. */
|
|
6
6
|
export const OFF_PREFIX = "off:";
|
|
7
|
+
/**
|
|
8
|
+
* The read mark: the review status in this state and with this description says a Review read that
|
|
9
|
+
* head and left a Finding open.
|
|
10
|
+
*/
|
|
11
|
+
export const MARK_STATE = "failure";
|
|
12
|
+
export const READ_MARK = "read, findings open";
|
|
13
|
+
/** How the description of the success the dev's `--accept` posts reads: the head, taken with its Findings open. */
|
|
14
|
+
export const ACCEPTED = "accepted by the dev, findings open";
|
|
7
15
|
export const QUERY = `query($owner: String!, $name: String!, $number: Int!) {
|
|
8
16
|
repository(owner: $owner, name: $name) {
|
|
9
17
|
pullRequest(number: $number) {
|
|
@@ -191,26 +199,57 @@ function threadOf(thread) {
|
|
|
191
199
|
export function awaitsReview(pr) {
|
|
192
200
|
return pr.viewerDidAuthor && !pr.checks.some((check) => check.name === REVIEW && check.bucket === "pass");
|
|
193
201
|
}
|
|
194
|
-
/**
|
|
195
|
-
|
|
202
|
+
/**
|
|
203
|
+
* What a commit's review status says: `clean` a success, `findings` the read mark, `off` a success
|
|
204
|
+
* posted under off, which says no one read the commit, null no status of the Review.
|
|
205
|
+
*/
|
|
206
|
+
function postedOn(commit) {
|
|
196
207
|
const status = childOrNull(commit, "status");
|
|
197
208
|
const context = status === null ? null : childOrNull(status, "context");
|
|
198
|
-
if (context === null
|
|
199
|
-
return
|
|
209
|
+
if (context === null) {
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
const state = string(context, "state");
|
|
213
|
+
const description = stringOrNull(context, "description") ?? "";
|
|
214
|
+
if (state === "SUCCESS") {
|
|
215
|
+
return description.startsWith(OFF_PREFIX) ? "off" : "clean";
|
|
200
216
|
}
|
|
201
|
-
return
|
|
217
|
+
return state === MARK_STATE.toUpperCase() && description === READ_MARK ? "findings" : null;
|
|
218
|
+
}
|
|
219
|
+
/** The pull request's last 100 commits, oldest first, each with the review status it carries. */
|
|
220
|
+
function historyOf(pr) {
|
|
221
|
+
return nodes(pr, "history").map((node) => {
|
|
222
|
+
const commit = child(node, "commit");
|
|
223
|
+
return {
|
|
224
|
+
oid: string(commit, "oid"),
|
|
225
|
+
posted: postedOn(commit),
|
|
226
|
+
merge: integer(child(commit, "parents"), "totalCount") > 1
|
|
227
|
+
};
|
|
228
|
+
});
|
|
202
229
|
}
|
|
203
|
-
/**
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
230
|
+
/**
|
|
231
|
+
* The last commit before `head` a Review read, the history oldest first. A read mark with only merges
|
|
232
|
+
* after it is passed over: no fix answers its Findings yet, and a Review from it would read only the
|
|
233
|
+
* base's changes, so the Review reads from the read before it and finds them again.
|
|
234
|
+
*/
|
|
235
|
+
function lastReadOf(history, head) {
|
|
236
|
+
const commits = history.map(({ oid, posted, merge }) => ({
|
|
237
|
+
oid,
|
|
238
|
+
read: oid === head || posted === "off" ? null : posted,
|
|
239
|
+
merge
|
|
240
|
+
}));
|
|
241
|
+
const mergesOnlyAfter = (index) => commits.slice(index + 1).every(({ merge }) => merge);
|
|
242
|
+
const index = commits.findLastIndex(({ read }, at) => read === "clean" || (read === "findings" && !mergesOnlyAfter(at)));
|
|
243
|
+
const last = commits[index];
|
|
244
|
+
if (last === undefined) {
|
|
209
245
|
return null;
|
|
210
246
|
}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
247
|
+
return { commit: last.oid, clean: last.read === "clean", mergesOnly: mergesOnlyAfter(index) };
|
|
248
|
+
}
|
|
249
|
+
function readMarksOf(history) {
|
|
250
|
+
const success = history.findLastIndex(({ posted }) => posted === "clean" || posted === "off");
|
|
251
|
+
const count = history.slice(success + 1).filter(({ posted }) => posted === "findings").length;
|
|
252
|
+
return { count, since: history[success]?.oid ?? null };
|
|
214
253
|
}
|
|
215
254
|
/** The repository and number of the pull request whose URL `gh pr view --json url` printed. */
|
|
216
255
|
export function parseView(reply) {
|
|
@@ -233,6 +272,7 @@ export function parsePr(reply, repo) {
|
|
|
233
272
|
const mergeCommit = childOrNull(pr, "mergeCommit");
|
|
234
273
|
const head = string(pr, "headRefOid");
|
|
235
274
|
const baseRef = childOrNull(pr, "baseRef");
|
|
275
|
+
const history = historyOf(pr);
|
|
236
276
|
return {
|
|
237
277
|
url: string(pr, "url"),
|
|
238
278
|
repo,
|
|
@@ -253,7 +293,8 @@ export function parsePr(reply, repo) {
|
|
|
253
293
|
unresolvedThreads: nodes(pr, "reviewThreads")
|
|
254
294
|
.filter((thread) => !boolean(thread, "isResolved") && !boolean(thread, "isOutdated"))
|
|
255
295
|
.map(threadOf),
|
|
256
|
-
commits:
|
|
257
|
-
|
|
296
|
+
commits: history.map(({ oid }) => oid),
|
|
297
|
+
lastRead: lastReadOf(history, head),
|
|
298
|
+
marks: readMarksOf(history)
|
|
258
299
|
};
|
|
259
300
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { PrState } from "./state.ts";
|
|
2
|
+
/**
|
|
3
|
+
* The remote branch name `branch` tracks, when it differs from `branch`. Read from the config, so it holds
|
|
4
|
+
* after the remote deleted the branch.
|
|
5
|
+
*/
|
|
6
|
+
export declare function upstreamName(root: string, branch: string): string | undefined;
|
|
7
|
+
/** Whether `pr`'s commits hold `head`, so the head is on GitHub whatever became of the upstream. */
|
|
8
|
+
export declare function holds(pr: PrState, head: string | null): boolean;
|
|
9
|
+
/** The checked-out branch at `root`, its head commit, and the other name its upstream gives. */
|
|
10
|
+
export type RenamedUpstream = {
|
|
11
|
+
branch: string;
|
|
12
|
+
head: string;
|
|
13
|
+
upstream: string;
|
|
14
|
+
};
|
|
15
|
+
/** The checked-out branch whose upstream has another name; undefined on a detached HEAD or a branch with no commit. */
|
|
16
|
+
export declare function renamedUpstream(root: string): RenamedUpstream | undefined;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// A branch's upstream, read from git, for a branch pushed under another name: gh resolves
|
|
2
|
+
// its pull request neither through `@{push}` nor by the local name, so `pr ship` and recap ask by the name
|
|
3
|
+
// the upstream gives, and take that pull request only when its commits hold the local head.
|
|
4
|
+
import { asked } from "../working-tree.js";
|
|
5
|
+
/**
|
|
6
|
+
* The remote branch name `branch` tracks, when it differs from `branch`. Read from the config, so it holds
|
|
7
|
+
* after the remote deleted the branch.
|
|
8
|
+
*/
|
|
9
|
+
export function upstreamName(root, branch) {
|
|
10
|
+
const remote = asked(root, ["config", "--get", `branch.${branch}.remote`]);
|
|
11
|
+
const merge = asked(root, ["config", "--get", `branch.${branch}.merge`]);
|
|
12
|
+
if (remote === undefined || remote === "." || merge === undefined || !merge.startsWith("refs/heads/")) {
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
const name = merge.slice("refs/heads/".length);
|
|
16
|
+
return name === branch ? undefined : name;
|
|
17
|
+
}
|
|
18
|
+
/** Whether `pr`'s commits hold `head`, so the head is on GitHub whatever became of the upstream. */
|
|
19
|
+
export function holds(pr, head) {
|
|
20
|
+
return head !== null && pr.commits.includes(head);
|
|
21
|
+
}
|
|
22
|
+
/** The checked-out branch whose upstream has another name; undefined on a detached HEAD or a branch with no commit. */
|
|
23
|
+
export function renamedUpstream(root) {
|
|
24
|
+
const branch = asked(root, ["symbolic-ref", "--quiet", "--short", "HEAD"]);
|
|
25
|
+
const head = asked(root, ["rev-parse", "--verify", "--quiet", "HEAD"]);
|
|
26
|
+
if (branch === undefined || head === undefined) {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
const upstream = upstreamName(root, branch);
|
|
30
|
+
return upstream === undefined ? undefined : { branch, head, upstream };
|
|
31
|
+
}
|
package/dist/run-command.d.ts
CHANGED
|
@@ -19,6 +19,6 @@ export type Ran = {
|
|
|
19
19
|
export declare const TAIL_LINES = 50;
|
|
20
20
|
/** Runs `body` with SIGINT and SIGTERM held, so a signal stops the command running and the Gate reports it. */
|
|
21
21
|
export declare function holdingSignals<T>(body: () => Promise<T>): Promise<T>;
|
|
22
|
-
/** The signal that stopped the run, once one has
|
|
22
|
+
/** The signal that stopped the run, once one has: one the Gate got or one that ended a command. */
|
|
23
23
|
export declare function stoppedBy(): NodeJS.Signals | undefined;
|
|
24
24
|
export declare function runCommand(command: string, root: string, output: Output): Promise<Ran>;
|
package/dist/run-command.js
CHANGED
|
@@ -13,6 +13,11 @@ const STOPPING = ["SIGINT", "SIGTERM"];
|
|
|
13
13
|
let running;
|
|
14
14
|
/** The first SIGINT or SIGTERM the Gate got; no command starts after it. */
|
|
15
15
|
let received;
|
|
16
|
+
/**
|
|
17
|
+
* The SIGINT or SIGTERM that ended a command; no command starts after it either. Ctrl-C reaches the
|
|
18
|
+
* command and the Gate together, and the command's exit can be seen before the Gate's own signal.
|
|
19
|
+
*/
|
|
20
|
+
let commandEndedBy;
|
|
16
21
|
function release() {
|
|
17
22
|
for (const signal of STOPPING) {
|
|
18
23
|
process.off(signal, stop);
|
|
@@ -32,6 +37,8 @@ function stop(signal) {
|
|
|
32
37
|
export async function holdingSignals(body) {
|
|
33
38
|
running = undefined;
|
|
34
39
|
received = undefined;
|
|
40
|
+
commandEndedBy = undefined;
|
|
41
|
+
release();
|
|
35
42
|
for (const signal of STOPPING) {
|
|
36
43
|
process.on(signal, stop);
|
|
37
44
|
}
|
|
@@ -41,12 +48,16 @@ export async function holdingSignals(body) {
|
|
|
41
48
|
finally {
|
|
42
49
|
// One turn of the event loop hands a signal that landed after the last command to its handler.
|
|
43
50
|
await new Promise((resolve) => setImmediate(resolve));
|
|
44
|
-
|
|
51
|
+
// The Gate's own signal may still be on its way after a command it ended: the handlers stay, so
|
|
52
|
+
// that signal does not end the Gate before its report.
|
|
53
|
+
if (commandEndedBy === undefined || received !== undefined) {
|
|
54
|
+
release();
|
|
55
|
+
}
|
|
45
56
|
}
|
|
46
57
|
}
|
|
47
|
-
/** The signal that stopped the run, once one has
|
|
58
|
+
/** The signal that stopped the run, once one has: one the Gate got or one that ended a command. */
|
|
48
59
|
export function stoppedBy() {
|
|
49
|
-
return received;
|
|
60
|
+
return received ?? commandEndedBy;
|
|
50
61
|
}
|
|
51
62
|
/** Everything written to `fd`, read from its start: the command's writes moved the offset it shares to the end. */
|
|
52
63
|
function written(fd) {
|
|
@@ -99,7 +110,12 @@ function run(command, root, stdio) {
|
|
|
99
110
|
done({ status: "error", exitCode: null, signal: null, detail: `could not start: ${error.message}` });
|
|
100
111
|
}
|
|
101
112
|
});
|
|
102
|
-
child.once("exit", (exitCode, signal) =>
|
|
113
|
+
child.once("exit", (exitCode, signal) => {
|
|
114
|
+
if (signal !== null && STOPPING.includes(signal)) {
|
|
115
|
+
commandEndedBy ??= signal;
|
|
116
|
+
}
|
|
117
|
+
done(answer(exitCode, signal));
|
|
118
|
+
});
|
|
103
119
|
});
|
|
104
120
|
}
|
|
105
121
|
export async function runCommand(command, root, output) {
|
package/dist/working-tree.d.ts
CHANGED
|
@@ -4,6 +4,8 @@ export declare function git(root: string, args: string[], index?: string): {
|
|
|
4
4
|
} | {
|
|
5
5
|
error: string;
|
|
6
6
|
};
|
|
7
|
+
/** `git <args>` at `root`, or undefined when it exits non-zero as a question answered no. */
|
|
8
|
+
export declare function asked(root: string, args: string[]): string | undefined;
|
|
7
9
|
/** The tree of the checkout at `root` and its git dir, or why it has none. */
|
|
8
10
|
export declare function workingTree(root: string): {
|
|
9
11
|
tree: string;
|
package/dist/working-tree.js
CHANGED
|
@@ -20,6 +20,11 @@ export function git(root, args, index) {
|
|
|
20
20
|
const reason = result.error?.message ?? result.stderr.split("\n").find((line) => line.trim() !== "");
|
|
21
21
|
return { error: `git ${args[0] ?? ""}: ${reason?.trim() ?? `exited ${result.status ?? "on a signal"}`}` };
|
|
22
22
|
}
|
|
23
|
+
/** `git <args>` at `root`, or undefined when it exits non-zero as a question answered no. */
|
|
24
|
+
export function asked(root, args) {
|
|
25
|
+
const result = git(root, args);
|
|
26
|
+
return "out" in result ? result.out : undefined;
|
|
27
|
+
}
|
|
23
28
|
/**
|
|
24
29
|
* The tree `git write-tree` hashes from a copy of the index after `git add -A` on it: the copy keeps the
|
|
25
30
|
* stat cache, so only what changed is read again; identical content survives a commit, an amend or a
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** The process is gone: `held` is the reason to put back should the removal fail, `why` what the Sweep tells. */
|
|
2
|
+
export type StaleLock = {
|
|
3
|
+
state: "stale";
|
|
4
|
+
held: string;
|
|
5
|
+
why: string;
|
|
6
|
+
};
|
|
7
|
+
export type LockVerdict = {
|
|
8
|
+
state: "none";
|
|
9
|
+
} | StaleLock | {
|
|
10
|
+
state: "stays";
|
|
11
|
+
reason: string;
|
|
12
|
+
command: string | null;
|
|
13
|
+
};
|
|
14
|
+
/** What the lock of the worktree at `path`, held for `reason`, says about whether the Sweep may lift it. */
|
|
15
|
+
export declare function lockVerdict(path: string, reason: string | undefined): LockVerdict;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// What a worktree's lock means to a Sweep. Claude Code locks the worktree of a session with the reason
|
|
2
|
+
// `claude session <name> (pid <N> start <time>)`, <time> as `ps -o lstart` writes it in UTC. Such a lock is
|
|
3
|
+
// stale once no process N runs or N started at another time. Any other reason is the dev's to lift.
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
import { shellLine } from "./pr/decide.js";
|
|
6
|
+
const CLAUDE_LOCK = /^claude session .* \(pid (?<pid>\d{1,10}) start (?<start>.+)\)$/;
|
|
7
|
+
const MAX_PID = 2 ** 31 - 1;
|
|
8
|
+
const squeezed = (text) => text.trim().replace(/\s+/g, " ");
|
|
9
|
+
/**
|
|
10
|
+
* Whether process `pid` runs and, if so, its start time as `ps` writes it in UTC and in local time, so a
|
|
11
|
+
* live lock is never taken for stale over a time zone.
|
|
12
|
+
*/
|
|
13
|
+
function runningOf(pid) {
|
|
14
|
+
try {
|
|
15
|
+
process.kill(pid, 0);
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
if (error instanceof Error && "code" in error && error.code === "ESRCH") {
|
|
19
|
+
return { at: "gone" };
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const starts = [{ TZ: "UTC" }, {}]
|
|
23
|
+
.map((zone) => {
|
|
24
|
+
const env = { ...process.env, LC_ALL: "C", ...zone };
|
|
25
|
+
const result = spawnSync("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf8", env });
|
|
26
|
+
return result.status === 0 ? squeezed(result.stdout) : "";
|
|
27
|
+
})
|
|
28
|
+
.filter((start) => start !== "");
|
|
29
|
+
return starts.length === 0 ? { at: "unknown" } : { at: "running", starts };
|
|
30
|
+
}
|
|
31
|
+
/** What the lock of the worktree at `path`, held for `reason`, says about whether the Sweep may lift it. */
|
|
32
|
+
export function lockVerdict(path, reason) {
|
|
33
|
+
if (reason === undefined) {
|
|
34
|
+
return { state: "none" };
|
|
35
|
+
}
|
|
36
|
+
const unlock = shellLine(["git", "worktree", "unlock", path]);
|
|
37
|
+
const { pid: pidText = "", start = "" } = CLAUDE_LOCK.exec(reason)?.groups ?? {};
|
|
38
|
+
const pid = Number(pidText);
|
|
39
|
+
if (pidText === "" || pid < 1 || pid > MAX_PID) {
|
|
40
|
+
const said = reason === "" ? "locked, with no reason given" : `locked: ${reason}`;
|
|
41
|
+
return { state: "stays", reason: said, command: unlock };
|
|
42
|
+
}
|
|
43
|
+
const now = runningOf(pid);
|
|
44
|
+
if (now.at === "gone") {
|
|
45
|
+
return { state: "stale", held: reason, why: `pid ${pid} is gone, no process runs under it` };
|
|
46
|
+
}
|
|
47
|
+
if (now.at === "unknown") {
|
|
48
|
+
return { state: "stays", reason: `locked: ${reason}; ps could not say whether pid ${pid} runs`, command: unlock };
|
|
49
|
+
}
|
|
50
|
+
if (!now.starts.includes(squeezed(start))) {
|
|
51
|
+
const started = now.starts[0] ?? "";
|
|
52
|
+
return {
|
|
53
|
+
state: "stale",
|
|
54
|
+
held: reason,
|
|
55
|
+
why: `pid ${pid} is gone, it now belongs to another process (started ${started} UTC)`
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
state: "stays",
|
|
60
|
+
reason: `locked by a live Claude Code session: pid ${pid} is running (${reason})`,
|
|
61
|
+
command: null
|
|
62
|
+
};
|
|
63
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dw-sdlc",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "The Gate and the agentic config of a repository: the dw-sdlc bin with the Gate, the setup, the doctor, the config check, the Sync and setup of References, and the Ship script that drives a pull request to merged; the resolver of the config's layers, its JSON Schema and the validator that reads it.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|