@forwardimpact/libwiki 0.2.29 → 0.2.31
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/README.md +5 -2
- package/bin/fit-wiki.js +11 -1
- package/package.json +2 -2
- package/src/audit/rules.js +34 -1
- package/src/audit/scopes.js +13 -1
- package/src/budget-gate.js +193 -0
- package/src/cli-definition.js +53 -3
- package/src/commands/claim.js +3 -1
- package/src/commands/fix.js +1 -1
- package/src/commands/ledger.js +208 -0
- package/src/commands/refresh.js +32 -2
- package/src/commands/sync.js +6 -1
- package/src/constants.js +7 -5
- package/src/ledger/anchor.js +96 -0
- package/src/ledger/projection.js +242 -0
- package/src/ledger/reader.js +45 -0
- package/src/wiki-sync.js +208 -34
package/src/wiki-sync.js
CHANGED
|
@@ -4,6 +4,26 @@ import { GITATTRIBUTES_FILE, SINGLETON_PATHS } from "./constants.js";
|
|
|
4
4
|
import { ensureMetricsCsvMergeAttribute } from "./gitattributes.js";
|
|
5
5
|
import { parseDiff, findAbsent, makeDetection, normLine } from "./integrity.js";
|
|
6
6
|
import { scanPushWindow, appendOverrideRecord } from "./secret-gate.js";
|
|
7
|
+
import { runBudgetGate } from "./budget-gate.js";
|
|
8
|
+
import { currentDayIso } from "./util/clock.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A git `insteadOf` rule that maps a URL to itself. Some sandboxed
|
|
12
|
+
* environments install a broad global rewrite —
|
|
13
|
+
* `url.<local-proxy>.insteadOf = https://github.com/` — that diverts every
|
|
14
|
+
* github.com URL, the wiki included, to a proxy that serves only the main repo
|
|
15
|
+
* and returns 403 for the wiki. An identity rule keyed to the full wiki URL is
|
|
16
|
+
* a longer prefix match than the broad `https://github.com/` rule, so git wins
|
|
17
|
+
* it by longest-match and leaves the URL untouched; the request then reaches
|
|
18
|
+
* github.com over the ambient HTTPS proxy. Where no such broad rewrite exists,
|
|
19
|
+
* the rule is a harmless no-op. Applied inline on clone and persisted into the
|
|
20
|
+
* clone's local config for every later network op (see {@link WikiSync#pinTransport}).
|
|
21
|
+
* @param {string} url - The wiki clone URL.
|
|
22
|
+
* @returns {string} A `-c`-form `url.<url>.insteadOf=<url>` entry.
|
|
23
|
+
*/
|
|
24
|
+
function selfInsteadOf(url) {
|
|
25
|
+
return `url.${url}.insteadOf=${url}`;
|
|
26
|
+
}
|
|
7
27
|
|
|
8
28
|
/** The branch the wiki clone publishes (hard-coded in fetch / rebase / push). */
|
|
9
29
|
const BRANCH = "master";
|
|
@@ -30,6 +50,7 @@ export const PUSH_REASONS = Object.freeze({
|
|
|
30
50
|
TRANSPORT: "transport",
|
|
31
51
|
PRECONDITION: "precondition",
|
|
32
52
|
CONSERVATION: "conservation",
|
|
53
|
+
BUDGET: "budget",
|
|
33
54
|
});
|
|
34
55
|
|
|
35
56
|
/** Error thrown when a wiki pull encounters a rebase conflict that cannot be resolved automatically. */
|
|
@@ -132,10 +153,12 @@ function pushFenceExempt(filePath) {
|
|
|
132
153
|
}
|
|
133
154
|
|
|
134
155
|
/**
|
|
135
|
-
* Error thrown when `commitAndPush` cannot honestly report a landed push
|
|
136
|
-
*
|
|
156
|
+
* Error thrown when `commitAndPush` cannot honestly report a landed push.
|
|
157
|
+
* `reason` is one of {@link PUSH_REASONS} other than `landed` /
|
|
137
158
|
* `nothing-to-push`; `stashSha` names a preserved autostash on a
|
|
138
|
-
* `residue-conflict
|
|
159
|
+
* `residue-conflict`; on a `budget` refusal `refusals` and `surfaced` carry the
|
|
160
|
+
* offending and surfaced-only `(file, ruleId, baseline, value)` tuples from the
|
|
161
|
+
* size-axis re-validation gate.
|
|
139
162
|
*/
|
|
140
163
|
export class WikiPushFailure extends Error {
|
|
141
164
|
/**
|
|
@@ -143,12 +166,16 @@ export class WikiPushFailure extends Error {
|
|
|
143
166
|
* @param {string} message - Operator message naming the reason and recovery.
|
|
144
167
|
* @param {object} [opts]
|
|
145
168
|
* @param {string} [opts.stashSha] - Preserved stash SHA (residue-conflict).
|
|
169
|
+
* @param {Array<object>} [opts.refusals] - Budget refusal tuples (budget).
|
|
170
|
+
* @param {Array<object>} [opts.surfaced] - Surfaced-only budget tuples (budget).
|
|
146
171
|
*/
|
|
147
|
-
constructor(reason, message, { stashSha } = {}) {
|
|
172
|
+
constructor(reason, message, { stashSha, refusals, surfaced } = {}) {
|
|
148
173
|
super(message);
|
|
149
174
|
this.name = "WikiPushFailure";
|
|
150
175
|
this.reason = reason;
|
|
151
176
|
if (stashSha) this.stashSha = stashSha;
|
|
177
|
+
if (refusals) this.refusals = refusals;
|
|
178
|
+
if (surfaced) this.surfaced = surfaced;
|
|
152
179
|
}
|
|
153
180
|
}
|
|
154
181
|
|
|
@@ -206,15 +233,36 @@ export class WikiSync {
|
|
|
206
233
|
|
|
207
234
|
/** Clone the wiki from `url` if it is not already cloned. */
|
|
208
235
|
async ensureCloned(url) {
|
|
209
|
-
if (this.isCloned())
|
|
236
|
+
if (this.isCloned()) {
|
|
237
|
+
await this.#pinTransport(url);
|
|
238
|
+
return { cloned: true, reason: "already-cloned" };
|
|
239
|
+
}
|
|
210
240
|
try {
|
|
211
|
-
await this.#authed().clone(url, this.#wikiDir
|
|
241
|
+
await this.#authed().clone(url, this.#wikiDir, {
|
|
242
|
+
config: [selfInsteadOf(url)],
|
|
243
|
+
});
|
|
244
|
+
await this.#pinTransport(url);
|
|
212
245
|
return { cloned: true, reason: "cloned" };
|
|
213
246
|
} catch (err) {
|
|
214
247
|
return { cloned: false, reason: err.stderr?.trim() || err.message };
|
|
215
248
|
}
|
|
216
249
|
}
|
|
217
250
|
|
|
251
|
+
/**
|
|
252
|
+
* Persist the identity-`insteadOf` for the wiki's own URL into the clone's
|
|
253
|
+
* local config, so every later network op (fetch / push / ls-remote) that
|
|
254
|
+
* runs against the stored remote URL — and re-applies `insteadOf` at
|
|
255
|
+
* transport time — is covered without threading `-c` through each call. The
|
|
256
|
+
* clone command itself takes the same rule inline (the `.git/config` does not
|
|
257
|
+
* exist yet). Idempotent; safe to re-run on a resumed clone. See
|
|
258
|
+
* {@link selfInsteadOf} for why the rule is needed.
|
|
259
|
+
*/
|
|
260
|
+
async #pinTransport(url) {
|
|
261
|
+
await this.#git.configSet(`url.${url}.insteadOf`, url, {
|
|
262
|
+
cwd: this.#wikiDir,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
218
266
|
/** Copy git user.name and user.email from the parent repository into the wiki repository. */
|
|
219
267
|
async inheritIdentity() {
|
|
220
268
|
const name = await this.#git.configGet("user.name", {
|
|
@@ -269,10 +317,13 @@ export class WikiSync {
|
|
|
269
317
|
* push — reporting an honest outcome The commit gate and the
|
|
270
318
|
* push gate are independent so a clean tree with local commits still pushes.
|
|
271
319
|
*
|
|
272
|
-
*
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
*
|
|
320
|
+
* The commit is always pathspec-scoped: with `paths` the scope is the
|
|
321
|
+
* caller's declared write-set, and without `paths` it is the session's own
|
|
322
|
+
* dirty set (`#dirtyPaths`, the `fit-wiki push` contract under per-session
|
|
323
|
+
* checkout isolation). The whole-tree `add -A` sweep is gone, so foreign
|
|
324
|
+
* content on undeclared paths is never staged and the stale fast-forward
|
|
325
|
+
* eraser it carried no longer exists; the rebase runs with --autostash
|
|
326
|
+
* because any foreign residue stays uncommitted.
|
|
276
327
|
*
|
|
277
328
|
* Outcome contract (D2 taxonomy):
|
|
278
329
|
* - Returns `{ landed: true, reason: "landed" }` only when the push is
|
|
@@ -342,14 +393,34 @@ export class WikiSync {
|
|
|
342
393
|
* failure is distinct: it still degrades to "saved locally" (the preserved
|
|
343
394
|
* fire-and-forget behaviour).
|
|
344
395
|
*
|
|
396
|
+
* **Budget re-validation gate (size axis).** After the reconcile
|
|
397
|
+
* (rebase / singleton re-apply re-derives content against the fresh tip) and
|
|
398
|
+
* after the secret gate, before the push, the flow re-runs the wiki audit's
|
|
399
|
+
* budget predicates over the **outgoing committed `HEAD`** (the tree that
|
|
400
|
+
* publishes — not the working dir, so autostash residue never counts). The
|
|
401
|
+
* push is refused with {@link WikiPushFailure} `budget` when it introduces or
|
|
402
|
+
* deepens a per-file/per-predicate budget breach relative to the worse of the
|
|
403
|
+
* writer's pre-fetch session base and the landed origin tip; the failure
|
|
404
|
+
* carries the offending and surfaced `(file, ruleId, baseline, value)` tuples.
|
|
405
|
+
* Equal-or-better states and foreign pre-existing breaches the writer did not
|
|
406
|
+
* worsen pass. Summary breaches on `exemptSummaryFiles` are surfaced (rode on
|
|
407
|
+
* the landed result's `surfaced`) rather than refused — the memo-delivery
|
|
408
|
+
* seam, so a delivery into deficient headroom is reported, not blocked.
|
|
409
|
+
* An unreadable baseline ref aborts the gate without refusing (the gate only
|
|
410
|
+
* refuses a regression it can prove), never fabricating a value-0 baseline.
|
|
411
|
+
* Inheriting the audit's rule objects by id, a future predicate change flows
|
|
412
|
+
* through the gate with no gate-code change.
|
|
413
|
+
*
|
|
345
414
|
* @param {string} message - The commit message.
|
|
346
415
|
* @param {string[]} [paths] - Pathspecs limiting what gets committed.
|
|
347
|
-
* @param {{reapply?: (freshText: string) => string | null, maxReapply?: number}} [options]
|
|
416
|
+
* @param {{reapply?: (freshText: string) => string | null, maxReapply?: number, exemptSummaryFiles?: string[]}} [options]
|
|
348
417
|
* `reapply` re-derives the registered file's content from the operation's
|
|
349
418
|
* own row edit against the fresh tip text; returns the new text or null when
|
|
350
|
-
* the op is already satisfied on the tip.
|
|
351
|
-
*
|
|
352
|
-
*
|
|
419
|
+
* the op is already satisfied on the tip. `exemptSummaryFiles` lists files
|
|
420
|
+
* (relative to the wiki root) whose summary budget breach is surfaced rather
|
|
421
|
+
* than refused (the memo-delivery seam).
|
|
422
|
+
* @returns {Promise<{landed?: boolean, pushed?: boolean, reason: string, findings?: Array<{file: string, line: number, rule: string}>, detections?: object[], surfaced?: object[], workAt?: string}>}
|
|
423
|
+
* A grounded landing (`{landed: true, reason: "landed", surfaced}`), a grounded
|
|
353
424
|
* nothing-to-push (`{landed: false, reason: "nothing-to-push"}`), a
|
|
354
425
|
* re-apply landing (`{pushed: true, reason: "reapplied"}` / `already-satisfied`),
|
|
355
426
|
* or a pre-push gate refusal ({@link WikiSyncRefusal}: `mid-merge`,
|
|
@@ -357,11 +428,15 @@ export class WikiSync {
|
|
|
357
428
|
* `scanner-unavailable`).
|
|
358
429
|
* @throws {WikiPushFailure} On a non-landed push outcome (D2 taxonomy:
|
|
359
430
|
* `precondition`, `conflict`, `residue-conflict`, `conservation`,
|
|
360
|
-
* `rejected`, `transport`).
|
|
431
|
+
* `rejected`, `transport`) or a `budget` breach.
|
|
361
432
|
* @throws {AncestryRefusal} When the published history cannot be verified.
|
|
362
433
|
* @throws {WikiSyncConflict} When the re-apply budget is exhausted.
|
|
363
434
|
*/
|
|
364
|
-
async commitAndPush(
|
|
435
|
+
async commitAndPush(
|
|
436
|
+
message,
|
|
437
|
+
paths,
|
|
438
|
+
{ reapply, maxReapply = 3, exemptSummaryFiles = [] } = {},
|
|
439
|
+
) {
|
|
365
440
|
// Precondition (D7): refuse mid-rebase before mutating. A
|
|
366
441
|
// detached HEAD is judged by the ancestry guard below (its `unverifiable`
|
|
367
442
|
// refusal and this `precondition` collapse to one observable refusal); the
|
|
@@ -374,6 +449,13 @@ export class WikiSync {
|
|
|
374
449
|
if (await this.#git.isMidMerge({ cwd: this.#wikiDir })) {
|
|
375
450
|
return WikiSyncRefusal.result("mid-merge");
|
|
376
451
|
}
|
|
452
|
+
// Capture the writer's branch point before the reconcile's fetch advances
|
|
453
|
+
// origin/master. The local commit below does not move origin/master, so
|
|
454
|
+
// reading it here yields the pre-edit session base; "" means an unborn
|
|
455
|
+
// origin (a fresh clone), which the gate treats as a value-0 baseline.
|
|
456
|
+
const sessionBaseSha = await this.#git.revParse("origin/master", {
|
|
457
|
+
cwd: this.#wikiDir,
|
|
458
|
+
});
|
|
377
459
|
// Ancestry guard: refuse a detached/unborn/unrelated history
|
|
378
460
|
// before any mutation.
|
|
379
461
|
await this.#assertPublishable();
|
|
@@ -381,22 +463,25 @@ export class WikiSync {
|
|
|
381
463
|
this.#wikiDir,
|
|
382
464
|
this.#runtime.fsSync,
|
|
383
465
|
).changed;
|
|
466
|
+
// Attribute the commit's write-set. A caller that knows
|
|
467
|
+
// its narrower write-set passes `paths` (the claim/release path, scoped to
|
|
468
|
+
// MEMORY.md). The session-close `fit-wiki push` passes none and the
|
|
469
|
+
// write-set is the session's own dirty set, read from the working tree —
|
|
470
|
+
// correct because the canonical mechanism runs it in a per-session isolated
|
|
471
|
+
// checkout where the dirty set holds no foreign content. The whole-tree
|
|
472
|
+
// `commitAll` sweep is gone: it carried the stale fast-forward eraser, a
|
|
473
|
+
// clean fast-forward no loud-conflict contract could reach, so the scoped
|
|
474
|
+
// commit closes it at the source rather than relying on a later conflict.
|
|
475
|
+
const writeSet = paths ?? (await this.#dirtyPaths());
|
|
384
476
|
// The metrics-CSV declaration must be committed when it was just written,
|
|
385
477
|
// even on the pathspec-scoped path; fold it into the effective pathspec.
|
|
386
|
-
// On a no-payload sweep (`paths` absent), this becomes the sole pathspec
|
|
387
|
-
// [GITATTRIBUTES_FILE], so provisioning still produces exactly one commit
|
|
388
|
-
// rather than sweeping the whole tree via commitAll.
|
|
389
478
|
const commitPaths = gitattributesChanged
|
|
390
|
-
? [...
|
|
391
|
-
:
|
|
392
|
-
if (!(await this.isClean(commitPaths))) {
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
});
|
|
397
|
-
} else {
|
|
398
|
-
await this.#git.commitAll(message, { cwd: this.#wikiDir });
|
|
399
|
-
}
|
|
479
|
+
? [...writeSet, GITATTRIBUTES_FILE]
|
|
480
|
+
: writeSet;
|
|
481
|
+
if (commitPaths.length && !(await this.isClean(commitPaths))) {
|
|
482
|
+
await this.#git.commitPaths(message, commitPaths, {
|
|
483
|
+
cwd: this.#wikiDir,
|
|
484
|
+
});
|
|
400
485
|
}
|
|
401
486
|
|
|
402
487
|
// Grounded nothing-to-push (D2): assert it only when the
|
|
@@ -413,6 +498,8 @@ export class WikiSync {
|
|
|
413
498
|
return this.#reconcileAndPush(message, paths, preTip, {
|
|
414
499
|
reapply,
|
|
415
500
|
maxReapply,
|
|
501
|
+
sessionBaseSha,
|
|
502
|
+
exemptSummaryFiles,
|
|
416
503
|
});
|
|
417
504
|
}
|
|
418
505
|
|
|
@@ -429,7 +516,9 @@ export class WikiSync {
|
|
|
429
516
|
* @param {string[]} [paths] - The caller's pathspec (singleton-discipline scope).
|
|
430
517
|
* @param {string[]} [commitPaths] - The effective commit pathspec.
|
|
431
518
|
* @param {string} preTip - The remote tip observed before the first reconcile.
|
|
432
|
-
* @param {{reapply?: function, maxReapply: number}}
|
|
519
|
+
* @param {{reapply?: function, maxReapply: number, sessionBaseSha?: string, exemptSummaryFiles?: string[]}} opts
|
|
520
|
+
* `sessionBaseSha` is the writer's pre-fetch branch point and
|
|
521
|
+
* `exemptSummaryFiles` the memo-delivery seam set, both for the budget gate.
|
|
433
522
|
*/
|
|
434
523
|
async #reconcileAndPush(message, paths, preTip, opts) {
|
|
435
524
|
// Bounded retry (D3): at most one reconcile-and-retry on `rejected`. The
|
|
@@ -463,7 +552,7 @@ export class WikiSync {
|
|
|
463
552
|
* @param {string} message
|
|
464
553
|
* @param {string[]} [paths] - The caller's pathspec (singleton-discipline scope).
|
|
465
554
|
* @param {string} tip - The remote tip observed before this attempt's reconcile.
|
|
466
|
-
* @param {{reapply?: function, maxReapply: number}} opts
|
|
555
|
+
* @param {{reapply?: function, maxReapply: number, sessionBaseSha?: string, exemptSummaryFiles?: string[]}} opts
|
|
467
556
|
*/
|
|
468
557
|
async #reconcileAttempt(message, paths, tip, opts) {
|
|
469
558
|
const fetched = await this.#fetchObserved();
|
|
@@ -507,19 +596,79 @@ export class WikiSync {
|
|
|
507
596
|
const refusal = await this.#gateOrRefuse();
|
|
508
597
|
if (refusal) return { result: refusal };
|
|
509
598
|
|
|
599
|
+
// Budget re-validation gate (size axis). The reconcile above made HEAD the
|
|
600
|
+
// final outgoing tree (rebase / singleton re-apply re-derived its
|
|
601
|
+
// content against the fresh tip), so measuring HEAD here measures exactly
|
|
602
|
+
// what publishes. A breach this push introduces or deepens throws `budget`;
|
|
603
|
+
// surfaced-only memo-delivery breaches ride the landed result.
|
|
604
|
+
const gate = await this.#revalidateBudgets(opts.sessionBaseSha, {
|
|
605
|
+
exemptSummaryFiles: opts.exemptSummaryFiles ?? [],
|
|
606
|
+
});
|
|
607
|
+
if (gate.refusals.length > 0) {
|
|
608
|
+
throw this.#budgetFailure(gate);
|
|
609
|
+
}
|
|
610
|
+
|
|
510
611
|
const verdict = await this.#groundedPush(fetched);
|
|
511
612
|
if (verdict.landed) {
|
|
512
613
|
// The push landed; any declared removal it carried is now published, so
|
|
513
614
|
// clear the intent sidecar — it must not leak into an unrelated push.
|
|
514
615
|
this.#clearIntentSidecar();
|
|
515
616
|
const detections = await this.#tier1Probe(pushedDelta);
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
617
|
+
const result = { landed: true, reason: PUSH_REASONS.LANDED, detections };
|
|
618
|
+
// Attach `surfaced` only when the gate surfaced a (memo-delivery exempt)
|
|
619
|
+
// breach, so a clean under-budget sync's landed result is byte-identical
|
|
620
|
+
// to today's — the gate adds no happy-path behaviour change (criterion 10).
|
|
621
|
+
if (gate.surfaced.length > 0) result.surfaced = gate.surfaced;
|
|
622
|
+
return { result };
|
|
519
623
|
}
|
|
520
624
|
return { verdict };
|
|
521
625
|
}
|
|
522
626
|
|
|
627
|
+
/**
|
|
628
|
+
* Build the `budget` {@link WikiPushFailure} for a refusing gate result,
|
|
629
|
+
* naming the worst offending file and carrying every refusal and surfaced
|
|
630
|
+
* tuple so the operator can adjudicate (trim own content, or surface the
|
|
631
|
+
* carried content to its owner). The gate never edits — it refuses, keeping
|
|
632
|
+
* the commits local for re-push after the breach is resolved.
|
|
633
|
+
* @param {{refusals: Array<object>, surfaced: Array<object>}} gate
|
|
634
|
+
* @returns {WikiPushFailure}
|
|
635
|
+
*/
|
|
636
|
+
#budgetFailure(gate) {
|
|
637
|
+
const lead = gate.refusals[0];
|
|
638
|
+
const files = [...new Set(gate.refusals.map((r) => r.file))].join(", ");
|
|
639
|
+
return new WikiPushFailure(
|
|
640
|
+
PUSH_REASONS.BUDGET,
|
|
641
|
+
"fit-wiki: refusing to push — it would introduce or deepen a budget " +
|
|
642
|
+
`breach in ${files} (${lead.ruleId}: ${lead.value} vs baseline ` +
|
|
643
|
+
`${lead.baseline}). Trim your own content or surface the carried ` +
|
|
644
|
+
"content to its owner, then re-push; your work is committed locally.",
|
|
645
|
+
{ refusals: gate.refusals, surfaced: gate.surfaced },
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* Run the budget gate over the outgoing tree, measuring the committed `HEAD`
|
|
651
|
+
* against the pre-fetch session base and the landed origin tip. Delegates the
|
|
652
|
+
* measurement, the unreadable-ref fail-visible posture, and the delta to
|
|
653
|
+
* {@link runBudgetGate}; this method binds only the clone's git/fs/clock.
|
|
654
|
+
* @param {string} sessionBaseSha - origin/master before fetch, or "" when unborn.
|
|
655
|
+
* @param {{exemptSummaryFiles: string[]}} options
|
|
656
|
+
* @returns {Promise<{refusals: Array<object>, surfaced: Array<object>}>}
|
|
657
|
+
*/
|
|
658
|
+
async #revalidateBudgets(sessionBaseSha, { exemptSummaryFiles }) {
|
|
659
|
+
return runBudgetGate({
|
|
660
|
+
showFile: (ref, file) =>
|
|
661
|
+
this.#git.showFile(ref, file, { cwd: this.#wikiDir }),
|
|
662
|
+
wikiRoot: this.#wikiDir,
|
|
663
|
+
today: currentDayIso(this.#runtime),
|
|
664
|
+
fs: this.#runtime.fsSync,
|
|
665
|
+
headRef: "HEAD",
|
|
666
|
+
originRef: REMOTE_BRANCH,
|
|
667
|
+
sessionBaseSha,
|
|
668
|
+
exemptSummaryFiles,
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
|
|
523
672
|
/**
|
|
524
673
|
* Refuse (`residue-conflict`) when the reconcile left unmerged paths — the
|
|
525
674
|
* autostash pop conflicted under an exit-0 rebase (D9). The stash is left
|
|
@@ -823,6 +972,31 @@ export class WikiSync {
|
|
|
823
972
|
.some((line) => UNMERGED_CODES.has(line.slice(0, 2)));
|
|
824
973
|
}
|
|
825
974
|
|
|
975
|
+
/**
|
|
976
|
+
* The paths dirty in the working tree — the session's own write-set when no
|
|
977
|
+
* explicit pathspec was supplied. Each porcelain v1 line is `XY <path>` or,
|
|
978
|
+
* for a rename, `XY <orig> -> <new>`; the destination is the path that exists
|
|
979
|
+
* in the tree and is the one emitted (a `git mv` source no longer exists, so
|
|
980
|
+
* adding it to the pathspec would fault). A `"`-quoted path (a name with a
|
|
981
|
+
* space or non-ASCII byte) is unquoted so the pathspec matches the
|
|
982
|
+
* working-tree entry. Under the canonical per-session isolated checkout this
|
|
983
|
+
* set holds no foreign content, so committing exactly it stages the session's
|
|
984
|
+
* own work and nothing else; wiki session writes are edits and appends, not
|
|
985
|
+
* renames, so the destination-only scope covers the real workload.
|
|
986
|
+
*/
|
|
987
|
+
async #dirtyPaths() {
|
|
988
|
+
const r = await this.#git.status({ cwd: this.#wikiDir });
|
|
989
|
+
return r.stdout
|
|
990
|
+
.split("\n")
|
|
991
|
+
.map((line) => line.slice(3))
|
|
992
|
+
.map((entry) => {
|
|
993
|
+
const arrow = entry.indexOf(" -> ");
|
|
994
|
+
return arrow === -1 ? entry : entry.slice(arrow + 4);
|
|
995
|
+
})
|
|
996
|
+
.map((p) => p.trim().replace(/^"(.*)"$/, "$1"))
|
|
997
|
+
.filter(Boolean);
|
|
998
|
+
}
|
|
999
|
+
|
|
826
1000
|
/**
|
|
827
1001
|
* Refuse (`conservation`) when the would-be-pushed tree drops foreign content
|
|
828
1002
|
* present at the observed remote tip, unless a deliberate removal carries it
|