agent-coord-mcp 0.26.20 → 0.26.21
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/dist/gated-head.js +130 -0
- package/dist/gated-head.js.map +1 -0
- package/dist/server.js +2 -0
- package/dist/server.js.map +1 -1
- package/dist/tools/queue-write.js +431 -0
- package/dist/tools/queue-write.js.map +1 -0
- package/dist/tools/records.js +164 -6
- package/dist/tools/records.js.map +1 -1
- package/dist/tools/tree-provenance.js +107 -0
- package/dist/tools/tree-provenance.js.map +1 -0
- package/package.json +1 -1
- package/src/gated-head.ts +134 -0
- package/src/server.ts +8 -0
- package/src/tools/queue-write.ts +485 -0
- package/src/tools/records.ts +188 -6
- package/src/tools/tree-provenance.ts +136 -0
package/src/tools/records.ts
CHANGED
|
@@ -15,6 +15,8 @@ import { execFileSync } from "node:child_process";
|
|
|
15
15
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
16
16
|
import path from "node:path";
|
|
17
17
|
import { z } from "zod";
|
|
18
|
+
|
|
19
|
+
import { treeProvenance } from "./tree-provenance.js";
|
|
18
20
|
import {
|
|
19
21
|
parseWorkDoc,
|
|
20
22
|
renderWorkDoc,
|
|
@@ -34,6 +36,8 @@ import {
|
|
|
34
36
|
type DoneEntry,
|
|
35
37
|
} from "@davidbalzan/groundwork-seam";
|
|
36
38
|
import { ensureWorktreeTool } from "./worktrees.js";
|
|
39
|
+
import { ROOT } from "../store.js";
|
|
40
|
+
import { verdictsFor, gatedAt } from "../gated-head.js";
|
|
37
41
|
import { boardRefFor, classifyBoardRef } from "./board-ref.js";
|
|
38
42
|
import { haltState, isInFlightStatus } from "./stall.js";
|
|
39
43
|
import { readSubs, evaluate, commitEvaluation, eventIsDerived, type RecordEvent } from "./events.js";
|
|
@@ -387,8 +391,15 @@ export async function nextUnblockedTool(args: { project: string; repo?: string }
|
|
|
387
391
|
};
|
|
388
392
|
}
|
|
389
393
|
const repo = args.repo ?? process.cwd();
|
|
394
|
+
// ⛔⛆ SAY WHICH TREE THIS ANSWER CAME FROM (⟨q-c1af2db3⟩). This verb reads
|
|
395
|
+
// `docs/QUEUE.md` out of a working tree nobody owns, and a stale one produced
|
|
396
|
+
// a confidently wrong routing decision in both directions inside ten minutes —
|
|
397
|
+
// a row ruled un-claimable from a stale file, and a worker told to hold on a
|
|
398
|
+
// row that was already split. The answer was well-formed and said nothing
|
|
399
|
+
// about its source, which is what made it invisible.
|
|
400
|
+
const tree = treeProvenance(repo);
|
|
390
401
|
const q = readDoc(repo, QUEUE_DOC);
|
|
391
|
-
if (!q) return { ok: false as const, error: `no ${QUEUE_DOC} under '${repo}'
|
|
402
|
+
if (!q) return { ok: false as const, error: `no ${QUEUE_DOC} under '${repo}'`, tree };
|
|
392
403
|
const items = queueItemsOf(q.doc);
|
|
393
404
|
// The seam's own count of unticked rows — the number every axis must add back
|
|
394
405
|
// up to. Taken BEFORE any exclusion so it cannot inherit one.
|
|
@@ -533,6 +544,10 @@ export async function nextUnblockedTool(args: { project: string; repo?: string }
|
|
|
533
544
|
// the severity finding one file over. Say the axis is uninformative instead.
|
|
534
545
|
const undiscriminating = silent.length === open.length && open.length > 1;
|
|
535
546
|
return {
|
|
547
|
+
// ⛔ THE PROVENANCE TRAVELS WITH THE ANSWER, not in a second call. A
|
|
548
|
+
// routing answer whose tree is unnamed is the defect this row exists for.
|
|
549
|
+
tree,
|
|
550
|
+
...(tree.warning ? { staleWarning: tree.warning } : {}),
|
|
536
551
|
ok: true as const,
|
|
537
552
|
project: args.project,
|
|
538
553
|
open: open.length,
|
|
@@ -822,7 +837,29 @@ export const landSchema = {
|
|
|
822
837
|
result: z.string().optional(),
|
|
823
838
|
};
|
|
824
839
|
|
|
825
|
-
|
|
840
|
+
/**
|
|
841
|
+
* What the recording step needs to know about a merge: which head actually
|
|
842
|
+
* landed, and when. Injected so the report is provable without the network.
|
|
843
|
+
*/
|
|
844
|
+
export type MergeFacts = { headRefOid: string; mergedAt: string } | null;
|
|
845
|
+
export type ReadMergeFacts = (repo: string, n: string) => MergeFacts;
|
|
846
|
+
const ghMergeFacts: ReadMergeFacts = (repo, n) => {
|
|
847
|
+
try {
|
|
848
|
+
const out = execFileSync("gh", ["pr", "view", n, "--json", "headRefOid,mergedAt"], {
|
|
849
|
+
cwd: repo,
|
|
850
|
+
encoding: "utf8",
|
|
851
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
852
|
+
});
|
|
853
|
+
const j = JSON.parse(out) as { headRefOid?: string; mergedAt?: string };
|
|
854
|
+
if (!j.headRefOid || !j.mergedAt) return null;
|
|
855
|
+
return { headRefOid: String(j.headRefOid), mergedAt: String(j.mergedAt) };
|
|
856
|
+
} catch {
|
|
857
|
+
return null;
|
|
858
|
+
}
|
|
859
|
+
};
|
|
860
|
+
|
|
861
|
+
export async function landTool(
|
|
862
|
+
args: {
|
|
826
863
|
project: string;
|
|
827
864
|
pr: string;
|
|
828
865
|
queueItemId?: string;
|
|
@@ -830,7 +867,10 @@ export async function landTool(args: {
|
|
|
830
867
|
base?: string;
|
|
831
868
|
write?: boolean;
|
|
832
869
|
result?: string;
|
|
833
|
-
}
|
|
870
|
+
},
|
|
871
|
+
readMergeFacts: ReadMergeFacts = ghMergeFacts,
|
|
872
|
+
readVerdictLog: ReadVerdictLog = roomLog,
|
|
873
|
+
) {
|
|
834
874
|
const repo = args.repo ?? process.cwd();
|
|
835
875
|
const base = args.base ?? "main";
|
|
836
876
|
const n = prNumber(args.pr);
|
|
@@ -945,8 +985,41 @@ export async function landTool(args: {
|
|
|
945
985
|
const bareRef = !/[\w.-]+\/[\w.-]+#\d+/.test(String(args.pr));
|
|
946
986
|
const summary = originalText !== null ? summarize(originalText) : null;
|
|
947
987
|
|
|
988
|
+
/*
|
|
989
|
+
* ⛔⛆⛆ THE ROW'S ID LEADS THE ENTRY, BECAUSE A TRUNCATED HEADLINE CANNOT CARRY IT
|
|
990
|
+
* — `⟨q-d046204d⟩`.
|
|
991
|
+
*
|
|
992
|
+
* `summarize` cuts at 96 characters and appends `…`. The sigil was never omitted
|
|
993
|
+
* from these entries: IT WAS CUT, because in the row it sits later than the
|
|
994
|
+
* truncation point. So the entry came out with a PERFECT `ref` slot and a `text`
|
|
995
|
+
* that names no row — and `closingRefs` needs BOTH halves, so the row reads
|
|
996
|
+
* untied and `main` goes red. `⟨q-c1af2db3⟩` at `17a9ffc` is that, measured.
|
|
997
|
+
*
|
|
998
|
+
* ⭐⭐ THE FIX IS POSITION, NOT LENGTH. Putting the id BEFORE the summary takes it
|
|
999
|
+
* out of the truncated region BY CONSTRUCTION, so it survives any headline length
|
|
1000
|
+
* — where raising `max` only moves the cliff. The row is explicit that "write
|
|
1001
|
+
* longer headlines" is not an acceptable fix, and it is right: a length that is
|
|
1002
|
+
* enough today is a truncation tomorrow.
|
|
1003
|
+
*
|
|
1004
|
+
* ✅ AND THE CONSUMER ALREADY EXPECTS THIS SPELLING. `doneRecordsDelivery` strips
|
|
1005
|
+
* a leading `⟨q-…⟩` before comparing, and says why: "an entry written as `⟨id⟩
|
|
1006
|
+
* <summary>` and one written as `<summary>` agree". So the composed-summary arm
|
|
1007
|
+
* keeps matching and this needed no change there.
|
|
1008
|
+
*
|
|
1009
|
+
* ⚠ THIS IS NOT THE ARM `⟨q-4a1e70c5⟩` REMOVED, and the difference is the whole
|
|
1010
|
+
* reason this is safe. That arm INFERRED delivery from a sigil appearing ANYWHERE
|
|
1011
|
+
* in an entry — a reader-side guess that produced eleven false positives, because
|
|
1012
|
+
* this repo's canon requires entries to cite item ids for other reasons. This
|
|
1013
|
+
* writes the id in a DETERMINISTIC LEADING POSITION so the tie can be read. It
|
|
1014
|
+
* infers nothing, and re-reading a sigil as "delivered" is still wrong.
|
|
1015
|
+
*
|
|
1016
|
+
* The id is the ITEM's, taken from the record rather than re-derived: `target` is
|
|
1017
|
+
* non-null whenever `summary` is, since the summary comes from its text.
|
|
1018
|
+
*/
|
|
948
1019
|
const doneLine =
|
|
949
|
-
already || !summary
|
|
1020
|
+
already || !summary || !target
|
|
1021
|
+
? null
|
|
1022
|
+
: `- [x] ⟨${target.id}⟩ ${summary} — ${args.pr} · ${new Date().toISOString().slice(0, 10)}`;
|
|
950
1023
|
|
|
951
1024
|
// APPEND TO DONE.md — the half this verb exists for.
|
|
952
1025
|
//
|
|
@@ -1034,12 +1107,53 @@ export async function landTool(args: {
|
|
|
1034
1107
|
}
|
|
1035
1108
|
}
|
|
1036
1109
|
|
|
1110
|
+
/*
|
|
1111
|
+
* ⛔⛆ SECOND LINE: WAS THE THING MERGED THE THING GATED? (kit#268, ⟨q-6a4f0c38⟩)
|
|
1112
|
+
*
|
|
1113
|
+
* `merge` refuses this BEFORE the fact and is the control that prevents. This
|
|
1114
|
+
* one runs after, and its job is different: it is how the fleet LEARNS a
|
|
1115
|
+
* crossing happened anyway — through a merge done by hand, by `gh` directly,
|
|
1116
|
+
* or by a seat that never called the verb.
|
|
1117
|
+
*
|
|
1118
|
+
* It REPORTS and never refuses. A closure is not the right place to litigate a
|
|
1119
|
+
* merge that already happened: the verdict is QA's artefact, the merge may have
|
|
1120
|
+
* been someone else's, and blocking the record would leave the work landed and
|
|
1121
|
+
* unrecorded — strictly worse than landed and recorded with a flag on it.
|
|
1122
|
+
*
|
|
1123
|
+
* ⚠ AND IT SAYS SO WHEN IT COULD NOT LOOK. An omitted field reads as "fine".
|
|
1124
|
+
*/
|
|
1125
|
+
const gatedHead = (() => {
|
|
1126
|
+
const mf = readMergeFacts(repo, n);
|
|
1127
|
+
if (!mf) return { checked: false as const, note: `could not read #${n}'s merged head and merge time — whether the merged head was gated is UNKNOWN, not clean.` };
|
|
1128
|
+
const log = readVerdictLog(args.project);
|
|
1129
|
+
if (log === null) return { checked: false as const, note: `could not read the verdict log for '${args.project}' — whether #${n}'s merged head was gated is UNKNOWN, not clean.` };
|
|
1130
|
+
const at = Date.parse(mf.mergedAt);
|
|
1131
|
+
if (!Number.isFinite(at)) return { checked: false as const, note: `#${n} reports an unparseable mergedAt ('${mf.mergedAt}') — cannot place the merge in time.` };
|
|
1132
|
+
const { verdicts, unparsed } = verdictsFor(log, n);
|
|
1133
|
+
const a = gatedAt(verdicts, mf.headRefOid, at);
|
|
1134
|
+
if (a.gated) {
|
|
1135
|
+
return { checked: true as const, gated: true as const, head: mf.headRefOid.slice(0, 8), by: { from: a.by.from, sha: a.by.head.slice(0, 8) } };
|
|
1136
|
+
}
|
|
1137
|
+
return {
|
|
1138
|
+
checked: true as const,
|
|
1139
|
+
gated: false as const,
|
|
1140
|
+
head: mf.headRefOid.slice(0, 8),
|
|
1141
|
+
reason: a.reason,
|
|
1142
|
+
gatedInstead: (a.crossed ?? []).map((c) => c.gatedSha.slice(0, 8)),
|
|
1143
|
+
note:
|
|
1144
|
+
`UNGATED MERGE RECORDED: #${n} merged ${mf.headRefOid.slice(0, 8)} and ${a.reason}. ` +
|
|
1145
|
+
`The record is written — this is a report, not a refusal — but the merge was not covered by a verdict when it happened.` +
|
|
1146
|
+
(unparsed ? ` (${unparsed} log line(s) unreadable and skipped.)` : ""),
|
|
1147
|
+
};
|
|
1148
|
+
})();
|
|
1149
|
+
|
|
1037
1150
|
return {
|
|
1038
1151
|
ok: true as const,
|
|
1039
1152
|
project: args.project,
|
|
1040
1153
|
pr: `#${n}`,
|
|
1041
1154
|
comparedAgainst: ref,
|
|
1042
1155
|
landedIn: landedIn.slice(0, 8),
|
|
1156
|
+
gatedHead,
|
|
1043
1157
|
// THE ABSORBING WRITER LEARNS IT ABSORBED SOMETHING. The aide's
|
|
1044
1158
|
// diff-before-rename guard REFUSES on a foreign change; this REPORTS one it
|
|
1045
1159
|
// fixed, so a foreign row cannot pass silently in either direction.
|
|
@@ -1151,13 +1265,15 @@ export type PrFacts = {
|
|
|
1151
1265
|
state: string;
|
|
1152
1266
|
mergeable: string;
|
|
1153
1267
|
checks: unknown[];
|
|
1268
|
+
/** The branch tip RIGHT NOW — what would actually be merged (⟨q-6a4f0c38⟩). */
|
|
1269
|
+
headRefOid?: string;
|
|
1154
1270
|
/** Unified diff of the PR, for the ticked-box audit. */
|
|
1155
1271
|
diff?: string;
|
|
1156
1272
|
/** Everything that will survive the merge as a citation: title + commit subjects + body. */
|
|
1157
1273
|
citationText?: string;
|
|
1158
1274
|
};
|
|
1159
1275
|
const ghFacts = (repo: string, n: string): PrFacts => {
|
|
1160
|
-
const out = execFileSync("gh", ["pr", "view", n, "--json", "state,mergeable,statusCheckRollup"], {
|
|
1276
|
+
const out = execFileSync("gh", ["pr", "view", n, "--json", "state,mergeable,statusCheckRollup,headRefOid"], {
|
|
1161
1277
|
cwd: repo,
|
|
1162
1278
|
encoding: "utf8",
|
|
1163
1279
|
stdio: ["ignore", "pipe", "ignore"],
|
|
@@ -1181,6 +1297,7 @@ const ghFacts = (repo: string, n: string): PrFacts => {
|
|
|
1181
1297
|
return {
|
|
1182
1298
|
state: String(j.state ?? ""),
|
|
1183
1299
|
mergeable: String(j.mergeable ?? ""),
|
|
1300
|
+
headRefOid: String(j.headRefOid ?? ""),
|
|
1184
1301
|
checks: (j.statusCheckRollup as unknown[]) ?? [],
|
|
1185
1302
|
diff: gh(["pr", "diff", n]),
|
|
1186
1303
|
citationText: [String(meta.title ?? ""), subjects, String(meta.body ?? "")].join("\n"),
|
|
@@ -1206,10 +1323,28 @@ const ghMerge: DoMerge = (repo, n, method) => {
|
|
|
1206
1323
|
});
|
|
1207
1324
|
};
|
|
1208
1325
|
|
|
1326
|
+
/**
|
|
1327
|
+
* The room log, injected for the same reason `doMerge` is: a test must be able to
|
|
1328
|
+
* drive every refusal branch without this host's ~/agent-coord.
|
|
1329
|
+
*
|
|
1330
|
+
* Returns null when the log cannot be read — distinct from "" (read, and empty),
|
|
1331
|
+
* because "no verdicts recorded" and "could not look" are different answers and
|
|
1332
|
+
* only one of them is safe to merge on.
|
|
1333
|
+
*/
|
|
1334
|
+
export type ReadVerdictLog = (project: string) => string | null;
|
|
1335
|
+
const roomLog: ReadVerdictLog = (project) => {
|
|
1336
|
+
try {
|
|
1337
|
+
return readFileSync(path.join(ROOT, "rooms", `${project}.jsonl`), "utf8");
|
|
1338
|
+
} catch {
|
|
1339
|
+
return null;
|
|
1340
|
+
}
|
|
1341
|
+
};
|
|
1342
|
+
|
|
1209
1343
|
export async function mergeTool(
|
|
1210
1344
|
args: { project: string; pr: string; repo?: string; method?: string; write?: boolean },
|
|
1211
1345
|
facts: (repo: string, n: string) => PrFacts = ghFacts,
|
|
1212
1346
|
doMerge: DoMerge = ghMerge,
|
|
1347
|
+
readVerdictLog: ReadVerdictLog = roomLog,
|
|
1213
1348
|
) {
|
|
1214
1349
|
const repo = args.repo ?? process.cwd();
|
|
1215
1350
|
const n = prNumber(args.pr);
|
|
@@ -1318,8 +1453,55 @@ export async function mergeTool(
|
|
|
1318
1453
|
if (f.mergeable === "CONFLICTING")
|
|
1319
1454
|
return { ok: false as const, error: `#${n} is CONFLICTING with its base.`, verdict };
|
|
1320
1455
|
|
|
1456
|
+
/*
|
|
1457
|
+
* ⛔⛆ THE THING MERGED MUST BE THE THING GATED — kit#268, ⟨q-6a4f0c38⟩.
|
|
1458
|
+
*
|
|
1459
|
+
* A PASS was posted for `6051193` at 10:23:14Z; the merge ran 8 seconds later
|
|
1460
|
+
* and took `1d6deab`, because the author force-pushed in between. Checks were
|
|
1461
|
+
* green on BOTH heads, so every refusal above passed honestly. Nothing asked
|
|
1462
|
+
* the only question that mattered: is the head in front of me the head the
|
|
1463
|
+
* verdict named?
|
|
1464
|
+
*
|
|
1465
|
+
* THIS IS THE FIRST-LINE CONTROL because it PREVENTS. The recording step can
|
|
1466
|
+
* only report afterwards. It sits before the dry-run return on purpose: a dry
|
|
1467
|
+
* run must say it would refuse, or the preview disagrees with the act.
|
|
1468
|
+
*/
|
|
1469
|
+
const head = f.headRefOid ?? "";
|
|
1470
|
+
if (!head) {
|
|
1471
|
+
return {
|
|
1472
|
+
ok: false as const,
|
|
1473
|
+
error: `could not read #${n}'s current head — NOT read, which is not the same as read and matching the verdict.`,
|
|
1474
|
+
verdict,
|
|
1475
|
+
};
|
|
1476
|
+
}
|
|
1477
|
+
const log = readVerdictLog(args.project);
|
|
1478
|
+
if (log === null) {
|
|
1479
|
+
return {
|
|
1480
|
+
ok: false as const,
|
|
1481
|
+
error:
|
|
1482
|
+
`could not read the verdict log for project '${args.project}' — so whether #${n}'s head ${head.slice(0, 7)} ` +
|
|
1483
|
+
`was ever gated is UNKNOWN, and unknown is not gated.`,
|
|
1484
|
+
verdict,
|
|
1485
|
+
};
|
|
1486
|
+
}
|
|
1487
|
+
const { verdicts, unparsed } = verdictsFor(log, n);
|
|
1488
|
+
const gate = gatedAt(verdicts, head, Date.now());
|
|
1489
|
+
if (!gate.gated) {
|
|
1490
|
+
const named = (gate.crossed ?? []).map((c) => c.gatedSha.slice(0, 7)).join(", ");
|
|
1491
|
+
return {
|
|
1492
|
+
ok: false as const,
|
|
1493
|
+
error:
|
|
1494
|
+
`#${n}'s head is ${head.slice(0, 7)} and ${gate.reason}. ` +
|
|
1495
|
+
(named ? `Gated instead: ${named}. ` : "") +
|
|
1496
|
+
`Re-gate this head before merging — a PASS that must be re-issued is cheap, a merge nobody gated is not.` +
|
|
1497
|
+
(unparsed ? ` (${unparsed} log line(s) unreadable and skipped.)` : ""),
|
|
1498
|
+
verdict,
|
|
1499
|
+
gatedHead: { head, gated: false as const, reason: gate.reason },
|
|
1500
|
+
};
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1321
1503
|
if (!args.write)
|
|
1322
|
-
return { ok: true as const, merged: false as const, verdict, note: `#${n} would merge: all ${checks.length} check(s) pass. Pass write:true to apply.` };
|
|
1504
|
+
return { ok: true as const, merged: false as const, verdict, note: `#${n} would merge: all ${checks.length} check(s) pass, and head ${head.slice(0, 7)} is gated by a PASS from ${gate.by.from}. Pass write:true to apply.` };
|
|
1323
1505
|
|
|
1324
1506
|
doMerge(repo, n, args.method ?? "squash");
|
|
1325
1507
|
return { ok: true as const, merged: true as const, verdict };
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ⟨q-c1af2db3⟩ — WHICH TREE DID THIS VERB JUST READ, AND HOW OLD IS IT?
|
|
3
|
+
*
|
|
4
|
+
* ⛔ THE BUG THIS EXISTS FOR ROUTED THE FLEET. `next_unblocked` reads
|
|
5
|
+
* `docs/QUEUE.md` out of whatever working tree it is handed, and that tree
|
|
6
|
+
* belongs to nobody: its freshness is a property of whoever last worked in it.
|
|
7
|
+
* Measured 2026-09-12 16:36 — the primary checkout sat FOUR commits behind
|
|
8
|
+
* `origin/main`, its `QUEUE.md` had zero hits for a tag committed six minutes
|
|
9
|
+
* earlier, and the verb returned the PRE-EDIT text as `next`.
|
|
10
|
+
*
|
|
11
|
+
* ⛆ AND THE HARM LANDED IN BOTH DIRECTIONS INSIDE TEN MINUTES: a row was ruled
|
|
12
|
+
* un-claimable from an observation that was actually a stale file, and a worker
|
|
13
|
+
* was told to hold on a row that was already split. ⭐ ***A true conclusion
|
|
14
|
+
* reached through an untrue reading is the shape, and here the INSTRUMENT
|
|
15
|
+
* supplied the untrue reading — the answer was well-formed, internally
|
|
16
|
+
* consistent, `accounting.reconciles` was TRUE, and nothing in it said which
|
|
17
|
+
* tree it read.***
|
|
18
|
+
*
|
|
19
|
+
* ⛔⛆⛆ THE TRAP THAT BREAKS THE OBVIOUS FIX, AND IT IS WHY THIS FETCHES:
|
|
20
|
+
*
|
|
21
|
+
* a checkout that has not fetched CANNOT REPORT that it has not fetched.
|
|
22
|
+
*
|
|
23
|
+
* `git rev-list --left-right --count origin/main...HEAD` in an unfetched clone
|
|
24
|
+
* compares HEAD against a STALE `origin/main` ref and returns `0 0` — *"I am
|
|
25
|
+
* perfectly current"* — while the real remote has moved. Measured: HEAD
|
|
26
|
+
* `1cab5c2`, its own `origin/main` also `1cab5c2`, true `origin/main` `558365d`,
|
|
27
|
+
* three commits behind, reported as ZERO. ⭐⭐ ***A distance of `0` from an
|
|
28
|
+
* unfetched ref and a distance of `0` from a fetched one are BYTE-IDENTICAL and
|
|
29
|
+
* mean opposite things. That distinction is the whole point of this module: the
|
|
30
|
+
* ref is fetched IN THIS CALL, and when the fetch fails the answer is
|
|
31
|
+
* `"unknown"` and never `0`.***
|
|
32
|
+
*
|
|
33
|
+
* ⚠ AND FETCHING IS NOT THE FIX — that is the row's named anti-control.
|
|
34
|
+
* Fetching updates REFS; the verb reads the TREE, which is still stale
|
|
35
|
+
* afterwards. So a fetch only makes the staleness *legible*; the caller must
|
|
36
|
+
* still refuse or flag. This module reports; it never silently repairs, because
|
|
37
|
+
* making someone else's working tree current is a destructive write into a
|
|
38
|
+
* checkout a seat may be mid-work in.
|
|
39
|
+
*/
|
|
40
|
+
import { execFileSync } from "node:child_process";
|
|
41
|
+
import path from "node:path";
|
|
42
|
+
|
|
43
|
+
export type TreeProvenance = {
|
|
44
|
+
/** The tree actually read, resolved — not the string the caller passed. */
|
|
45
|
+
path: string;
|
|
46
|
+
head: string | null;
|
|
47
|
+
/** The ref the distance is measured against, e.g. `origin/main`. */
|
|
48
|
+
base: string;
|
|
49
|
+
baseSha: string | null;
|
|
50
|
+
/**
|
|
51
|
+
* Commits the tree is behind `base`.
|
|
52
|
+
*
|
|
53
|
+
* ⛔ `"unknown"` WHEN THE REF COULD NOT BE FRESHENED IN THIS CALL. Never `0`
|
|
54
|
+
* in that case: `0` is a claim of currency and an unfetched ref cannot make it.
|
|
55
|
+
*/
|
|
56
|
+
behind: number | "unknown";
|
|
57
|
+
/** Whether the ref this distance rests on was freshened in THIS call. */
|
|
58
|
+
fetched: boolean;
|
|
59
|
+
fetchError?: string;
|
|
60
|
+
dirty: boolean | "unknown";
|
|
61
|
+
/** True only when the tree is MEASURABLY behind a ref fetched in this call. */
|
|
62
|
+
stale: boolean;
|
|
63
|
+
/** Present whenever the answer cannot be trusted as current. */
|
|
64
|
+
warning?: string;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const run = (repo: string, args: string[]): string | null => {
|
|
68
|
+
try {
|
|
69
|
+
return execFileSync("git", args, {
|
|
70
|
+
cwd: repo,
|
|
71
|
+
encoding: "utf8",
|
|
72
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
73
|
+
timeout: 20_000,
|
|
74
|
+
}).trim();
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* What the caller needs to say out loud about the tree it just read.
|
|
82
|
+
*
|
|
83
|
+
* @param repo the working tree a verb was handed
|
|
84
|
+
* @param baseBranch the branch the tree should be current with
|
|
85
|
+
*/
|
|
86
|
+
export function treeProvenance(repo: string, baseBranch = "main"): TreeProvenance {
|
|
87
|
+
const resolved = run(repo, ["rev-parse", "--show-toplevel"]) ?? path.resolve(repo);
|
|
88
|
+
const base = `origin/${baseBranch}`;
|
|
89
|
+
const head = run(resolved, ["rev-parse", "HEAD"]);
|
|
90
|
+
const status = run(resolved, ["status", "--porcelain"]);
|
|
91
|
+
|
|
92
|
+
// ⛔ FETCH FIRST, AND THE ORDER IS THE POINT. Every number below is worthless
|
|
93
|
+
// if it rests on a ref the tree last updated at some unknown past moment.
|
|
94
|
+
const fetched = run(resolved, ["fetch", "--quiet", "origin", baseBranch]) !== null;
|
|
95
|
+
|
|
96
|
+
const out: TreeProvenance = {
|
|
97
|
+
path: resolved,
|
|
98
|
+
head,
|
|
99
|
+
base,
|
|
100
|
+
baseSha: null,
|
|
101
|
+
behind: "unknown",
|
|
102
|
+
fetched,
|
|
103
|
+
dirty: status === null ? "unknown" : status.length > 0,
|
|
104
|
+
stale: false,
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
if (!fetched) {
|
|
108
|
+
out.fetchError = `could not fetch origin/${baseBranch} — the distance cannot be computed`;
|
|
109
|
+
out.warning =
|
|
110
|
+
`TREE FRESHNESS UNKNOWN: ${resolved} could not reach origin/${baseBranch} in this call, so its ` +
|
|
111
|
+
`distance is UNKNOWN rather than 0. A checkout that has not fetched cannot report that it has not ` +
|
|
112
|
+
`fetched — an unfetched ref answers "0 behind" and means nothing.`;
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
out.baseSha = run(resolved, ["rev-parse", base]);
|
|
117
|
+
const counts = run(resolved, ["rev-list", "--left-right", "--count", `${base}...HEAD`]);
|
|
118
|
+
const behind = counts ? Number(counts.split(/\s+/)[0]) : NaN;
|
|
119
|
+
if (!Number.isFinite(behind)) {
|
|
120
|
+
out.warning = `TREE FRESHNESS UNKNOWN: could not count ${base}...HEAD in ${resolved}.`;
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
out.behind = behind;
|
|
125
|
+
out.stale = behind > 0;
|
|
126
|
+
if (out.stale) {
|
|
127
|
+
// ⚠ FETCHING DID NOT FIX THE TREE — it only made this sentence possible.
|
|
128
|
+
// The file the verb read is still the old one.
|
|
129
|
+
out.warning =
|
|
130
|
+
`STALE TREE: ${resolved} is ${behind} commit(s) behind ${base} (HEAD ${head?.slice(0, 7)}, ` +
|
|
131
|
+
`${base} ${out.baseSha?.slice(0, 7)}). The answer above was read from THAT tree's files, so a row ` +
|
|
132
|
+
`filed or amended on ${base} since is invisible here and a superseded row can be offered as current. ` +
|
|
133
|
+
`Fetching refreshed the REF, not the working tree.`;
|
|
134
|
+
}
|
|
135
|
+
return out;
|
|
136
|
+
}
|