@forwardimpact/libwiki 0.2.25 → 0.2.27

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/src/wiki-sync.js CHANGED
@@ -1,4 +1,17 @@
1
1
  import path from "node:path";
2
+ import { scanConflictMarkers } from "./conflict-markers.js";
3
+ import { GITATTRIBUTES_FILE, SINGLETON_PATHS } from "./constants.js";
4
+ import { ensureMetricsCsvMergeAttribute } from "./gitattributes.js";
5
+ import { parseDiff, findAbsent, makeDetection, normLine } from "./integrity.js";
6
+ import { scanPushWindow, appendOverrideRecord } from "./secret-gate.js";
7
+
8
+ /** The branch the wiki clone publishes (hard-coded in fetch / rebase / push). */
9
+ const BRANCH = "master";
10
+ const REMOTE = "origin";
11
+ const REMOTE_BRANCH = `${REMOTE}/${BRANCH}`;
12
+
13
+ /** The commit range a wiki push introduces relative to the remote it reconciles against. */
14
+ const PUSH_RANGE = "origin/master..HEAD";
2
15
 
3
16
  /** Error thrown when a wiki pull encounters a rebase conflict that cannot be resolved automatically. */
4
17
  export class WikiPullConflict extends Error {
@@ -10,6 +23,95 @@ export class WikiPullConflict extends Error {
10
23
  }
11
24
  }
12
25
 
26
+ // A push rejected because the remote tip moved (non-fast-forward) is the
27
+ // re-apply loop's retry signal; an auth or network failure is not contention.
28
+ // git surfaces a rejection on stderr with these markers.
29
+ const PUSH_REJECTION_RE =
30
+ /\b(rejected|non-fast-forward|fetch first|tip of your current branch is behind)\b/i;
31
+
32
+ /** Whether a thrown push error is a non-fast-forward rejection (vs. auth/network). */
33
+ function isPushRejection(err) {
34
+ const text = `${err?.stderr ?? ""}\n${err?.message ?? ""}`;
35
+ return PUSH_REJECTION_RE.test(text);
36
+ }
37
+
38
+ /**
39
+ * Error thrown when the ancestry guard refuses to commit or push because the
40
+ * relationship between the history that would be published and the remote
41
+ * branch cannot be positively confirmed. `kind` is `"unrelated"`
42
+ * (confirmed no shared history) or `"unverifiable"` (the relationship could be
43
+ * neither confirmed nor refuted). The two kinds carry distinct messages so the
44
+ * operator knows which state they are recovering from.
45
+ */
46
+ export class AncestryRefusal extends Error {
47
+ /**
48
+ * @param {"unrelated"|"unverifiable"} kind
49
+ * @param {string} message - Recovery-naming message.
50
+ */
51
+ constructor(kind, message) {
52
+ super(message);
53
+ this.name = "AncestryRefusal";
54
+ this.kind = kind;
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Error thrown when a registered-singleton operation cannot land within the
60
+ * bounded re-apply budget: contention recurred on each round, so the publish
61
+ * fails loud rather than resolving the contended hunk textually.
62
+ */
63
+ export class WikiSyncConflict extends Error {
64
+ /** @param {string[]} paths @param {string} reason */
65
+ constructor(paths, reason) {
66
+ super(`wiki sync conflict on ${paths.join(", ")} (${reason})`);
67
+ this.name = "WikiSyncConflict";
68
+ this.paths = paths;
69
+ this.reason = reason;
70
+ }
71
+ }
72
+
73
+ /**
74
+ * The refusal reason taxonomy for {@link WikiSync.commitAndPush}. A refusal is
75
+ * surfaced in the result rather than thrown, so callers reading the result keep
76
+ * working; `WikiSyncRefusal.result(reason, details)` builds that result object.
77
+ * `reason` is one of `mid-merge`, `stranded-merge`, `would-publish-markers`,
78
+ * `introduced-scan-failed`; `workAt` (only for `stranded-merge`) names where
79
+ * retained work lives. The reason set is additive to the existing `clean` and
80
+ * `pushed` outcomes, so a future refusal taxonomy on this flow can union new
81
+ * reasons in without rewriting the existing ones.
82
+ */
83
+ export class WikiSyncRefusal {
84
+ /** @type {readonly string[]} The recognized refusal reasons. */
85
+ static REASONS = Object.freeze([
86
+ "mid-merge",
87
+ "stranded-merge",
88
+ "would-publish-markers",
89
+ "introduced-scan-failed",
90
+ ]);
91
+
92
+ /**
93
+ * Build a `commitAndPush` refusal result.
94
+ * @param {string} reason - One of {@link WikiSyncRefusal.REASONS}.
95
+ * @param {{workAt?: string}} [details] - `workAt` names where retained work lives.
96
+ * @returns {{pushed: false, reason: string, workAt?: string}}
97
+ */
98
+ static result(reason, { workAt } = {}) {
99
+ const result = { pushed: false, reason };
100
+ if (workAt) result.workAt = workAt;
101
+ return result;
102
+ }
103
+ }
104
+
105
+ // Markers introduced into a prose markdown surface may be legitimately quoted
106
+ // inside a fenced code block (the false-positive surface Layer 1 exempts).
107
+ // STATUS.md is data, not prose — its fenced rows are never legitimately marked
108
+ // — and non-markdown files (e.g. metrics CSVs) have no quoted-form idiom, so
109
+ // neither is fence-exempt on the publish path.
110
+ function pushFenceExempt(filePath) {
111
+ const base = path.basename(filePath);
112
+ return filePath.endsWith(".md") && base !== "STATUS.md";
113
+ }
114
+
13
115
  /**
14
116
  * Consolidates the wiki repository's pull / rebase / conflict-resolve / push
15
117
  * flow over an injected {@link import('@forwardimpact/libutil').GitClient}.
@@ -134,33 +236,118 @@ export class WikiSync {
134
236
  * rebase and merge fallback then run with --autostash because that residue
135
237
  * stays uncommitted in the tree.
136
238
  *
239
+ * Ancestry guard: before the commit and again before the push,
240
+ * {@link AncestryRefusal} is thrown when the published history's relationship
241
+ * to `origin/master` cannot be positively confirmed — a detached HEAD, an
242
+ * unborn HEAD or unrelated history against an existing remote branch, or a
243
+ * remote that cannot be observed. A new wiki's first publication is allowed
244
+ * only on positive evidence the remote branch is absent (a non-swallowed
245
+ * `ls-remote`); mere absence of the local remote-tracking ref never grants
246
+ * it, and the allowance is re-derived from live git on every call so a failed
247
+ * first publication is re-judged rather than auto-re-granted. The guard
248
+ * creates no commit, attempts no push, and adds no working-tree changes.
249
+ *
250
+ * Before the gates, the metrics-CSV union merge declaration is ensured in
251
+ * `.gitattributes`. When the ensure writes the file, the commit must carry it
252
+ * regardless of the session's payload: on the pathspec-scoped path the
253
+ * declaration is outside `paths` and would otherwise be autostashed aside, and
254
+ * on a no-payload sync there would be no commit at all. So `.gitattributes` is
255
+ * appended to the effective commit pathspec only when the ensure changed it;
256
+ * when it is already present-and-correct, behavior is byte-identical to a
257
+ * commit-and-push that ensures nothing.
258
+ *
259
+ * **Singleton merge discipline.** The discipline applies when a
260
+ * rebase conflict arises for a *registered* row-structured singleton (the
261
+ * single committed path is in `SINGLETON_PATHS`) and the caller supplied a
262
+ * `reapply` operation. The contended hunk is then never resolved textually.
263
+ * The conflicting local commit is dropped with `resetSoft`, which preserves
264
+ * the working tree. Only the registered file is reset to the fresh tip with
265
+ * `checkoutPaths`. The operation is re-derived against that tip's content,
266
+ * re-committed, and pushed, bounded by `maxReapply`. A rejected push (the tip
267
+ * moved again) drives the retry; exhaustion fails loud with
268
+ * {@link WikiSyncConflict}. Foreign rows and untouched prose ride through from
269
+ * the tip. Without a `reapply` the conflict keeps the `-X ours` fallback, so
270
+ * prose surfaces and unregistered paths stay on the side-biased behavior.
271
+ *
272
+ * **Fail-closed secret gate.** After the reconcile and before the push, the
273
+ * content the push introduces (the commit range `origin/master..HEAD`) is
274
+ * secret-scanned fail-closed. A detected secret or an unavailable scanner
275
+ * refuses the push with a distinct reason and no remote contact, unless the
276
+ * matching off-by-default override is set in the environment —
277
+ * `FIT_WIKI_SECRET_OVERRIDE` permits a finding, `FIT_WIKI_SCANNER_ABSENT_OK`
278
+ * permits a scanner absence. Each override appends an audited line to the wiki
279
+ * tree's `secret-overrides.log` before the push. A *network/credential* push
280
+ * failure is distinct: it still degrades to "saved locally" (the preserved
281
+ * fire-and-forget behaviour).
282
+ *
137
283
  * @param {string} message - The commit message.
138
284
  * @param {string[]} [paths] - Pathspecs limiting what gets committed.
285
+ * @param {{reapply?: (freshText: string) => string | null, maxReapply?: number}} [options]
286
+ * `reapply` re-derives the registered file's content from the operation's
287
+ * own row edit against the fresh tip text; returns the new text or null when
288
+ * the op is already satisfied on the tip.
289
+ * @returns {Promise<{pushed: boolean, reason: "pushed"|"clean"|"secret-detected"|"scanner-unavailable"|"mid-merge"|"stranded-merge"|"would-publish-markers"|"introduced-scan-failed", findings?: Array<{file: string, line: number, rule: string}>, detections?: object[], workAt?: string}>}
290
+ * @throws {AncestryRefusal} When the published history cannot be verified.
291
+ * @throws {WikiSyncConflict} When the re-apply budget is exhausted.
139
292
  */
140
- async commitAndPush(message, paths) {
141
- if (!(await this.isClean(paths))) {
142
- if (paths?.length) {
143
- await this.#git.commitPaths(message, paths, { cwd: this.#wikiDir });
293
+ async commitAndPush(message, paths, { reapply, maxReapply = 3 } = {}) {
294
+ // Guard 1 (hole 1): refuse mid-merge before staging. An abandoned merge
295
+ // leaves unmerged hunks or a pinned MERGE_HEAD; sweeping them would
296
+ // silently "complete" the merge and publish the markers. Decidable from
297
+ // the index/working tree alone, so it holds on a shallow clone.
298
+ if (await this.#git.isMidMerge({ cwd: this.#wikiDir })) {
299
+ return WikiSyncRefusal.result("mid-merge");
300
+ }
301
+ await this.#assertPublishable();
302
+ const gitattributesChanged = ensureMetricsCsvMergeAttribute(
303
+ this.#wikiDir,
304
+ this.#runtime.fsSync,
305
+ ).changed;
306
+ // The metrics-CSV declaration must be committed when it was just written,
307
+ // even on the pathspec-scoped path; fold it into the effective pathspec.
308
+ // On a no-payload sweep (`paths` absent), this becomes the sole pathspec
309
+ // [GITATTRIBUTES_FILE], so provisioning still produces exactly one commit
310
+ // rather than sweeping the whole tree via commitAll.
311
+ const commitPaths = gitattributesChanged
312
+ ? [...(paths ?? []), GITATTRIBUTES_FILE]
313
+ : paths;
314
+ if (!(await this.isClean(commitPaths))) {
315
+ if (commitPaths?.length) {
316
+ await this.#git.commitPaths(message, commitPaths, {
317
+ cwd: this.#wikiDir,
318
+ });
144
319
  } else {
145
320
  await this.#git.commitAll(message, { cwd: this.#wikiDir });
146
321
  }
147
322
  }
148
323
  if (!(await this.#hasCommitsAhead())) {
149
- return { pushed: false, reason: "clean" };
324
+ return { pushed: false, reason: "clean", detections: [] };
150
325
  }
326
+ await this.#assertPublishable();
151
327
  await this.fetch();
152
328
  const rebase = await this.#git.rebase("origin/master", {
153
329
  cwd: this.#wikiDir,
154
330
  autostash: true,
155
331
  });
156
332
  if (rebase.exitCode !== 0) {
157
- await this.#git.rebaseAbort({ cwd: this.#wikiDir });
158
- await this.#git.mergeOursStrategy({
159
- cwd: this.#wikiDir,
160
- ref: "origin/master",
161
- autostash: true,
333
+ const resolved = await this.#resolveRebaseConflict(message, paths, {
334
+ reapply,
335
+ maxReapply,
162
336
  });
337
+ if (resolved) return resolved;
163
338
  }
339
+ // Guard 3 (hole 3 / Layer 2): refuse to push commits that introduce an
340
+ // unresolved conflict block.
341
+ const markerRefusal = await this.#refuseIfIntroducedMarkers();
342
+ if (markerRefusal) return markerRefusal;
343
+ // Capture the pushed delta now: HEAD is the final (rebased/merged) local
344
+ // tip and origin/master is still the pre-push base.
345
+ const pushedDelta = await this.#capturePushedDelta();
346
+ // Fail-closed secret gate. Scan exactly the commits this push introduces
347
+ // (the reconcile above made the range correct) before any remote contact;
348
+ // a finding or missing scanner refuses unless its own override is set.
349
+ const refusal = await this.#gateOrRefuse();
350
+ if (refusal) return refusal;
164
351
  // Resolve auth first so a misconfigured `resolveToken` still surfaces; the
165
352
  // push itself is fire-and-forget like WikiRepo (which ignored the push
166
353
  // result and reported pushed:true regardless), so a network/credential
@@ -171,7 +358,233 @@ export class WikiSync {
171
358
  } catch {
172
359
  // Intentionally ignored — preserves WikiRepo's fire-and-forget push.
173
360
  }
174
- return { pushed: true, reason: "pushed" };
361
+ const detections = await this.#tier1Probe(pushedDelta);
362
+ return { pushed: true, reason: "pushed", detections };
363
+ }
364
+
365
+ /**
366
+ * Capture the `origin/master..HEAD` delta for the post-push tier-1 probe. A
367
+ * two-tree range diff (not a single-commit show) is correct even when HEAD is
368
+ * a merge commit. Detection-only: a capture failure degrades to `null` so the
369
+ * probe never gates the push it follows.
370
+ * @returns {Promise<string|null>}
371
+ */
372
+ async #capturePushedDelta() {
373
+ try {
374
+ return await this.#git.diffRange("origin/master HEAD", {
375
+ cwd: this.#wikiDir,
376
+ });
377
+ } catch {
378
+ return null;
379
+ }
380
+ }
381
+
382
+ /**
383
+ * Resolve a failed rebase against the fresh tip. Aborts the rebase, then for a
384
+ * registered singleton with a `reapply` op re-derives the row against the tip
385
+ * via the bounded re-apply loop; otherwise falls back to the `-X ours` merge
386
+ * with failure allowance (Guard 2). A conflicting merge is aborted and refused
387
+ * rather than left mid-merge for the next sweep (hole 1) to publish.
388
+ * @param {string} message - The commit message.
389
+ * @param {string[]} [paths] - Pathspecs committed.
390
+ * @param {{reapply?: (freshText: string) => string | null, maxReapply: number}} options
391
+ * @returns {Promise<object|null>} A terminal result (re-apply outcome or a
392
+ * stranded-merge refusal), or null when the conflict resolved and the push
393
+ * should proceed.
394
+ */
395
+ async #resolveRebaseConflict(message, paths, { reapply, maxReapply }) {
396
+ await this.#git.rebaseAbort({ cwd: this.#wikiDir });
397
+ const registered =
398
+ typeof reapply === "function" &&
399
+ paths?.length === 1 &&
400
+ paths.every((p) => SINGLETON_PATHS.has(p));
401
+ if (registered) {
402
+ return this.#reapplyLoop(message, paths, reapply, maxReapply);
403
+ }
404
+ // Guard 2 (hole 2/3): the ours-strategy fallback runs with failure
405
+ // allowance. On a conflict it would otherwise throw and strand a mid-merge
406
+ // tree for the next sweep (hole 1) to publish; instead abort and refuse,
407
+ // reporting where retained work went (the autostash stash).
408
+ const merge = await this.#git.mergeOursStrategy({
409
+ cwd: this.#wikiDir,
410
+ ref: "origin/master",
411
+ autostash: true,
412
+ allowFailure: true,
413
+ });
414
+ if (merge.exitCode !== 0) {
415
+ await this.#git.mergeAbort({ cwd: this.#wikiDir });
416
+ return WikiSyncRefusal.result("stranded-merge", { workAt: "stash" });
417
+ }
418
+ return null;
419
+ }
420
+
421
+ /**
422
+ * Scan the content introduced by `origin/master..HEAD` for unresolved
423
+ * conflict-marker blocks (Guard 3). Runs after the fetch + rebase/merge
424
+ * resolve, so the diff is against the freshly-fetched origin tip; pre-existing
425
+ * origin corruption is on the base side, never the added side, so an unrelated
426
+ * writer's push is not blocked. A throw from the scan (unresolvable ref on a
427
+ * shallow clone) refuses with a reason — never a silent pass.
428
+ * @returns {Promise<{pushed: false, reason: string}|null>} A refusal result, or
429
+ * null when nothing introduced would publish a marker.
430
+ */
431
+ async #refuseIfIntroducedMarkers() {
432
+ let introduced;
433
+ try {
434
+ introduced = await this.#git.introducedByFile("origin/master..HEAD", {
435
+ cwd: this.#wikiDir,
436
+ });
437
+ } catch {
438
+ return WikiSyncRefusal.result("introduced-scan-failed");
439
+ }
440
+ for (const [filePath, addedText] of introduced) {
441
+ const hits = scanConflictMarkers(addedText, {
442
+ fenceExempt: pushFenceExempt(filePath),
443
+ });
444
+ if (hits.length > 0) {
445
+ return WikiSyncRefusal.result("would-publish-markers");
446
+ }
447
+ }
448
+ return null;
449
+ }
450
+
451
+ /**
452
+ * Tier-1 post-push integrity probe: re-fetch the origin tip and verify the
453
+ * just-pushed delta — the full delta including shared surfaces —
454
+ * is still content-present, returning detections for any absence. Reads only;
455
+ * any error degrades to no detections so the probe never gates the push.
456
+ * @param {string|null} pushedDelta - `diffRange` text of the pushed delta.
457
+ * @returns {Promise<object[]>}
458
+ */
459
+ async #tier1Probe(pushedDelta) {
460
+ try {
461
+ if (pushedDelta == null) return [];
462
+ const changes = parseDiff(pushedDelta);
463
+ if (changes.length === 0) return [];
464
+ // A new post-push fetch advances origin/master to the current tip.
465
+ await this.fetch();
466
+ const homes = [
467
+ ...new Set(changes.map((c) => c.home).filter((h) => h !== "/dev/null")),
468
+ ];
469
+ const tipText = (
470
+ await Promise.all(
471
+ homes.map((home) =>
472
+ this.#git.showFile("origin/master", home, { cwd: this.#wikiDir }),
473
+ ),
474
+ )
475
+ )
476
+ .filter((t) => t != null)
477
+ .join("\n");
478
+ const now = this.#runtime.clock.now();
479
+ return findAbsent(changes, tipText, normLine).map((a) =>
480
+ makeDetection({
481
+ tier: 1,
482
+ contentId: a.contentId,
483
+ pushHome: a.pushHome,
484
+ now,
485
+ }),
486
+ );
487
+ } catch {
488
+ return [];
489
+ }
490
+ }
491
+
492
+ /**
493
+ * Re-apply a registered singleton operation against the fresh remote tip,
494
+ * bounded by `maxReapply` rounds. The caller has already aborted the rebase.
495
+ * Each round: refresh the tip, drop the stale local commit (`resetSoft`,
496
+ * working tree untouched so foreign residue survives), reset only the
497
+ * registered file to the tip (`checkoutPaths`, tolerating a tip that lacks
498
+ * it), re-derive via `reapply`, and — when the op still changes the tip —
499
+ * re-commit and push. A rejected push (the tip moved again) loops; an
500
+ * unchanged op is already satisfied; bound exhaustion throws.
501
+ */
502
+ async #reapplyLoop(message, paths, reapply, maxReapply) {
503
+ const filePath = path.join(this.#wikiDir, paths[0]);
504
+ for (let round = 0; round < maxReapply; round++) {
505
+ await this.fetch();
506
+ await this.#git.resetSoft("origin/master", { cwd: this.#wikiDir });
507
+ // Reset only the registered file to the tip. `resetSoft` leaves the
508
+ // working tree (so the dropped commit's copy of the file may linger), so
509
+ // a non-zero checkout — the tip lacks the file (a founding write) — means
510
+ // the fresh base is empty, NOT the lingering local copy.
511
+ const checkout = await this.#git.checkoutPaths("origin/master", paths, {
512
+ cwd: this.#wikiDir,
513
+ allowMissing: true,
514
+ });
515
+ const tipHasFile = (checkout?.exitCode ?? 0) === 0;
516
+ const freshText =
517
+ tipHasFile && this.#runtime.fsSync.existsSync(filePath)
518
+ ? this.#runtime.fsSync.readFileSync(filePath, "utf-8")
519
+ : "";
520
+ const newText = reapply(freshText);
521
+ if (newText === null) {
522
+ // The op is already satisfied on the tip; HEAD now equals the tip.
523
+ return { pushed: false, reason: "already-satisfied" };
524
+ }
525
+ this.#runtime.fsSync.writeFileSync(filePath, newText);
526
+ await this.#git.commitPaths(message, paths, { cwd: this.#wikiDir });
527
+ try {
528
+ await this.#authed().push("origin", "master", { cwd: this.#wikiDir });
529
+ return { pushed: true, reason: "reapplied" };
530
+ } catch (err) {
531
+ // Only a rejected push (the tip moved again) is a retry signal. An auth
532
+ // or network failure is not contention: rethrow it so the caller
533
+ // degrades to "saved locally" rather than burning the budget and
534
+ // misreporting a conflict that never happened.
535
+ if (!isPushRejection(err)) throw err;
536
+ }
537
+ }
538
+ throw new WikiSyncConflict(paths, "reapply-bound");
539
+ }
540
+
541
+ /**
542
+ * Run the secret gate over the push window and decide whether to refuse.
543
+ * Returns a refusal envelope to short-circuit the push, or `null` to proceed
544
+ * (clean, or an override that wrote its audit record). An override appends a
545
+ * secret-free line to `secret-overrides.log` and commits it into the push
546
+ * range before returning `null`.
547
+ *
548
+ * @returns {Promise<{pushed: false, reason: "secret-detected"|"scanner-unavailable", findings?: Array<{file: string, line: number, rule: string}>}|null>}
549
+ */
550
+ async #gateOrRefuse() {
551
+ const env = this.#runtime.proc.env;
552
+ const verdict = await scanPushWindow({
553
+ runtime: this.#runtime,
554
+ wikiDir: this.#wikiDir,
555
+ range: PUSH_RANGE,
556
+ });
557
+ if (verdict.status === "finding") {
558
+ const reason = env.FIT_WIKI_SECRET_OVERRIDE;
559
+ if (!reason) {
560
+ return {
561
+ pushed: false,
562
+ reason: "secret-detected",
563
+ findings: verdict.findings,
564
+ };
565
+ }
566
+ await appendOverrideRecord({
567
+ runtime: this.#runtime,
568
+ gitClient: this.#git,
569
+ wikiDir: this.#wikiDir,
570
+ klass: "finding",
571
+ reason,
572
+ findings: verdict.findings,
573
+ });
574
+ } else if (verdict.status === "scanner-absent") {
575
+ const reason = env.FIT_WIKI_SCANNER_ABSENT_OK;
576
+ if (!reason) {
577
+ return { pushed: false, reason: "scanner-unavailable" };
578
+ }
579
+ await appendOverrideRecord({
580
+ runtime: this.#runtime,
581
+ gitClient: this.#git,
582
+ wikiDir: this.#wikiDir,
583
+ klass: "scanner-absent",
584
+ reason,
585
+ });
586
+ }
587
+ return null;
175
588
  }
176
589
 
177
590
  async #hasCommitsAhead() {
@@ -180,4 +593,108 @@ export class WikiSync {
180
593
  });
181
594
  return count > 0;
182
595
  }
596
+
597
+ /** Whether the wiki clone is shallow (has a `.git/shallow` file). */
598
+ #isShallow() {
599
+ return this.#runtime.fsSync.existsSync(
600
+ path.join(this.#wikiDir, ".git", "shallow"),
601
+ );
602
+ }
603
+
604
+ /**
605
+ * Refuse, before any commit or push, whenever the relationship between the
606
+ * history that would be published (the `master` branch ref, never bare HEAD)
607
+ * and the remote branch can be neither confirmed nor refuted. Implements the
608
+ * the ancestry decision table; throws {@link AncestryRefusal} on refusal and
609
+ * returns silently when publication is verified or the remote is positively
610
+ * empty. The emptiness probe runs only on the absent-tracking-ref path, so
611
+ * the healthy hot path adds no remote round-trip.
612
+ */
613
+ async #assertPublishable() {
614
+ const cwd = this.#wikiDir;
615
+
616
+ // 1. Detached HEAD: the push publishes the branch ref, not HEAD, so the
617
+ // session's commits would be silently lost. Verify nothing — refuse.
618
+ if ((await this.#git.headBranch({ cwd })) !== BRANCH) {
619
+ throw new AncestryRefusal(
620
+ "unverifiable",
621
+ "fit-wiki: refusing to publish — HEAD is detached, so the configured " +
622
+ "branch would be pushed instead of your work. Re-clone the wiki.",
623
+ );
624
+ }
625
+
626
+ // 2. Establish whether the remote branch is present. A resolvable local
627
+ // remote-tracking ref is sufficient; otherwise probe the remote (the
628
+ // only added round-trip, and only here).
629
+ const branchPresent = await this.#git.refExists(REMOTE_BRANCH, { cwd });
630
+ if (!branchPresent) {
631
+ let observed;
632
+ try {
633
+ observed = await this.#git.remoteBranchExists(REMOTE, BRANCH, { cwd });
634
+ } catch {
635
+ throw new AncestryRefusal(
636
+ "unverifiable",
637
+ "fit-wiki: refusing to publish — could not observe the remote to " +
638
+ "verify ancestry; the local change is not published.",
639
+ );
640
+ }
641
+ // Positive evidence the remote branch is absent ⇒ empty-new-wiki.
642
+ if (!observed) return;
643
+ // Remote branch present but no local tracking ref: fetch it into the
644
+ // tracking ref so the unborn-HEAD and merge-base steps below judge
645
+ // against the probed branch tip rather than an unresolvable ref.
646
+ try {
647
+ await this.#git.fetch(
648
+ REMOTE,
649
+ `${BRANCH}:refs/remotes/${REMOTE_BRANCH}`,
650
+ {
651
+ cwd,
652
+ },
653
+ );
654
+ } catch {
655
+ throw new AncestryRefusal(
656
+ "unverifiable",
657
+ "fit-wiki: refusing to publish — could not fetch the remote branch " +
658
+ "to verify ancestry; the local change is not published.",
659
+ );
660
+ }
661
+ }
662
+
663
+ // 3. Branch present + unborn HEAD ⇒ confirmed unrelated.
664
+ if (!(await this.#git.refExists("HEAD", { cwd }))) {
665
+ throw new AncestryRefusal(
666
+ "unrelated",
667
+ "fit-wiki: refusing to publish — HEAD is unborn but the remote " +
668
+ "branch exists. Re-clone the wiki.",
669
+ );
670
+ }
671
+
672
+ // 4. Shared ancestry within the fetched window ⇒ allow.
673
+ if (await this.#git.mergeBaseExists(REMOTE_BRANCH, "HEAD", { cwd })) return;
674
+
675
+ // 5. No merge-base on a complete clone ⇒ confirmed unrelated.
676
+ if (!this.#isShallow()) {
677
+ throw new AncestryRefusal(
678
+ "unrelated",
679
+ "fit-wiki: refusing to publish — local history is unrelated to the " +
680
+ "remote branch. Re-clone the wiki.",
681
+ );
682
+ }
683
+
684
+ // 6. Shallow clone: deepen to full history, then re-judge.
685
+ const deepen = await this.#git.fetchDeepen(REMOTE, BRANCH, { cwd });
686
+ if (deepen.exitCode !== 0) {
687
+ throw new AncestryRefusal(
688
+ "unverifiable",
689
+ "fit-wiki: refusing to publish — could not deepen history to verify " +
690
+ "ancestry; the local change is not published.",
691
+ );
692
+ }
693
+ if (await this.#git.mergeBaseExists(REMOTE_BRANCH, "HEAD", { cwd })) return;
694
+ throw new AncestryRefusal(
695
+ "unrelated",
696
+ "fit-wiki: refusing to publish — local history is unrelated to the " +
697
+ "remote branch (confirmed against full history). Re-clone the wiki.",
698
+ );
699
+ }
183
700
  }