@brainervirus/workit-core 8.24.0 → 8.25.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-fanout/SKILL.md +1 -1
- package/skills/workit-fanout/references/brief.md +4 -4
- package/skills/workit-review/SKILL.md +2 -0
- package/skills/workit-ship/SKILL.md +1 -1
- package/src/core/methods.ts +2 -2
- package/src/core/policy/carry.ts +74 -0
- package/src/core/policy/lane.ts +63 -14
- package/src/fanout-brief.ts +8 -6
- package/src/fanout-worktree.ts +1 -1
- package/src/forge/pr-ops.ts +11 -2
- package/src/git/default-base.ts +49 -0
- package/src/git/rev.ts +111 -0
- package/src/hooks/cwd-normalize.ts +44 -10
- package/src/hooks/delivery-verbs.ts +129 -0
- package/src/hooks/handle.ts +21 -6
- package/src/hooks/hosts/claude-code.ts +11 -0
- package/src/hooks/index.ts +6 -0
- package/src/hooks/protocol.ts +2 -0
- package/src/hooks/raw-git.ts +34 -8
- package/src/hooks/shell-words.ts +32 -1
- package/src/hooks/worktree-confine.ts +3 -3
- package/src/hooks/write-gate.ts +10 -1
- package/src/ledger.ts +148 -50
- package/src/stack.ts +14 -4
- package/src/verdict-cover.ts +151 -0
package/src/ledger.ts
CHANGED
|
@@ -33,7 +33,10 @@
|
|
|
33
33
|
// `base`, the stable patch-id of `base...head` and an exact diff hash. It is
|
|
34
34
|
// `fresh` (same head; verdicts on a dirty worktree are refused, so the head
|
|
35
35
|
// is the judged tree), `carried` (new head, same patch-id AND same exact
|
|
36
|
-
// diff
|
|
36
|
+
// diff AND, for a pass, byte-identical own files the base left alone: a
|
|
37
|
+
// rebase that only moved the base; or a trusted verdict whose
|
|
38
|
+
// follow-up change is lane none, or test-only for a unit verdict, see
|
|
39
|
+
// core/policy/carry.ts) or `stale`.
|
|
37
40
|
// - Integrity (D18): every row the CLI appends carries `prevHash` (sha256 of
|
|
38
41
|
// the previous line) and `rowHash` (sha256 over `prevHash` and the row), a
|
|
39
42
|
// running chain. A row without it after the chain began, or whose hashes do
|
|
@@ -54,8 +57,16 @@ import { createHash, randomBytes } from "node:crypto";
|
|
|
54
57
|
import fs from "node:fs";
|
|
55
58
|
import path from "node:path";
|
|
56
59
|
import { hostAwareLockTimeout } from "./core/store-lock";
|
|
57
|
-
import {
|
|
58
|
-
import {
|
|
60
|
+
import { verdictDrift, type CarryRule, type Drift } from "./core/policy/carry";
|
|
61
|
+
import { defaultBase } from "./git/default-base";
|
|
62
|
+
import {
|
|
63
|
+
GIT_TIMEOUTS,
|
|
64
|
+
currentBranch,
|
|
65
|
+
headSha,
|
|
66
|
+
patchId,
|
|
67
|
+
sameOwnChange,
|
|
68
|
+
worktreeTree,
|
|
69
|
+
} from "./git/rev";
|
|
59
70
|
import { hostSessionFromEnv, issuedBinding } from "./host-session";
|
|
60
71
|
import { bumpStandingRev } from "./hooks/standing-signal";
|
|
61
72
|
import { resolveStore } from "./store/paths";
|
|
@@ -281,47 +292,8 @@ export function branchHead(cwd: string, branch: string): string | null {
|
|
|
281
292
|
);
|
|
282
293
|
}
|
|
283
294
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
* configures tracks (as `fanout plan` and `stack plan` pick their trunk); a
|
|
287
|
-
* plain workspace default does not override origin's default branch.
|
|
288
|
-
*/
|
|
289
|
-
const trackTrunk = (cwd: string): string | null => {
|
|
290
|
-
try {
|
|
291
|
-
const resolved = vcsConfig("resolve", cwd);
|
|
292
|
-
if (resolved.ok === false || !resolved.releaseTrack || resolved.releaseTrack.blocking)
|
|
293
|
-
return null;
|
|
294
|
-
const target = String(resolved.defaultTargetBranch ?? "").trim();
|
|
295
|
-
return target && safeRef(target) ? target : null;
|
|
296
|
-
} catch {
|
|
297
|
-
return null;
|
|
298
|
-
}
|
|
299
|
-
};
|
|
300
|
-
|
|
301
|
-
/**
|
|
302
|
-
* The default base for patch-ids and author trailers: the release track's PR
|
|
303
|
-
* target (origin's copy, else the local one) when tracks are configured, else
|
|
304
|
-
* origin's HEAD branch, else the first of origin/main, origin/master, main,
|
|
305
|
-
* master, origin/develop, develop that exists.
|
|
306
|
-
*/
|
|
307
|
-
export function defaultBase(cwd: string): string | null {
|
|
308
|
-
const trunk = trackTrunk(cwd);
|
|
309
|
-
if (trunk)
|
|
310
|
-
for (const candidate of [`origin/${trunk}`, trunk])
|
|
311
|
-
if (resolveCommit(cwd, candidate)) return candidate;
|
|
312
|
-
const originHead = git(cwd, ["symbolic-ref", "-q", "--short", "refs/remotes/origin/HEAD"]);
|
|
313
|
-
if (originHead && resolveCommit(cwd, originHead)) return originHead;
|
|
314
|
-
for (const candidate of [
|
|
315
|
-
"origin/main",
|
|
316
|
-
"origin/master",
|
|
317
|
-
"main",
|
|
318
|
-
"master",
|
|
319
|
-
"origin/develop",
|
|
320
|
-
"develop",
|
|
321
|
-
])
|
|
322
|
-
if (resolveCommit(cwd, candidate)) return candidate;
|
|
323
|
-
return null;
|
|
324
|
-
}
|
|
295
|
+
// The default base lives in git/default-base.ts (shared with the review lane).
|
|
296
|
+
export { defaultBase };
|
|
325
297
|
|
|
326
298
|
/**
|
|
327
299
|
* sha256 of the exact `base...head` diff with the pinned options rev.ts uses
|
|
@@ -1136,9 +1108,21 @@ export type VerdictEntry = {
|
|
|
1136
1108
|
/** current AND passing AND independent. */
|
|
1137
1109
|
accepted: boolean;
|
|
1138
1110
|
reasons: RejectReason[];
|
|
1111
|
+
/** Why a verdict on an older head still applies after a follow-up change (core/policy/carry.ts). */
|
|
1112
|
+
carry: CarryNote | null;
|
|
1139
1113
|
verdict: ReadRow;
|
|
1140
1114
|
};
|
|
1141
1115
|
|
|
1116
|
+
/** A verdict carried over a follow-up change: by which rule, from which judged head, over which files. */
|
|
1117
|
+
export type CarryNote = { rule: CarryRule; since: string; files: string[] };
|
|
1118
|
+
|
|
1119
|
+
/** A stale verdict whose follow-up change is small and off risk paths: re-verify only these files. */
|
|
1120
|
+
export type Reverify = { scope: "focused"; since: string; files: string[] };
|
|
1121
|
+
|
|
1122
|
+
/** `carried (lane none since <head>)`: how a carried verdict reads in `ledger check` and `pr status`. */
|
|
1123
|
+
export const carryLabel = (carry: CarryNote): string =>
|
|
1124
|
+
`carried (${carry.rule === "lane-none" ? "lane none" : "tests only"} since ${carry.since.slice(0, 12)})`;
|
|
1125
|
+
|
|
1142
1126
|
export type VerdictCheck = {
|
|
1143
1127
|
branch: string;
|
|
1144
1128
|
head: string | null;
|
|
@@ -1146,6 +1130,10 @@ export type VerdictCheck = {
|
|
|
1146
1130
|
patchId: string | null;
|
|
1147
1131
|
/** Newest current verdict (else newest verdict): does it still apply? */
|
|
1148
1132
|
current: { basis: VerdictBasis; verdict: ReadRow | null };
|
|
1133
|
+
/** The carry of the accepted verdict (else of the newest current one), when a follow-up change carried it. */
|
|
1134
|
+
carry: CarryNote | null;
|
|
1135
|
+
/** No verdict applies, but the newest passing one is stale only by a small change: re-verify that. */
|
|
1136
|
+
reverify: Reverify | null;
|
|
1149
1137
|
/**
|
|
1150
1138
|
* What merge gates read: an accepted strong (`verified`/`tests-verified`)
|
|
1151
1139
|
* verdict and no current independent failure. `type-check-only` never counts.
|
|
@@ -1276,7 +1264,7 @@ const derivesFrom = (derived: string, bare: string): boolean =>
|
|
|
1276
1264
|
* child that committed as `<lead>:<child>` and judges as `<child>` is still
|
|
1277
1265
|
* the author). Two derived ids never match on a shared last segment.
|
|
1278
1266
|
*/
|
|
1279
|
-
const authoredBy = (authors: Set<string>, session: string): boolean => {
|
|
1267
|
+
export const authoredBy = (authors: Set<string>, session: string): boolean => {
|
|
1280
1268
|
if (authors.has(session)) return true;
|
|
1281
1269
|
for (const author of authors)
|
|
1282
1270
|
if (derivesFrom(author, session) || derivesFrom(session, author)) return true;
|
|
@@ -1414,15 +1402,39 @@ function effectiveOf(
|
|
|
1414
1402
|
* each restack itself and records `stack.restacked` with `patchEqual` when
|
|
1415
1403
|
* the change relative to the parent is identical (same patch-id AND same
|
|
1416
1404
|
* exact diff hash, D18). A verdict carries when such observed rows link its
|
|
1417
|
-
* head to the current head.
|
|
1405
|
+
* head to the current head. With `exactIn` (the checkout), each link is
|
|
1406
|
+
* re-read with exact blobs (sameOwnChange, #334), so a row recorded before
|
|
1407
|
+
* that check carries a pass only when the branch's own files are identical
|
|
1408
|
+
* across the restack. Null keeps every observed link: a failure keeps
|
|
1409
|
+
* applying through them (failing closed the other way).
|
|
1418
1410
|
*/
|
|
1411
|
+
const restackExact = new WeakMap<ReadRow, boolean>();
|
|
1419
1412
|
function restackCarries(
|
|
1413
|
+
exactIn: string | null,
|
|
1420
1414
|
rows: readonly ReadRow[],
|
|
1421
1415
|
branch: string,
|
|
1422
1416
|
from: string | null,
|
|
1423
1417
|
to: string | null,
|
|
1424
1418
|
): boolean {
|
|
1425
1419
|
if (!from || !to) return false;
|
|
1420
|
+
const exact = (row: ReadRow, fromHead: string, head: string): boolean => {
|
|
1421
|
+
if (exactIn === null) return true;
|
|
1422
|
+
const cwd = exactIn;
|
|
1423
|
+
let known = restackExact.get(row);
|
|
1424
|
+
if (known === undefined) {
|
|
1425
|
+
known =
|
|
1426
|
+
typeof row.fromBase === "string" &&
|
|
1427
|
+
typeof row.toBase === "string" &&
|
|
1428
|
+
sameOwnChange(cwd, {
|
|
1429
|
+
from: fromHead,
|
|
1430
|
+
to: head,
|
|
1431
|
+
fromBase: row.fromBase,
|
|
1432
|
+
toBase: row.toBase,
|
|
1433
|
+
});
|
|
1434
|
+
restackExact.set(row, known);
|
|
1435
|
+
}
|
|
1436
|
+
return known;
|
|
1437
|
+
};
|
|
1426
1438
|
const edges = new Map<string, string[]>();
|
|
1427
1439
|
for (const row of rows)
|
|
1428
1440
|
if (
|
|
@@ -1432,7 +1444,8 @@ function restackCarries(
|
|
|
1432
1444
|
row.branch === branch &&
|
|
1433
1445
|
row.patchEqual === true &&
|
|
1434
1446
|
typeof row.fromHead === "string" &&
|
|
1435
|
-
row.head
|
|
1447
|
+
row.head &&
|
|
1448
|
+
exact(row, row.fromHead, row.head)
|
|
1436
1449
|
)
|
|
1437
1450
|
edges.set(row.fromHead, [...(edges.get(row.fromHead) ?? []), row.head]);
|
|
1438
1451
|
const seen = new Set<string>([from]);
|
|
@@ -1471,9 +1484,73 @@ export function checkVerdicts(
|
|
|
1471
1484
|
return key;
|
|
1472
1485
|
};
|
|
1473
1486
|
const keyFor = (base: string | null) => keysAt(base, head);
|
|
1474
|
-
|
|
1487
|
+
// The branch's own change since a trusted verdict's head, read once per
|
|
1488
|
+
// row (#334). A row the gates cannot trust never carries.
|
|
1489
|
+
const driftCache = new Map<ReadRow, Drift | null>();
|
|
1490
|
+
const driftOf = (row: ReadRow): Drift | null => {
|
|
1491
|
+
if (driftCache.has(row)) return driftCache.get(row) ?? null;
|
|
1492
|
+
let drift: Drift | null = null;
|
|
1493
|
+
const toBase = row.base ?? fallbackBase;
|
|
1494
|
+
const toBaseSha = toBase ? resolveCommit(cwd, toBase) : null;
|
|
1495
|
+
const fromBase = row.baseSha ?? toBaseSha;
|
|
1496
|
+
if (
|
|
1497
|
+
row.head &&
|
|
1498
|
+
head &&
|
|
1499
|
+
row.head !== head &&
|
|
1500
|
+
toBaseSha &&
|
|
1501
|
+
fromBase &&
|
|
1502
|
+
!untrusted(reasonsOf(row))
|
|
1503
|
+
)
|
|
1504
|
+
drift = verdictDrift(cwd, {
|
|
1505
|
+
from: row.head,
|
|
1506
|
+
to: head,
|
|
1507
|
+
fromBase,
|
|
1508
|
+
toBase: toBaseSha,
|
|
1509
|
+
kind: typeof row.kind === "string" ? row.kind : "review",
|
|
1510
|
+
});
|
|
1511
|
+
driftCache.set(row, drift);
|
|
1512
|
+
return drift;
|
|
1513
|
+
};
|
|
1514
|
+
// The same patch-id and diff hash are not enough to carry a pass (#334):
|
|
1515
|
+
// both drop hunk line numbers, so an edit moved within a file keeps them.
|
|
1516
|
+
// The branch's own files must be identical blobs too (sameOwnChange). A
|
|
1517
|
+
// failure keeps the looser carry: it fails closed by applying more.
|
|
1518
|
+
const exactCache = new Map<ReadRow, boolean>();
|
|
1519
|
+
const exactCarry = (row: ReadRow): boolean => {
|
|
1520
|
+
if (FAILING.has(String(row.result))) return true;
|
|
1521
|
+
let exact = exactCache.get(row);
|
|
1522
|
+
if (exact === undefined) {
|
|
1523
|
+
const toBase = row.base ?? fallbackBase;
|
|
1524
|
+
const toBaseSha = toBase ? resolveCommit(cwd, toBase) : null;
|
|
1525
|
+
const fromBase = row.baseSha ?? toBaseSha;
|
|
1526
|
+
exact = Boolean(
|
|
1527
|
+
row.head &&
|
|
1528
|
+
head &&
|
|
1529
|
+
toBaseSha &&
|
|
1530
|
+
fromBase &&
|
|
1531
|
+
sameOwnChange(cwd, { from: row.head, to: head, fromBase, toBase: toBaseSha }),
|
|
1532
|
+
);
|
|
1533
|
+
exactCache.set(row, exact);
|
|
1534
|
+
}
|
|
1535
|
+
return exact;
|
|
1536
|
+
};
|
|
1537
|
+
const keyBasis = (row: ReadRow): Exclude<VerdictBasis, "none"> => {
|
|
1475
1538
|
const basis = verdictBasis(row, { head, ...keyFor(row.base ?? fallbackBase) });
|
|
1476
|
-
return basis === "
|
|
1539
|
+
return basis === "carried" && !exactCarry(row) ? "stale" : basis;
|
|
1540
|
+
};
|
|
1541
|
+
const restacked = (row: ReadRow): boolean =>
|
|
1542
|
+
restackCarries(FAILING.has(String(row.result)) ? null : cwd, rows, branch, row.head, head);
|
|
1543
|
+
/** The follow-up carry of a verdict the exact keys and restacks do not carry. */
|
|
1544
|
+
const carryOf = (row: ReadRow): CarryNote | null => {
|
|
1545
|
+
if (keyBasis(row) !== "stale") return null;
|
|
1546
|
+
if (restacked(row)) return null;
|
|
1547
|
+
const drift = driftOf(row);
|
|
1548
|
+
return drift?.carries ? { rule: drift.rule, since: drift.since, files: drift.files } : null;
|
|
1549
|
+
};
|
|
1550
|
+
const basisOf = (row: ReadRow): Exclude<VerdictBasis, "none"> => {
|
|
1551
|
+
const basis = keyBasis(row);
|
|
1552
|
+
if (basis !== "stale") return basis;
|
|
1553
|
+
return restacked(row) || carryOf(row) ? "carried" : basis;
|
|
1477
1554
|
};
|
|
1478
1555
|
// A newer row is judged under the failure's own base, so a row recorded
|
|
1479
1556
|
// with a different --base cannot make unchanged code look changed. When
|
|
@@ -1487,7 +1564,7 @@ export function checkVerdicts(
|
|
|
1487
1564
|
const keys = stored ? { patchId: row.patchId, diffHash: row.diffHash } : keysAt(base, row.head);
|
|
1488
1565
|
return (
|
|
1489
1566
|
verdictBasis(failed, { head: row.head, ...keys }) !== "stale" ||
|
|
1490
|
-
restackCarries(rows, branch, failed.head, row.head)
|
|
1567
|
+
restackCarries(null, rows, branch, failed.head, row.head)
|
|
1491
1568
|
);
|
|
1492
1569
|
};
|
|
1493
1570
|
const authors = authorSessions(cwd, rows, branch, { base: fallbackBase, head }, scope);
|
|
@@ -1566,6 +1643,7 @@ export function checkVerdicts(
|
|
|
1566
1643
|
independent: independence.length === 0,
|
|
1567
1644
|
accepted: reasons.length === 0,
|
|
1568
1645
|
reasons,
|
|
1646
|
+
carry: basis === "carried" ? carryOf(row) : null,
|
|
1569
1647
|
verdict: row,
|
|
1570
1648
|
});
|
|
1571
1649
|
// The author's own failure never displaces an independent pass, but it
|
|
@@ -1636,6 +1714,24 @@ export function checkVerdicts(
|
|
|
1636
1714
|
);
|
|
1637
1715
|
const selfEntry =
|
|
1638
1716
|
latestSelf && STRONG_RESULTS.has(String(latestSelf.verdict.result)) ? latestSelf : undefined;
|
|
1717
|
+
const carryEntry =
|
|
1718
|
+
verdicts.find((entry) => entry.verdict === accepted.verdict && entry.accepted) ?? newestCurrent;
|
|
1719
|
+
// A focused re-verify: nothing applies now, and the newest trusted pass is
|
|
1720
|
+
// stale only by a small change off risk paths (the drift's lane is self).
|
|
1721
|
+
const lastPass =
|
|
1722
|
+
accepted.accepted || failing || tampered.length
|
|
1723
|
+
? undefined
|
|
1724
|
+
: verdicts.findLast(
|
|
1725
|
+
(entry) =>
|
|
1726
|
+
!entry.current &&
|
|
1727
|
+
!untrusted(entry.reasons) &&
|
|
1728
|
+
STRONG_RESULTS.has(String(entry.verdict.result)),
|
|
1729
|
+
);
|
|
1730
|
+
const drift = lastPass ? driftOf(lastPass.verdict) : null;
|
|
1731
|
+
const reverify: Reverify | null =
|
|
1732
|
+
drift && !drift.carries && drift.focused && drift.files
|
|
1733
|
+
? { scope: "focused", since: drift.since, files: drift.files }
|
|
1734
|
+
: null;
|
|
1639
1735
|
return {
|
|
1640
1736
|
branch,
|
|
1641
1737
|
head,
|
|
@@ -1644,6 +1740,8 @@ export function checkVerdicts(
|
|
|
1644
1740
|
base: newestCurrent?.verdict.base ?? fallbackBase,
|
|
1645
1741
|
patchId: keyFor(newestCurrent?.verdict.base ?? fallbackBase).patchId,
|
|
1646
1742
|
current: { basis: newestCurrent?.basis ?? "none", verdict: newestCurrent?.verdict ?? null },
|
|
1743
|
+
carry: carryEntry?.carry ?? null,
|
|
1744
|
+
reverify,
|
|
1647
1745
|
accepted,
|
|
1648
1746
|
verdicts,
|
|
1649
1747
|
authors: [...authors].toSorted(),
|
package/src/stack.ts
CHANGED
|
@@ -62,10 +62,10 @@ import {
|
|
|
62
62
|
pushRemoteName,
|
|
63
63
|
remoteRefTip,
|
|
64
64
|
resolveRef,
|
|
65
|
+
sameOwnChange,
|
|
65
66
|
} from "./git/rev";
|
|
66
67
|
import {
|
|
67
68
|
appendObserved,
|
|
68
|
-
checkVerdicts,
|
|
69
69
|
diffHash,
|
|
70
70
|
readLedger,
|
|
71
71
|
storeRoot,
|
|
@@ -75,6 +75,7 @@ import {
|
|
|
75
75
|
} from "./ledger";
|
|
76
76
|
import type { LaneNeed, ReviewLane } from "./core/policy/lane";
|
|
77
77
|
import { prLaneGate } from "./core/policy/pr-lane";
|
|
78
|
+
import { checkVerdictsCovered, type StackCover } from "./verdict-cover";
|
|
78
79
|
|
|
79
80
|
export const STACK_VERSION = 1;
|
|
80
81
|
|
|
@@ -1031,6 +1032,8 @@ export type BranchStatus = {
|
|
|
1031
1032
|
verdictId: string | null;
|
|
1032
1033
|
lane: ReviewLane | null;
|
|
1033
1034
|
need: LaneNeed;
|
|
1035
|
+
/** The verdict higher in the stack that covers this branch, when it has none of its own. */
|
|
1036
|
+
cover: StackCover | null;
|
|
1034
1037
|
};
|
|
1035
1038
|
};
|
|
1036
1039
|
|
|
@@ -1102,7 +1105,8 @@ const verdictOf = (
|
|
|
1102
1105
|
rows: readonly ReadRow[],
|
|
1103
1106
|
doc: PrStatusDoc | null,
|
|
1104
1107
|
): BranchStatus["verdict"] => {
|
|
1105
|
-
|
|
1108
|
+
// A lower branch may be covered by the verdict at the top of the stack (#334).
|
|
1109
|
+
const check = checkVerdictsCovered(cwd, branch, rows);
|
|
1106
1110
|
// Without a PR there is no base to diff against: the gate stays independent.
|
|
1107
1111
|
const gate = doc ? prLaneGate(cwd, check, { branch, base: doc.base, head: doc.head.sha }) : null;
|
|
1108
1112
|
return {
|
|
@@ -1117,6 +1121,7 @@ const verdictOf = (
|
|
|
1117
1121
|
: null,
|
|
1118
1122
|
lane: gate?.lane.lane ?? null,
|
|
1119
1123
|
need: gate?.need ?? "non-author",
|
|
1124
|
+
cover: check.cover,
|
|
1120
1125
|
};
|
|
1121
1126
|
};
|
|
1122
1127
|
|
|
@@ -1257,6 +1262,7 @@ export function stackStatus(ctx: Ctx, stack: StackFile): StackResult<StatusOutco
|
|
|
1257
1262
|
verdictId: null,
|
|
1258
1263
|
lane: null,
|
|
1259
1264
|
need: "non-author",
|
|
1265
|
+
cover: null,
|
|
1260
1266
|
}
|
|
1261
1267
|
: verdictOf(ctx.cwd, entry.branch, ledger.value.rows, doc),
|
|
1262
1268
|
};
|
|
@@ -1536,7 +1542,7 @@ export function syncStack(
|
|
|
1536
1542
|
return stackFail(
|
|
1537
1543
|
"blocked",
|
|
1538
1544
|
`a rebase is in progress in ${owner}`,
|
|
1539
|
-
`git -C ${owner} rebase --continue # or --abort; then workit --cwd ${owner}
|
|
1545
|
+
`git -C ${owner} rebase --continue # or --abort; then workit stack --cwd ${owner} sync`,
|
|
1540
1546
|
{ conflict: entry.branch, worktree: owner, progress: outcome },
|
|
1541
1547
|
);
|
|
1542
1548
|
if (owner && trackedDirt(owner))
|
|
@@ -1666,7 +1672,11 @@ export function syncStack(
|
|
|
1666
1672
|
};
|
|
1667
1673
|
const after = { p: patchId(cwd, parentTip, newTip), d: diffHash(cwd, parentTip, newTip) };
|
|
1668
1674
|
const equal =
|
|
1669
|
-
before.d !== null &&
|
|
1675
|
+
before.d !== null &&
|
|
1676
|
+
before.d === after.d &&
|
|
1677
|
+
before.p !== null &&
|
|
1678
|
+
before.p === after.p &&
|
|
1679
|
+
sameOwnChange(cwd, { from: fromHead, to: newTip, fromBase, toBase: parentTip });
|
|
1670
1680
|
appendObserved(cwd, {
|
|
1671
1681
|
type: "stack.restacked",
|
|
1672
1682
|
actor,
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// Stack-wide verdicts (#334, decision mvk5q3io): one verifier at the top of
|
|
2
|
+
// a stack, after its last push, covers the lower PRs it judged. A branch with
|
|
3
|
+
// no accepted verdict of its own is covered by a branch above it in a cached
|
|
4
|
+
// stack (`<store>/stacks/*.json`, S12) when:
|
|
5
|
+
//
|
|
6
|
+
// - that branch has an accepted independent verdict now (fresh or carried);
|
|
7
|
+
// - the verdict's session is not an author of the lower branch either;
|
|
8
|
+
// - every commit of the lower branch since its fork from the trunk has a
|
|
9
|
+
// verbatim patch-id (whitespace counts) among the commits of the judged
|
|
10
|
+
// head (the verdict's own head, not the upper branch's current tip): the
|
|
11
|
+
// lower change was judged unchanged. No merge commits, at least one
|
|
12
|
+
// non-empty commit;
|
|
13
|
+
// - the judged head holds exactly the lower branch's resulting files (no
|
|
14
|
+
// `<lower>..<judged>` difference in any file the lower branch changed):
|
|
15
|
+
// an upper commit that reverts or rewrites a lower file means the lower
|
|
16
|
+
// PR's own code was never judged;
|
|
17
|
+
// - the lower branch's own review lane is below full (decision B3): a risk
|
|
18
|
+
// path or a large change needs its own verdict.
|
|
19
|
+
//
|
|
20
|
+
// A lower branch that changed after the verdict (a new commit, an amend, a
|
|
21
|
+
// conflict resolved differently) holds a patch-id the judged head lacks and
|
|
22
|
+
// is not covered. When git cannot read any of this, nothing is covered. A current independent failure or a tampered ledger on the
|
|
23
|
+
// lower branch is never covered over. `pr merge`, `stack land`, `stack
|
|
24
|
+
// status`, `pr status` and `ledger check` read this; the Stop hook does not.
|
|
25
|
+
import fs from "node:fs";
|
|
26
|
+
import path from "node:path";
|
|
27
|
+
import { branchLane } from "./core/policy/lane";
|
|
28
|
+
import { commitPatchIds, diffRaw, mergeBase } from "./git/rev";
|
|
29
|
+
import {
|
|
30
|
+
authorSessions,
|
|
31
|
+
authoredBy,
|
|
32
|
+
checkVerdicts,
|
|
33
|
+
defaultBase,
|
|
34
|
+
resolveCommit,
|
|
35
|
+
storeRoot,
|
|
36
|
+
type ReadRow,
|
|
37
|
+
type VerdictCheck,
|
|
38
|
+
} from "./ledger";
|
|
39
|
+
|
|
40
|
+
/** The verdict above `branch` in a stack that covers it. */
|
|
41
|
+
export type StackCover = {
|
|
42
|
+
stack: string;
|
|
43
|
+
/** The upper branch the verdict was recorded on. */
|
|
44
|
+
branch: string;
|
|
45
|
+
verdictId: string;
|
|
46
|
+
/** The head that verdict judged. */
|
|
47
|
+
head: string;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export type CoveredCheck = VerdictCheck & { cover: StackCover | null };
|
|
51
|
+
|
|
52
|
+
type Chain = { name: string; trunk: string; above: string[] };
|
|
53
|
+
|
|
54
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
55
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
56
|
+
|
|
57
|
+
/** The open branches above `branch` in each cached stack holding it, top first. */
|
|
58
|
+
function chainsAbove(cwd: string, branch: string): Chain[] {
|
|
59
|
+
const root = storeRoot(cwd);
|
|
60
|
+
if (!root.ok) return [];
|
|
61
|
+
const dir = path.join(root.value.root, "stacks");
|
|
62
|
+
let names: string[] = [];
|
|
63
|
+
try {
|
|
64
|
+
names = fs.readdirSync(dir).filter((name) => name.endsWith(".json"));
|
|
65
|
+
} catch {
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
const chains: Chain[] = [];
|
|
69
|
+
for (const name of names.toSorted()) {
|
|
70
|
+
let parsed: unknown;
|
|
71
|
+
try {
|
|
72
|
+
parsed = JSON.parse(fs.readFileSync(path.join(dir, name), "utf8"));
|
|
73
|
+
} catch {
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (!isRecord(parsed) || !Array.isArray(parsed.branches) || typeof parsed.trunk !== "string")
|
|
77
|
+
continue;
|
|
78
|
+
const entries = parsed.branches.filter(isRecord);
|
|
79
|
+
const index = entries.findIndex((entry) => entry.branch === branch && !entry.merged);
|
|
80
|
+
if (index < 0) continue;
|
|
81
|
+
chains.push({
|
|
82
|
+
name: typeof parsed.name === "string" ? parsed.name : name,
|
|
83
|
+
trunk: parsed.trunk,
|
|
84
|
+
above: entries
|
|
85
|
+
.slice(index + 1)
|
|
86
|
+
.filter((entry) => typeof entry.branch === "string" && !entry.merged)
|
|
87
|
+
.map((entry) => entry.branch as string)
|
|
88
|
+
.toReversed(),
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
return chains;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Whether the verdict on `judged` covers the lower branch at `head` (see the
|
|
96
|
+
* header). Anything git cannot read is not covered.
|
|
97
|
+
*/
|
|
98
|
+
function covers(cwd: string, trunk: string, head: string, judged: string): boolean {
|
|
99
|
+
const trunkSha = [`origin/${trunk}`, trunk].map((ref) => resolveCommit(cwd, ref)).find(Boolean);
|
|
100
|
+
const ownFork = trunkSha ? mergeBase(cwd, trunkSha, head) : null;
|
|
101
|
+
const judgedFork = trunkSha ? mergeBase(cwd, trunkSha, judged) : null;
|
|
102
|
+
if (!ownFork || !judgedFork) return false;
|
|
103
|
+
const own = commitPatchIds(cwd, ownFork, head);
|
|
104
|
+
const top = commitPatchIds(cwd, judgedFork, judged);
|
|
105
|
+
if (!own || !top || own.merges > 0 || own.patchIds.length === 0) return false;
|
|
106
|
+
const judgedIds = new Set(top.patchIds);
|
|
107
|
+
if (!own.patchIds.every((id) => judgedIds.has(id))) return false;
|
|
108
|
+
// The judged tree holds exactly the lower branch's resulting files.
|
|
109
|
+
const changed = diffRaw(cwd, ownFork, head);
|
|
110
|
+
const between = changed ? diffRaw(cwd, head, judged) : null;
|
|
111
|
+
if (!changed || !between) return false;
|
|
112
|
+
const files = new Set(changed.map((entry) => entry.path));
|
|
113
|
+
if (between.some((entry) => files.has(entry.path))) return false;
|
|
114
|
+
return branchLane(cwd, { base: trunk, head }).lane !== "full";
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* `checkVerdicts` for `branch`, and when it has no accepted verdict of its
|
|
119
|
+
* own, the stack verdict that covers it (see the header): then `accepted`
|
|
120
|
+
* names that verdict and `cover` says where it came from.
|
|
121
|
+
*/
|
|
122
|
+
export function checkVerdictsCovered(
|
|
123
|
+
cwd: string,
|
|
124
|
+
branch: string,
|
|
125
|
+
rows: readonly ReadRow[],
|
|
126
|
+
): CoveredCheck {
|
|
127
|
+
const check = checkVerdicts(cwd, branch, rows);
|
|
128
|
+
const blocked = check.accepted.reasons.some(
|
|
129
|
+
(reason) => reason === "failing_verdict" || reason === "ledger_tampered",
|
|
130
|
+
);
|
|
131
|
+
if (check.accepted.accepted || blocked || !check.head) return { ...check, cover: null };
|
|
132
|
+
let authors: Set<string> | null = null;
|
|
133
|
+
for (const chain of chainsAbove(cwd, branch))
|
|
134
|
+
for (const upper of chain.above) {
|
|
135
|
+
const verdict = checkVerdicts(cwd, upper, rows).accepted;
|
|
136
|
+
const row = verdict.accepted ? verdict.verdict : null;
|
|
137
|
+
const session = row?.actor.session;
|
|
138
|
+
if (!row?.head || !session || typeof row.id !== "string") continue;
|
|
139
|
+
authors ??= authorSessions(cwd, rows, branch, { base: defaultBase(cwd), head: check.head });
|
|
140
|
+
if (authoredBy(authors, session)) continue;
|
|
141
|
+
if (!covers(cwd, chain.trunk, check.head, row.head)) continue;
|
|
142
|
+
return {
|
|
143
|
+
...check,
|
|
144
|
+
accepted: { accepted: true, verdict: row, reasons: [] },
|
|
145
|
+
review: "verified",
|
|
146
|
+
selfVerdict: null,
|
|
147
|
+
cover: { stack: chain.name, branch: upper, verdictId: row.id, head: row.head },
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
return { ...check, cover: null };
|
|
151
|
+
}
|