@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/LICENSE +21 -201
- package/README.md +5 -3
- package/bin/fit-wiki.js +1 -1
- package/package.json +1 -1
- package/src/audit/admission.js +53 -0
- package/src/audit/conflict-markers-rule.js +24 -0
- package/src/audit/grammar.js +97 -0
- package/src/audit/rules.js +96 -5
- package/src/audit/scopes.js +121 -5
- package/src/audit/status-row.js +91 -6
- package/src/block-renderer.js +19 -5
- package/src/boot.js +39 -1
- package/src/cli-definition.js +17 -16
- package/src/commands/audit.js +6 -1
- package/src/commands/boot.js +7 -2
- package/src/commands/claim.js +138 -32
- package/src/commands/fix.js +13 -5
- package/src/commands/inbox.js +7 -8
- package/src/commands/init.js +6 -0
- package/src/commands/log.js +47 -25
- package/src/commands/memo.js +10 -9
- package/src/commands/refresh.js +1 -0
- package/src/commands/rotate.js +40 -19
- package/src/commands/sync.js +54 -6
- package/src/conflict-markers.js +78 -0
- package/src/constants.js +31 -1
- package/src/gitattributes.js +40 -0
- package/src/index.js +2 -1
- package/src/integrity.js +288 -0
- package/src/lane-files.js +62 -0
- package/src/marker-scanner.js +2 -0
- package/src/secret-gate.js +177 -0
- package/src/status.js +39 -12
- package/src/util/agent-flag.js +31 -0
- package/src/weekly-log.js +146 -37
- package/src/wiki-sync.js +528 -11
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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.#
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
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
|
}
|