@forwardimpact/libwiki 0.2.35 → 0.3.1
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 +53 -52
- package/package.json +6 -8
- package/src/active-claims.js +5 -5
- package/src/agent-roster.js +2 -2
- package/src/audit/admission.js +14 -11
- package/src/audit/conflict-markers-rule.js +9 -9
- package/src/audit/grammar.js +21 -18
- package/src/audit/rule-builders.js +20 -19
- package/src/audit/rules.js +35 -32
- package/src/audit/scopes.js +35 -34
- package/src/audit/status-row.js +14 -15
- package/src/block-renderer.js +5 -4
- package/src/boot.js +10 -8
- package/src/budget-gate.js +39 -36
- package/src/budget.js +3 -3
- package/src/cli-definition.js +35 -32
- package/src/commands/audit.js +3 -3
- package/src/commands/boot.js +2 -2
- package/src/commands/claim.js +46 -40
- package/src/commands/curate.js +34 -31
- package/src/commands/fix.js +74 -70
- package/src/commands/inbox.js +2 -2
- package/src/commands/init.js +13 -8
- package/src/commands/ledger.js +12 -12
- package/src/commands/log.js +20 -18
- package/src/commands/memo.js +5 -2
- package/src/commands/product-mix.js +18 -17
- package/src/commands/refresh.js +25 -22
- package/src/commands/rotate.js +10 -9
- package/src/commands/sync.js +26 -17
- package/src/conflict-markers.js +21 -21
- package/src/constants.js +38 -34
- package/src/gitattributes.js +10 -9
- package/src/integrity.js +29 -27
- package/src/issue-list-renderer.js +24 -16
- package/src/lane-files.js +11 -10
- package/src/ledger/anchor.js +6 -6
- package/src/ledger/projection.js +35 -32
- package/src/ledger/reader.js +4 -4
- package/src/marker-scanner.js +3 -2
- package/src/sanitize.js +12 -11
- package/src/secret-gate.js +41 -40
- package/src/status.js +12 -11
- package/src/storyboard-skeleton.js +20 -18
- package/src/util/agent-flag.js +8 -8
- package/src/util/clock.js +1 -1
- package/src/util/wiki-dir.js +7 -7
- package/src/weekly-log.js +115 -101
- package/src/wiki-sync.js +411 -379
- package/bin/fit-wiki.js +0 -94
package/src/wiki-sync.js
CHANGED
|
@@ -9,15 +9,17 @@ import { currentDayIso } from "./util/clock.js";
|
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
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
|
|
14
|
-
* github.com URL, the wiki included
|
|
15
|
-
* and returns 403 for the wiki. An identity rule keyed to
|
|
16
|
-
* a longer prefix match than the broad
|
|
17
|
-
* it by longest
|
|
18
|
-
* github.com over the ambient HTTPS
|
|
19
|
-
* the rule is a harmless no-op.
|
|
20
|
-
*
|
|
12
|
+
* environments install a broad global rewrite,
|
|
13
|
+
* `url.<local-proxy>.insteadOf = https://github.com/`. That rewrite diverts
|
|
14
|
+
* every github.com URL to a proxy, the wiki URL included. The proxy serves
|
|
15
|
+
* only the main repo and returns 403 for the wiki. An identity rule keyed to
|
|
16
|
+
* the full wiki URL is a longer prefix match than the broad
|
|
17
|
+
* `https://github.com/` rule. So git picks it by longest match and leaves the
|
|
18
|
+
* URL untouched. The request then reaches github.com over the ambient HTTPS
|
|
19
|
+
* proxy. Where no such broad rewrite exists, the rule is a harmless no-op.
|
|
20
|
+
* `WikiSync` applies the rule inline on clone. It also persists the rule into
|
|
21
|
+
* the clone's local config for every later network op
|
|
22
|
+
* (see {@link WikiSync#pinTransport}).
|
|
21
23
|
* @param {string} url - The wiki clone URL.
|
|
22
24
|
* @returns {string} A `-c`-form `url.<url>.insteadOf=<url>` entry.
|
|
23
25
|
*/
|
|
@@ -37,9 +39,9 @@ const PUSH_RANGE = "origin/master..HEAD";
|
|
|
37
39
|
const UNMERGED_CODES = new Set(["UU", "AA", "DD", "AU", "UA", "DU", "UD"]);
|
|
38
40
|
|
|
39
41
|
/**
|
|
40
|
-
* The honest push-outcome reason taxonomy (D2).
|
|
41
|
-
* outcomes (`landed`, `nothing-to-push`)
|
|
42
|
-
*
|
|
42
|
+
* The honest push-outcome reason taxonomy (D2). `commitAndPush` returns the
|
|
43
|
+
* success-shaped outcomes (`landed`, `nothing-to-push`). A thrown
|
|
44
|
+
* {@link WikiPushFailure} carries every other reason.
|
|
43
45
|
*/
|
|
44
46
|
export const PUSH_REASONS = Object.freeze({
|
|
45
47
|
LANDED: "landed",
|
|
@@ -53,7 +55,7 @@ export const PUSH_REASONS = Object.freeze({
|
|
|
53
55
|
BUDGET: "budget",
|
|
54
56
|
});
|
|
55
57
|
|
|
56
|
-
/** Error thrown when a wiki pull
|
|
58
|
+
/** Error thrown when a wiki pull hits a rebase conflict it cannot resolve automatically. */
|
|
57
59
|
export class WikiPullConflict extends Error {
|
|
58
60
|
/** Create a WikiPullConflict with the stderr output from the failed rebase. */
|
|
59
61
|
constructor(stderr) {
|
|
@@ -64,8 +66,8 @@ export class WikiPullConflict extends Error {
|
|
|
64
66
|
}
|
|
65
67
|
|
|
66
68
|
// A push rejected because the remote tip moved (non-fast-forward) is the
|
|
67
|
-
// re-apply loop's retry signal
|
|
68
|
-
// git surfaces a rejection on stderr with these markers.
|
|
69
|
+
// re-apply loop's retry signal. An auth or network failure is not
|
|
70
|
+
// contention. git surfaces a rejection on stderr with these markers.
|
|
69
71
|
const PUSH_REJECTION_RE =
|
|
70
72
|
/\b(rejected|non-fast-forward|fetch first|tip of your current branch is behind)\b/i;
|
|
71
73
|
|
|
@@ -76,17 +78,17 @@ function isPushRejection(err) {
|
|
|
76
78
|
}
|
|
77
79
|
|
|
78
80
|
/**
|
|
79
|
-
* Error thrown when the ancestry guard refuses to commit or push
|
|
80
|
-
*
|
|
81
|
-
* branch
|
|
82
|
-
* (confirmed no shared history) or `"unverifiable"` (the
|
|
83
|
-
* neither
|
|
84
|
-
* operator knows which state they
|
|
81
|
+
* Error thrown when the ancestry guard refuses to commit or push. The guard
|
|
82
|
+
* refuses when it cannot positively confirm the relationship between the
|
|
83
|
+
* remote branch and the history the push would publish. `kind` is
|
|
84
|
+
* `"unrelated"` (confirmed no shared history) or `"unverifiable"` (the guard
|
|
85
|
+
* could neither confirm nor refute the relationship). The two kinds carry
|
|
86
|
+
* distinct messages, so the operator knows which state they recover from.
|
|
85
87
|
*/
|
|
86
88
|
export class AncestryRefusal extends Error {
|
|
87
89
|
/**
|
|
88
90
|
* @param {"unrelated"|"unverifiable"} kind
|
|
89
|
-
* @param {string} message -
|
|
91
|
+
* @param {string} message - A message that names the recovery.
|
|
90
92
|
*/
|
|
91
93
|
constructor(kind, message) {
|
|
92
94
|
super(message);
|
|
@@ -97,8 +99,8 @@ export class AncestryRefusal extends Error {
|
|
|
97
99
|
|
|
98
100
|
/**
|
|
99
101
|
* Error thrown when a registered-singleton operation cannot land within the
|
|
100
|
-
* bounded re-apply budget
|
|
101
|
-
* fails loud
|
|
102
|
+
* bounded re-apply budget. Contention recurred on each round. The publish
|
|
103
|
+
* fails loud and does not resolve the contended hunk textually.
|
|
102
104
|
*/
|
|
103
105
|
export class WikiSyncConflict extends Error {
|
|
104
106
|
/** @param {string[]} paths @param {string} reason */
|
|
@@ -111,14 +113,15 @@ export class WikiSyncConflict extends Error {
|
|
|
111
113
|
}
|
|
112
114
|
|
|
113
115
|
/**
|
|
114
|
-
* The refusal reason taxonomy for {@link WikiSync.commitAndPush}.
|
|
115
|
-
*
|
|
116
|
-
* working
|
|
117
|
-
* `reason` is one of `mid-merge`,
|
|
118
|
-
* `
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
116
|
+
* The refusal reason taxonomy for {@link WikiSync.commitAndPush}. The method
|
|
117
|
+
* surfaces a refusal in the result and does not throw it, so a caller that
|
|
118
|
+
* reads the result keeps working. `WikiSyncRefusal.result(reason, details)`
|
|
119
|
+
* builds that result object. `reason` is one of `mid-merge`,
|
|
120
|
+
* `stranded-merge`, `would-publish-markers`, `introduced-scan-failed`.
|
|
121
|
+
* `workAt` (only for `stranded-merge`) names where retained work lives. The
|
|
122
|
+
* reason set is additive to the `clean` and `pushed` outcomes already
|
|
123
|
+
* defined. So a future refusal taxonomy on this flow can union new reasons in
|
|
124
|
+
* and rewrite none of the reasons that exist.
|
|
122
125
|
*/
|
|
123
126
|
export class WikiSyncRefusal {
|
|
124
127
|
/** @type {readonly string[]} The recognized refusal reasons. */
|
|
@@ -144,9 +147,9 @@ export class WikiSyncRefusal {
|
|
|
144
147
|
|
|
145
148
|
// Markers introduced into a prose markdown surface may be legitimately quoted
|
|
146
149
|
// inside a fenced code block (the false-positive surface Layer 1 exempts).
|
|
147
|
-
// STATUS.md is data
|
|
148
|
-
//
|
|
149
|
-
// neither is fence-exempt on the publish path.
|
|
150
|
+
// STATUS.md is data. It is not prose, and its fenced rows are never
|
|
151
|
+
// legitimately marked. Non-markdown files (e.g. metrics CSVs) have no
|
|
152
|
+
// quoted-form idiom. So neither is fence-exempt on the publish path.
|
|
150
153
|
function pushFenceExempt(filePath) {
|
|
151
154
|
const base = path.basename(filePath);
|
|
152
155
|
return filePath.endsWith(".md") && base !== "STATUS.md";
|
|
@@ -155,15 +158,16 @@ function pushFenceExempt(filePath) {
|
|
|
155
158
|
/**
|
|
156
159
|
* Error thrown when `commitAndPush` cannot honestly report a landed push.
|
|
157
160
|
* `reason` is one of {@link PUSH_REASONS} other than `landed` /
|
|
158
|
-
* `nothing-to-push
|
|
159
|
-
* `residue-conflict
|
|
160
|
-
* offending and surfaced-only `(file, ruleId, baseline, value)` tuples
|
|
161
|
-
* size-axis re-validation gate.
|
|
161
|
+
* `nothing-to-push`. `stashSha` names a preserved autostash on a
|
|
162
|
+
* `residue-conflict`. On a `budget` refusal, `refusals` and `surfaced` carry
|
|
163
|
+
* the offending and surfaced-only `(file, ruleId, baseline, value)` tuples
|
|
164
|
+
* from the size-axis re-validation gate.
|
|
162
165
|
*/
|
|
163
166
|
export class WikiPushFailure extends Error {
|
|
164
167
|
/**
|
|
165
168
|
* @param {string} reason - A {@link PUSH_REASONS} value.
|
|
166
|
-
* @param {string} message -
|
|
169
|
+
* @param {string} message - An operator message that names the reason and
|
|
170
|
+
* the recovery.
|
|
167
171
|
* @param {object} [opts]
|
|
168
172
|
* @param {string} [opts.stashSha] - Preserved stash SHA (residue-conflict).
|
|
169
173
|
* @param {Array<object>} [opts.refusals] - Budget refusal tuples (budget).
|
|
@@ -182,14 +186,15 @@ export class WikiPushFailure extends Error {
|
|
|
182
186
|
/**
|
|
183
187
|
* Consolidates the wiki repository's pull / rebase / conflict-resolve / push
|
|
184
188
|
* flow over an injected {@link import('@forwardimpact/libutil').GitClient}.
|
|
185
|
-
* Replaces the pre-1370 `WikiRepo
|
|
189
|
+
* Replaces the pre-1370 `WikiRepo`. Every subprocess call flows through
|
|
186
190
|
* `gitClient` (itself over `runtime.subprocess`), so libwiki never imports
|
|
187
191
|
* `node:child_process` and tests inject `createMockGitClient`.
|
|
188
192
|
*
|
|
189
|
-
* Network operations (fetch / clone / push) authenticate
|
|
190
|
-
* lazily through `resolveToken
|
|
191
|
-
*
|
|
192
|
-
* resolution policy
|
|
193
|
+
* Network operations (fetch / clone / push) authenticate with a token. They
|
|
194
|
+
* resolve the token lazily through `resolveToken`. They thread it through
|
|
195
|
+
* `gitClient.withAuth`. Local operations never call `resolveToken`. The
|
|
196
|
+
* callback owns the entire resolution policy. Its throws propagate to the
|
|
197
|
+
* caller.
|
|
193
198
|
*/
|
|
194
199
|
export class WikiSync {
|
|
195
200
|
#runtime;
|
|
@@ -205,7 +210,7 @@ export class WikiSync {
|
|
|
205
210
|
* @param {string} options.wikiDir - The wiki clone directory.
|
|
206
211
|
* @param {string} options.parentDir - The parent project directory (identity source).
|
|
207
212
|
* @param {() => (string|null)} [options.resolveToken] - Lazy token resolver
|
|
208
|
-
* for network operations
|
|
213
|
+
* for network operations. Returns a token string, or null for anonymous.
|
|
209
214
|
*/
|
|
210
215
|
constructor({ runtime, gitClient, wikiDir, parentDir, resolveToken }) {
|
|
211
216
|
if (!runtime) throw new Error("WikiSync: runtime is required");
|
|
@@ -250,12 +255,12 @@ export class WikiSync {
|
|
|
250
255
|
|
|
251
256
|
/**
|
|
252
257
|
* Persist the identity-`insteadOf` for the wiki's own URL into the clone's
|
|
253
|
-
* local config
|
|
254
|
-
*
|
|
255
|
-
*
|
|
256
|
-
* clone command itself takes the same rule inline (the `.git/config`
|
|
257
|
-
* exist yet).
|
|
258
|
-
* {@link selfInsteadOf} for why the rule is needed.
|
|
258
|
+
* local config. Every later network op (fetch / push / ls-remote) runs
|
|
259
|
+
* against the stored remote URL and re-applies `insteadOf` at transport
|
|
260
|
+
* time. The stored rule covers each one, and no call has to thread `-c`.
|
|
261
|
+
* The clone command itself takes the same rule inline (the `.git/config`
|
|
262
|
+
* does not exist yet). This method is idempotent. It is safe to re-run on a
|
|
263
|
+
* resumed clone. See {@link selfInsteadOf} for why the rule is needed.
|
|
259
264
|
*/
|
|
260
265
|
async #pinTransport(url) {
|
|
261
266
|
await this.#git.configSet(`url.${url}.insteadOf`, url, {
|
|
@@ -278,17 +283,18 @@ export class WikiSync {
|
|
|
278
283
|
}
|
|
279
284
|
}
|
|
280
285
|
|
|
281
|
-
/** Fetch origin/master
|
|
286
|
+
/** Fetch origin/master with token auth when it is available. */
|
|
282
287
|
async fetch() {
|
|
283
288
|
// Resolve auth first so a misconfigured `resolveToken` still surfaces.
|
|
284
289
|
const client = this.#authed();
|
|
285
290
|
try {
|
|
286
291
|
await client.fetch("origin", "master", { cwd: this.#wikiDir });
|
|
287
292
|
} catch {
|
|
288
|
-
// WikiRepo treated fetch as fire-and-forget (it ignored the git result)
|
|
289
|
-
//
|
|
290
|
-
// rebase proceeds against it.
|
|
291
|
-
//
|
|
293
|
+
// WikiRepo treated fetch as fire-and-forget (it ignored the git result).
|
|
294
|
+
// A failed fetch leaves the local origin/master ref in place, and the
|
|
295
|
+
// rebase proceeds against it. This code keeps that behaviour so push and
|
|
296
|
+
// pull degrade gracefully when the network or credentials are
|
|
297
|
+
// unavailable. They do not crash.
|
|
292
298
|
}
|
|
293
299
|
}
|
|
294
300
|
|
|
@@ -302,7 +308,7 @@ export class WikiSync {
|
|
|
302
308
|
return r.stdout.trim() === "";
|
|
303
309
|
}
|
|
304
310
|
|
|
305
|
-
/** Fetch and rebase on origin/master
|
|
311
|
+
/** Fetch and rebase on origin/master. Throws WikiPullConflict if the rebase fails. */
|
|
306
312
|
async pull() {
|
|
307
313
|
await this.fetch();
|
|
308
314
|
const r = await this.#git.rebase("origin/master", { cwd: this.#wikiDir });
|
|
@@ -313,146 +319,155 @@ export class WikiSync {
|
|
|
313
319
|
}
|
|
314
320
|
|
|
315
321
|
/**
|
|
316
|
-
* Stage and commit working-tree changes
|
|
317
|
-
* push
|
|
318
|
-
*
|
|
322
|
+
* Stage and commit working-tree changes. Then reconcile on origin/master
|
|
323
|
+
* and push. Report an honest outcome. The commit gate and the push gate are
|
|
324
|
+
* independent, so a clean tree with local commits still pushes.
|
|
319
325
|
*
|
|
320
|
-
* The commit is always pathspec-scoped
|
|
321
|
-
* caller's declared write-set
|
|
322
|
-
* dirty set (`#dirtyPaths`, the `
|
|
323
|
-
* checkout isolation). The whole-tree `add -A` sweep is gone
|
|
324
|
-
* content on undeclared paths
|
|
325
|
-
* eraser
|
|
326
|
-
* because any foreign residue stays uncommitted.
|
|
326
|
+
* The commit is always pathspec-scoped. With `paths` the scope is the
|
|
327
|
+
* caller's declared write-set. Without `paths` it is the session's own
|
|
328
|
+
* dirty set (`#dirtyPaths`, the `gemba-wiki push` contract under per-session
|
|
329
|
+
* checkout isolation). The whole-tree `add -A` sweep is gone. So the commit
|
|
330
|
+
* never stages foreign content on undeclared paths, and the stale
|
|
331
|
+
* fast-forward eraser that sweep carried no longer exists. The rebase runs
|
|
332
|
+
* with --autostash because any foreign residue stays uncommitted.
|
|
327
333
|
*
|
|
328
334
|
* Outcome contract (D2 taxonomy):
|
|
329
|
-
* - Returns `{ landed: true, reason: "landed" }` only when
|
|
330
|
-
* **
|
|
331
|
-
* or a post-push read of
|
|
332
|
-
* from the subprocess's exit or prose.
|
|
335
|
+
* - Returns `{ landed: true, reason: "landed" }` only when observed remote
|
|
336
|
+
* state **grounds** the push. That state is the per-ref `--porcelain`
|
|
337
|
+
* report, or a post-push read of a remote tip that contains HEAD. The
|
|
338
|
+
* method never infers the outcome from the subprocess's exit or prose.
|
|
333
339
|
* - Returns `{ landed: false, reason: "nothing-to-push" }` only when the
|
|
334
|
-
* observed remote ref already contains local HEAD
|
|
335
|
-
* arithmetic
|
|
340
|
+
* observed remote ref already contains local HEAD. The method never uses
|
|
341
|
+
* pre-fetch arithmetic, so a stranded-resume tree re-pushes.
|
|
336
342
|
* - Throws {@link WikiPushFailure} for every failure reason: `precondition`
|
|
337
|
-
* (rebase-in-progress / detached HEAD, before
|
|
338
|
-
* (rebase conflict
|
|
339
|
-
*
|
|
340
|
-
*
|
|
341
|
-
* `
|
|
342
|
-
* (
|
|
343
|
-
* work.
|
|
343
|
+
* (rebase-in-progress / detached HEAD, before any mutation), `conflict`
|
|
344
|
+
* (a rebase conflict that the flow aborts, and it never mechanically
|
|
345
|
+
* discards the remote side), `residue-conflict` (the autostash pop left
|
|
346
|
+
* unmerged paths, and the flow preserves the stash by SHA),
|
|
347
|
+
* `conservation` (the push would drop foreign content), `rejected`
|
|
348
|
+
* (non-fast-forward after a successful fetch), `transport` (push/fetch
|
|
349
|
+
* transport failure). A failed push never loses uncommitted work.
|
|
344
350
|
*
|
|
345
|
-
* Bounded retry (D3) is in contract
|
|
346
|
-
* (this is the second lander)
|
|
347
|
-
* re-pushes
|
|
348
|
-
*
|
|
349
|
-
* one
|
|
350
|
-
* outcome
|
|
351
|
+
* Bounded retry (D3) is in contract. The ancestry judgment is present
|
|
352
|
+
* (this is the second lander). So a `rejected` outcome reconciles once and
|
|
353
|
+
* re-pushes. It re-enters {@link #assertPublishable} before the replay, so
|
|
354
|
+
* the flow never auto-re-grants the empty-remote allowance. The retry is
|
|
355
|
+
* bounded at one. It never re-pops a conflicted autostash. It never masks
|
|
356
|
+
* the final outcome. Exhaustion reports `rejected`.
|
|
351
357
|
*
|
|
352
|
-
* Ancestry guard:
|
|
353
|
-
*
|
|
354
|
-
* to `origin/master
|
|
355
|
-
* unborn HEAD or unrelated history against
|
|
356
|
-
* remote that cannot
|
|
357
|
-
*
|
|
358
|
-
* `ls-remote`)
|
|
359
|
-
*
|
|
360
|
-
*
|
|
361
|
-
* creates no commit, attempts no
|
|
358
|
+
* Ancestry guard: the flow throws {@link AncestryRefusal} before the commit
|
|
359
|
+
* and again before the push. It throws when it cannot positively confirm
|
|
360
|
+
* the published history's relationship to `origin/master`. The unconfirmed
|
|
361
|
+
* cases are a detached HEAD, an unborn HEAD or unrelated history against a
|
|
362
|
+
* remote branch that exists, and a remote it cannot observe. A new wiki's
|
|
363
|
+
* first publication needs positive evidence that the remote branch is
|
|
364
|
+
* absent (a non-swallowed `ls-remote`). Mere absence of the local
|
|
365
|
+
* remote-tracking ref never grants it. The guard re-derives the allowance
|
|
366
|
+
* from live git on every call, so it re-judges a failed first publication
|
|
367
|
+
* and never auto-re-grants it. The guard creates no commit, attempts no
|
|
368
|
+
* push, and adds no working-tree changes.
|
|
362
369
|
*
|
|
363
|
-
* Before the gates, the metrics-CSV union merge
|
|
364
|
-
* `.gitattributes`. When the ensure writes the file, the
|
|
365
|
-
*
|
|
366
|
-
* declaration
|
|
367
|
-
*
|
|
368
|
-
*
|
|
369
|
-
*
|
|
370
|
-
*
|
|
370
|
+
* Before the gates, the flow ensures the metrics-CSV union merge
|
|
371
|
+
* declaration in `.gitattributes`. When the ensure writes the file, the
|
|
372
|
+
* commit must carry it whatever the session's payload is. On the
|
|
373
|
+
* pathspec-scoped path the declaration sits outside `paths`, and the
|
|
374
|
+
* autostash would otherwise set it aside. On a no-payload sync there would
|
|
375
|
+
* be no commit at all. So the flow appends `.gitattributes` to the
|
|
376
|
+
* effective commit pathspec only when the ensure changed it. When the
|
|
377
|
+
* declaration is already present-and-correct, behavior is byte-identical to
|
|
378
|
+
* a commit-and-push that ensures nothing.
|
|
371
379
|
*
|
|
372
|
-
* **Singleton merge discipline.** The discipline applies when a
|
|
373
|
-
*
|
|
374
|
-
*
|
|
375
|
-
*
|
|
376
|
-
*
|
|
377
|
-
*
|
|
378
|
-
* `checkoutPaths`.
|
|
379
|
-
* re-
|
|
380
|
-
* moved again) drives the retry
|
|
381
|
-
* {@link WikiSyncConflict}. Foreign rows and
|
|
382
|
-
* the tip. Without a `reapply` the
|
|
383
|
-
*
|
|
380
|
+
* **Singleton merge discipline.** The discipline applies when a rebase
|
|
381
|
+
* conflict arises for a *registered* row-structured singleton and the
|
|
382
|
+
* caller supplied a `reapply` operation. A registered singleton has its
|
|
383
|
+
* single committed path in `SINGLETON_PATHS`. The flow then never resolves
|
|
384
|
+
* the contended hunk textually. It uses `resetSoft` to drop the local commit
|
|
385
|
+
* that conflicts, which preserves the working tree. It resets only the
|
|
386
|
+
* registered file to the fresh tip with `checkoutPaths`. It re-derives the
|
|
387
|
+
* operation against that tip's content, re-commits it, and pushes, bounded
|
|
388
|
+
* by `maxReapply`. A rejected push (the tip moved again) drives the retry.
|
|
389
|
+
* Exhaustion fails loud with {@link WikiSyncConflict}. Foreign rows and
|
|
390
|
+
* untouched prose ride through from the tip. Without a `reapply` the
|
|
391
|
+
* conflict keeps the `-X ours` fallback. Prose surfaces and unregistered
|
|
392
|
+
* paths then stay on the side-biased behavior.
|
|
384
393
|
*
|
|
385
394
|
* **Fail-closed secret gate.** After the reconcile and before the push, the
|
|
386
|
-
* content the push introduces (the commit range
|
|
387
|
-
*
|
|
388
|
-
* refuses the push with a distinct reason and no remote
|
|
389
|
-
* matching off-by-default override
|
|
390
|
-
* `FIT_WIKI_SECRET_OVERRIDE` permits a
|
|
391
|
-
* permits a scanner absence. Each
|
|
392
|
-
*
|
|
393
|
-
*
|
|
395
|
+
* gate secret-scans the content the push introduces (the commit range
|
|
396
|
+
* `origin/master..HEAD`). The scan is fail-closed. A detected secret or an
|
|
397
|
+
* unavailable scanner refuses the push with a distinct reason and no remote
|
|
398
|
+
* contact. The matching off-by-default override lifts the refusal only when
|
|
399
|
+
* it is set in the environment. `FIT_WIKI_SECRET_OVERRIDE` permits a
|
|
400
|
+
* finding, and `FIT_WIKI_SCANNER_ABSENT_OK` permits a scanner absence. Each
|
|
401
|
+
* override appends an audited line to the wiki tree's
|
|
402
|
+
* `secret-overrides.log` before the push. A *network/credential* push
|
|
403
|
+
* failure is distinct. It still degrades to "saved locally" (the preserved
|
|
394
404
|
* fire-and-forget behaviour).
|
|
395
405
|
*
|
|
396
|
-
* **Budget re-validation gate (size axis).**
|
|
397
|
-
*
|
|
398
|
-
* after the secret gate, before the push, the flow re-runs
|
|
399
|
-
* budget predicates over the **outgoing committed `HEAD
|
|
400
|
-
* publishes
|
|
401
|
-
*
|
|
402
|
-
*
|
|
403
|
-
*
|
|
404
|
-
*
|
|
405
|
-
*
|
|
406
|
-
*
|
|
407
|
-
*
|
|
408
|
-
*
|
|
409
|
-
*
|
|
410
|
-
*
|
|
411
|
-
*
|
|
412
|
-
*
|
|
406
|
+
* **Budget re-validation gate (size axis).** The reconcile (rebase or
|
|
407
|
+
* singleton re-apply) re-derives content against the fresh tip. After that
|
|
408
|
+
* reconcile, after the secret gate, and before the push, the flow re-runs
|
|
409
|
+
* the wiki audit's budget predicates over the **outgoing committed `HEAD`**.
|
|
410
|
+
* That is the tree that publishes. It is not the working dir, so autostash
|
|
411
|
+
* residue never counts. The gate refuses the push with
|
|
412
|
+
* {@link WikiPushFailure} `budget` when the push introduces or deepens a
|
|
413
|
+
* per-file/per-predicate budget breach. The baseline is the worse of the
|
|
414
|
+
* writer's pre-fetch session base and the landed origin tip. The failure
|
|
415
|
+
* carries the offending and surfaced `(file, ruleId, baseline, value)`
|
|
416
|
+
* tuples. Equal-or-better states pass. Foreign pre-existing breaches the
|
|
417
|
+
* writer did not worsen also pass. The gate surfaces a summary breach on
|
|
418
|
+
* `exemptSummaryFiles` on the landed result's `surfaced` and does not refuse
|
|
419
|
+
* it. That is the memo-delivery seam. The gate reports a delivery into
|
|
420
|
+
* deficient headroom. It does not block it. An unreadable baseline ref
|
|
421
|
+
* aborts the gate and refuses nothing, because the gate only refuses a
|
|
422
|
+
* regression it can prove. The gate never fabricates a value-0 baseline. The
|
|
423
|
+
* gate inherits the audit's rule objects by id, so a future predicate change
|
|
424
|
+
* flows through the gate with no gate-code change.
|
|
413
425
|
*
|
|
414
426
|
* @param {string} message - The commit message.
|
|
415
|
-
* @param {string[]} [paths] - Pathspecs
|
|
427
|
+
* @param {string[]} [paths] - Pathspecs that limit what gets committed.
|
|
416
428
|
* @param {{reapply?: (freshText: string) => string | null, maxReapply?: number, exemptSummaryFiles?: string[]}} [options]
|
|
417
429
|
* `reapply` re-derives the registered file's content from the operation's
|
|
418
|
-
* own row edit against the fresh tip text
|
|
419
|
-
* the op is already satisfied on the tip. `exemptSummaryFiles`
|
|
420
|
-
* (relative to the wiki root)
|
|
421
|
-
*
|
|
430
|
+
* own row edit against the fresh tip text. It returns the new text, or
|
|
431
|
+
* null when the op is already satisfied on the tip. `exemptSummaryFiles`
|
|
432
|
+
* lists files (relative to the wiki root). The gate surfaces a summary
|
|
433
|
+
* budget breach in those files and does not refuse it (the memo-delivery
|
|
434
|
+
* seam).
|
|
422
435
|
* @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
|
|
424
|
-
* nothing-to-push (`{landed: false, reason: "nothing-to-push"}`),
|
|
425
|
-
* re-apply
|
|
436
|
+
* A grounded landed outcome (`{landed: true, reason: "landed", surfaced}`),
|
|
437
|
+
* a grounded nothing-to-push (`{landed: false, reason: "nothing-to-push"}`),
|
|
438
|
+
* a re-apply outcome (`{pushed: true, reason: "reapplied"}` / `already-satisfied`),
|
|
426
439
|
* or a pre-push gate refusal ({@link WikiSyncRefusal}: `mid-merge`,
|
|
427
440
|
* `would-publish-markers`, `introduced-scan-failed`, `secret-detected`,
|
|
428
441
|
* `scanner-unavailable`).
|
|
429
442
|
* @throws {WikiPushFailure} On a non-landed push outcome (D2 taxonomy:
|
|
430
443
|
* `precondition`, `conflict`, `residue-conflict`, `conservation`,
|
|
431
444
|
* `rejected`, `transport`) or a `budget` breach.
|
|
432
|
-
* @throws {AncestryRefusal} When the
|
|
433
|
-
*
|
|
445
|
+
* @throws {AncestryRefusal} When the guard cannot verify the published
|
|
446
|
+
* history.
|
|
447
|
+
* @throws {WikiSyncConflict} When the flow exhausts the re-apply budget.
|
|
434
448
|
*/
|
|
435
449
|
async commitAndPush(
|
|
436
450
|
message,
|
|
437
451
|
paths,
|
|
438
452
|
{ reapply, maxReapply = 3, exemptSummaryFiles = [] } = {},
|
|
439
453
|
) {
|
|
440
|
-
// Precondition (D7): refuse mid-rebase before
|
|
441
|
-
//
|
|
442
|
-
//
|
|
454
|
+
// Precondition (D7): refuse mid-rebase before any mutation. The ancestry
|
|
455
|
+
// guard below judges a detached HEAD (its `unverifiable` refusal and this
|
|
456
|
+
// `precondition` collapse to one observable refusal). The
|
|
443
457
|
// rebase-in-progress check is the residual this guard owns.
|
|
444
458
|
await this.#assertPreconditions();
|
|
445
|
-
// Guard 1 (hole 1): refuse mid-merge before
|
|
446
|
-
// leaves unmerged hunks or a pinned MERGE_HEAD
|
|
447
|
-
// silently "complete" the merge and publish the markers.
|
|
448
|
-
//
|
|
459
|
+
// Guard 1 (hole 1): refuse mid-merge before the flow stages anything. An
|
|
460
|
+
// abandoned merge leaves unmerged hunks or a pinned MERGE_HEAD. A sweep of
|
|
461
|
+
// them would silently "complete" the merge and publish the markers. The
|
|
462
|
+
// index and working tree alone decide this guard, so it holds on a shallow
|
|
463
|
+
// clone.
|
|
449
464
|
if (await this.#git.isMidMerge({ cwd: this.#wikiDir })) {
|
|
450
465
|
return WikiSyncRefusal.result("mid-merge");
|
|
451
466
|
}
|
|
452
467
|
// 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
|
-
//
|
|
455
|
-
//
|
|
468
|
+
// origin/master. The local commit below does not move origin/master, so a
|
|
469
|
+
// read here yields the pre-edit session base. "" means an unborn origin (a
|
|
470
|
+
// fresh clone), which the gate treats as a value-0 baseline.
|
|
456
471
|
const sessionBaseSha = await this.#git.revParse("origin/master", {
|
|
457
472
|
cwd: this.#wikiDir,
|
|
458
473
|
});
|
|
@@ -463,18 +478,19 @@ export class WikiSync {
|
|
|
463
478
|
this.#wikiDir,
|
|
464
479
|
this.#runtime.fsSync,
|
|
465
480
|
).changed;
|
|
466
|
-
// Attribute the commit's write-set. A caller that knows
|
|
467
|
-
//
|
|
468
|
-
//
|
|
469
|
-
//
|
|
470
|
-
//
|
|
481
|
+
// Attribute the commit's write-set. A caller that knows its narrower
|
|
482
|
+
// write-set passes `paths` (the claim/release path, scoped to MEMORY.md).
|
|
483
|
+
// The session-close `gemba-wiki push` passes none, and the write-set is
|
|
484
|
+
// the session's own dirty set, read from the working tree. That is correct
|
|
485
|
+
// because the canonical mechanism runs it in a per-session isolated
|
|
471
486
|
// checkout where the dirty set holds no foreign content. The whole-tree
|
|
472
|
-
// `commitAll` sweep is gone
|
|
473
|
-
// clean fast-forward no loud-conflict contract could reach
|
|
474
|
-
// commit closes it at the source
|
|
487
|
+
// `commitAll` sweep is gone. It carried the stale fast-forward eraser, a
|
|
488
|
+
// clean fast-forward no loud-conflict contract could reach. So the scoped
|
|
489
|
+
// commit closes it at the source and does not rely on a later conflict.
|
|
475
490
|
const writeSet = paths ?? (await this.#dirtyPaths());
|
|
476
|
-
// The metrics-CSV declaration
|
|
477
|
-
// even on the pathspec-scoped path
|
|
491
|
+
// The commit must carry the metrics-CSV declaration when the ensure just
|
|
492
|
+
// wrote it, even on the pathspec-scoped path. Fold it into the effective
|
|
493
|
+
// pathspec.
|
|
478
494
|
const commitPaths = gitattributesChanged
|
|
479
495
|
? [...writeSet, GITATTRIBUTES_FILE]
|
|
480
496
|
: writeSet;
|
|
@@ -484,16 +500,17 @@ export class WikiSync {
|
|
|
484
500
|
});
|
|
485
501
|
}
|
|
486
502
|
|
|
487
|
-
// Grounded nothing-to-push (D2): assert it only when the
|
|
488
|
-
//
|
|
489
|
-
//
|
|
503
|
+
// Grounded nothing-to-push (D2): assert it only when the observed remote
|
|
504
|
+
// ref already contains local HEAD. Never use pre-fetch arithmetic, so a
|
|
505
|
+
// stranded-resume tree (clean, ahead) re-pushes.
|
|
490
506
|
const preTip = await this.#observeRemoteTip();
|
|
491
507
|
if (preTip && (await this.#headContainedIn(preTip))) {
|
|
492
508
|
return { landed: false, reason: PUSH_REASONS.NOTHING };
|
|
493
509
|
}
|
|
494
510
|
|
|
495
|
-
// Ancestry guard again before the push (the
|
|
496
|
-
//
|
|
511
|
+
// Ancestry guard again before the push (the guard re-derives the
|
|
512
|
+
// empty-remote allowance per call, so it re-judges a failed first
|
|
513
|
+
// publication).
|
|
497
514
|
await this.#assertPublishable();
|
|
498
515
|
return this.#reconcileAndPush(message, paths, preTip, {
|
|
499
516
|
reapply,
|
|
@@ -504,13 +521,13 @@ export class WikiSync {
|
|
|
504
521
|
}
|
|
505
522
|
|
|
506
523
|
/**
|
|
507
|
-
* Reconcile on the remote and push
|
|
508
|
-
*
|
|
509
|
-
*
|
|
510
|
-
*
|
|
524
|
+
* Reconcile on the remote and push. Ground the outcome (D2/D3). Split from
|
|
525
|
+
* {@link commitAndPush} so the bounded ×1 retry (D3) re-enters the ancestry
|
|
526
|
+
* judgment and re-reconciles with no duplicate gates. On a `rejected`
|
|
527
|
+
* outcome it retries once. The retry re-asserts {@link #assertPublishable}
|
|
511
528
|
* (no auto-re-grant), refreshes the observed tip, and replays. The retry
|
|
512
|
-
* never re-pops a conflicted autostash
|
|
513
|
-
*
|
|
529
|
+
* never re-pops a conflicted autostash. The flow refuses a
|
|
530
|
+
* `residue-conflict`. It never retries one.
|
|
514
531
|
*
|
|
515
532
|
* @param {string} message
|
|
516
533
|
* @param {string[]} [paths] - The caller's pathspec (singleton-discipline scope).
|
|
@@ -522,16 +539,18 @@ export class WikiSync {
|
|
|
522
539
|
*/
|
|
523
540
|
async #reconcileAndPush(message, paths, preTip, opts) {
|
|
524
541
|
// Bounded retry (D3): at most one reconcile-and-retry on `rejected`. The
|
|
525
|
-
// first iteration uses `preTip
|
|
526
|
-
// (no auto-re-grant) and re-observes the tip before
|
|
527
|
-
// `conflict`, `residue-conflict`, and
|
|
542
|
+
// first iteration uses `preTip`. The retry re-asserts the ancestry
|
|
543
|
+
// judgment (no auto-re-grant) and re-observes the tip before the replay.
|
|
544
|
+
// The loop never retries `transport`, `conflict`, `residue-conflict`, and
|
|
545
|
+
// `conservation`.
|
|
528
546
|
let tip = preTip;
|
|
529
547
|
for (let attempt = 0; attempt <= 1; attempt++) {
|
|
530
548
|
const outcome = await this.#reconcileAttempt(message, paths, tip, opts);
|
|
531
549
|
if (outcome.result) return outcome.result;
|
|
532
|
-
// A non-landed grounded verdict.
|
|
533
|
-
// retries once, re-
|
|
534
|
-
//
|
|
550
|
+
// A non-landed grounded verdict. The loop never retries `transport`. It
|
|
551
|
+
// retries `rejected` once, and it re-enters the ancestry judgment first
|
|
552
|
+
// so it never auto-re-grants the empty-remote allowance. The loop never
|
|
553
|
+
// masks the outcome.
|
|
535
554
|
if (outcome.verdict.reason === PUSH_REASONS.TRANSPORT || attempt === 1) {
|
|
536
555
|
throw outcome.verdict.error;
|
|
537
556
|
}
|
|
@@ -545,10 +564,10 @@ export class WikiSync {
|
|
|
545
564
|
|
|
546
565
|
/**
|
|
547
566
|
* One reconcile-guards-push attempt. Returns `{ result }` for a terminal
|
|
548
|
-
* outcome (a landed/re-applied push, or a pre-push gate refusal)
|
|
549
|
-
* `{ verdict }`
|
|
550
|
-
* (it owns the retry decision). Throws for the unsafe-state refusals
|
|
551
|
-
* never
|
|
567
|
+
* outcome (a landed/re-applied push, or a pre-push gate refusal). Returns
|
|
568
|
+
* `{ verdict }` with a non-landed grounded verdict that the retry loop reads
|
|
569
|
+
* (it owns the retry decision). Throws for the unsafe-state refusals the
|
|
570
|
+
* loop never retries: `conflict`, `residue-conflict`, `conservation`.
|
|
552
571
|
* @param {string} message
|
|
553
572
|
* @param {string[]} [paths] - The caller's pathspec (singleton-discipline scope).
|
|
554
573
|
* @param {string} tip - The remote tip observed before this attempt's reconcile.
|
|
@@ -562,19 +581,20 @@ export class WikiSync {
|
|
|
562
581
|
});
|
|
563
582
|
|
|
564
583
|
// Rebase conflict (D2): a non-zero rebase exit is a conflict on the rebase
|
|
565
|
-
// itself. For a registered singleton with a `reapply` op the singleton
|
|
566
|
-
// discipline re-derives the row against the fresh tip
|
|
567
|
-
// fails loud (the `mergeOursStrategy` clobber fallback is removed
|
|
568
|
-
// merge-discipline fail-loud floor applies).
|
|
569
|
-
// because a stopped rebase also leaves UU markers.
|
|
584
|
+
// itself. For a registered singleton with a `reapply` op, the singleton
|
|
585
|
+
// merge discipline re-derives the row against the fresh tip. The no-intent
|
|
586
|
+
// path fails loud (the `mergeOursStrategy` clobber fallback is removed,
|
|
587
|
+
// and the merge-discipline fail-loud floor applies). This check runs
|
|
588
|
+
// before the residue read because a stopped rebase also leaves UU markers.
|
|
570
589
|
if (rebase.exitCode !== 0) {
|
|
571
590
|
const resolved = await this.#resolveRebaseConflict(message, paths, opts);
|
|
572
591
|
if (resolved) return { result: resolved };
|
|
573
592
|
}
|
|
574
593
|
|
|
575
|
-
// Residue check (D9): the rebase exited 0 but the autostash pop
|
|
576
|
-
//
|
|
577
|
-
// autostash site after the clobber
|
|
594
|
+
// Residue check (D9): the rebase exited 0 but the autostash pop
|
|
595
|
+
// conflicted and left unmerged paths. Tree state grounds this check. The
|
|
596
|
+
// pop is the sole conflict-capable autostash site after the clobber
|
|
597
|
+
// fallback's removal. The loop never retries this refusal (D3).
|
|
578
598
|
await this.#assertNoResidue();
|
|
579
599
|
|
|
580
600
|
// Guard 3 (hole 3 / Layer 2): refuse to push commits that introduce an
|
|
@@ -586,21 +606,21 @@ export class WikiSync {
|
|
|
586
606
|
// observed remote tip unless the removal is a deliberate act.
|
|
587
607
|
await this.#assertConserved(tip, message);
|
|
588
608
|
|
|
589
|
-
// Capture the pushed delta now
|
|
609
|
+
// Capture the pushed delta now. HEAD is the final (rebased) local tip and
|
|
590
610
|
// origin/master is still the pre-push base (the tier-1 integrity probe).
|
|
591
611
|
const pushedDelta = await this.#capturePushedDelta();
|
|
592
612
|
|
|
593
613
|
// Fail-closed secret gate. Scan exactly the commits this push introduces
|
|
594
|
-
// (the reconcile above made the range correct) before any remote contact
|
|
595
|
-
//
|
|
614
|
+
// (the reconcile above made the range correct) before any remote contact.
|
|
615
|
+
// A finding or an absent scanner refuses unless its own override is set.
|
|
596
616
|
const refusal = await this.#gateOrRefuse();
|
|
597
617
|
if (refusal) return { result: refusal };
|
|
598
618
|
|
|
599
619
|
// Budget re-validation gate (size axis). The reconcile above made HEAD the
|
|
600
|
-
// final outgoing tree (rebase / singleton re-apply re-derived its
|
|
601
|
-
//
|
|
602
|
-
// what publishes. A breach this push introduces or deepens throws
|
|
603
|
-
//
|
|
620
|
+
// final outgoing tree (rebase / singleton re-apply re-derived its content
|
|
621
|
+
// against the fresh tip). So a measurement of HEAD here measures exactly
|
|
622
|
+
// what publishes. A breach this push introduces or deepens throws
|
|
623
|
+
// `budget`. Surfaced-only memo-delivery breaches ride the landed result.
|
|
604
624
|
const gate = await this.#revalidateBudgets(opts.sessionBaseSha, {
|
|
605
625
|
exemptSummaryFiles: opts.exemptSummaryFiles ?? [],
|
|
606
626
|
});
|
|
@@ -610,14 +630,14 @@ export class WikiSync {
|
|
|
610
630
|
|
|
611
631
|
const verdict = await this.#groundedPush(fetched);
|
|
612
632
|
if (verdict.landed) {
|
|
613
|
-
// The push landed
|
|
614
|
-
// clear the intent sidecar
|
|
633
|
+
// The push landed. Any declared removal it carried is now published, so
|
|
634
|
+
// clear the intent sidecar. It must not leak into an unrelated push.
|
|
615
635
|
this.#clearIntentSidecar();
|
|
616
636
|
const detections = await this.#tier1Probe(pushedDelta);
|
|
617
637
|
const result = { landed: true, reason: PUSH_REASONS.LANDED, detections };
|
|
618
638
|
// Attach `surfaced` only when the gate surfaced a (memo-delivery exempt)
|
|
619
639
|
// breach, so a clean under-budget sync's landed result is byte-identical
|
|
620
|
-
// to today's
|
|
640
|
+
// to today's. The gate adds no happy-path behaviour change (criterion 10).
|
|
621
641
|
if (gate.surfaced.length > 0) result.surfaced = gate.surfaced;
|
|
622
642
|
return { result };
|
|
623
643
|
}
|
|
@@ -625,11 +645,12 @@ export class WikiSync {
|
|
|
625
645
|
}
|
|
626
646
|
|
|
627
647
|
/**
|
|
628
|
-
* Build the `budget` {@link WikiPushFailure} for a
|
|
629
|
-
*
|
|
630
|
-
* tuple so the operator can adjudicate (trim own content, or
|
|
631
|
-
* carried content to its owner). The gate never edits
|
|
632
|
-
* the commits local for re-push after the
|
|
648
|
+
* Build the `budget` {@link WikiPushFailure} for a gate result that refuses.
|
|
649
|
+
* The failure names the worst offending file. It carries every refusal and
|
|
650
|
+
* surfaced tuple, so the operator can adjudicate (trim own content, or
|
|
651
|
+
* surface the carried content to its owner). The gate never edits. It
|
|
652
|
+
* refuses and keeps the commits local for re-push after the operator
|
|
653
|
+
* resolves the breach.
|
|
633
654
|
* @param {{refusals: Array<object>, surfaced: Array<object>}} gate
|
|
634
655
|
* @returns {WikiPushFailure}
|
|
635
656
|
*/
|
|
@@ -638,19 +659,19 @@ export class WikiSync {
|
|
|
638
659
|
const files = [...new Set(gate.refusals.map((r) => r.file))].join(", ");
|
|
639
660
|
return new WikiPushFailure(
|
|
640
661
|
PUSH_REASONS.BUDGET,
|
|
641
|
-
"
|
|
662
|
+
"gemba-wiki: refusing to push — it would introduce or deepen a budget " +
|
|
642
663
|
`breach in ${files} (${lead.ruleId}: ${lead.value} vs baseline ` +
|
|
643
664
|
`${lead.baseline}). Trim your own content or surface the carried ` +
|
|
644
|
-
"content to its owner, then re-push
|
|
665
|
+
"content to its owner, then re-push. Your work is committed locally.",
|
|
645
666
|
{ refusals: gate.refusals, surfaced: gate.surfaced },
|
|
646
667
|
);
|
|
647
668
|
}
|
|
648
669
|
|
|
649
670
|
/**
|
|
650
|
-
* Run the budget gate over the outgoing tree
|
|
651
|
-
* against the pre-fetch session base and the landed origin tip. Delegates
|
|
652
|
-
* measurement, the unreadable-ref fail-visible posture, and the delta to
|
|
653
|
-
* {@link runBudgetGate}
|
|
671
|
+
* Run the budget gate over the outgoing tree. Measure the committed `HEAD`
|
|
672
|
+
* against the pre-fetch session base and the landed origin tip. Delegates
|
|
673
|
+
* the measurement, the unreadable-ref fail-visible posture, and the delta to
|
|
674
|
+
* {@link runBudgetGate}. This method binds only the clone's git/fs/clock.
|
|
654
675
|
* @param {string} sessionBaseSha - origin/master before fetch, or "" when unborn.
|
|
655
676
|
* @param {{exemptSummaryFiles: string[]}} options
|
|
656
677
|
* @returns {Promise<{refusals: Array<object>, surfaced: Array<object>}>}
|
|
@@ -670,9 +691,10 @@ export class WikiSync {
|
|
|
670
691
|
}
|
|
671
692
|
|
|
672
693
|
/**
|
|
673
|
-
* Refuse (`residue-conflict`) when the reconcile left unmerged paths
|
|
674
|
-
* autostash pop conflicted under an exit-0 rebase (D9). The stash
|
|
675
|
-
* intact (git already kept it) and
|
|
694
|
+
* Refuse (`residue-conflict`) when the reconcile left unmerged paths. The
|
|
695
|
+
* autostash pop conflicted under an exit-0 rebase (D9). The stash stays
|
|
696
|
+
* intact (git already kept it), and the message names it by SHA for
|
|
697
|
+
* recovery.
|
|
676
698
|
* @throws {WikiPushFailure} `residue-conflict` when the tree carries UU paths.
|
|
677
699
|
*/
|
|
678
700
|
async #assertNoResidue() {
|
|
@@ -682,8 +704,8 @@ export class WikiSync {
|
|
|
682
704
|
});
|
|
683
705
|
throw new WikiPushFailure(
|
|
684
706
|
PUSH_REASONS.RESIDUE_CONFLICT,
|
|
685
|
-
"
|
|
686
|
-
"on the autostash pop
|
|
707
|
+
"gemba-wiki: refusing to push — a foreign writer's residue conflicted " +
|
|
708
|
+
"on the autostash pop. Your stash is preserved at " +
|
|
687
709
|
`${stashSha || "refs/stash"} (git stash list). Resolve or pop it ` +
|
|
688
710
|
"from the true tip.",
|
|
689
711
|
{ stashSha: stashSha || undefined },
|
|
@@ -692,9 +714,9 @@ export class WikiSync {
|
|
|
692
714
|
|
|
693
715
|
/**
|
|
694
716
|
* Capture the `origin/master..HEAD` delta for the post-push tier-1 probe. A
|
|
695
|
-
* two-tree range diff
|
|
696
|
-
*
|
|
697
|
-
* probe never gates the push it follows.
|
|
717
|
+
* two-tree range diff is correct even when HEAD is a merge commit. A
|
|
718
|
+
* single-commit show is not. This capture is detection-only. A capture
|
|
719
|
+
* failure degrades to `null`, so the probe never gates the push it follows.
|
|
698
720
|
* @returns {Promise<string|null>}
|
|
699
721
|
*/
|
|
700
722
|
async #capturePushedDelta() {
|
|
@@ -708,14 +730,14 @@ export class WikiSync {
|
|
|
708
730
|
}
|
|
709
731
|
|
|
710
732
|
/**
|
|
711
|
-
* Resolve a failed rebase against the fresh tip. Aborts the rebase,
|
|
712
|
-
* registered singleton with a `reapply` op re-derives the row against
|
|
713
|
-
*
|
|
714
|
-
* registered `reapply` the conflict fails loud
|
|
715
|
-
* is **removed** (the merge-discipline fail-loud
|
|
716
|
-
*
|
|
717
|
-
*
|
|
718
|
-
* loses no uncommitted work.
|
|
733
|
+
* Resolve a failed rebase against the fresh tip. Aborts the rebase. Then,
|
|
734
|
+
* for a registered singleton with a `reapply` op, re-derives the row against
|
|
735
|
+
* the tip through the bounded re-apply loop (the singleton merge
|
|
736
|
+
* discipline). Without a registered `reapply` the conflict fails loud. The
|
|
737
|
+
* `-X ours` clobber fallback is **removed** (the merge-discipline fail-loud
|
|
738
|
+
* floor), so the method never mechanically discards the remote side. The
|
|
739
|
+
* rebase is already aborted, and the working tree stays at `orig_head` with
|
|
740
|
+
* the autostash re-applied. So a `conflict` throw loses no uncommitted work.
|
|
719
741
|
* @param {string} message - The commit message.
|
|
720
742
|
* @param {string[]} [paths] - Pathspecs committed.
|
|
721
743
|
* @param {{reapply?: (freshText: string) => string | null, maxReapply: number}} options
|
|
@@ -736,20 +758,21 @@ export class WikiSync {
|
|
|
736
758
|
// No-intent path: fail loud rather than discard the remote side (D2).
|
|
737
759
|
throw new WikiPushFailure(
|
|
738
760
|
PUSH_REASONS.CONFLICT,
|
|
739
|
-
"
|
|
740
|
-
"Resolve or retry from the true tip (
|
|
761
|
+
"gemba-wiki: refusing to push — rebase conflict with the remote. " +
|
|
762
|
+
"Resolve or retry from the true tip (gemba-wiki pull, then push).",
|
|
741
763
|
);
|
|
742
764
|
}
|
|
743
765
|
|
|
744
766
|
/**
|
|
745
767
|
* Scan the content introduced by `origin/master..HEAD` for unresolved
|
|
746
768
|
* conflict-marker blocks (Guard 3). Runs after the fetch + rebase/merge
|
|
747
|
-
* resolve, so the diff is against the freshly-fetched origin tip
|
|
748
|
-
* origin corruption is on the base side
|
|
749
|
-
*
|
|
750
|
-
* shallow clone) refuses with a
|
|
751
|
-
*
|
|
752
|
-
*
|
|
769
|
+
* resolve, so the diff is against the freshly-fetched origin tip.
|
|
770
|
+
* Pre-existing origin corruption is on the base side. It is never on the
|
|
771
|
+
* added side, so the guard does not block an unrelated writer's push. A
|
|
772
|
+
* throw from the scan (unresolvable ref on a shallow clone) refuses with a
|
|
773
|
+
* reason. It never passes silently.
|
|
774
|
+
* @returns {Promise<{pushed: false, reason: string}|null>} A refusal result,
|
|
775
|
+
* or null when nothing introduced would publish a marker.
|
|
753
776
|
*/
|
|
754
777
|
async #refuseIfIntroducedMarkers() {
|
|
755
778
|
let introduced;
|
|
@@ -772,10 +795,11 @@ export class WikiSync {
|
|
|
772
795
|
}
|
|
773
796
|
|
|
774
797
|
/**
|
|
775
|
-
* Tier-1 post-push integrity probe: re-fetch the origin tip and verify
|
|
776
|
-
* just-pushed delta
|
|
777
|
-
*
|
|
778
|
-
*
|
|
798
|
+
* Tier-1 post-push integrity probe: re-fetch the origin tip and verify that
|
|
799
|
+
* the just-pushed delta is still content-present. That delta is the full
|
|
800
|
+
* delta, and it covers shared surfaces. The probe returns detections for any
|
|
801
|
+
* absence. It only reads. Any error degrades to no detections, so the probe
|
|
802
|
+
* never gates the push.
|
|
779
803
|
* @param {string|null} pushedDelta - `diffRange` text of the pushed delta.
|
|
780
804
|
* @returns {Promise<object[]>}
|
|
781
805
|
*/
|
|
@@ -814,13 +838,13 @@ export class WikiSync {
|
|
|
814
838
|
|
|
815
839
|
/**
|
|
816
840
|
* Re-apply a registered singleton operation against the fresh remote tip,
|
|
817
|
-
* bounded by `maxReapply` rounds. The caller
|
|
841
|
+
* bounded by `maxReapply` rounds. The caller already aborted the rebase.
|
|
818
842
|
* Each round: refresh the tip, drop the stale local commit (`resetSoft`,
|
|
819
843
|
* working tree untouched so foreign residue survives), reset only the
|
|
820
|
-
* registered file to the tip (`checkoutPaths`,
|
|
821
|
-
* it), re-derive
|
|
822
|
-
*
|
|
823
|
-
* unchanged op is already satisfied
|
|
844
|
+
* registered file to the tip (`checkoutPaths`, which tolerates a tip that
|
|
845
|
+
* lacks it), re-derive through `reapply`, and re-commit and push when the op
|
|
846
|
+
* still changes the tip. A rejected push (the tip moved again) loops. An
|
|
847
|
+
* unchanged op is already satisfied. Bound exhaustion throws.
|
|
824
848
|
*/
|
|
825
849
|
async #reapplyLoop(message, paths, reapply, maxReapply) {
|
|
826
850
|
const filePath = path.join(this.#wikiDir, paths[0]);
|
|
@@ -828,9 +852,9 @@ export class WikiSync {
|
|
|
828
852
|
await this.fetch();
|
|
829
853
|
await this.#git.resetSoft("origin/master", { cwd: this.#wikiDir });
|
|
830
854
|
// Reset only the registered file to the tip. `resetSoft` leaves the
|
|
831
|
-
// working tree
|
|
832
|
-
//
|
|
833
|
-
//
|
|
855
|
+
// working tree, so the dropped commit's copy of the file may linger. A
|
|
856
|
+
// non-zero checkout means the tip lacks the file (a founding write). The
|
|
857
|
+
// fresh base is then empty. It is not the local copy that lingered.
|
|
834
858
|
const checkout = await this.#git.checkoutPaths("origin/master", paths, {
|
|
835
859
|
cwd: this.#wikiDir,
|
|
836
860
|
allowMissing: true,
|
|
@@ -842,7 +866,7 @@ export class WikiSync {
|
|
|
842
866
|
: "";
|
|
843
867
|
const newText = reapply(freshText);
|
|
844
868
|
if (newText === null) {
|
|
845
|
-
// The op is already satisfied on the tip
|
|
869
|
+
// The op is already satisfied on the tip. HEAD now equals the tip.
|
|
846
870
|
return { pushed: false, reason: "already-satisfied" };
|
|
847
871
|
}
|
|
848
872
|
this.#runtime.fsSync.writeFileSync(filePath, newText);
|
|
@@ -851,10 +875,10 @@ export class WikiSync {
|
|
|
851
875
|
await this.#authed().push("origin", "master", { cwd: this.#wikiDir });
|
|
852
876
|
return { pushed: true, reason: "reapplied" };
|
|
853
877
|
} catch (err) {
|
|
854
|
-
// Only a rejected push (the tip moved again) is a retry signal. An
|
|
855
|
-
// or network failure is not contention
|
|
856
|
-
// degrades to "saved locally"
|
|
857
|
-
//
|
|
878
|
+
// Only a rejected push (the tip moved again) is a retry signal. An
|
|
879
|
+
// auth or network failure is not contention. Rethrow it so the caller
|
|
880
|
+
// degrades to "saved locally". The loop must not burn the budget and
|
|
881
|
+
// misreport a conflict that never happened.
|
|
858
882
|
if (!isPushRejection(err)) throw err;
|
|
859
883
|
}
|
|
860
884
|
}
|
|
@@ -866,7 +890,7 @@ export class WikiSync {
|
|
|
866
890
|
* Returns a refusal envelope to short-circuit the push, or `null` to proceed
|
|
867
891
|
* (clean, or an override that wrote its audit record). An override appends a
|
|
868
892
|
* secret-free line to `secret-overrides.log` and commits it into the push
|
|
869
|
-
* range before
|
|
893
|
+
* range before the method returns `null`.
|
|
870
894
|
*
|
|
871
895
|
* @returns {Promise<{pushed: false, reason: "secret-detected"|"scanner-unavailable", findings?: Array<{file: string, line: number, rule: string}>}|null>}
|
|
872
896
|
*/
|
|
@@ -911,20 +935,19 @@ export class WikiSync {
|
|
|
911
935
|
}
|
|
912
936
|
|
|
913
937
|
/**
|
|
914
|
-
* Refuse before
|
|
915
|
-
*
|
|
916
|
-
*
|
|
917
|
-
*
|
|
918
|
-
* the
|
|
919
|
-
*
|
|
920
|
-
* cover.
|
|
938
|
+
* Refuse before any mutation when a rebase is mid-flight (D7). The ancestry
|
|
939
|
+
* guard ({@link #assertPublishable}) takes the other D7 fixture, a detached
|
|
940
|
+
* HEAD. There it surfaces as an `AncestryRefusal` ("unverifiable"). The two
|
|
941
|
+
* refusals collapse to one observable refusal, and the ancestry guard names
|
|
942
|
+
* the reason for that fixture. This guard owns only the rebase-in-progress
|
|
943
|
+
* residual, which the ancestry guard does not cover.
|
|
921
944
|
*/
|
|
922
945
|
async #assertPreconditions() {
|
|
923
946
|
if (this.#rebaseInProgress()) {
|
|
924
947
|
throw new WikiPushFailure(
|
|
925
948
|
PUSH_REASONS.PRECONDITION,
|
|
926
|
-
"
|
|
927
|
-
"abort it before
|
|
949
|
+
"gemba-wiki: refusing to act — a rebase is in progress. Resolve or " +
|
|
950
|
+
"abort it before you retry. Your uncommitted edit is preserved.",
|
|
928
951
|
);
|
|
929
952
|
}
|
|
930
953
|
}
|
|
@@ -949,12 +972,12 @@ export class WikiSync {
|
|
|
949
972
|
}
|
|
950
973
|
}
|
|
951
974
|
|
|
952
|
-
/** Whether
|
|
975
|
+
/** Whether `tip` contains HEAD (grounded nothing-to-push). */
|
|
953
976
|
async #headContainedIn(tip) {
|
|
954
977
|
return this.#git.isAncestor("HEAD", tip, { cwd: this.#wikiDir });
|
|
955
978
|
}
|
|
956
979
|
|
|
957
|
-
/** Fetch
|
|
980
|
+
/** Fetch and return whether it succeeded (feeds the rejected-vs-transport split). */
|
|
958
981
|
async #fetchObserved() {
|
|
959
982
|
try {
|
|
960
983
|
await this.#authed().fetch(REMOTE, BRANCH, { cwd: this.#wikiDir });
|
|
@@ -973,16 +996,17 @@ export class WikiSync {
|
|
|
973
996
|
}
|
|
974
997
|
|
|
975
998
|
/**
|
|
976
|
-
* The paths dirty in the working tree
|
|
977
|
-
*
|
|
978
|
-
* for a rename, `XY <orig> -> <new
|
|
979
|
-
* in the tree and
|
|
980
|
-
*
|
|
981
|
-
* space or non-ASCII byte)
|
|
982
|
-
* working-tree entry. Under the canonical
|
|
983
|
-
* set holds no foreign content
|
|
984
|
-
*
|
|
985
|
-
*
|
|
999
|
+
* The paths dirty in the working tree. This is the session's own write-set
|
|
1000
|
+
* when the caller supplied no explicit pathspec. Each porcelain v1 line is
|
|
1001
|
+
* `XY <path>` or, for a rename, `XY <orig> -> <new>`. The destination is the
|
|
1002
|
+
* path that exists in the tree, and the method emits that one. A `git mv`
|
|
1003
|
+
* source no longer exists, so a pathspec that named it would fault. The
|
|
1004
|
+
* method unquotes a `"`-quoted path (a name with a space or non-ASCII byte),
|
|
1005
|
+
* so the pathspec matches the working-tree entry. Under the canonical
|
|
1006
|
+
* per-session isolated checkout this set holds no foreign content. So a
|
|
1007
|
+
* commit of exactly this set stages the session's own work and nothing else.
|
|
1008
|
+
* Wiki session writes are edits and appends. They are not renames, so the
|
|
1009
|
+
* destination-only scope covers the real workload.
|
|
986
1010
|
*/
|
|
987
1011
|
async #dirtyPaths() {
|
|
988
1012
|
const r = await this.#git.status({ cwd: this.#wikiDir });
|
|
@@ -998,15 +1022,15 @@ export class WikiSync {
|
|
|
998
1022
|
}
|
|
999
1023
|
|
|
1000
1024
|
/**
|
|
1001
|
-
* Refuse (`conservation`) when the would-be-pushed tree drops foreign
|
|
1002
|
-
* present at the observed remote tip, unless a deliberate removal
|
|
1003
|
-
* (D5). After a clean rebase HEAD descends from the remote tip
|
|
1004
|
-
* tip-first diff (`D`/`M`) is exactly the net effect of the pushed
|
|
1005
|
-
*
|
|
1006
|
-
* authored changes,
|
|
1007
|
-
* transition (passes)
|
|
1008
|
-
* (refuses). Row identity is the line's
|
|
1009
|
-
* written over a foreign row keeps the row key and passes.
|
|
1025
|
+
* Refuse (`conservation`) when the would-be-pushed tree drops foreign
|
|
1026
|
+
* content present at the observed remote tip, unless a deliberate removal
|
|
1027
|
+
* carries it (D5). After a clean rebase HEAD descends from the remote tip.
|
|
1028
|
+
* So the tip-first diff (`D`/`M`) is exactly the net effect of the pushed
|
|
1029
|
+
* history. A `D` is a foreign file deleted. An `M` carries the pushed
|
|
1030
|
+
* history's authored changes. There, a row rewritten to a new state is an
|
|
1031
|
+
* authored transition (passes), and a row removed without replacement is a
|
|
1032
|
+
* drop (refuses). Row identity is the line's first field, so a
|
|
1033
|
+
* `plan approved` written over a foreign row keeps the row key and passes.
|
|
1010
1034
|
*
|
|
1011
1035
|
* @param {string} remoteTip - The observed remote tip SHA.
|
|
1012
1036
|
* @param {string} message - The pushed commit message (carries release intent).
|
|
@@ -1019,12 +1043,13 @@ export class WikiSync {
|
|
|
1019
1043
|
const status = await this.#git.diffNameStatus(remoteTip, "HEAD", {
|
|
1020
1044
|
cwd: this.#wikiDir,
|
|
1021
1045
|
});
|
|
1022
|
-
// When HEAD does not descend from the observed remote tip, the
|
|
1023
|
-
//
|
|
1024
|
-
//
|
|
1025
|
-
//
|
|
1026
|
-
//
|
|
1027
|
-
//
|
|
1046
|
+
// When HEAD does not descend from the observed remote tip, the writer
|
|
1047
|
+
// built the pushed history from a stale base. That history never saw the
|
|
1048
|
+
// remote's advance. So a row that keeps its key but carries a value
|
|
1049
|
+
// different from the remote is a stale revert. The pushed history holds
|
|
1050
|
+
// no authored transition to the restored state. It is not a transition
|
|
1051
|
+
// that propagates an approval. Only a HEAD that descends from the remote
|
|
1052
|
+
// tip can author a transition over it.
|
|
1028
1053
|
const headAuthoredOverRemote = await this.#git.isAncestor(
|
|
1029
1054
|
remoteTip,
|
|
1030
1055
|
"HEAD",
|
|
@@ -1061,7 +1086,7 @@ export class WikiSync {
|
|
|
1061
1086
|
this.#reportConservation("refusal");
|
|
1062
1087
|
throw new WikiPushFailure(
|
|
1063
1088
|
PUSH_REASONS.CONSERVATION,
|
|
1064
|
-
"
|
|
1089
|
+
"gemba-wiki: refusing to push — it would drop another writer's " +
|
|
1065
1090
|
`content in ${file} that is present on the remote. Pull and ` +
|
|
1066
1091
|
"re-apply, or declare the removal if it is deliberate.",
|
|
1067
1092
|
);
|
|
@@ -1071,20 +1096,21 @@ export class WikiSync {
|
|
|
1071
1096
|
|
|
1072
1097
|
/**
|
|
1073
1098
|
* Whether the pushed tree drops foreign content present at the remote tip.
|
|
1074
|
-
* A whole-file deletion drops it. Otherwise a remote line
|
|
1075
|
-
* when neither it **nor a line
|
|
1076
|
-
*
|
|
1077
|
-
*
|
|
1078
|
-
* never trip this because they remove no remote line.
|
|
1099
|
+
* A whole-file deletion drops it. Otherwise the tree drops a remote line
|
|
1100
|
+
* only when neither it **nor a line that shares its identity key** survives
|
|
1101
|
+
* in HEAD. So the tree conserves a row rewritten to a new state (an authored
|
|
1102
|
+
* transition), and a row removed outright is a drop. The pusher's own
|
|
1103
|
+
* additive edits never trip this because they remove no remote line.
|
|
1079
1104
|
*
|
|
1080
|
-
* A
|
|
1081
|
-
* the pushed history descends from the remote tip**
|
|
1082
|
-
* When it does not
|
|
1083
|
-
*
|
|
1084
|
-
*
|
|
1105
|
+
* A key that survives with a changed value is an authored transition **only
|
|
1106
|
+
* when the pushed history descends from the remote tip**
|
|
1107
|
+
* (`headAuthoredOverRemote`). When it does not, the commit has a stale base
|
|
1108
|
+
* and never saw the remote's advance. The changed value then restores a
|
|
1109
|
+
* superseded state with no commit that authored it. So it is a stale revert
|
|
1110
|
+
* and counts as a drop (it erases the foreign advance).
|
|
1085
1111
|
*
|
|
1086
|
-
* `showFile` returns `null` for an absent blob
|
|
1087
|
-
* file is gone at that ref.
|
|
1112
|
+
* `showFile` returns `null` for an absent blob. Both `null` and `""` mean
|
|
1113
|
+
* the file is gone at that ref.
|
|
1088
1114
|
*/
|
|
1089
1115
|
#dropsForeignContent(remoteContent, headContent, headAuthoredOverRemote) {
|
|
1090
1116
|
if (remoteContent == null || remoteContent === "") return false;
|
|
@@ -1097,29 +1123,31 @@ export class WikiSync {
|
|
|
1097
1123
|
if (headSet.has(line)) return false; // exact line survives
|
|
1098
1124
|
const key = this.#rowKey(line);
|
|
1099
1125
|
if (key === null) {
|
|
1100
|
-
// Unkeyed prose absent from HEAD. When the pushed history descends
|
|
1101
|
-
// the remote tip, the pusher saw this line and authored its edit
|
|
1102
|
-
// legitimate prose change
|
|
1103
|
-
// that never saw the line (a side-pick /
|
|
1126
|
+
// Unkeyed prose absent from HEAD. When the pushed history descends
|
|
1127
|
+
// from the remote tip, the pusher saw this line and authored its edit.
|
|
1128
|
+
// That is a legitimate prose change. It is not a foreign drop. Only a
|
|
1129
|
+
// stale-base commit that never saw the line (a side-pick /
|
|
1130
|
+
// clean-replay erasure) drops it.
|
|
1104
1131
|
return !headAuthoredOverRemote;
|
|
1105
1132
|
}
|
|
1106
1133
|
if (!headKeys.has(key)) return true; // key gone outright ⇒ drop
|
|
1107
|
-
//
|
|
1108
|
-
//
|
|
1134
|
+
// The key survives with a changed value. This is an authored transition
|
|
1135
|
+
// only if the writer built the pushed history over the remote tip.
|
|
1136
|
+
// Otherwise it is a stale revert.
|
|
1109
1137
|
return !headAuthoredOverRemote;
|
|
1110
1138
|
});
|
|
1111
1139
|
}
|
|
1112
1140
|
|
|
1113
1141
|
/**
|
|
1114
|
-
* The identity key of a structured row
|
|
1115
|
-
* (same row, new state) from a drop (row gone). For a
|
|
1116
|
-
* key is the **first two cells
|
|
1117
|
-
*
|
|
1118
|
-
* rows)
|
|
1119
|
-
* a transition. For a tab-delimited
|
|
1120
|
-
* `id<TAB>phase<TAB>status`) the key is the first
|
|
1121
|
-
* are the state that transitions. Unstructured
|
|
1122
|
-
* (`null`)
|
|
1142
|
+
* The identity key of a structured row. The caller uses it to tell an
|
|
1143
|
+
* authored transition (same row, new state) from a drop (row gone). For a
|
|
1144
|
+
* Markdown table row the key is the **first two cells**. The canonical
|
|
1145
|
+
* Active Claims table has the key `(agent, target)`, and `agent` alone is
|
|
1146
|
+
* non-unique (one agent holds many rows). So a single-cell key would let a
|
|
1147
|
+
* real foreign-row drop masquerade as a transition. For a tab-delimited
|
|
1148
|
+
* ledger row (e.g. STATUS `id<TAB>phase<TAB>status`) the key is the first
|
|
1149
|
+
* field, whose later fields are the state that transitions. Unstructured
|
|
1150
|
+
* prose has no stable key (`null`). An exact-line match alone conserves it.
|
|
1123
1151
|
*/
|
|
1124
1152
|
#rowKey(line) {
|
|
1125
1153
|
const trimmed = line.trim();
|
|
@@ -1139,13 +1167,13 @@ export class WikiSync {
|
|
|
1139
1167
|
/** Whether the removal of `file` is declared deliberate (release/expiry/sidecar). */
|
|
1140
1168
|
#removalDeclared(file, message, sidecar, headAuthoredOverRemote) {
|
|
1141
1169
|
// A claim release/expiry records the deliberate act in the commit message,
|
|
1142
|
-
// and a claim/release commit is pathspec-scoped to MEMORY.md
|
|
1143
|
-
// exemption
|
|
1144
|
-
// message exemption
|
|
1145
|
-
//
|
|
1146
|
-
//
|
|
1147
|
-
// it must not blanket-exempt that collateral live-row drop (D5
|
|
1148
|
-
// deliberate act is the released row
|
|
1170
|
+
// and a claim/release commit is pathspec-scoped to MEMORY.md. So the
|
|
1171
|
+
// exemption covers that file only. It never covers a whole-tree trim. The
|
|
1172
|
+
// blanket message exemption holds only when HEAD descends from the remote
|
|
1173
|
+
// tip. A release authored over current state drops exactly the row it
|
|
1174
|
+
// released. A stale-base release never saw a foreign row another writer
|
|
1175
|
+
// added, so it must not blanket-exempt that collateral live-row drop (D5).
|
|
1176
|
+
// The deliberate act is the released row. It is not a file-level pass.
|
|
1149
1177
|
if (
|
|
1150
1178
|
headAuthoredOverRemote &&
|
|
1151
1179
|
/^wiki: release\b/.test(message) &&
|
|
@@ -1159,10 +1187,10 @@ export class WikiSync {
|
|
|
1159
1187
|
|
|
1160
1188
|
/**
|
|
1161
1189
|
* Declare that the next push deliberately removes foreign content in `paths`
|
|
1162
|
-
* (the cross-lane budget-trim shape, D5).
|
|
1163
|
-
* clone-locally so it survives a stranded-push retry from the
|
|
1164
|
-
*
|
|
1165
|
-
* unrelated later push
|
|
1190
|
+
* (the cross-lane budget-trim shape, D5). This method records the
|
|
1191
|
+
* declaration clone-locally, so it survives a stranded-push retry from the
|
|
1192
|
+
* same clone. Only a landed push clears it, so the declaration never leaks
|
|
1193
|
+
* into an unrelated later push.
|
|
1166
1194
|
* @param {string[]} paths - Files whose foreign-content removal is deliberate.
|
|
1167
1195
|
*/
|
|
1168
1196
|
declareRemoval(paths) {
|
|
@@ -1176,7 +1204,7 @@ export class WikiSync {
|
|
|
1176
1204
|
}
|
|
1177
1205
|
|
|
1178
1206
|
#sidecarPath() {
|
|
1179
|
-
return path.join(this.#wikiDir, ".git", "
|
|
1207
|
+
return path.join(this.#wikiDir, ".git", "gemba-wiki-removal-intent");
|
|
1180
1208
|
}
|
|
1181
1209
|
|
|
1182
1210
|
/** Read the clone-local removal-intent sidecar (declared deliberate removals). */
|
|
@@ -1204,12 +1232,13 @@ export class WikiSync {
|
|
|
1204
1232
|
}
|
|
1205
1233
|
|
|
1206
1234
|
/**
|
|
1207
|
-
* Push once and classify the outcome
|
|
1208
|
-
* remote-originated per-ref report or a post-push remote-tip read.
|
|
1209
|
-
* verdict the retry loop reads
|
|
1210
|
-
* throw on a terminal non-land so the loop owns
|
|
1211
|
-
* non-landed push is `rejected` when the fetch
|
|
1212
|
-
* failed or the push itself
|
|
1235
|
+
* Push once and classify the outcome. Ground *landed* in the
|
|
1236
|
+
* remote-originated per-ref report or in a post-push remote-tip read.
|
|
1237
|
+
* Returns a verdict the retry loop reads. The verdict carries the
|
|
1238
|
+
* {@link WikiPushFailure} to throw on a terminal non-land, so the loop owns
|
|
1239
|
+
* the retry decision. A non-landed push is `rejected` when the fetch
|
|
1240
|
+
* succeeded. It is `transport` when the fetch failed or the push itself
|
|
1241
|
+
* raised a transport error.
|
|
1213
1242
|
* @param {boolean} fetched - Whether the pre-push fetch observed the remote.
|
|
1214
1243
|
* @returns {Promise<{landed: boolean, reason: string, error?: WikiPushFailure}>}
|
|
1215
1244
|
*/
|
|
@@ -1226,8 +1255,8 @@ export class WikiSync {
|
|
|
1226
1255
|
reason: PUSH_REASONS.TRANSPORT,
|
|
1227
1256
|
error: new WikiPushFailure(
|
|
1228
1257
|
PUSH_REASONS.TRANSPORT,
|
|
1229
|
-
"
|
|
1230
|
-
"Your work is committed locally
|
|
1258
|
+
"gemba-wiki: push failed at transport (network or credentials). " +
|
|
1259
|
+
"Your work is committed locally. Retry when connectivity returns.",
|
|
1231
1260
|
),
|
|
1232
1261
|
};
|
|
1233
1262
|
}
|
|
@@ -1240,7 +1269,7 @@ export class WikiSync {
|
|
|
1240
1269
|
reason: PUSH_REASONS.TRANSPORT,
|
|
1241
1270
|
error: new WikiPushFailure(
|
|
1242
1271
|
PUSH_REASONS.TRANSPORT,
|
|
1243
|
-
"
|
|
1272
|
+
"gemba-wiki: push did not land and the remote could not be observed " +
|
|
1244
1273
|
"(network or credentials). Your work is committed locally.",
|
|
1245
1274
|
),
|
|
1246
1275
|
};
|
|
@@ -1250,17 +1279,17 @@ export class WikiSync {
|
|
|
1250
1279
|
reason: PUSH_REASONS.REJECTED,
|
|
1251
1280
|
error: new WikiPushFailure(
|
|
1252
1281
|
PUSH_REASONS.REJECTED,
|
|
1253
|
-
"
|
|
1254
|
-
"tip (
|
|
1282
|
+
"gemba-wiki: push rejected — the remote advanced. Rerun from the true " +
|
|
1283
|
+
"tip (gemba-wiki pull, then push).",
|
|
1255
1284
|
),
|
|
1256
1285
|
};
|
|
1257
1286
|
}
|
|
1258
1287
|
|
|
1259
1288
|
/**
|
|
1260
|
-
* Whether the push landed
|
|
1261
|
-
* `--porcelain` report for `refs/heads/master` (flag
|
|
1262
|
-
* rejected)
|
|
1263
|
-
*
|
|
1289
|
+
* Whether the push landed. Observed remote state grounds the answer. That
|
|
1290
|
+
* state is the per-ref `--porcelain` report for `refs/heads/master` (flag
|
|
1291
|
+
* ` `/`=` accepted, `!` rejected). The method falls back to a post-push
|
|
1292
|
+
* remote-tip read when it cannot parse the report.
|
|
1264
1293
|
*/
|
|
1265
1294
|
async #pushLanded(result) {
|
|
1266
1295
|
const verdict = this.#parsePorcelain(result.stdout);
|
|
@@ -1293,29 +1322,31 @@ export class WikiSync {
|
|
|
1293
1322
|
}
|
|
1294
1323
|
|
|
1295
1324
|
/**
|
|
1296
|
-
* Refuse
|
|
1297
|
-
*
|
|
1298
|
-
*
|
|
1299
|
-
* the ancestry decision table
|
|
1300
|
-
*
|
|
1301
|
-
*
|
|
1302
|
-
* the
|
|
1325
|
+
* Refuse before any commit or push. Refuse whenever the guard can neither
|
|
1326
|
+
* confirm nor refute the relationship between the remote branch and the
|
|
1327
|
+
* history the push would publish. That history is the `master` branch ref,
|
|
1328
|
+
* never bare HEAD. Implements the ancestry decision table. Throws
|
|
1329
|
+
* {@link AncestryRefusal} on refusal. Returns silently when the guard
|
|
1330
|
+
* verifies the publication or finds the remote positively empty. The
|
|
1331
|
+
* emptiness probe runs only on the absent-tracking-ref path, so the healthy
|
|
1332
|
+
* hot path adds no remote round-trip.
|
|
1303
1333
|
*/
|
|
1304
1334
|
async #assertPublishable() {
|
|
1305
1335
|
const cwd = this.#wikiDir;
|
|
1306
1336
|
|
|
1307
|
-
// 1. Detached HEAD: the push publishes the branch ref
|
|
1308
|
-
// session
|
|
1337
|
+
// 1. Detached HEAD: the push publishes the branch ref. It does not publish
|
|
1338
|
+
// HEAD, so the session would silently lose its commits. Verify nothing.
|
|
1339
|
+
// Refuse.
|
|
1309
1340
|
if ((await this.#git.headBranch({ cwd })) !== BRANCH) {
|
|
1310
1341
|
throw new AncestryRefusal(
|
|
1311
1342
|
"unverifiable",
|
|
1312
|
-
"
|
|
1313
|
-
"
|
|
1343
|
+
"gemba-wiki: refusing to publish — HEAD is detached, so the push would " +
|
|
1344
|
+
"send the configured branch instead of your work. Re-clone the wiki.",
|
|
1314
1345
|
);
|
|
1315
1346
|
}
|
|
1316
1347
|
|
|
1317
1348
|
// 2. Establish whether the remote branch is present. A resolvable local
|
|
1318
|
-
// remote-tracking ref is sufficient
|
|
1349
|
+
// remote-tracking ref is sufficient. Otherwise probe the remote (the
|
|
1319
1350
|
// only added round-trip, and only here).
|
|
1320
1351
|
const branchPresent = await this.#git.refExists(REMOTE_BRANCH, { cwd });
|
|
1321
1352
|
if (!branchPresent) {
|
|
@@ -1325,15 +1356,16 @@ export class WikiSync {
|
|
|
1325
1356
|
} catch {
|
|
1326
1357
|
throw new AncestryRefusal(
|
|
1327
1358
|
"unverifiable",
|
|
1328
|
-
"
|
|
1329
|
-
"verify ancestry
|
|
1359
|
+
"gemba-wiki: refusing to publish — could not observe the remote to " +
|
|
1360
|
+
"verify ancestry. The local change is not published.",
|
|
1330
1361
|
);
|
|
1331
1362
|
}
|
|
1332
1363
|
// Positive evidence the remote branch is absent ⇒ empty-new-wiki.
|
|
1333
1364
|
if (!observed) return;
|
|
1334
|
-
//
|
|
1335
|
-
// tracking ref so the unborn-HEAD and merge-base
|
|
1336
|
-
// against the probed branch tip
|
|
1365
|
+
// The remote branch is present but there is no local tracking ref.
|
|
1366
|
+
// Fetch it into the tracking ref, so the unborn-HEAD and merge-base
|
|
1367
|
+
// steps below judge against the probed branch tip. They must not judge
|
|
1368
|
+
// against an unresolvable ref.
|
|
1337
1369
|
try {
|
|
1338
1370
|
await this.#git.fetch(
|
|
1339
1371
|
REMOTE,
|
|
@@ -1345,8 +1377,8 @@ export class WikiSync {
|
|
|
1345
1377
|
} catch {
|
|
1346
1378
|
throw new AncestryRefusal(
|
|
1347
1379
|
"unverifiable",
|
|
1348
|
-
"
|
|
1349
|
-
"to verify ancestry
|
|
1380
|
+
"gemba-wiki: refusing to publish — could not fetch the remote branch " +
|
|
1381
|
+
"to verify ancestry. The local change is not published.",
|
|
1350
1382
|
);
|
|
1351
1383
|
}
|
|
1352
1384
|
}
|
|
@@ -1355,7 +1387,7 @@ export class WikiSync {
|
|
|
1355
1387
|
if (!(await this.#git.refExists("HEAD", { cwd }))) {
|
|
1356
1388
|
throw new AncestryRefusal(
|
|
1357
1389
|
"unrelated",
|
|
1358
|
-
"
|
|
1390
|
+
"gemba-wiki: refusing to publish — HEAD is unborn but the remote " +
|
|
1359
1391
|
"branch exists. Re-clone the wiki.",
|
|
1360
1392
|
);
|
|
1361
1393
|
}
|
|
@@ -1367,7 +1399,7 @@ export class WikiSync {
|
|
|
1367
1399
|
if (!this.#isShallow()) {
|
|
1368
1400
|
throw new AncestryRefusal(
|
|
1369
1401
|
"unrelated",
|
|
1370
|
-
"
|
|
1402
|
+
"gemba-wiki: refusing to publish — local history is unrelated to the " +
|
|
1371
1403
|
"remote branch. Re-clone the wiki.",
|
|
1372
1404
|
);
|
|
1373
1405
|
}
|
|
@@ -1377,14 +1409,14 @@ export class WikiSync {
|
|
|
1377
1409
|
if (deepen.exitCode !== 0) {
|
|
1378
1410
|
throw new AncestryRefusal(
|
|
1379
1411
|
"unverifiable",
|
|
1380
|
-
"
|
|
1381
|
-
"ancestry
|
|
1412
|
+
"gemba-wiki: refusing to publish — could not deepen history to verify " +
|
|
1413
|
+
"ancestry. The local change is not published.",
|
|
1382
1414
|
);
|
|
1383
1415
|
}
|
|
1384
1416
|
if (await this.#git.mergeBaseExists(REMOTE_BRANCH, "HEAD", { cwd })) return;
|
|
1385
1417
|
throw new AncestryRefusal(
|
|
1386
1418
|
"unrelated",
|
|
1387
|
-
"
|
|
1419
|
+
"gemba-wiki: refusing to publish — local history is unrelated to the " +
|
|
1388
1420
|
"remote branch (confirmed against full history). Re-clone the wiki.",
|
|
1389
1421
|
);
|
|
1390
1422
|
}
|