@brainervirus/workit-core 8.35.0 → 8.36.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/package.json +1 -1
- package/skills/workit-implement/SKILL.md +1 -1
- package/skills/workit-ship/SKILL.md +1 -1
- package/src/core/workspaces.ts +58 -13
- package/src/hooks/context.ts +12 -4
- package/src/ledger.ts +118 -31
- package/src/stack.ts +135 -25
package/package.json
CHANGED
|
@@ -23,7 +23,7 @@ description: Build a requested change in small verified steps - follow local pat
|
|
|
23
23
|
(`workit check test`, plus `lint` and `typecheck` when the repo has them),
|
|
24
24
|
not after every edit. Heavy work runs under one machine-wide lease: an ad-hoc
|
|
25
25
|
suite is `workit check --heavy -- <cmd>`, never a hand-made flock; a busy
|
|
26
|
-
lease waits and names its holder (`workit lock status`).
|
|
26
|
+
lease waits and names its holder (`workit lock status`). A tool warning is a finding: fix it in this PR in the right layer, or record the false positive with evidence; pre-existing ones get their own PR.
|
|
27
27
|
5. Prove the feature on its real surface with the project's `verify-<app>`
|
|
28
28
|
skill; none yet? Write one (workit-verify-app). Tests show branch behavior,
|
|
29
29
|
not that the feature works. The report carries what you observed: a UI
|
|
@@ -49,7 +49,7 @@ the same check. Fix round and recheck both failed: a decider (workit-fanout).
|
|
|
49
49
|
5. **CI.** Local gates mirror CI: push and continue; `workit ci wait` once on
|
|
50
50
|
the final head, in the background where the host allows. Red: read `logTail`, classify. Flake or infra: one
|
|
51
51
|
`workit ci rerun --failed --reason flake|infra` per head. Real: reproduce
|
|
52
|
-
with `workit check`, fix the root cause, batch every fix in one push.
|
|
52
|
+
with `workit check`, fix the root cause, batch every fix in one push. A tool warning is a finding: fix it in this PR in the right layer, or record the false positive with evidence; pre-existing ones get their own PR.
|
|
53
53
|
6. **Verified.** The lane in `workit pr status` decides: none, no verdict; self, your `--self` verdict; focused or full (or `verification: self|independent`), one non-author verifier per PR after the last push, never per push (workit-review), on Claude Code with the `model` and `effort` of `workit models show --role verifier`. A docs-only push (or test-only under a unit verdict) keeps the verdict (`carried`); `reverify: focused` re-checks only the new diff, reusing green CI; one verdict at a stack's top covers its unchanged lower PRs. Land only when
|
|
54
54
|
granted: `workit stack land` or `workit pr merge`; `--unverified --reason` if user-asked.
|
|
55
55
|
7. **Observe it landed:** `workit verify-delivery pr` or `merge`. After a failed
|
package/src/core/workspaces.ts
CHANGED
|
@@ -103,7 +103,11 @@ const releaseTrackSchema = z
|
|
|
103
103
|
["requiredChecks", track.requiredChecks ?? []],
|
|
104
104
|
] as const) {
|
|
105
105
|
if (new Set(values).size !== values.length) {
|
|
106
|
-
context.addIssue({
|
|
106
|
+
context.addIssue({
|
|
107
|
+
code: "custom",
|
|
108
|
+
path: [field],
|
|
109
|
+
message: "entries must be unique",
|
|
110
|
+
});
|
|
107
111
|
}
|
|
108
112
|
}
|
|
109
113
|
if (
|
|
@@ -192,7 +196,10 @@ const workspaceConfigSchema = z
|
|
|
192
196
|
.passthrough()
|
|
193
197
|
.optional(),
|
|
194
198
|
youtrack: z
|
|
195
|
-
.object({
|
|
199
|
+
.object({
|
|
200
|
+
baseUrl: nonBlank.optional(),
|
|
201
|
+
link_issues: z.boolean().optional(),
|
|
202
|
+
})
|
|
196
203
|
.passthrough()
|
|
197
204
|
.optional(),
|
|
198
205
|
/**
|
|
@@ -302,9 +309,16 @@ export const workspacesRevision = (contents: string | null): string =>
|
|
|
302
309
|
|
|
303
310
|
export const validateWorkspacesDocument = (parsed: unknown, file: string): WorkspacesResult => {
|
|
304
311
|
if (!isConfigObject(parsed)) {
|
|
305
|
-
return {
|
|
312
|
+
return {
|
|
313
|
+
status: "malformed",
|
|
314
|
+
path: file,
|
|
315
|
+
entries: [],
|
|
316
|
+
error: `${file} is not a JSON object`,
|
|
317
|
+
};
|
|
306
318
|
}
|
|
307
|
-
const validated = workspacesFileSchema.safeParse(parsed, {
|
|
319
|
+
const validated = workspacesFileSchema.safeParse(parsed, {
|
|
320
|
+
reportInput: true,
|
|
321
|
+
});
|
|
308
322
|
if (!validated.success) {
|
|
309
323
|
const detail = validated.error.issues
|
|
310
324
|
.map((issue) => {
|
|
@@ -353,7 +367,12 @@ export const readWorkspacesResult = (dir: string = configDir()): WorkspacesResul
|
|
|
353
367
|
raw = readFileSync(file, "utf8");
|
|
354
368
|
} catch (error) {
|
|
355
369
|
if ((error as NodeJS.ErrnoException).code === "ENOENT")
|
|
356
|
-
return {
|
|
370
|
+
return {
|
|
371
|
+
status: "missing",
|
|
372
|
+
path: file,
|
|
373
|
+
entries: [],
|
|
374
|
+
revision: workspacesRevision(null),
|
|
375
|
+
};
|
|
357
376
|
return {
|
|
358
377
|
status: "malformed",
|
|
359
378
|
path: file,
|
|
@@ -373,7 +392,10 @@ export const readWorkspacesResult = (dir: string = configDir()): WorkspacesResul
|
|
|
373
392
|
error: `${file} is not valid JSON`,
|
|
374
393
|
};
|
|
375
394
|
}
|
|
376
|
-
return {
|
|
395
|
+
return {
|
|
396
|
+
...validateWorkspacesDocument(parsed, file),
|
|
397
|
+
revision: workspacesRevision(raw),
|
|
398
|
+
};
|
|
377
399
|
};
|
|
378
400
|
|
|
379
401
|
/** Parse the workspaces.json list under an explicit config dir; [] when missing/malformed. */
|
|
@@ -615,7 +637,7 @@ export const workspaceOverlaps = (
|
|
|
615
637
|
for (const b of entries.slice(i + 1)) {
|
|
616
638
|
const aInB = matchWorkspace(b.glob, globRoot(a.glob));
|
|
617
639
|
const bInA = matchWorkspace(a.glob, globRoot(b.glob));
|
|
618
|
-
if (!aInB && !bInA) continue;
|
|
640
|
+
if (!aInB && !bInA && !globsMayOverlap(a.glob, b.glob)) continue;
|
|
619
641
|
const [inner, outer] =
|
|
620
642
|
aInB && !bInA
|
|
621
643
|
? [a, b]
|
|
@@ -818,7 +840,10 @@ export const resolveRuntimeReleaseTracks = (
|
|
|
818
840
|
): { workspace: string; releaseTracks: unknown } | null => {
|
|
819
841
|
const matched = matchRuntimeWorkspace(cwd);
|
|
820
842
|
return matched
|
|
821
|
-
? {
|
|
843
|
+
? {
|
|
844
|
+
workspace: matched.selected.name,
|
|
845
|
+
releaseTracks: matched.selected.releaseTracks,
|
|
846
|
+
}
|
|
822
847
|
: null;
|
|
823
848
|
};
|
|
824
849
|
|
|
@@ -846,7 +871,10 @@ const inheritedSetting = (
|
|
|
846
871
|
const enclosing = matches
|
|
847
872
|
.filter((entry) => entry !== selected)
|
|
848
873
|
.toSorted((a, b) => globSpecificity(b.glob) - globSpecificity(a.glob));
|
|
849
|
-
return {
|
|
874
|
+
return {
|
|
875
|
+
workspace: selected.name,
|
|
876
|
+
source: [selected, ...enclosing].find(has) ?? null,
|
|
877
|
+
};
|
|
850
878
|
};
|
|
851
879
|
|
|
852
880
|
/** Whether `pr merge` deletes the head branch by default: true unless a covering entry sets `deleteBranch: false`. */
|
|
@@ -906,8 +934,18 @@ export const workspaceDelegation = (cwd: string, dir?: string): DelegationMode =
|
|
|
906
934
|
export const modelSources = (
|
|
907
935
|
cwd: string,
|
|
908
936
|
dir?: string,
|
|
909
|
-
): Array<{
|
|
910
|
-
|
|
937
|
+
): Array<{
|
|
938
|
+
name: string;
|
|
939
|
+
global: boolean;
|
|
940
|
+
models: unknown;
|
|
941
|
+
tierModels: unknown;
|
|
942
|
+
}> => {
|
|
943
|
+
const out: Array<{
|
|
944
|
+
name: string;
|
|
945
|
+
global: boolean;
|
|
946
|
+
models: unknown;
|
|
947
|
+
tierModels: unknown;
|
|
948
|
+
}> = [];
|
|
911
949
|
try {
|
|
912
950
|
const matched = matchRuntimeWorkspace(cwd, dir);
|
|
913
951
|
if (matched) {
|
|
@@ -1099,7 +1137,10 @@ export const resolveWorkspacePolicy = (
|
|
|
1099
1137
|
status: "resolved" as const,
|
|
1100
1138
|
profileName: null,
|
|
1101
1139
|
workspace: null,
|
|
1102
|
-
provenance: {
|
|
1140
|
+
provenance: {
|
|
1141
|
+
branchPolicy: "user-default",
|
|
1142
|
+
commitPolicy: "user-default",
|
|
1143
|
+
},
|
|
1103
1144
|
};
|
|
1104
1145
|
if (selected.status === "invalid") return selected;
|
|
1105
1146
|
return {
|
|
@@ -1130,7 +1171,11 @@ export const selectReleaseTrack = (
|
|
|
1130
1171
|
error: `workspace ${JSON.stringify(workspace.name)} has no release track ${JSON.stringify(requestedTrack)}`,
|
|
1131
1172
|
};
|
|
1132
1173
|
}
|
|
1133
|
-
return {
|
|
1174
|
+
return {
|
|
1175
|
+
status: "selected",
|
|
1176
|
+
name: requestedTrack,
|
|
1177
|
+
track: tracks[requestedTrack],
|
|
1178
|
+
};
|
|
1134
1179
|
};
|
|
1135
1180
|
|
|
1136
1181
|
/**
|
package/src/hooks/context.ts
CHANGED
|
@@ -223,7 +223,10 @@ function entryCompactContext(
|
|
|
223
223
|
return hit.value;
|
|
224
224
|
const task = entry ? store.readTask(entry.id) : null;
|
|
225
225
|
if (task && !task.ok) return null;
|
|
226
|
-
const freshness: Freshness = {
|
|
226
|
+
const freshness: Freshness = {
|
|
227
|
+
mode: "signal",
|
|
228
|
+
current: () => recentSignal(store.root),
|
|
229
|
+
};
|
|
227
230
|
const observed = task?.data.evidence.some(isObservedCheck) ?? false;
|
|
228
231
|
const files = (task?.data.decisions ?? []).flatMap((decision) =>
|
|
229
232
|
decision.data.binding.contentRefs.flatMap((ref) =>
|
|
@@ -298,7 +301,6 @@ export const sessionContextText = (
|
|
|
298
301
|
return `<workit-contract>\n${invariantBootstrap()}${compact}${offer ? `\n${offer}` : ""}${options.addendum ? `\n${options.addendum}` : ""}\n</workit-contract>`;
|
|
299
302
|
};
|
|
300
303
|
|
|
301
|
-
/** Per-turn task context only (no bootstrap or offer), or null when none applies. */
|
|
302
304
|
/** The checkout holding `cwd` and its branch, read from the git dir (no spawn). */
|
|
303
305
|
const checkoutAt = (cwd: string): { root: string; branch: string | null } | null => {
|
|
304
306
|
for (let dir = path.resolve(cwd); ; dir = path.dirname(dir)) {
|
|
@@ -311,7 +313,10 @@ const checkoutAt = (cwd: string): { root: string; branch: string | null } | null
|
|
|
311
313
|
)
|
|
312
314
|
: dotGit;
|
|
313
315
|
const head = fs.readFileSync(path.join(admin, "HEAD"), "utf8").trim();
|
|
314
|
-
return {
|
|
316
|
+
return {
|
|
317
|
+
root: dir,
|
|
318
|
+
branch: /^ref: refs\/heads\/(.+)$/u.exec(head)?.[1] ?? null,
|
|
319
|
+
};
|
|
315
320
|
}
|
|
316
321
|
if (path.dirname(dir) === dir) return null;
|
|
317
322
|
}
|
|
@@ -333,6 +338,7 @@ export const whereLine = (cwd: string): string | null => {
|
|
|
333
338
|
}
|
|
334
339
|
};
|
|
335
340
|
|
|
341
|
+
/** Per-turn task context only (no bootstrap or offer), or null when none applies. */
|
|
336
342
|
export const turnContextText = (input: HookInput, descriptor: HostDescriptor): string | null => {
|
|
337
343
|
try {
|
|
338
344
|
const text = sessionCompactContext(
|
|
@@ -422,7 +428,9 @@ export const changedTurnContext = (input: HookInput, text: string | null): strin
|
|
|
422
428
|
if (!file) return text;
|
|
423
429
|
const digest = digestOf(text);
|
|
424
430
|
try {
|
|
425
|
-
const previous = JSON.parse(fs.readFileSync(file, "utf8")) as {
|
|
431
|
+
const previous = JSON.parse(fs.readFileSync(file, "utf8")) as {
|
|
432
|
+
digest?: unknown;
|
|
433
|
+
};
|
|
426
434
|
if (previous.digest === digest) return null;
|
|
427
435
|
} catch {
|
|
428
436
|
// Missing or unreadable digest: treat as changed.
|
package/src/ledger.ts
CHANGED
|
@@ -110,7 +110,11 @@ export const PR_ROW_TYPES: ReadonlySet<string> = new Set([
|
|
|
110
110
|
]);
|
|
111
111
|
|
|
112
112
|
/** Who wrote a row. Unverified: the environment says so (D18). */
|
|
113
|
-
export type LedgerActor = {
|
|
113
|
+
export type LedgerActor = {
|
|
114
|
+
host: string;
|
|
115
|
+
session: string | null;
|
|
116
|
+
agentId: string | null;
|
|
117
|
+
};
|
|
114
118
|
|
|
115
119
|
/** The code state a row was recorded against (§2.2). */
|
|
116
120
|
export type CodeKey = {
|
|
@@ -180,7 +184,11 @@ export type VerdictRow = RowCommon & {
|
|
|
180
184
|
untracked?: string[];
|
|
181
185
|
untrackedCount?: number;
|
|
182
186
|
};
|
|
183
|
-
export type HandoffRow = RowCommon & {
|
|
187
|
+
export type HandoffRow = RowCommon & {
|
|
188
|
+
type: "handoff";
|
|
189
|
+
note: string | null;
|
|
190
|
+
next: string;
|
|
191
|
+
};
|
|
184
192
|
/**
|
|
185
193
|
* A lead's standing order for every worker of a fanout ("no new dependencies").
|
|
186
194
|
* `stop` marks the stop line: running workers stop and report.
|
|
@@ -801,7 +809,11 @@ export type UnverifiedRow = {
|
|
|
801
809
|
reason: UnverifiedReason;
|
|
802
810
|
};
|
|
803
811
|
/** The hash chain over the file: rows the CLI wrote, rows from before the chain, and the rest. */
|
|
804
|
-
export type LedgerIntegrity = {
|
|
812
|
+
export type LedgerIntegrity = {
|
|
813
|
+
chained: number;
|
|
814
|
+
legacy: number;
|
|
815
|
+
unverified: UnverifiedRow[];
|
|
816
|
+
};
|
|
805
817
|
export type LedgerRead = {
|
|
806
818
|
path: string;
|
|
807
819
|
rows: ReadRow[];
|
|
@@ -950,7 +962,12 @@ export function readLedger(
|
|
|
950
962
|
});
|
|
951
963
|
return {
|
|
952
964
|
ok: true,
|
|
953
|
-
value: {
|
|
965
|
+
value: {
|
|
966
|
+
path: file.value,
|
|
967
|
+
rows,
|
|
968
|
+
skipped,
|
|
969
|
+
integrity: options.integrity ? integrity : null,
|
|
970
|
+
},
|
|
954
971
|
};
|
|
955
972
|
}
|
|
956
973
|
|
|
@@ -997,7 +1014,12 @@ const tamperedWarning = (cwd: string, breaks: readonly UnverifiedRow[]): string
|
|
|
997
1014
|
", ",
|
|
998
1015
|
)}${breaks.length > 3 ? ", …" : ""}): no verdict is accepted until it is repaired. ${tamperedUnblock(cwd)}`;
|
|
999
1016
|
|
|
1000
|
-
export type RowFilter = {
|
|
1017
|
+
export type RowFilter = {
|
|
1018
|
+
branch?: string;
|
|
1019
|
+
pr?: number;
|
|
1020
|
+
type?: string;
|
|
1021
|
+
last?: number;
|
|
1022
|
+
};
|
|
1001
1023
|
|
|
1002
1024
|
export function filterRows(rows: readonly ReadRow[], filter: RowFilter): ReadRow[] {
|
|
1003
1025
|
const matched = rows.filter(
|
|
@@ -1088,7 +1110,11 @@ export function codeKey(
|
|
|
1088
1110
|
|
|
1089
1111
|
export type VerdictBasis = "fresh" | "carried" | "stale" | "none";
|
|
1090
1112
|
|
|
1091
|
-
type Keyed = {
|
|
1113
|
+
type Keyed = {
|
|
1114
|
+
head: string | null;
|
|
1115
|
+
patchId: string | null;
|
|
1116
|
+
diffHash: string | null;
|
|
1117
|
+
};
|
|
1092
1118
|
|
|
1093
1119
|
/** fresh: same head · carried: same non-null patch-id AND exact diff · stale: otherwise. */
|
|
1094
1120
|
export function verdictBasis(verdict: Keyed, current: Keyed): Exclude<VerdictBasis, "none"> {
|
|
@@ -1170,7 +1196,11 @@ export type VerdictCheck = {
|
|
|
1170
1196
|
* What merge gates read: an accepted strong (`verified`/`tests-verified`)
|
|
1171
1197
|
* verdict and no current independent failure. `type-check-only` never counts.
|
|
1172
1198
|
*/
|
|
1173
|
-
accepted: {
|
|
1199
|
+
accepted: {
|
|
1200
|
+
accepted: boolean;
|
|
1201
|
+
verdict: ReadRow | null;
|
|
1202
|
+
reasons: RejectReason[];
|
|
1203
|
+
};
|
|
1174
1204
|
review: ReviewLabel;
|
|
1175
1205
|
/** The newest current, strong, self verdict when `review` is self-reviewed. */
|
|
1176
1206
|
selfVerdict: ReadRow | null;
|
|
@@ -1269,7 +1299,10 @@ export function authorSessions(
|
|
|
1269
1299
|
cwd: string,
|
|
1270
1300
|
rows: readonly ReadRow[],
|
|
1271
1301
|
branch: string,
|
|
1272
|
-
range: { base: string | null; head: string | null } = {
|
|
1302
|
+
range: { base: string | null; head: string | null } = {
|
|
1303
|
+
base: null,
|
|
1304
|
+
head: null,
|
|
1305
|
+
},
|
|
1273
1306
|
scope: BranchScope = branchScope(cwd, branch),
|
|
1274
1307
|
): Set<string> {
|
|
1275
1308
|
const sessions = new Set<string>();
|
|
@@ -1437,7 +1470,7 @@ function effectiveOf(
|
|
|
1437
1470
|
* head to the current head. With `exactIn` (the checkout), each link is
|
|
1438
1471
|
* re-read with exact blobs (sameOwnChange, #334), so a row recorded before
|
|
1439
1472
|
* that check carries a pass only when the branch's own files are identical
|
|
1440
|
-
* across the restack. Null keeps every observed link: a failure keeps
|
|
1473
|
+
* across the restack or the head is the judged head cleanly rebased (#468). Null keeps every observed link: a failure keeps
|
|
1441
1474
|
* applying through them (failing closed the other way).
|
|
1442
1475
|
*/
|
|
1443
1476
|
const restackExact = new WeakMap<ReadRow, boolean>();
|
|
@@ -1454,15 +1487,18 @@ function restackCarries(
|
|
|
1454
1487
|
const cwd = exactIn;
|
|
1455
1488
|
let known = restackExact.get(row);
|
|
1456
1489
|
if (known === undefined) {
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1490
|
+
// Identical own blobs, or the head is exactly the judged head cleanly
|
|
1491
|
+
// rebased onto the moved base (#468): a base edit elsewhere in a shared file.
|
|
1492
|
+
const range =
|
|
1493
|
+
typeof row.fromBase === "string" && typeof row.toBase === "string"
|
|
1494
|
+
? {
|
|
1495
|
+
from: fromHead,
|
|
1496
|
+
to: head,
|
|
1497
|
+
fromBase: row.fromBase,
|
|
1498
|
+
toBase: row.toBase,
|
|
1499
|
+
}
|
|
1500
|
+
: null;
|
|
1501
|
+
known = range !== null && (sameOwnChange(cwd, range) || cleanRebase(cwd, range));
|
|
1466
1502
|
restackExact.set(row, known);
|
|
1467
1503
|
}
|
|
1468
1504
|
return known;
|
|
@@ -1510,7 +1546,10 @@ export function checkVerdicts(
|
|
|
1510
1546
|
const cacheKey = `${base}\0${at}`;
|
|
1511
1547
|
let key = keyCache.get(cacheKey);
|
|
1512
1548
|
if (!key) {
|
|
1513
|
-
key = {
|
|
1549
|
+
key = {
|
|
1550
|
+
patchId: patchId(cwd, base, at),
|
|
1551
|
+
diffHash: diffHash(cwd, base, at),
|
|
1552
|
+
};
|
|
1514
1553
|
keyCache.set(cacheKey, key);
|
|
1515
1554
|
}
|
|
1516
1555
|
return key;
|
|
@@ -1575,7 +1614,10 @@ export function checkVerdicts(
|
|
|
1575
1614
|
const exactCarry = (row: ReadRow): boolean =>
|
|
1576
1615
|
FAILING.has(String(row.result)) || exactMatch(row) !== null;
|
|
1577
1616
|
const keyBasis = (row: ReadRow): Exclude<VerdictBasis, "none"> => {
|
|
1578
|
-
const basis = verdictBasis(row, {
|
|
1617
|
+
const basis = verdictBasis(row, {
|
|
1618
|
+
head,
|
|
1619
|
+
...keyFor(row.base ?? fallbackBase),
|
|
1620
|
+
});
|
|
1579
1621
|
return basis === "carried" && !exactCarry(row) ? "stale" : basis;
|
|
1580
1622
|
};
|
|
1581
1623
|
/** A pass the exact keys carry only through a clean rebase (the base also edited a branch file). */
|
|
@@ -1736,7 +1778,11 @@ export function checkVerdicts(
|
|
|
1736
1778
|
(entry) => entry.accepted && STRONG_RESULTS.has(String(entry.verdict.result)),
|
|
1737
1779
|
) ?? null;
|
|
1738
1780
|
const accepted = tampered.length
|
|
1739
|
-
? {
|
|
1781
|
+
? {
|
|
1782
|
+
accepted: false,
|
|
1783
|
+
verdict: null,
|
|
1784
|
+
reasons: ["ledger_tampered"] as RejectReason[],
|
|
1785
|
+
}
|
|
1740
1786
|
: newestAccepted && !failing
|
|
1741
1787
|
? { accepted: true, verdict: newestAccepted.verdict, reasons: [] }
|
|
1742
1788
|
: {
|
|
@@ -1789,7 +1835,10 @@ export function checkVerdicts(
|
|
|
1789
1835
|
selfVerdict: accepted.accepted ? null : (selfEntry?.verdict ?? null),
|
|
1790
1836
|
base: newestCurrent?.verdict.base ?? fallbackBase,
|
|
1791
1837
|
patchId: keyFor(newestCurrent?.verdict.base ?? fallbackBase).patchId,
|
|
1792
|
-
current: {
|
|
1838
|
+
current: {
|
|
1839
|
+
basis: newestCurrent?.basis ?? "none",
|
|
1840
|
+
verdict: newestCurrent?.verdict ?? null,
|
|
1841
|
+
},
|
|
1793
1842
|
carry: carryEntry?.carry ?? null,
|
|
1794
1843
|
reverify,
|
|
1795
1844
|
accepted,
|
|
@@ -1868,7 +1917,13 @@ function checkSupersede(
|
|
|
1868
1917
|
|
|
1869
1918
|
export function recordDecision(
|
|
1870
1919
|
context: RecordContext,
|
|
1871
|
-
input: {
|
|
1920
|
+
input: {
|
|
1921
|
+
what?: string;
|
|
1922
|
+
why?: string;
|
|
1923
|
+
refs?: string[];
|
|
1924
|
+
by?: string;
|
|
1925
|
+
views?: string[];
|
|
1926
|
+
},
|
|
1872
1927
|
): LedgerResult<DecisionRow> {
|
|
1873
1928
|
if (input.by !== undefined && input.by !== "decider")
|
|
1874
1929
|
return err("invalid_input", `--by takes "decider", got "${input.by}"`);
|
|
@@ -2081,7 +2136,11 @@ export function clearStanding(
|
|
|
2081
2136
|
/** Lift the stop line: clear each stop order in force and keep the others; lead only. */
|
|
2082
2137
|
export function liftStop(
|
|
2083
2138
|
context: RecordContext,
|
|
2084
|
-
input: {
|
|
2139
|
+
input: {
|
|
2140
|
+
fanout: string;
|
|
2141
|
+
lead: string | null;
|
|
2142
|
+
leads?: readonly string[] | null;
|
|
2143
|
+
},
|
|
2085
2144
|
): LedgerResult<StandingClearedRow[]> {
|
|
2086
2145
|
const lead = leadOnly(context, input.lead);
|
|
2087
2146
|
if (!lead.ok) return lead;
|
|
@@ -2242,10 +2301,16 @@ export function recordVerdict(
|
|
|
2242
2301
|
);
|
|
2243
2302
|
}
|
|
2244
2303
|
|
|
2245
|
-
function untrackedField(cwd: string): {
|
|
2304
|
+
function untrackedField(cwd: string): {
|
|
2305
|
+
untracked?: string[];
|
|
2306
|
+
untrackedCount?: number;
|
|
2307
|
+
} {
|
|
2246
2308
|
const files = untrackedFiles(cwd);
|
|
2247
2309
|
return files.length
|
|
2248
|
-
? {
|
|
2310
|
+
? {
|
|
2311
|
+
untracked: files.slice(0, MAX_UNTRACKED_LISTED),
|
|
2312
|
+
untrackedCount: files.length,
|
|
2313
|
+
}
|
|
2249
2314
|
: {};
|
|
2250
2315
|
}
|
|
2251
2316
|
|
|
@@ -2255,7 +2320,12 @@ export function recordHandoff(
|
|
|
2255
2320
|
): LedgerResult<HandoffRow> {
|
|
2256
2321
|
return appendRow<HandoffRow>(
|
|
2257
2322
|
context.cwd,
|
|
2258
|
-
{
|
|
2323
|
+
{
|
|
2324
|
+
...common(context, noteKey(context)),
|
|
2325
|
+
type: "handoff",
|
|
2326
|
+
note: input.note,
|
|
2327
|
+
next: input.next,
|
|
2328
|
+
},
|
|
2259
2329
|
{ now: context.now },
|
|
2260
2330
|
);
|
|
2261
2331
|
}
|
|
@@ -2404,7 +2474,12 @@ export function stackPosition(root: string, branch: string): StackPosition | nul
|
|
|
2404
2474
|
return null;
|
|
2405
2475
|
}
|
|
2406
2476
|
|
|
2407
|
-
export type CheckFreshness = {
|
|
2477
|
+
export type CheckFreshness = {
|
|
2478
|
+
name: string;
|
|
2479
|
+
result: string | null;
|
|
2480
|
+
fresh: boolean;
|
|
2481
|
+
at: string;
|
|
2482
|
+
};
|
|
2408
2483
|
|
|
2409
2484
|
type VerdictSummary = Omit<VerdictEntry, "verdict"> & { summary: string };
|
|
2410
2485
|
|
|
@@ -2453,7 +2528,11 @@ const dirtyState = (cwd: string): HandoffBrief["dirty"] => {
|
|
|
2453
2528
|
// Renames and copies carry the source path as the next entry.
|
|
2454
2529
|
if (entry[0] === "R" || entry[0] === "C") index += 1;
|
|
2455
2530
|
}
|
|
2456
|
-
return {
|
|
2531
|
+
return {
|
|
2532
|
+
dirty: paths.length > 0,
|
|
2533
|
+
files: paths.length,
|
|
2534
|
+
paths: paths.slice(0, MAX_DIRTY_PATHS),
|
|
2535
|
+
};
|
|
2457
2536
|
};
|
|
2458
2537
|
|
|
2459
2538
|
const nextCommandFor = (brief: Omit<HandoffBrief, "nextCommand" | "next">): string => {
|
|
@@ -2521,7 +2600,11 @@ export function buildHandoff(
|
|
|
2521
2600
|
}));
|
|
2522
2601
|
const lastHandoffRow = mine.findLast((row) => row.type === "handoff");
|
|
2523
2602
|
const partial: Omit<HandoffBrief, "nextCommand" | "next"> = {
|
|
2524
|
-
store: {
|
|
2603
|
+
store: {
|
|
2604
|
+
root: store.value.root,
|
|
2605
|
+
shared: store.value.shared,
|
|
2606
|
+
ledger: ledger.path,
|
|
2607
|
+
},
|
|
2525
2608
|
branch,
|
|
2526
2609
|
head,
|
|
2527
2610
|
subject: head ? git(cwd, ["log", "-1", "--format=%s", head]) : null,
|
|
@@ -2559,6 +2642,10 @@ export function buildHandoff(
|
|
|
2559
2642
|
const nextCommand = nextCommandFor(partial);
|
|
2560
2643
|
return {
|
|
2561
2644
|
ok: true,
|
|
2562
|
-
value: {
|
|
2645
|
+
value: {
|
|
2646
|
+
...partial,
|
|
2647
|
+
nextCommand,
|
|
2648
|
+
next: options.next?.trim() || nextCommand,
|
|
2649
|
+
},
|
|
2563
2650
|
};
|
|
2564
2651
|
}
|
package/src/stack.ts
CHANGED
|
@@ -160,7 +160,12 @@ export const stackDeps: {
|
|
|
160
160
|
// ---------------------------------------------------------------------------
|
|
161
161
|
// git plumbing
|
|
162
162
|
|
|
163
|
-
type GitRun = {
|
|
163
|
+
type GitRun = {
|
|
164
|
+
ok: boolean;
|
|
165
|
+
stdout: string;
|
|
166
|
+
stderr: string;
|
|
167
|
+
timedOut: boolean;
|
|
168
|
+
};
|
|
164
169
|
|
|
165
170
|
const gitRun = (cwd: string, args: string[], timeoutMs: number = GIT_TIMEOUTS.local): GitRun => {
|
|
166
171
|
const run = spawnSync("git", args, {
|
|
@@ -245,7 +250,13 @@ const trackedDirt = (dir: string): boolean => {
|
|
|
245
250
|
|
|
246
251
|
export type RestackOutcome =
|
|
247
252
|
| { ok: true; head: string }
|
|
248
|
-
| {
|
|
253
|
+
| {
|
|
254
|
+
ok: false;
|
|
255
|
+
conflict: boolean;
|
|
256
|
+
timedOut?: boolean;
|
|
257
|
+
worktree: string;
|
|
258
|
+
error: string;
|
|
259
|
+
};
|
|
249
260
|
|
|
250
261
|
/** Where sync checks out branches nobody has checked out (never the user's checkout). */
|
|
251
262
|
const scratchDir = (cwd: string): string | null => {
|
|
@@ -269,14 +280,26 @@ function gitRestack(
|
|
|
269
280
|
let dir = owner;
|
|
270
281
|
if (!dir) {
|
|
271
282
|
const scratch = scratchDir(cwd);
|
|
272
|
-
if (!scratch)
|
|
283
|
+
if (!scratch)
|
|
284
|
+
return {
|
|
285
|
+
ok: false,
|
|
286
|
+
conflict: false,
|
|
287
|
+
worktree: cwd,
|
|
288
|
+
error: "no store root",
|
|
289
|
+
};
|
|
273
290
|
fs.mkdirSync(scratch, { recursive: true });
|
|
274
291
|
dir = path.join(
|
|
275
292
|
scratch,
|
|
276
293
|
`${input.branch.replace(/[^A-Za-z0-9._-]+/gu, "_").slice(0, 40)}-${randomBytes(4).toString("hex")}`,
|
|
277
294
|
);
|
|
278
295
|
const added = gitRun(cwd, ["worktree", "add", "-q", dir, input.branch], GIT_TIMEOUTS.worktree);
|
|
279
|
-
if (!added.ok)
|
|
296
|
+
if (!added.ok)
|
|
297
|
+
return {
|
|
298
|
+
ok: false,
|
|
299
|
+
conflict: false,
|
|
300
|
+
worktree: cwd,
|
|
301
|
+
error: firstLine(added),
|
|
302
|
+
};
|
|
280
303
|
}
|
|
281
304
|
const run = gitRun(
|
|
282
305
|
dir,
|
|
@@ -304,7 +327,12 @@ function gitRestack(
|
|
|
304
327
|
error: `git rebase did not finish within ${stackDeps.rebaseTimeoutMs} ms`,
|
|
305
328
|
};
|
|
306
329
|
if (rebaseInProgress(dir))
|
|
307
|
-
return {
|
|
330
|
+
return {
|
|
331
|
+
ok: false,
|
|
332
|
+
conflict: true,
|
|
333
|
+
worktree: dir,
|
|
334
|
+
error: firstLine(run),
|
|
335
|
+
};
|
|
308
336
|
if (!owner) gitRun(cwd, ["worktree", "remove", "--force", dir]);
|
|
309
337
|
return { ok: false, conflict: false, worktree: dir, error: firstLine(run) };
|
|
310
338
|
}
|
|
@@ -312,7 +340,12 @@ function gitRestack(
|
|
|
312
340
|
const head = branchTip(cwd, input.branch);
|
|
313
341
|
return head
|
|
314
342
|
? { ok: true, head }
|
|
315
|
-
: {
|
|
343
|
+
: {
|
|
344
|
+
ok: false,
|
|
345
|
+
conflict: false,
|
|
346
|
+
worktree: dir,
|
|
347
|
+
error: `${input.branch} vanished`,
|
|
348
|
+
};
|
|
316
349
|
}
|
|
317
350
|
|
|
318
351
|
/** Remove temporary sync worktrees that are clean and not mid-rebase. */
|
|
@@ -489,7 +522,10 @@ export function listStacks(cwd: string): StackResult<StackFile[]> {
|
|
|
489
522
|
// Only files at the name's own path count (a renamed copy is ignored).
|
|
490
523
|
if (parsed && fileFor(parsed.name) === name) out.push(parsed);
|
|
491
524
|
}
|
|
492
|
-
return {
|
|
525
|
+
return {
|
|
526
|
+
ok: true,
|
|
527
|
+
data: out.toSorted((a, b) => a.name.localeCompare(b.name)),
|
|
528
|
+
};
|
|
493
529
|
}
|
|
494
530
|
|
|
495
531
|
/** The stack `name`, else the one holding the current branch, else the only one. */
|
|
@@ -539,7 +575,12 @@ const OWNERLESS_STALE_MS = 30_000;
|
|
|
539
575
|
|
|
540
576
|
const OWNER = "owner";
|
|
541
577
|
|
|
542
|
-
type LockOwner = {
|
|
578
|
+
type LockOwner = {
|
|
579
|
+
pid: number;
|
|
580
|
+
processStart: string | null;
|
|
581
|
+
host: string;
|
|
582
|
+
nonce: string;
|
|
583
|
+
};
|
|
543
584
|
|
|
544
585
|
const readOwner = (lock: string): { raw: string | null; age: number | null } => {
|
|
545
586
|
let raw: string | null = null;
|
|
@@ -1039,7 +1080,13 @@ export type BranchStatus = {
|
|
|
1039
1080
|
|
|
1040
1081
|
export type Qualification =
|
|
1041
1082
|
| { ok: true }
|
|
1042
|
-
| {
|
|
1083
|
+
| {
|
|
1084
|
+
ok: false;
|
|
1085
|
+
reason: StopReason;
|
|
1086
|
+
detail: string;
|
|
1087
|
+
unblock: string;
|
|
1088
|
+
ready: boolean;
|
|
1089
|
+
};
|
|
1043
1090
|
|
|
1044
1091
|
export type StatusOutcome = {
|
|
1045
1092
|
name: string;
|
|
@@ -1063,7 +1110,10 @@ const noun = (resolved: ResolvedForge, pr: number): string =>
|
|
|
1063
1110
|
function statusDoc(ctx: Ctx, pr: number): ForgeResult<PrStatusDoc> {
|
|
1064
1111
|
const status = ctx.resolved.forge.prStatus(pr);
|
|
1065
1112
|
if (!status.ok) return status;
|
|
1066
|
-
return buildStatusDoc(ctx.cwd, ctx.resolved, status.data, {
|
|
1113
|
+
return buildStatusDoc(ctx.cwd, ctx.resolved, status.data, {
|
|
1114
|
+
logLines: 0,
|
|
1115
|
+
behind: false,
|
|
1116
|
+
});
|
|
1067
1117
|
}
|
|
1068
1118
|
|
|
1069
1119
|
/**
|
|
@@ -1094,7 +1144,10 @@ export function repoMismatch(ctx: Pick<Ctx, "resolved">, stack: StackFile): Stac
|
|
|
1094
1144
|
"blocked",
|
|
1095
1145
|
`repo_mismatch: stack ${stack.name} was planned on ${stack.forge ?? "?"} ${stack.repo ?? "?"}, but this checkout resolves to ${kind} ${repo}`,
|
|
1096
1146
|
`workit stack plan --name ${stack.name} <branch…> # re-plan it here, or run from the right checkout`,
|
|
1097
|
-
{
|
|
1147
|
+
{
|
|
1148
|
+
planned: { forge: stack.forge, repo: stack.repo },
|
|
1149
|
+
resolved: { forge: kind, repo },
|
|
1150
|
+
},
|
|
1098
1151
|
);
|
|
1099
1152
|
return null;
|
|
1100
1153
|
}
|
|
@@ -1332,7 +1385,12 @@ export type SyncStep = {
|
|
|
1332
1385
|
pr: number | null;
|
|
1333
1386
|
parent: string;
|
|
1334
1387
|
/** null: already on its parent. */
|
|
1335
|
-
restack: {
|
|
1388
|
+
restack: {
|
|
1389
|
+
from: string;
|
|
1390
|
+
onto: string;
|
|
1391
|
+
head: string | null;
|
|
1392
|
+
carried: boolean | null;
|
|
1393
|
+
} | null;
|
|
1336
1394
|
push: { pushed: boolean; forced: boolean; sha: string } | null;
|
|
1337
1395
|
retarget: { from: string; to: string } | null;
|
|
1338
1396
|
};
|
|
@@ -1383,7 +1441,11 @@ function detectMerged(
|
|
|
1383
1441
|
isAncestor(cwd, tip, trunkSha);
|
|
1384
1442
|
}
|
|
1385
1443
|
if (merged) {
|
|
1386
|
-
entry.merged = {
|
|
1444
|
+
entry.merged = {
|
|
1445
|
+
pr: entry.pr,
|
|
1446
|
+
mergeSha: null,
|
|
1447
|
+
at: new Date().toISOString(),
|
|
1448
|
+
};
|
|
1387
1449
|
newly.push({ branch: entry.branch, pr: entry.pr });
|
|
1388
1450
|
}
|
|
1389
1451
|
}
|
|
@@ -1407,21 +1469,34 @@ function pushBranch(
|
|
|
1407
1469
|
const remote = remoteRefTip(cwd, plan.data.rawUrl, `refs/heads/${branch}`);
|
|
1408
1470
|
if (!remote.ok) return stackFail(remote.code, remote.error);
|
|
1409
1471
|
if (remote.sha === plan.data.sha)
|
|
1410
|
-
return {
|
|
1472
|
+
return {
|
|
1473
|
+
ok: true,
|
|
1474
|
+
data: { pushed: false, forced: false, sha: plan.data.sha },
|
|
1475
|
+
};
|
|
1411
1476
|
const grant = requireGrant(cwd, "push", { forge: !plan.data.local });
|
|
1412
1477
|
if (!grant.allowed)
|
|
1413
|
-
return stackFail("blocked", grant.error, grant.unblock, {
|
|
1478
|
+
return stackFail("blocked", grant.error, grant.unblock, {
|
|
1479
|
+
reason: grant.reason,
|
|
1480
|
+
});
|
|
1414
1481
|
const force = remote.sha !== null && !isAncestor(cwd, remote.sha, plan.data.sha);
|
|
1415
1482
|
const expect = !force
|
|
1416
1483
|
? null
|
|
1417
1484
|
: remote.sha === priorTip
|
|
1418
1485
|
? priorTip
|
|
1419
1486
|
: (recordedRemoteTip(cwd, plan.data) ?? null);
|
|
1420
|
-
const pushed = executePush(cwd, plan.data, {
|
|
1487
|
+
const pushed = executePush(cwd, plan.data, {
|
|
1488
|
+
forceWithLease: force,
|
|
1489
|
+
expect,
|
|
1490
|
+
actor,
|
|
1491
|
+
});
|
|
1421
1492
|
if (!pushed.ok) return { ...fromForge(pushed), data: { branch } };
|
|
1422
1493
|
return {
|
|
1423
1494
|
ok: true,
|
|
1424
|
-
data: {
|
|
1495
|
+
data: {
|
|
1496
|
+
pushed: pushed.data.pushed,
|
|
1497
|
+
forced: pushed.data.forced,
|
|
1498
|
+
sha: plan.data.sha,
|
|
1499
|
+
},
|
|
1425
1500
|
};
|
|
1426
1501
|
}
|
|
1427
1502
|
|
|
@@ -1584,7 +1659,11 @@ export function syncStack(
|
|
|
1584
1659
|
"blocked",
|
|
1585
1660
|
`conflict restacking ${entry.branch} onto ${parent.name}: ${rebased.error}`,
|
|
1586
1661
|
`resolve the conflicts in ${rebased.worktree}, git rebase --continue, then workit stack sync # or git rebase --abort`,
|
|
1587
|
-
{
|
|
1662
|
+
{
|
|
1663
|
+
conflict: entry.branch,
|
|
1664
|
+
worktree: rebased.worktree,
|
|
1665
|
+
progress: outcome,
|
|
1666
|
+
},
|
|
1588
1667
|
)
|
|
1589
1668
|
: stackFail(
|
|
1590
1669
|
"failed",
|
|
@@ -1670,13 +1749,21 @@ export function syncStack(
|
|
|
1670
1749
|
p: patchId(cwd, fromBase, fromHead),
|
|
1671
1750
|
d: diffHash(cwd, fromBase, fromHead),
|
|
1672
1751
|
};
|
|
1673
|
-
const after = {
|
|
1752
|
+
const after = {
|
|
1753
|
+
p: patchId(cwd, parentTip, newTip),
|
|
1754
|
+
d: diffHash(cwd, parentTip, newTip),
|
|
1755
|
+
};
|
|
1674
1756
|
const equal =
|
|
1675
1757
|
before.d !== null &&
|
|
1676
1758
|
before.d === after.d &&
|
|
1677
1759
|
before.p !== null &&
|
|
1678
1760
|
before.p === after.p &&
|
|
1679
|
-
sameOwnChange(cwd, {
|
|
1761
|
+
sameOwnChange(cwd, {
|
|
1762
|
+
from: fromHead,
|
|
1763
|
+
to: newTip,
|
|
1764
|
+
fromBase,
|
|
1765
|
+
toBase: parentTip,
|
|
1766
|
+
});
|
|
1680
1767
|
appendObserved(cwd, {
|
|
1681
1768
|
type: "stack.restacked",
|
|
1682
1769
|
actor,
|
|
@@ -1692,7 +1779,13 @@ export function syncStack(
|
|
|
1692
1779
|
forced,
|
|
1693
1780
|
});
|
|
1694
1781
|
if (step.restack) step.restack.carried = equal;
|
|
1695
|
-
else
|
|
1782
|
+
else
|
|
1783
|
+
step.restack = {
|
|
1784
|
+
from: fromBase,
|
|
1785
|
+
onto: parentTip,
|
|
1786
|
+
head: newTip,
|
|
1787
|
+
carried: equal,
|
|
1788
|
+
};
|
|
1696
1789
|
}
|
|
1697
1790
|
if (options.publish) {
|
|
1698
1791
|
touchStackLock();
|
|
@@ -1721,7 +1814,10 @@ export function syncStack(
|
|
|
1721
1814
|
});
|
|
1722
1815
|
const updated = ctx.resolved.forge.updateBase(entry.pr, parent.name);
|
|
1723
1816
|
if (!updated.ok)
|
|
1724
|
-
return {
|
|
1817
|
+
return {
|
|
1818
|
+
...fromForge(updated),
|
|
1819
|
+
data: { branch: entry.branch, progress: outcome },
|
|
1820
|
+
};
|
|
1725
1821
|
appendObserved(cwd, {
|
|
1726
1822
|
type: "pr.retargeted",
|
|
1727
1823
|
actor,
|
|
@@ -1766,6 +1862,8 @@ export type LandOptions = {
|
|
|
1766
1862
|
method: MergeMethod;
|
|
1767
1863
|
/** Land without verdicts (`merge: true` only); each bypass is recorded. */
|
|
1768
1864
|
unverified?: { reason: string } | null;
|
|
1865
|
+
/** Merge onto a red base (`--base-red-ok`); each merge records the reason. */
|
|
1866
|
+
baseRedOk?: { reason: string } | null;
|
|
1769
1867
|
/** How long to wait for CI on a PR that was just restacked. */
|
|
1770
1868
|
timeoutMs: number;
|
|
1771
1869
|
intervalMs: number;
|
|
@@ -1775,7 +1873,12 @@ export type LandOutcome = {
|
|
|
1775
1873
|
name: string;
|
|
1776
1874
|
dryRun: boolean;
|
|
1777
1875
|
/** `unverified`: the ledger row of an `--unverified` bypass, else null. */
|
|
1778
|
-
landed: Array<{
|
|
1876
|
+
landed: Array<{
|
|
1877
|
+
pr: number;
|
|
1878
|
+
branch: string;
|
|
1879
|
+
mergeSha: string | null;
|
|
1880
|
+
unverified: string | null;
|
|
1881
|
+
}>;
|
|
1779
1882
|
/** Dry run: the PRs that qualify now, in order (later ones assume a clean restack). */
|
|
1780
1883
|
wouldLand: Array<{ pr: number; branch: string }>;
|
|
1781
1884
|
stoppedAt: {
|
|
@@ -1849,7 +1952,9 @@ export async function landStack(
|
|
|
1849
1952
|
const grant = requireGrant(ctx.cwd, "merge");
|
|
1850
1953
|
if (!grant.allowed)
|
|
1851
1954
|
return failWith(
|
|
1852
|
-
stackFail("blocked", grant.error, grant.unblock, {
|
|
1955
|
+
stackFail("blocked", grant.error, grant.unblock, {
|
|
1956
|
+
reason: "grant_required",
|
|
1957
|
+
}),
|
|
1853
1958
|
);
|
|
1854
1959
|
if (!grant.allowUnverified)
|
|
1855
1960
|
return failWith(
|
|
@@ -1959,6 +2064,7 @@ export async function landStack(
|
|
|
1959
2064
|
actor: ctx.actor,
|
|
1960
2065
|
expect: { base: stack.trunk, branch: entry.branch },
|
|
1961
2066
|
...(bypass && !verdict.satisfied ? { unverified: bypass } : {}),
|
|
2067
|
+
...(options.baseRedOk ? { baseRedOk: options.baseRedOk } : {}),
|
|
1962
2068
|
},
|
|
1963
2069
|
ctx.sleep,
|
|
1964
2070
|
);
|
|
@@ -1982,7 +2088,11 @@ export async function landStack(
|
|
|
1982
2088
|
mergeSha: merged.data.mergeSha,
|
|
1983
2089
|
unverified: merged.data.unverified?.recorded ?? null,
|
|
1984
2090
|
});
|
|
1985
|
-
entry.merged = {
|
|
2091
|
+
entry.merged = {
|
|
2092
|
+
pr,
|
|
2093
|
+
mergeSha: merged.data.mergeSha,
|
|
2094
|
+
at: new Date().toISOString(),
|
|
2095
|
+
};
|
|
1986
2096
|
const written = writeStack(ctx.cwd, stack);
|
|
1987
2097
|
if (!written.ok) return failWith(written);
|
|
1988
2098
|
// Move the next PR onto the trunk: restack, lease push, retarget. The
|