@everystack/cli 0.4.76 → 0.4.78
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/CHANGELOG.md +29 -0
- package/package.json +1 -1
- package/src/cli/apply-execute.ts +12 -4
- package/src/cli/apply-refusals.ts +1 -1
- package/src/cli/authz-adoption-class.ts +112 -22
- package/src/cli/authz-reconcile.ts +26 -12
- package/src/cli/commands/db-adopt.ts +10 -5
- package/src/cli/commands/db-apply.ts +106 -42
- package/src/cli/commands/db-plan.ts +101 -24
- package/src/cli/commands/db-pull.ts +6 -4
- package/src/cli/edge-plan.ts +18 -3
- package/src/cli/git-descent.ts +624 -119
- package/src/cli/index.ts +2 -2
- package/src/cli/state-apply.ts +6 -3
package/src/cli/git-descent.ts
CHANGED
|
@@ -17,15 +17,24 @@
|
|
|
17
17
|
* historical state of the models directory across all refs, compile each
|
|
18
18
|
* unique tree, and ask whether its predicted live endpoint equals the
|
|
19
19
|
* target's live fingerprint (brick 4's merged prediction, so unmodeled
|
|
20
|
-
* brownfield tables ride through the test).
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
20
|
+
* brownfield tables ride through the test). Identity is the models-directory
|
|
21
|
+
* TREE, not the commit: a cherry-picked identical state is the same declared
|
|
22
|
+
* state.
|
|
23
|
+
*
|
|
24
|
+
* A fingerprint NO commit on ANY ref declares is DRIFT, and proven drift is the
|
|
25
|
+
* operator's decision the design promised: it PASSES at apply. The rule exists to
|
|
26
|
+
* stop an apply from reverting MERGED work, and when no ref anywhere declares the
|
|
27
|
+
* live state there is no merged work to revert — by construction. The proof is
|
|
28
|
+
* refuse-closed: a ref you do not descend from that DOES declare live refuses by
|
|
29
|
+
* name (`declared-elsewhere`, a teammate got there first); a tree that could not be
|
|
30
|
+
* read, or a clone that is shallow, partial, or behind a remote, is UNVERIFIABLE,
|
|
31
|
+
* never drift. `--force-descent` used to be the operator's drift decision; its
|
|
32
|
+
* retirement (2026-08-26) left drift with no escape at all, and a consumer walked
|
|
33
|
+
* into that dead end on 2026-09-07. The proof replaced the flag (Fable-ruled).
|
|
24
34
|
*/
|
|
25
35
|
|
|
26
36
|
import { execFileSync } from 'node:child_process';
|
|
27
37
|
import fs from 'node:fs';
|
|
28
|
-
import os from 'node:os';
|
|
29
38
|
import path from 'node:path';
|
|
30
39
|
import { pathToFileURL } from 'node:url';
|
|
31
40
|
import type { ModelDescriptor } from '@everystack/model';
|
|
@@ -76,9 +85,13 @@ export interface ModelTreeCandidate {
|
|
|
76
85
|
|
|
77
86
|
const short = (ref: string): string => ref.slice(0, 12);
|
|
78
87
|
|
|
88
|
+
/** Every git command this module runs is bounded: in a partial clone even `cat-file` and
|
|
89
|
+
* `merge-base` can fetch lazily over the network, and a hang must become a null (refuse-closed). */
|
|
90
|
+
const GIT_TIMEOUT_MS = 60_000;
|
|
91
|
+
|
|
79
92
|
function git(args: string[], cwd: string): string | null {
|
|
80
93
|
try {
|
|
81
|
-
return execFileSync('git', args, { cwd, stdio: ['ignore', 'pipe', 'ignore'] }).toString();
|
|
94
|
+
return execFileSync('git', args, { cwd, stdio: ['ignore', 'pipe', 'ignore'], timeout: GIT_TIMEOUT_MS }).toString();
|
|
82
95
|
} catch {
|
|
83
96
|
return null;
|
|
84
97
|
}
|
|
@@ -91,6 +104,44 @@ const defaultLoader: ModelsLoader = async (barrel) => {
|
|
|
91
104
|
return models;
|
|
92
105
|
};
|
|
93
106
|
|
|
107
|
+
/**
|
|
108
|
+
* The SQUASHED SUCCESSOR of a witness commit HEAD does not descend from: an ancestor of HEAD that
|
|
109
|
+
* carries the witness's models tree UNCHANGED (same tree oid) and is not itself an ancestor of
|
|
110
|
+
* the witness. Deploy from a PR branch, squash-merge, deploy the next stage — the ledger names the
|
|
111
|
+
* PR commit, main carries its tree under a new sha, and every line of the declaring commit is
|
|
112
|
+
* inside HEAD. Refusing that as a revert is false (consumer cutover, cli 0.4.77, 2026-09-07).
|
|
113
|
+
*
|
|
114
|
+
* The second clause is the whole safety. In an S→T→S episode the pre-episode ancestor carries S's
|
|
115
|
+
* tree too — byte-identical — and a checkout branched there must still be refused: the witness
|
|
116
|
+
* (the re-declaration of S) DESCENDS from that carrier, so the carrier is the state the episode
|
|
117
|
+
* started from, not a successor. A squash commit is never an ancestor of the branch it squashed.
|
|
118
|
+
* Tree identity is exact and cheap (`rev-parse <sha>:<dir>`, no materialization); a state-level
|
|
119
|
+
* recompile is deliberately NOT used here, because it cannot tell those two shapes apart.
|
|
120
|
+
*
|
|
121
|
+
* One carrier that IS an ancestor of the witness still counts: the one with NO models change
|
|
122
|
+
* between it and the witness. That is the app-only PR — the deploy changed no models, the
|
|
123
|
+
* witness's tree is the base tree, and `rev-list --full-history -- <dir>` lists only the base as a
|
|
124
|
+
* carrier (closing-gate review, 2026-09-07). The pre-episode carrier never qualifies: the T
|
|
125
|
+
* episode sits between it and the witness, so `rev-list carrier..witness -- <dir>` is non-empty.
|
|
126
|
+
*/
|
|
127
|
+
function squashedSuccessor(witness: string, head: string, modelsDir: string, repoRoot: string): string | null {
|
|
128
|
+
const spec = modelsDir === '.' ? `${witness}^{tree}` : `${witness}:${modelsDir}`;
|
|
129
|
+
const tree = git(['rev-parse', '--verify', '--quiet', spec], repoRoot)?.trim();
|
|
130
|
+
if (!tree) return null;
|
|
131
|
+
const carriers = enumerateModelTrees(modelsDir, repoRoot, { ancestorsOf: head }).find((c) => c.tree === tree)?.commits ?? [];
|
|
132
|
+
const modelsUnchangedBetween = (carrier: string): boolean => {
|
|
133
|
+
const changes = git(['rev-list', `${carrier}..${witness}`, '--', modelsDir], repoRoot);
|
|
134
|
+
return changes !== null && changes.trim() === '';
|
|
135
|
+
};
|
|
136
|
+
// Refuse-closed on BOTH git questions: an ancestry git could not answer is not "not an
|
|
137
|
+
// ancestor" (that would accept the carrier), and a rev-list that failed is not "no changes".
|
|
138
|
+
return carriers.find((c) => {
|
|
139
|
+
const anc = ancestry(c, witness, repoRoot);
|
|
140
|
+
if (anc === null) return false;
|
|
141
|
+
return !anc || modelsUnchangedBetween(c);
|
|
142
|
+
}) ?? null;
|
|
143
|
+
}
|
|
144
|
+
|
|
94
145
|
/**
|
|
95
146
|
* Every historical state of the models directory across ALL refs, newest
|
|
96
147
|
* first, grouped by tree oid. `--full-history` keeps every !TREESAME commit
|
|
@@ -107,7 +158,10 @@ export function enumerateModelTrees(
|
|
|
107
158
|
// `ancestorsOf`, restrict to the ancestors of one commit — the fallback proof only ever
|
|
108
159
|
// considers trees the checkout descends from, so a diverged side branch is not even a
|
|
109
160
|
// candidate (a non-ancestor declaring commit is the ledger's REVERT case, not recompile's).
|
|
110
|
-
|
|
161
|
+
// `--all` walks refs/stash too. A stash is a WIP snapshot, never a deployed state: one that will
|
|
162
|
+
// not compile would poison the drift proof to unverifiable, and one that happens to match live
|
|
163
|
+
// would be named as a teammate's deployment. Excluded here and in refsContaining, consistently.
|
|
164
|
+
const range = opts.ancestorsOf ? [opts.ancestorsOf] : ['--exclude=refs/stash', '--all'];
|
|
111
165
|
const out = git(['rev-list', ...range, '--full-history', '--date-order', '--', modelsDir], cwd);
|
|
112
166
|
if (out === null) return [];
|
|
113
167
|
const shas = out.trim().split('\n').filter(Boolean);
|
|
@@ -120,6 +174,7 @@ export function enumerateModelTrees(
|
|
|
120
174
|
cwd,
|
|
121
175
|
input: specs.join('\n') + '\n',
|
|
122
176
|
stdio: ['pipe', 'pipe', 'ignore'],
|
|
177
|
+
timeout: GIT_TIMEOUT_MS,
|
|
123
178
|
}).toString();
|
|
124
179
|
} catch {
|
|
125
180
|
return [];
|
|
@@ -183,8 +238,9 @@ export function materializeTree(tree: string, dest: string, cwd: string): void {
|
|
|
183
238
|
type TreeEval =
|
|
184
239
|
| { kind: 'declares' }
|
|
185
240
|
| { kind: 'no' }
|
|
186
|
-
|
|
187
|
-
| { kind: '
|
|
241
|
+
/** `detail` is the one-line record (`<tree> (at <commit>): <error>`); `error` is the bare cause. */
|
|
242
|
+
| { kind: 'compile-failed'; detail: string; error: string }
|
|
243
|
+
| { kind: 'unevaluated'; detail: string; error: string };
|
|
188
244
|
|
|
189
245
|
interface EvalContext {
|
|
190
246
|
repoRoot: string;
|
|
@@ -213,7 +269,8 @@ async function evaluateTree(candidate: ModelTreeCandidate, live: LiveState, ctx:
|
|
|
213
269
|
fs.rmSync(dest, { recursive: true, force: true });
|
|
214
270
|
ctx.materialize(candidate.tree, dest, ctx.repoRoot);
|
|
215
271
|
} catch (err: any) {
|
|
216
|
-
|
|
272
|
+
const error = `could not extract — ${firstLine(err.message)}`;
|
|
273
|
+
return { kind: 'unevaluated', detail: `${where}: ${error}`, error };
|
|
217
274
|
}
|
|
218
275
|
}
|
|
219
276
|
|
|
@@ -228,6 +285,12 @@ async function evaluateTree(candidate: ModelTreeCandidate, live: LiveState, ctx:
|
|
|
228
285
|
|
|
229
286
|
const evaluate = async (): Promise<boolean> => {
|
|
230
287
|
const models = await ctx.loader(barrelPath);
|
|
288
|
+
// A barrel that compiles to ZERO models declares NOTHING. The prediction rides every
|
|
289
|
+
// undeclared live table through unchanged, so an empty models array "predicts" any live
|
|
290
|
+
// state exactly — it would declare every database there is. Absent barrel and empty barrel
|
|
291
|
+
// are the same clean 'no'. (Closing-gate review round 2, 2026-09-07: on a side branch this
|
|
292
|
+
// minted a false declared-elsewhere; as a HEAD ancestor it silently proved a fast-forward.)
|
|
293
|
+
if (models.length === 0) return false;
|
|
231
294
|
const governedRoles = governedRolesForModels(models);
|
|
232
295
|
const predicted = predictLiveFingerprint(models, live.snapshot, live.contract, { schema: ctx.schema, governedRoles });
|
|
233
296
|
return predicted === governedLiveFingerprint(live.snapshot, live.contract, governedRoles);
|
|
@@ -241,15 +304,153 @@ async function evaluateTree(candidate: ModelTreeCandidate, live: LiveState, ctx:
|
|
|
241
304
|
}
|
|
242
305
|
return declares ? { kind: 'declares' } : { kind: 'no' };
|
|
243
306
|
} catch (err: any) {
|
|
244
|
-
|
|
307
|
+
const error = firstLine(err.message);
|
|
308
|
+
return { kind: 'compile-failed', detail: `${where}: ${error}`, error };
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** One tree the walk could not read, with where it sits in history — the refusal names it. */
|
|
313
|
+
interface UnreadableTree {
|
|
314
|
+
candidate: ModelTreeCandidate;
|
|
315
|
+
/** Carried by a commit HEAD descends from — no ref prune removes it. */
|
|
316
|
+
ancestor: boolean;
|
|
317
|
+
error: string;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** The git verb that removes one ref, or null for a ref shape we do not prescribe deleting. */
|
|
321
|
+
export function pruneCommand(ref: string): string | null {
|
|
322
|
+
const head = ref.match(/^refs\/heads\/(.+)$/);
|
|
323
|
+
if (head) return `git branch -D ${head[1]}`;
|
|
324
|
+
const remote = ref.match(/^refs\/remotes\/([^/]+)\/(.+)$/);
|
|
325
|
+
if (remote) return `git push ${remote[1]} --delete ${remote[2]}`;
|
|
326
|
+
const tag = ref.match(/^refs\/tags\/(.+)$/);
|
|
327
|
+
if (tag) return `git tag -d ${tag[1]}`;
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* The unreadable-tree refusal, built from what the walk KNOWS: each tree, the refs that carry it,
|
|
333
|
+
* whether HEAD descends from it, and what the target's ledger says about who deployed what.
|
|
334
|
+
*
|
|
335
|
+
* Consumer cutover, cli 0.4.77 (2026-09-07): four August trees carried only by one backup branch
|
|
336
|
+
* on origin refused a production apply, and the message led with "upgrade/pin the CLI that last
|
|
337
|
+
* deployed this state" — on a database no CLI had ever deployed (schema_log was empty). The
|
|
338
|
+
* operator reconstructed the carrying ref with `git rev-list --all -- db/models` and a per-tree
|
|
339
|
+
* grep. Every fact in that reconstruction was already in this function's hands.
|
|
340
|
+
*
|
|
341
|
+
* The verdict does not change here; only the account of it. A tree we cannot read may still
|
|
342
|
+
* describe live, so the walk stays refuse-closed; the ledger is a memoir and never decides
|
|
343
|
+
* (decision 7). What the ledger DOES decide is which fix comes first: with no recorded apply,
|
|
344
|
+
* no CLI deployed these trees and "pin the CLI" is false, so bundle + prune leads; with one, pin
|
|
345
|
+
* the CLI only when the recorded commit is itself among the unreadable trees.
|
|
346
|
+
*/
|
|
347
|
+
function unreadableRunbook(
|
|
348
|
+
unreadable: readonly UnreadableTree[],
|
|
349
|
+
lastApplied: DescentLedgerRow | undefined,
|
|
350
|
+
probe: GitLineageProbe,
|
|
351
|
+
): string {
|
|
352
|
+
// Bounded on both axes, because this reason is RECORDED in schema_log and each ref lookup is a
|
|
353
|
+
// full ref walk: at most MAX_TREES trees are named, and a tree's refs are the union over its
|
|
354
|
+
// first MAX_COMMITS carrying commits (a tree is often carried by several commits on several
|
|
355
|
+
// branches — naming one commit's refs under-named the prune; closing-gate review, 2026-09-07).
|
|
356
|
+
const MAX_TREES = 10;
|
|
357
|
+
const MAX_COMMITS = 5;
|
|
358
|
+
const listed = unreadable.slice(0, MAX_TREES);
|
|
359
|
+
const refsOf = new Map<string, string[]>();
|
|
360
|
+
for (const u of listed) {
|
|
361
|
+
const refs = new Set<string>();
|
|
362
|
+
for (const commit of u.candidate.commits.slice(0, MAX_COMMITS)) {
|
|
363
|
+
for (const ref of probe.refsContaining(commit)) if (ref !== 'refs/stash') refs.add(ref);
|
|
364
|
+
}
|
|
365
|
+
refsOf.set(u.candidate.tree, [...refs].sort());
|
|
366
|
+
}
|
|
367
|
+
const lines = listed.map((u) => {
|
|
368
|
+
const refs = refsOf.get(u.candidate.tree)!;
|
|
369
|
+
const carried = refs.length === 0
|
|
370
|
+
? 'carried by no ref'
|
|
371
|
+
: `carried by ${refs.slice(0, 3).join(', ')}${refs.length > 3 ? ` and ${refs.length - 3} more` : ''}`;
|
|
372
|
+
const where = u.ancestor ? "on your checkout's own history" : 'not an ancestor of your checkout';
|
|
373
|
+
return ` ${short(u.candidate.tree)} at ${short(u.candidate.commits[0])} — ${carried} (${where}) — ${scrubPaths(u.error)}`;
|
|
374
|
+
});
|
|
375
|
+
if (unreadable.length > listed.length) {
|
|
376
|
+
lines.push(` … and ${unreadable.length - listed.length} more unreadable tree(s) not listed — their refs are not in the prune list below; re-run after pruning to see them`);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const prunable = listed.filter((u) => !u.ancestor);
|
|
380
|
+
const ancestors = unreadable.filter((u) => u.ancestor);
|
|
381
|
+
const pruneRefs = [...new Set(prunable.flatMap((u) => refsOf.get(u.candidate.tree)!))];
|
|
382
|
+
const pruneCmds = pruneRefs.map(pruneCommand).filter((c): c is string => c !== null);
|
|
383
|
+
const prune = pruneRefs.length === 0
|
|
384
|
+
? ''
|
|
385
|
+
: `Keep them if you want (git bundle create unreadable-trees.bundle ${pruneRefs.join(' ')}), then prune: ${pruneCmds.join('; ')}; then re-run.`;
|
|
386
|
+
const rewrite = ancestors.length === 0
|
|
387
|
+
? ''
|
|
388
|
+
: `${ancestors.length === unreadable.length ? 'These trees are' : `${ancestors.length} of them ${ancestors.length === 1 ? 'is' : 'are'}`} on your checkout's own history, which no ref prune removes; the walk needs every ancestor tree readable. Rewriting that history is the git-native remedy — keep it first (git bundle create everything.bundle --all).`;
|
|
389
|
+
|
|
390
|
+
let lead: string;
|
|
391
|
+
if (!lastApplied) {
|
|
392
|
+
lead = 'No everystack apply is recorded on this target, so no CLI deployed these trees.';
|
|
393
|
+
} else {
|
|
394
|
+
const at = lastApplied.gitRef ? `at ${short(lastApplied.gitRef)} ` : '';
|
|
395
|
+
const among = lastApplied.gitRef !== null && unreadable.some((u) => u.candidate.commits.includes(lastApplied.gitRef!));
|
|
396
|
+
lead = `everystack last applied here ${at}on ${shortDate(lastApplied.appliedAt)}.`
|
|
397
|
+
+ (among ? ' That commit is among the unreadable trees — pin the CLI that applied it (the version in that commit\'s lockfile) and re-run.' : '');
|
|
398
|
+
if (among) return `${lines.join('\n')}\n${lead}`;
|
|
245
399
|
}
|
|
400
|
+
return `${lines.join('\n')}\n${[lead, prune, rewrite].filter(Boolean).join(' ')}`;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* A compile/extraction error, summarized for a refusal that is RECORDED in a shared stage's
|
|
405
|
+
* schema_log: the cause, never the operator's filesystem.
|
|
406
|
+
*
|
|
407
|
+
* - MODULE_NOT_FOUND: line 1 is the cause ("Cannot find module '@everystack/model'"); the
|
|
408
|
+
* `Require stack:` that follows is absolute local paths — dropped.
|
|
409
|
+
* - An esbuild TransformError (the "older tree that no longer builds" case): line 1 is only the
|
|
410
|
+
* header "Transform failed with 1 error:" and the cause is on line 2 — kept, with the sandbox
|
|
411
|
+
* path prefix stripped so what remains is `db/models/index.ts:2:0: ERROR: …`.
|
|
412
|
+
* - An empty message says so instead of rendering nothing.
|
|
413
|
+
*
|
|
414
|
+
* (Closing-gate review round 5: a bare first-line take blanked the cause on exactly the class the
|
|
415
|
+
* `unverifiable` message exists to explain.)
|
|
416
|
+
*/
|
|
417
|
+
export function summarizeCompileError(message: unknown): string {
|
|
418
|
+
const lines = String(message ?? '').split('\n').map((l) => l.trimEnd());
|
|
419
|
+
const stop = lines.findIndex((l) => /^\s*Require stack:/.test(l));
|
|
420
|
+
const useful = (stop === -1 ? lines : lines.slice(0, stop)).filter((l) => l.trim().length > 0);
|
|
421
|
+
if (useful.length === 0) return '(error with no message)';
|
|
422
|
+
const take = /^Transform failed with \d+ errors?:?$/.test(useful[0].trim()) && useful.length > 1 ? useful.slice(0, 2) : useful.slice(0, 1);
|
|
423
|
+
return take
|
|
424
|
+
.map((l) => l.replace(/\/[^\s:'"]*\.everystack-descent-[^/\s]+\/[0-9a-f]+\//g, '').trim())
|
|
425
|
+
.join(' — ');
|
|
426
|
+
}
|
|
427
|
+
const firstLine = summarizeCompileError;
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Where historical trees materialize so that the materialized barrel can `import '@everystack/model'`:
|
|
431
|
+
* node walks UP from the importing file, so the sandbox must sit below a `node_modules` that holds
|
|
432
|
+
* the package. That is the nearest `node_modules` ABOVE THE MODELS BARREL, not the repo root's —
|
|
433
|
+
* in a monorepo the app (`apps/expo/db/models`) has `apps/expo/node_modules`, and the root
|
|
434
|
+
* `node_modules` may not carry the package at all (closing-gate review round 4, reproduced). No
|
|
435
|
+
* `node_modules` anywhere up to the repo root → the repo root itself (a nested checkout still walks
|
|
436
|
+
* up to its host's). Never the OS temp dir — that resolves nothing (the db:adopt 0.4.74 bug).
|
|
437
|
+
*/
|
|
438
|
+
export function materializeBase(repoRoot: string, modelsAbs: string): string {
|
|
439
|
+
let dir = path.dirname(modelsAbs);
|
|
440
|
+
while (dir.startsWith(repoRoot)) {
|
|
441
|
+
const nm = path.join(dir, 'node_modules');
|
|
442
|
+
if (fs.existsSync(nm)) return nm;
|
|
443
|
+
if (dir === repoRoot) break;
|
|
444
|
+
dir = path.dirname(dir);
|
|
445
|
+
}
|
|
446
|
+
return repoRoot;
|
|
246
447
|
}
|
|
247
448
|
|
|
248
449
|
/** Resolve the repo root, HEAD, and the models directory/barrel split — shared setup. */
|
|
249
450
|
function resolveRepoContext(
|
|
250
451
|
modelsPath: string,
|
|
251
452
|
opts: DescentOptions,
|
|
252
|
-
): { repoRoot: string; head: string; modelsDir: string; barrel: string } | null {
|
|
453
|
+
): { repoRoot: string; head: string; modelsDir: string; barrel: string; modelsAbs: string } | null {
|
|
253
454
|
const cwd = opts.cwd ?? process.cwd();
|
|
254
455
|
const rootOut = git(['rev-parse', '--show-toplevel'], cwd);
|
|
255
456
|
if (rootOut === null) return null;
|
|
@@ -261,23 +462,38 @@ function resolveRepoContext(
|
|
|
261
462
|
// /private/var), and a mismatched prefix would walk the models path right out of the repo.
|
|
262
463
|
const modelsAbs = path.resolve(fs.realpathSync(cwd), modelsPath);
|
|
263
464
|
const rel = path.relative(repoRoot, modelsAbs);
|
|
264
|
-
return { repoRoot, head, modelsDir: path.dirname(rel) || '.', barrel: path.basename(modelsAbs) };
|
|
465
|
+
return { repoRoot, head, modelsDir: path.dirname(rel) || '.', barrel: path.basename(modelsAbs), modelsAbs };
|
|
265
466
|
}
|
|
266
467
|
|
|
267
468
|
/** `git cat-file -e <ref>^{commit}` — is the commit present in THIS clone (not a shallow gap)? */
|
|
268
469
|
function commitPresent(ref: string, cwd: string): boolean {
|
|
269
470
|
try {
|
|
270
|
-
execFileSync('git', ['cat-file', '-e', `${ref}^{commit}`], { cwd, stdio: 'ignore' });
|
|
471
|
+
execFileSync('git', ['cat-file', '-e', `${ref}^{commit}`], { cwd, stdio: 'ignore', timeout: GIT_TIMEOUT_MS });
|
|
271
472
|
return true;
|
|
272
473
|
} catch {
|
|
273
474
|
return false;
|
|
274
475
|
}
|
|
275
476
|
}
|
|
276
477
|
|
|
277
|
-
/**
|
|
478
|
+
/**
|
|
479
|
+
* `git merge-base --is-ancestor`, three-valued: true / false / null when git could not answer
|
|
480
|
+
* (a timeout, a missing object, a broken repo). Every caller whose SAFE answer is "not an
|
|
481
|
+
* ancestor" may use {@link isAncestor}; a caller whose safe answer is the OTHER way — the squash
|
|
482
|
+
* guard accepts a carrier that is NOT an ancestor of the witness — must read null as "refuse".
|
|
483
|
+
*/
|
|
484
|
+
function ancestry(ancestor: string, head: string, cwd: string): boolean | null {
|
|
485
|
+
try {
|
|
486
|
+
execFileSync('git', ['merge-base', '--is-ancestor', ancestor, head], { cwd, stdio: 'ignore', timeout: GIT_TIMEOUT_MS });
|
|
487
|
+
return true;
|
|
488
|
+
} catch (err: any) {
|
|
489
|
+
return err?.status === 1 ? false : null; // exit 1 = "not an ancestor"; anything else = could not tell
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** `git merge-base --is-ancestor <ancestor> <head>` — does HEAD descend from the commit? False when git cannot tell. */
|
|
278
494
|
function isAncestor(ancestor: string, head: string, cwd: string): boolean {
|
|
279
495
|
try {
|
|
280
|
-
execFileSync('git', ['merge-base', '--is-ancestor', ancestor, head], { cwd, stdio: 'ignore' });
|
|
496
|
+
execFileSync('git', ['merge-base', '--is-ancestor', ancestor, head], { cwd, stdio: 'ignore', timeout: GIT_TIMEOUT_MS });
|
|
281
497
|
return true;
|
|
282
498
|
} catch {
|
|
283
499
|
return false;
|
|
@@ -314,11 +530,55 @@ export interface GitLineageProbe {
|
|
|
314
530
|
remotes(): string[] | null;
|
|
315
531
|
/** `git ls-remote --heads --tags <remote>` advertised tip shas; null when the remote is unreachable. */
|
|
316
532
|
lsRemote(remote: string): string[] | null;
|
|
533
|
+
/**
|
|
534
|
+
* Why the last `lsRemote(remote)` returned null — git's own first stderr line, paths scrubbed —
|
|
535
|
+
* or null when nothing was captured. Optional: a probe without it still names the remote.
|
|
536
|
+
*/
|
|
537
|
+
lsRemoteError?(remote: string): string | null;
|
|
538
|
+
/**
|
|
539
|
+
* Does this object peel to a commit (`<sha>^{commit}`)? `null` when the object is absent from
|
|
540
|
+
* the clone. An advertised tip that is present but is a tag on a blob or tree can carry no
|
|
541
|
+
* models and cannot be `--contains`-tested (git errors on a non-commit), so currency skips it.
|
|
542
|
+
* Optional: a probe without it treats every tip as a commit (refuse-closed).
|
|
543
|
+
*/
|
|
544
|
+
peelsToCommit?(sha: string): boolean | null;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* The runbook for a remote `git ls-remote` could not read. Three things it must do, each learned
|
|
549
|
+
* from a consumer's production cutover (cli 0.4.77, 2026-09-07) where the remote was named `db`
|
|
550
|
+
* and "could not read db" was read as the database being unreachable: name the remote AS a git
|
|
551
|
+
* remote, carry git's own error so the cause is on the surface, and name the verb that clears a
|
|
552
|
+
* dead one — `git remote remove` — next to the one that fixes a live one.
|
|
553
|
+
*/
|
|
554
|
+
function unreadableRemote(remote: string, git: GitLineageProbe): string {
|
|
555
|
+
const err = git.lsRemoteError?.(remote);
|
|
556
|
+
return `git remote '${remote}' could not be read: \`git ls-remote --heads --tags ${remote}\` failed${err ? ` — ${err}` : ''}. `
|
|
557
|
+
+ `Fix the remote or remove it (git remote remove ${remote}), then re-run.`;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* git's stderr, reduced to what a refusal may carry: the first non-empty line, absolute paths
|
|
562
|
+
* and file:// URLs replaced by `<path>` (the reason is recorded, and a home directory is not
|
|
563
|
+
* part of the diagnosis), capped so a chatty transport cannot flood the surface.
|
|
564
|
+
*/
|
|
565
|
+
export function firstErrorLine(stderr: string): string | null {
|
|
566
|
+
const line = stderr.split('\n').map((l) => l.trim()).find(Boolean);
|
|
567
|
+
if (!line) return null;
|
|
568
|
+
return scrubPaths(line).slice(0, 200);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/** Absolute paths and file:// URLs replaced by `<path>` — a recorded reason never carries a home directory. */
|
|
572
|
+
export function scrubPaths(s: string): string {
|
|
573
|
+
return s
|
|
574
|
+
.replace(/file:\/\/\S+/g, 'file://<path>')
|
|
575
|
+
.replace(/(^|[\s'"(])\/[^\s'"()]+/g, '$1<path>');
|
|
317
576
|
}
|
|
318
577
|
|
|
319
578
|
export type LineageProof =
|
|
320
579
|
| { proven: true; subcase: 'revert-unreachable' | 'unknown-commit-proven-absent' }
|
|
321
|
-
|
|
580
|
+
/** `ref` is set for `reachable`: the ref (or `HEAD`, or a remote name) that still holds the commit. */
|
|
581
|
+
| { proven: false; kind: 'shallow' | 'partial' | 'reachable' | 'behind-remote' | 'remote-unreachable' | 'remote-list-failed' | 'no-remotes'; reason: string; ref?: string };
|
|
322
582
|
|
|
323
583
|
/**
|
|
324
584
|
* Prove the declaring commit `sha` is reachable from NO ref, local or remote. Refuse-closed: every
|
|
@@ -339,10 +599,10 @@ export function proveLineageLost(sha: string, git: GitLineageProbe): LineageProo
|
|
|
339
599
|
if (present) {
|
|
340
600
|
const containing = git.refsContaining(sha);
|
|
341
601
|
if (containing.length > 0) {
|
|
342
|
-
return { proven: false, kind: 'reachable', reason: `commit ${short(sha)} is still reachable (${containing[0]}) — revert it in git and re-plan instead of attesting it lost.` };
|
|
602
|
+
return { proven: false, kind: 'reachable', ref: containing[0], reason: `commit ${short(sha)} is still reachable (${containing[0]}) — revert it in git and re-plan instead of attesting it lost.` };
|
|
343
603
|
}
|
|
344
604
|
if (git.isAncestorOf(sha, 'HEAD')) {
|
|
345
|
-
return { proven: false, kind: 'reachable', reason: `commit ${short(sha)} is still reachable (an ancestor of HEAD) — revert it in git and re-plan instead of attesting it lost.` };
|
|
605
|
+
return { proven: false, kind: 'reachable', ref: 'HEAD', reason: `commit ${short(sha)} is still reachable (an ancestor of HEAD) — revert it in git and re-plan instead of attesting it lost.` };
|
|
346
606
|
}
|
|
347
607
|
}
|
|
348
608
|
|
|
@@ -359,14 +619,14 @@ export function proveLineageLost(sha: string, git: GitLineageProbe): LineageProo
|
|
|
359
619
|
for (const remote of remotes) {
|
|
360
620
|
const tips = git.lsRemote(remote);
|
|
361
621
|
if (tips === null) {
|
|
362
|
-
return { proven: false, kind: 'remote-unreachable', reason: `
|
|
622
|
+
return { proven: false, kind: 'remote-unreachable', reason: `cannot confirm ${short(sha)} is not on ${remote}: ${unreadableRemote(remote, git)}` };
|
|
363
623
|
}
|
|
364
624
|
for (const tip of tips) {
|
|
365
625
|
if (!git.objectExists(tip)) {
|
|
366
626
|
return { proven: false, kind: 'behind-remote', reason: `this clone is behind ${remote} (it advertises ${short(tip)}, which you do not have) — fetch it first; the declaring commit may still be on the remote.` };
|
|
367
627
|
}
|
|
368
628
|
if (present && git.isAncestorOf(sha, tip)) {
|
|
369
|
-
return { proven: false, kind: 'reachable', reason: `commit ${short(sha)} is still reachable (on ${remote}) — revert it in git and re-plan instead of attesting it lost.` };
|
|
629
|
+
return { proven: false, kind: 'reachable', ref: remote, reason: `commit ${short(sha)} is still reachable (on ${remote}) — revert it in git and re-plan instead of attesting it lost.` };
|
|
370
630
|
}
|
|
371
631
|
}
|
|
372
632
|
}
|
|
@@ -393,9 +653,12 @@ export function proveLineageLost(sha: string, git: GitLineageProbe): LineageProo
|
|
|
393
653
|
|
|
394
654
|
/** The real probe: each method is one git command run in `cwd`. */
|
|
395
655
|
export function realLineageProbe(cwd: string): GitLineageProbe {
|
|
656
|
+
const lsRemoteErrors = new Map<string, string>();
|
|
657
|
+
// A hung remote must become a refusal, not a hang: every probe command is bounded. A timeout
|
|
658
|
+
// throws, so it reads as null — unreachable — and the proof refuses closed as it would offline.
|
|
396
659
|
const run = (args: string[]): string | null => {
|
|
397
660
|
try {
|
|
398
|
-
return execFileSync('git', args, { cwd, stdio: ['ignore', 'pipe', 'ignore'] }).toString();
|
|
661
|
+
return execFileSync('git', args, { cwd, stdio: ['ignore', 'pipe', 'ignore'], timeout: GIT_TIMEOUT_MS }).toString();
|
|
399
662
|
} catch {
|
|
400
663
|
return null;
|
|
401
664
|
}
|
|
@@ -418,10 +681,24 @@ export function realLineageProbe(cwd: string): GitLineageProbe {
|
|
|
418
681
|
return out === null ? null : out.split('\n').map((l) => l.trim()).filter(Boolean);
|
|
419
682
|
},
|
|
420
683
|
lsRemote: (remote) => {
|
|
421
|
-
|
|
422
|
-
|
|
684
|
+
// stderr is KEPT here, unlike `run`: the refusal this feeds must say why git could not read
|
|
685
|
+
// the remote, or "could not read db" is the database to whoever reads it.
|
|
686
|
+
let out: string;
|
|
687
|
+
try {
|
|
688
|
+
out = execFileSync('git', ['ls-remote', '--heads', '--tags', remote], { cwd, stdio: ['ignore', 'pipe', 'pipe'], timeout: GIT_TIMEOUT_MS }).toString();
|
|
689
|
+
} catch (e) {
|
|
690
|
+
const stderr = (e as { stderr?: Buffer | string }).stderr?.toString() ?? '';
|
|
691
|
+
lsRemoteErrors.set(remote, firstErrorLine(stderr) ?? firstErrorLine((e as Error).message ?? '') ?? 'no error output');
|
|
692
|
+
return null;
|
|
693
|
+
}
|
|
694
|
+
lsRemoteErrors.delete(remote);
|
|
423
695
|
return out.split('\n').map((l) => l.split('\t')[0]?.trim()).filter((s): s is string => !!s && s.length >= 7);
|
|
424
696
|
},
|
|
697
|
+
lsRemoteError: (remote) => lsRemoteErrors.get(remote) ?? null,
|
|
698
|
+
peelsToCommit: (sha) => {
|
|
699
|
+
if (!commitPresent(sha, cwd) && run(['cat-file', '-e', sha]) === null) return null; // absent
|
|
700
|
+
return run(['rev-parse', '--verify', '--quiet', `${sha}^{commit}`]) !== null;
|
|
701
|
+
},
|
|
425
702
|
};
|
|
426
703
|
}
|
|
427
704
|
|
|
@@ -451,34 +728,127 @@ export interface DescentLedgerRow {
|
|
|
451
728
|
dirty: boolean;
|
|
452
729
|
/** When the apply landed — for the REVERT message (names the commit + date). */
|
|
453
730
|
appliedAt: string;
|
|
454
|
-
/** Only a successful state apply is a witness
|
|
731
|
+
/** Only a successful state apply is a witness: `applied`, or `applied — <descent note>`. */
|
|
455
732
|
outcome: string;
|
|
456
733
|
}
|
|
457
734
|
|
|
735
|
+
/** A successful state apply, with or without a recorded descent note (`applied — …`). */
|
|
736
|
+
export const isAppliedOutcome = (outcome: string): boolean => outcome === 'applied' || outcome.startsWith('applied — ');
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* Where a proven drift came from — informs the warning and the schema_log note, never the
|
|
740
|
+
* verdict (the ledger is a memoir, decision 7). `never-declared`: no successful state apply is
|
|
741
|
+
* recorded here — first adoption of a database everystack never wrote. `hand-edited`: everystack
|
|
742
|
+
* did apply here once (the newest such row is named), and the database has moved since, outside
|
|
743
|
+
* the tool.
|
|
744
|
+
*/
|
|
745
|
+
export type DriftOrigin =
|
|
746
|
+
| { kind: 'never-declared' }
|
|
747
|
+
/** `dirty`: that last apply ran from a dirty worktree, so its commit never declared what it applied. */
|
|
748
|
+
| { kind: 'hand-edited'; commit: string | null; appliedAt: string; dirty?: boolean };
|
|
749
|
+
|
|
458
750
|
export type ProveVerdict =
|
|
459
751
|
/** The database has no tables — first apply bootstraps it. */
|
|
460
752
|
| { status: 'fresh-target' }
|
|
461
753
|
/** Not a git checkout — the rule cannot be verified here. */
|
|
462
754
|
| { status: 'no-git' }
|
|
463
|
-
/** Fast-forward proven — via a ledger witness or an ancestor recompile.
|
|
464
|
-
|
|
755
|
+
/** Fast-forward proven — via a ledger witness, its squashed successor, or an ancestor recompile.
|
|
756
|
+
* For `squash`, `commit` is the CARRIER (an ancestor of HEAD holding the deploying commit's
|
|
757
|
+
* models tree) and `witness` is the deploying commit the ledger names, which HEAD does not
|
|
758
|
+
* descend from — both are surfaced and the weaker proof is recorded in schema_log. */
|
|
759
|
+
| { status: 'proven'; via: 'ledger' | 'squash' | 'recompile'; commit: string; witness?: string }
|
|
465
760
|
/** The newest ledger witness is NOT an ancestor of HEAD — applying would revert merged work. */
|
|
466
761
|
| { status: 'revert'; commit: string; appliedAt: string; reason: string }
|
|
467
762
|
/** The witness names a commit this clone does not have (shallow) — fetch, do not guess. */
|
|
468
763
|
| { status: 'unknown-commit'; commit: string; reason: string }
|
|
469
|
-
/**
|
|
764
|
+
/** A ref HEAD does not descend from declares the live state — a teammate's merged work. */
|
|
765
|
+
| { status: 'declared-elsewhere'; commit: string; reason: string }
|
|
766
|
+
/** The search could not read every tree, or the clone is not full and current — inconclusive. */
|
|
470
767
|
| { status: 'unverifiable'; reason: string; unevaluated: string[]; compileFailures: string[] }
|
|
471
|
-
/**
|
|
472
|
-
|
|
768
|
+
/** Every tree on every ref read cleanly in a full, current clone, and none declares live —
|
|
769
|
+
* PROVEN drift. Passes at apply: nothing merged exists to revert. */
|
|
770
|
+
| { status: 'drift'; reason: string; scannedTrees: number; origin: DriftOrigin };
|
|
473
771
|
|
|
474
772
|
export interface ProveDescentOptions extends DescentOptions {
|
|
475
773
|
/** Retained-canon registry override — the cross-bump recompute path (tests inject a
|
|
476
774
|
* synthetic retired canon). Defaults to the module's RETAINED_CANONS. */
|
|
477
775
|
canons?: Readonly<Record<number, CanonHasher>>;
|
|
776
|
+
/** The git probe the drift proof uses to establish the clone is full and current.
|
|
777
|
+
* Default: {@link realLineageProbe} over the repo root. Injected so tests can prove each
|
|
778
|
+
* currency refusal without a remote. */
|
|
779
|
+
probe?: GitLineageProbe;
|
|
478
780
|
}
|
|
479
781
|
|
|
480
782
|
const shortDate = (at: string): string => at.slice(0, 10);
|
|
481
783
|
|
|
784
|
+
/**
|
|
785
|
+
* The origin, in words — ONE rendering for the plan warning, the apply warning, and the recorded
|
|
786
|
+
* note, so the dirty-worktree sub-case can never be surfaced in one place and dropped in another
|
|
787
|
+
* (closing-gate review round 4: the flag was computed and reached no human and no record).
|
|
788
|
+
*
|
|
789
|
+
* never-declared — no everystack apply is recorded here
|
|
790
|
+
* hand-edited since <sha>, <date> — everystack applied from that commit; the database moved since
|
|
791
|
+
* applied from a DIRTY worktree at <sha>, <date> — that commit never declared what was applied,
|
|
792
|
+
* so nothing committed can declare this state
|
|
793
|
+
*/
|
|
794
|
+
export function describeDriftOrigin(origin: DriftOrigin, opts: { human?: boolean } = {}): string {
|
|
795
|
+
if (origin.kind === 'never-declared') return opts.human ? 'never declared: no everystack apply is recorded here' : 'never-declared';
|
|
796
|
+
const at = origin.commit ? `${short(origin.commit)}, ${shortDate(origin.appliedAt)}` : `an apply on ${shortDate(origin.appliedAt)}`;
|
|
797
|
+
return origin.dirty ? `applied from a DIRTY worktree at ${at}` : `hand-edited since ${at}`;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/** The recorded note for a proven drift — goes in the apply row's schema_log outcome. */
|
|
801
|
+
export function driftOriginNote(origin: DriftOrigin): string {
|
|
802
|
+
return `descent: drift-origin proven (${describeDriftOrigin(origin)})`;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* Is this clone full and current with every remote? The drift proof's precondition: "no ref
|
|
807
|
+
* anywhere declares live" is only a claim we may make when we hold every ref there is. Same
|
|
808
|
+
* probe as the lineage proof, but the question differs — currency, not the fate of one commit —
|
|
809
|
+
* so an empty remote set is simply current (a purely local repo has nothing to be behind).
|
|
810
|
+
* Refuse-closed: a failed `git remote` or an unreachable remote is NOT current.
|
|
811
|
+
*
|
|
812
|
+
* The test is REF reachability, never object presence. The scan this guards is `git rev-list
|
|
813
|
+
* --all`, which walks refs; an advertised tip that is present as an object but contained by no
|
|
814
|
+
* ref (a `git fetch origin <branch>` that wrote only FETCH_HEAD, or a locally deleted branch
|
|
815
|
+
* the remote still has) is invisible to the scan and MUST count as behind. Testing
|
|
816
|
+
* `objectExists` here let exactly that tip satisfy currency while the scan missed the models
|
|
817
|
+
* it declared — found by the closing-gate review 2026-09-07, before it shipped.
|
|
818
|
+
*/
|
|
819
|
+
export function proveCloneCurrent(git: GitLineageProbe): { current: true } | { current: false; reason: string } {
|
|
820
|
+
if (git.isShallow()) {
|
|
821
|
+
return { current: false, reason: 'this is a shallow clone, so a ref that declares the live state may simply not be here — unshallow first (git fetch --unshallow), then re-run.' };
|
|
822
|
+
}
|
|
823
|
+
if (git.isPartial()) {
|
|
824
|
+
return { current: false, reason: 'this is a partial clone, so objects can be missing locally — fetch the full history, then re-run.' };
|
|
825
|
+
}
|
|
826
|
+
const remotes = git.remotes();
|
|
827
|
+
if (remotes === null) {
|
|
828
|
+
return { current: false, reason: "could not list this repo's git remotes to confirm the clone is current — retry, or fetch the remotes first." };
|
|
829
|
+
}
|
|
830
|
+
for (const remote of remotes) {
|
|
831
|
+
const tips = git.lsRemote(remote);
|
|
832
|
+
if (tips === null) {
|
|
833
|
+
return { current: false, reason: `cannot confirm the clone is current: ${unreadableRemote(remote, git)}` };
|
|
834
|
+
}
|
|
835
|
+
for (const tip of tips) {
|
|
836
|
+
// refs/stash is excluded from the scan (enumerateModelTrees), so a tip contained ONLY by a
|
|
837
|
+
// stash counts as behind here — the two sides of the currency proof see the same refs, and
|
|
838
|
+
// this direction is refuse-closed. The probe itself stays raw: for the lineage proof a stash
|
|
839
|
+
// that reaches a commit is a REACHABLE commit, and filtering it there would fall open.
|
|
840
|
+
if (git.refsContaining(tip).filter((ref) => ref !== 'refs/stash').length === 0) {
|
|
841
|
+
// A tip we HOLD that is not a commit and peels to none (a tag on a blob or tree — the
|
|
842
|
+
// `junio-gpg-pub` shape) carries no models and can never be contained by a ref; it is
|
|
843
|
+
// not evidence of being behind. An ABSENT tip, or a present commit no ref contains, is.
|
|
844
|
+
if (git.peelsToCommit && git.peelsToCommit(tip) === false) continue;
|
|
845
|
+
return { current: false, reason: `this clone is behind ${remote} (it advertises ${short(tip)}, which no ref here contains) — fetch it first (git fetch ${remote} --tags); a ref there may declare the live state.` };
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
return { current: true };
|
|
850
|
+
}
|
|
851
|
+
|
|
482
852
|
/**
|
|
483
853
|
* The fast-forward proof, ledger-first. Refuse-closed: no inconclusive branch passes.
|
|
484
854
|
*
|
|
@@ -508,7 +878,7 @@ export async function proveDescent(
|
|
|
508
878
|
|
|
509
879
|
const ctx = resolveRepoContext(modelsPath, opts);
|
|
510
880
|
if (ctx === null) return { status: 'no-git' };
|
|
511
|
-
const { repoRoot, head, modelsDir, barrel } = ctx;
|
|
881
|
+
const { repoRoot, head, modelsDir, barrel, modelsAbs } = ctx;
|
|
512
882
|
|
|
513
883
|
// ---- Phase A: the ledger witness (newest usable row wins). ----
|
|
514
884
|
// "Newest wins" (the S→T→S pre-episode refusal) is a security-relevant property, so do not
|
|
@@ -516,7 +886,7 @@ export async function proveDescent(
|
|
|
516
886
|
// future caller passing an unsorted ledger must not silently let a pre-episode row win.
|
|
517
887
|
const ordered = [...ledger].sort((a, b) => Number(b.id) - Number(a.id));
|
|
518
888
|
for (const r of ordered) {
|
|
519
|
-
if (r.outcome
|
|
889
|
+
if (!isAppliedOutcome(r.outcome)) continue; // only a successful state apply is a witness
|
|
520
890
|
if (!r.toFingerprint) continue;
|
|
521
891
|
if (!r.gitRef) continue; // a null git_ref cannot declare a commit
|
|
522
892
|
if (r.dirty) continue; // a dirty mint's git_ref does not declare what was applied
|
|
@@ -540,23 +910,43 @@ export async function proveDescent(
|
|
|
540
910
|
if (isAncestor(r.gitRef, head, repoRoot)) {
|
|
541
911
|
return { status: 'proven', via: 'ledger', commit: r.gitRef };
|
|
542
912
|
}
|
|
913
|
+
const squash = squashedSuccessor(r.gitRef, head, modelsDir, repoRoot);
|
|
914
|
+
if (squash) return { status: 'proven', via: 'squash', commit: squash, witness: r.gitRef };
|
|
543
915
|
return {
|
|
544
916
|
status: 'revert',
|
|
545
917
|
commit: r.gitRef,
|
|
546
918
|
appliedAt: r.appliedAt,
|
|
547
919
|
reason:
|
|
548
920
|
`your checkout is behind the branch that last deployed this state — commit ${short(r.gitRef)} ` +
|
|
549
|
-
`(applied ${shortDate(r.appliedAt)}) is NOT an ancestor of HEAD
|
|
550
|
-
'
|
|
921
|
+
`(applied ${shortDate(r.appliedAt)}) is NOT an ancestor of HEAD, and no ancestor of HEAD carries ` +
|
|
922
|
+
'its models tree as a squashed successor. Applying now would revert that merged work. ' +
|
|
923
|
+
'Pull the deploying branch, rebase onto it, and re-plan.',
|
|
551
924
|
};
|
|
552
925
|
}
|
|
553
926
|
|
|
554
927
|
// ---- Phase B: no usable ledger witness → ancestor-restricted recompile. ----
|
|
555
928
|
const candidates = enumerateModelTrees(modelsDir, repoRoot, { ancestorsOf: head });
|
|
556
929
|
|
|
557
|
-
|
|
930
|
+
// No ancestor carries a committed models directory to compare against — a shallow clone that
|
|
931
|
+
// truncated the deploying history, or the deploying branch is simply not in this checkout.
|
|
932
|
+
// That is a limit of THIS clone, not a hand-edit: UNVERIFIABLE, never drift.
|
|
933
|
+
if (candidates.length === 0) {
|
|
934
|
+
return {
|
|
935
|
+
status: 'unverifiable',
|
|
936
|
+
reason:
|
|
937
|
+
'cannot verify the fast-forward rule — no ancestor of your checkout carries a committed ' +
|
|
938
|
+
'models directory to compare against. This is a shallow clone (fetch the full history: ' +
|
|
939
|
+
'git fetch --unshallow) or the deploying branch is not in this checkout, not evidence ' +
|
|
940
|
+
'about the database — it is NOT drift. Fetch the deploying history and re-run.',
|
|
941
|
+
unevaluated: [],
|
|
942
|
+
compileFailures: [],
|
|
943
|
+
};
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
// Historical trees materialize INSIDE the project, below the nearest node_modules above the
|
|
947
|
+
// models barrel — see materializeBase. Never the OS temp dir.
|
|
558
948
|
const materializeRoot = opts.materializeRoot
|
|
559
|
-
?? fs.mkdtempSync(path.join(
|
|
949
|
+
?? fs.mkdtempSync(path.join(materializeBase(repoRoot, modelsAbs), '.everystack-descent-'));
|
|
560
950
|
const ownsRoot = !opts.materializeRoot;
|
|
561
951
|
const evalCtx: EvalContext = {
|
|
562
952
|
repoRoot,
|
|
@@ -569,15 +959,39 @@ export async function proveDescent(
|
|
|
569
959
|
|
|
570
960
|
const compileFailures: string[] = [];
|
|
571
961
|
const unevaluated: string[] = [];
|
|
962
|
+
const unreadable: UnreadableTree[] = [];
|
|
963
|
+
const probe = opts.probe ?? realLineageProbe(repoRoot);
|
|
964
|
+
const lastApplied = ordered.find((r) => isAppliedOutcome(r.outcome));
|
|
965
|
+
const unverifiable = (scope: string, scanned: number): ProveVerdict => {
|
|
966
|
+
const notes: string[] = [];
|
|
967
|
+
if (compileFailures.length > 0) notes.push(`${compileFailures.length} would not compile under this CLI`);
|
|
968
|
+
if (unevaluated.length > 0) notes.push(`${unevaluated.length} could not be extracted`);
|
|
969
|
+
// Every unreadable tree is NAMED — with the refs that carry it, its place in history, and its
|
|
970
|
+
// own error ("Cannot find module '@everystack/model'" is a resolution problem with a different
|
|
971
|
+
// fix than "an older tree that no longer builds") — and the runbook leads with the fix the
|
|
972
|
+
// ledger supports. See unreadableRunbook for the incident that shaped this.
|
|
973
|
+
return {
|
|
974
|
+
status: 'unverifiable',
|
|
975
|
+
reason:
|
|
976
|
+
`cannot verify the fast-forward rule — of ${scanned} ${scope} model state(s), ` +
|
|
977
|
+
`${notes.join(' and ')}, and none of the readable ones declares the live state. This is a ` +
|
|
978
|
+
'limit of THIS run (an older tree that no longer builds, or a failed extraction), not ' +
|
|
979
|
+
`evidence about the database — it is NOT drift. Unreadable:\n${unreadableRunbook(unreadable, lastApplied, probe)}`,
|
|
980
|
+
unevaluated,
|
|
981
|
+
compileFailures,
|
|
982
|
+
};
|
|
983
|
+
};
|
|
984
|
+
const record = (candidate: ModelTreeCandidate, result: Extract<TreeEval, { error: string }>, ancestor: boolean): void => {
|
|
985
|
+
(result.kind === 'unevaluated' ? unevaluated : compileFailures).push(result.detail);
|
|
986
|
+
unreadable.push({ candidate, ancestor, error: result.error });
|
|
987
|
+
};
|
|
988
|
+
|
|
989
|
+
let others: ModelTreeCandidate[] = [];
|
|
572
990
|
try {
|
|
573
991
|
for (const candidate of candidates) {
|
|
574
992
|
const result = await evaluateTree(candidate, live, evalCtx);
|
|
575
|
-
if (result.kind === 'unevaluated') {
|
|
576
|
-
|
|
577
|
-
continue;
|
|
578
|
-
}
|
|
579
|
-
if (result.kind === 'compile-failed') {
|
|
580
|
-
compileFailures.push(result.detail);
|
|
993
|
+
if (result.kind === 'unevaluated' || result.kind === 'compile-failed') {
|
|
994
|
+
record(candidate, result, true);
|
|
581
995
|
continue;
|
|
582
996
|
}
|
|
583
997
|
if (result.kind === 'no') continue;
|
|
@@ -586,79 +1000,108 @@ export async function proveDescent(
|
|
|
586
1000
|
const ancestor = candidate.commits.find((commit) => isAncestor(commit, head, repoRoot)) ?? candidate.commits[0];
|
|
587
1001
|
return { status: 'proven', via: 'recompile', commit: ancestor };
|
|
588
1002
|
}
|
|
589
|
-
} finally {
|
|
590
|
-
if (ownsRoot) fs.rmSync(materializeRoot, { recursive: true, force: true });
|
|
591
|
-
}
|
|
592
1003
|
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
return {
|
|
598
|
-
status: 'unverifiable',
|
|
599
|
-
reason:
|
|
600
|
-
'cannot verify the fast-forward rule — no ancestor of your checkout carries a committed ' +
|
|
601
|
-
'models directory to compare against. This is a shallow clone (fetch the full history: ' +
|
|
602
|
-
'git fetch --unshallow) or the deploying branch is not in this checkout, not evidence ' +
|
|
603
|
-
'about the database — it is NOT drift. Fetch the deploying history, or waive the rule deliberately.',
|
|
604
|
-
unevaluated,
|
|
605
|
-
compileFailures,
|
|
606
|
-
};
|
|
607
|
-
}
|
|
1004
|
+
// A tree we could not READ is not evidence about the database. If any ancestor tree could not
|
|
1005
|
+
// be extracted or compiled, "nothing declares this state" is a claim we are not entitled to
|
|
1006
|
+
// make — the answer is UNVERIFIABLE, and its next move is retry/upgrade, never db:pull.
|
|
1007
|
+
if (unevaluated.length > 0 || compileFailures.length > 0) return unverifiable('ancestor', candidates.length);
|
|
608
1008
|
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
1009
|
+
// ---- Phase C: the drift proof — no ancestor declares live, so scan EVERY ref. ----
|
|
1010
|
+
// The fast-forward rule protects MERGED work. Before calling the live state undeclared, look
|
|
1011
|
+
// at every tree on every ref this clone holds: one that declares live belongs to a line of
|
|
1012
|
+
// history HEAD does not descend from — a teammate deployed it — and applying our edge would
|
|
1013
|
+
// revert it. That refuses BY NAME. Only when no tree anywhere declares live is there nothing
|
|
1014
|
+
// to protect.
|
|
1015
|
+
const seen = new Set(candidates.map((c) => c.tree));
|
|
1016
|
+
const all = enumerateModelTrees(modelsDir, repoRoot);
|
|
1017
|
+
// The all-refs walk must be a SUPERSET of the ancestor walk — every ancestor tree is reachable
|
|
1018
|
+
// from `--all`. When it is not, `git rev-list --all` failed (a broken or dangling ref makes it
|
|
1019
|
+
// exit 128 while the ancestor walk still succeeds) and returned nothing, which is NOT "no
|
|
1020
|
+
// other refs". Reading an empty result as "nothing else declares live" fell open to drift;
|
|
1021
|
+
// found by the closing-gate review 2026-09-07.
|
|
1022
|
+
const allTrees = new Set(all.map((c) => c.tree));
|
|
1023
|
+
if ([...seen].some((tree) => !allTrees.has(tree))) {
|
|
1024
|
+
return {
|
|
1025
|
+
status: 'unverifiable',
|
|
1026
|
+
reason:
|
|
1027
|
+
'cannot verify the fast-forward rule — the walk over EVERY ref (git rev-list --all) did not ' +
|
|
1028
|
+
'return the trees the ancestor walk did, so it failed part-way: a broken or dangling ref ' +
|
|
1029
|
+
'(run `git fsck`, prune the bad ref) or a git error. That is a limit of THIS clone, not ' +
|
|
1030
|
+
'evidence about the database — it is NOT drift. Repair the ref and re-run.',
|
|
1031
|
+
unevaluated,
|
|
1032
|
+
compileFailures,
|
|
1033
|
+
};
|
|
1034
|
+
}
|
|
1035
|
+
// Currency BEFORE the all-refs compile: a clone that is shallow, partial, or behind a remote
|
|
1036
|
+
// is told to fetch now, not after paying for every historical tree on every ref.
|
|
1037
|
+
const currency = proveCloneCurrent(probe);
|
|
1038
|
+
if (!currency.current) {
|
|
1039
|
+
return {
|
|
1040
|
+
status: 'unverifiable',
|
|
1041
|
+
reason:
|
|
1042
|
+
`cannot verify the fast-forward rule — no ancestor of your checkout declares the live state, and ` +
|
|
1043
|
+
`${currency.reason} That is a limit of THIS clone, not evidence about the database — it is NOT drift.`,
|
|
1044
|
+
unevaluated,
|
|
1045
|
+
compileFailures,
|
|
1046
|
+
};
|
|
1047
|
+
}
|
|
1048
|
+
others = all.filter((c) => !seen.has(c.tree));
|
|
1049
|
+
for (const candidate of others) {
|
|
1050
|
+
const result = await evaluateTree(candidate, live, evalCtx);
|
|
1051
|
+
if (result.kind === 'unevaluated' || result.kind === 'compile-failed') {
|
|
1052
|
+
record(candidate, result, false);
|
|
1053
|
+
continue;
|
|
1054
|
+
}
|
|
1055
|
+
if (result.kind === 'no') continue;
|
|
1056
|
+
const commit = candidate.commits[0];
|
|
1057
|
+
return {
|
|
1058
|
+
status: 'declared-elsewhere',
|
|
1059
|
+
commit,
|
|
1060
|
+
reason:
|
|
1061
|
+
`a ref your checkout does NOT descend from declares the target's live state — commit ` +
|
|
1062
|
+
`${short(commit)} carries the models that match it. Someone deployed from that line of ` +
|
|
1063
|
+
'history; applying your edge would revert their work. Fetch and merge or rebase onto it, ' +
|
|
1064
|
+
'then re-plan.',
|
|
1065
|
+
};
|
|
1066
|
+
}
|
|
1067
|
+
} finally {
|
|
1068
|
+
if (ownsRoot) fs.rmSync(materializeRoot, { recursive: true, force: true });
|
|
627
1069
|
}
|
|
628
1070
|
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
`no ancestor of your checkout declares the target's live fingerprint ` +
|
|
634
|
-
`${short(fingerprintLive(live.snapshot, live.contract).hash)} — read ${candidates.length} ` +
|
|
635
|
-
'ancestor model state(s), all cleanly, and none matches. Either this database has never been ' +
|
|
636
|
-
'adopted into Models (nothing committed can declare it — the first-adoption case), or it ' +
|
|
637
|
-
'drifted after adoption. THE RUNBOOK: run everystack db:adopt — it renders Models from the ' +
|
|
638
|
-
'live database and PROVES they declare it exactly (it REFUSES rather than paper over any ' +
|
|
639
|
-
'difference); commit AND push that baseline, then re-run db:plan / db:apply and it descends ' +
|
|
640
|
-
'normally. If instead your Models are the source of truth and the database drifted, reconcile ' +
|
|
641
|
-
'with db:generate, or waive the rule deliberately.',
|
|
642
|
-
};
|
|
643
|
-
}
|
|
1071
|
+
const scannedTrees = candidates.length + others.length;
|
|
1072
|
+
// A side branch we could not read is not evidence either — refuse-closed, naming the count so
|
|
1073
|
+
// the operator can prune the branch that carries it.
|
|
1074
|
+
if (unevaluated.length > 0 || compileFailures.length > 0) return unverifiable('reachable', scannedTrees);
|
|
644
1075
|
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
1076
|
+
// PROVEN DRIFT (the clone was proven full and current before the all-refs scan). The origin is
|
|
1077
|
+
// read from the ledger for the MESSAGE only (memoir, not control): any successful state apply
|
|
1078
|
+
// recorded here means everystack once wrote this database and it has moved since; none means
|
|
1079
|
+
// this is the first adoption of a database everystack never wrote. A dirty-worktree apply is
|
|
1080
|
+
// still an apply — its git_ref declares nothing (Phase A skips it as a witness), so the state
|
|
1081
|
+
// it produced was never committed anywhere; the origin says so.
|
|
1082
|
+
const origin: DriftOrigin = lastApplied
|
|
1083
|
+
? { kind: 'hand-edited', commit: lastApplied.gitRef, appliedAt: lastApplied.appliedAt, ...(lastApplied.dirty ? { dirty: true } : {}) }
|
|
1084
|
+
: { kind: 'never-declared' };
|
|
1085
|
+
const fp = short(fingerprintLive(live.snapshot, live.contract).hash);
|
|
1086
|
+
// The scan is scoped to THIS checkout's models path: a ref that declares live from a different
|
|
1087
|
+
// directory (a models move on another line of history) is outside it. Say so — the claim must
|
|
1088
|
+
// be exactly as wide as the search.
|
|
1089
|
+
const scanned = `read ${scannedTrees} model state(s) at ${modelsDir}/ across every ref of a full, current clone, all cleanly, and none matches`;
|
|
1090
|
+
const runbook =
|
|
1091
|
+
'The reviewed plan you mint is the ORIGIN: `everystack db:plan --stage <name>` mints it, ' +
|
|
1092
|
+
'`everystack db:apply --plan <file> --stage <name>` applies it, warns, and records the origin ' +
|
|
1093
|
+
'in schema_log; every later apply descends from that row.';
|
|
1094
|
+
const reason = origin.kind === 'never-declared'
|
|
1095
|
+
? `no commit on any ref declares the target's live fingerprint ${fp} — ${scanned}. Nothing has ever ` +
|
|
1096
|
+
'declared this database (no everystack state apply is recorded here), so there is no merged work ' +
|
|
1097
|
+
`the fast-forward rule could protect. ${runbook}`
|
|
1098
|
+
: `no commit on any ref declares the target's live fingerprint ${fp} — ${scanned}. everystack last ` +
|
|
1099
|
+
`applied here ${origin.commit ? `at commit ${short(origin.commit)} ` : ''}on ${shortDate(origin.appliedAt)}` +
|
|
1100
|
+
(origin.dirty
|
|
1101
|
+
? ' from a DIRTY worktree — that commit never declared what was applied, so nothing committed can declare this state. '
|
|
1102
|
+
: ', and the database has been changed outside the tool since (hand-edited). No ref declares what it is now, so ') +
|
|
1103
|
+
`there is no merged work the fast-forward rule could protect; your committed Models are the source of truth. ${runbook}`;
|
|
1104
|
+
return { status: 'drift', scannedTrees, origin, reason };
|
|
662
1105
|
}
|
|
663
1106
|
|
|
664
1107
|
// ---------------------------------------------------------------------------
|
|
@@ -681,8 +1124,9 @@ export interface DescentApplyContext {
|
|
|
681
1124
|
}
|
|
682
1125
|
|
|
683
1126
|
export type DescentApplyDecision =
|
|
684
|
-
/** Proceed. `attested` is set only when the attestation was honoured — record it in schema_log.
|
|
685
|
-
|
|
1127
|
+
/** Proceed. `attested` is set only when the attestation was honoured — record it in schema_log.
|
|
1128
|
+
* `note` is set for a proven drift — its origin, recorded in schema_log the same way. */
|
|
1129
|
+
| { ok: true; attested?: { commit: string; subcase: 'revert-unreachable' | 'unknown-commit-proven-absent' }; note?: string }
|
|
686
1130
|
| { ok: false; reason: string };
|
|
687
1131
|
|
|
688
1132
|
/** A short recorded note for a honoured attestation — goes in the apply row's schema_log outcome. */
|
|
@@ -690,9 +1134,44 @@ export function attestationNote(a: { commit: string; subcase: string }): string
|
|
|
690
1134
|
return `descent-attested-lineage-lost (${short(a.commit)}, ${a.subcase})`;
|
|
691
1135
|
}
|
|
692
1136
|
|
|
1137
|
+
/** The recorded note for a squash proof — goes in the apply row's schema_log outcome. */
|
|
1138
|
+
export function squashNote(carrier: string, witness: string): string {
|
|
1139
|
+
return `descent: squash-successor (${short(carrier)} carries the models tree of ${short(witness)}, which HEAD does not descend from)`;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
/**
|
|
1143
|
+
* Does the operator's attestation name this commit? Exact, or a hex prefix of at least 7
|
|
1144
|
+
* characters — compared against the ONE commit the refusal named, so there is nothing for a
|
|
1145
|
+
* prefix to be ambiguous with. The refusal prints the 12-character form, and an operator who
|
|
1146
|
+
* pastes it back is naming that commit. A strict string compare refused
|
|
1147
|
+
* "42a3b97af246 does not match (42a3b97af246)" — two identical strings — because the ledger holds
|
|
1148
|
+
* 40 characters and the refusal printed 12 (consumer cutover, cli 0.4.77, 2026-09-07).
|
|
1149
|
+
*/
|
|
1150
|
+
export function attestsCommit(attestation: string, commit: string): boolean {
|
|
1151
|
+
const a = attestation.trim().toLowerCase();
|
|
1152
|
+
if (a === commit.toLowerCase()) return true;
|
|
1153
|
+
return a.length >= 7 && /^[0-9a-f]+$/.test(a) && commit.toLowerCase().startsWith(a);
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
/**
|
|
1157
|
+
* Resolve an attestation through git before it is compared: a prefix, a tag, a branch, or a
|
|
1158
|
+
* `HEAD~n` becomes the full sha. A value git cannot resolve (the commit is absent from this clone —
|
|
1159
|
+
* the `unknown-commit-proven-absent` subcase, or a typo) passes through unchanged so the prefix
|
|
1160
|
+
* compare above still gets its chance and a mismatch names what was given.
|
|
1161
|
+
*/
|
|
1162
|
+
export function resolveAttestation(attestation: string, cwd: string): string {
|
|
1163
|
+
const full = git(['rev-parse', '--verify', '--quiet', `${attestation.trim()}^{commit}`], cwd)?.trim();
|
|
1164
|
+
return full && /^[0-9a-f]{40,64}$/.test(full) ? full : attestation;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
693
1167
|
export function decideDescentApply(verdict: ProveVerdict, ctx: DescentApplyContext): DescentApplyDecision {
|
|
694
1168
|
switch (verdict.status) {
|
|
695
1169
|
case 'proven':
|
|
1170
|
+
// A squash proof rests on models-tree identity, not ancestry of the deploying commit — the
|
|
1171
|
+
// record says so, exactly as an honoured attestation and a proven drift leave their trace.
|
|
1172
|
+
return verdict.via === 'squash' && verdict.witness
|
|
1173
|
+
? { ok: true, note: squashNote(verdict.commit, verdict.witness) }
|
|
1174
|
+
: { ok: true };
|
|
696
1175
|
case 'fresh-target':
|
|
697
1176
|
return { ok: true };
|
|
698
1177
|
|
|
@@ -703,17 +1182,42 @@ export function decideDescentApply(verdict: ProveVerdict, ctx: DescentApplyConte
|
|
|
703
1182
|
? { ok: false, reason: 'this is not a git checkout and the plan is DESTRUCTIVE, so the fast-forward rule cannot be verified — run db:apply from the git checkout that declares your models.' }
|
|
704
1183
|
: { ok: true };
|
|
705
1184
|
|
|
706
|
-
case 'unverifiable':
|
|
707
1185
|
case 'drift':
|
|
708
|
-
//
|
|
709
|
-
//
|
|
1186
|
+
// PROVEN drift is the operator's decision: no ref anywhere declares live, so there is no merged
|
|
1187
|
+
// work to revert. It passes, and its origin is recorded. An attestation here is a category
|
|
1188
|
+
// error — there is no single declaring commit to attest about — refused as one.
|
|
1189
|
+
if (ctx.attestation) {
|
|
1190
|
+
return {
|
|
1191
|
+
ok: false,
|
|
1192
|
+
reason: `--attest-lineage-lost does not apply here: there is no single declaring commit to attest about. ${verdict.reason} Proven drift needs no attestation — drop the flag and re-run.`,
|
|
1193
|
+
};
|
|
1194
|
+
}
|
|
1195
|
+
return { ok: true, note: driftOriginNote(verdict.origin) };
|
|
1196
|
+
|
|
1197
|
+
case 'unverifiable':
|
|
1198
|
+
// The search could not finish (an unreadable tree, or a clone that is not full and current).
|
|
1199
|
+
// The verdict carries its own fetch/retry runbook; nothing to attest ABOUT.
|
|
710
1200
|
return {
|
|
711
1201
|
ok: false,
|
|
712
1202
|
reason: ctx.attestation
|
|
713
|
-
? `--attest-lineage-lost does not apply here: there is no single declaring commit to attest about. ${verdict.reason}
|
|
714
|
-
:
|
|
1203
|
+
? `--attest-lineage-lost does not apply here: there is no single declaring commit to attest about. ${verdict.reason}`
|
|
1204
|
+
: verdict.reason,
|
|
715
1205
|
};
|
|
716
1206
|
|
|
1207
|
+
case 'declared-elsewhere': {
|
|
1208
|
+
// A commit on a ref we hold declares live and HEAD does not descend from it. Reachable by
|
|
1209
|
+
// construction, so the lineage-lost attestation can never be honoured here — the proof refuses
|
|
1210
|
+
// it as reachable. The escape is git-native: merge or rebase.
|
|
1211
|
+
if (!ctx.attestation) return { ok: false, reason: verdict.reason };
|
|
1212
|
+
if (!attestsCommit(ctx.attestation, verdict.commit)) {
|
|
1213
|
+
return { ok: false, reason: `--attest-lineage-lost ${ctx.attestation} does not match the commit that declares this state (${verdict.commit}) — drop the flag; that commit is reachable, so merge or rebase instead.` };
|
|
1214
|
+
}
|
|
1215
|
+
const proof = ctx.proveLineage(verdict.commit);
|
|
1216
|
+
return proof.proven
|
|
1217
|
+
? { ok: true, attested: { commit: verdict.commit, subcase: proof.subcase } }
|
|
1218
|
+
: { ok: false, reason: proof.reason };
|
|
1219
|
+
}
|
|
1220
|
+
|
|
717
1221
|
case 'revert':
|
|
718
1222
|
case 'unknown-commit': {
|
|
719
1223
|
// These name a declaring commit. Without an attestation the flagless escape (git revert / fetch)
|
|
@@ -724,10 +1228,11 @@ export function decideDescentApply(verdict: ProveVerdict, ctx: DescentApplyConte
|
|
|
724
1228
|
reason: `${verdict.reason} If that commit is gone for good (its branch was rebased away or deleted), attest it: --attest-lineage-lost ${short(verdict.commit)}${ctx.destructive ? ' --confirm' : ''}.`,
|
|
725
1229
|
};
|
|
726
1230
|
}
|
|
727
|
-
if (ctx.attestation
|
|
1231
|
+
if (!attestsCommit(ctx.attestation, verdict.commit)) {
|
|
1232
|
+
// Both in FULL: a short form on both sides once printed two identical strings as a mismatch.
|
|
728
1233
|
return {
|
|
729
1234
|
ok: false,
|
|
730
|
-
reason: `--attest-lineage-lost ${
|
|
1235
|
+
reason: `--attest-lineage-lost ${ctx.attestation} does not match the commit that declared this state (${verdict.commit}) — attest the exact commit the refusal names, or drop the flag.`,
|
|
731
1236
|
};
|
|
732
1237
|
}
|
|
733
1238
|
const proof = ctx.proveLineage(verdict.commit);
|