canopycms 0.0.65 → 0.0.66-int.82
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 +1 -3
- package/dist/ai/generate.d.ts +13 -0
- package/dist/ai/generate.js +9 -3
- package/dist/ai/to-plain-text.js +4 -0
- package/dist/ai/types.d.ts +20 -1
- package/dist/api/admin-branch-health.js +13 -12
- package/dist/api/branch.js +2 -2
- package/dist/branch-health.js +1 -1
- package/dist/branch-metadata-file.d.ts +58 -0
- package/dist/branch-metadata-file.js +65 -0
- package/dist/branch-metadata.d.ts +2 -26
- package/dist/branch-metadata.js +6 -33
- package/dist/branch-registry.js +4 -2
- package/dist/build/generate-ai-content.js +103 -0
- package/dist/cli/cli.js +432 -317
- package/dist/cli/generate-ai-content.js +264 -160
- package/dist/content-id-index.js +22 -0
- package/dist/content-listing.js +3 -0
- package/dist/content-reader.d.ts +25 -1
- package/dist/content-reader.js +15 -0
- package/dist/content-store.d.ts +50 -1
- package/dist/content-store.js +102 -4
- package/dist/context.d.ts +36 -4
- package/dist/context.js +15 -4
- package/dist/editor/admin/SystemHealthPanel.js +2 -2
- package/dist/entry-schema.d.ts +22 -0
- package/dist/git-manager.d.ts +9 -8
- package/dist/git-manager.js +35 -9
- package/dist/github-service.d.ts +1 -1
- package/dist/github-service.js +1 -1
- package/dist/paths/branch-name.d.ts +1 -1
- package/dist/paths/branch-name.js +1 -1
- package/dist/schema/index.d.ts +1 -1
- package/dist/schema/index.js +1 -1
- package/dist/schema/meta-loader.js +1 -1
- package/dist/services.d.ts +22 -0
- package/dist/types.d.ts +1 -1
- package/dist/url-exclusivity-fixtures.d.ts +76 -0
- package/dist/url-exclusivity-fixtures.js +119 -0
- package/dist/url-path-resolver.js +8 -4
- package/dist/utils/content-write-lock.d.ts +2 -1
- package/dist/utils/content-write-lock.js +2 -1
- package/dist/utils/error.d.ts +17 -0
- package/dist/utils/error.js +17 -0
- package/dist/utils/git.d.ts +1 -1
- package/dist/utils/git.js +1 -1
- package/dist/utils/occ-json-write.js +1 -1
- package/dist/worker/cms-worker.d.ts +22 -288
- package/dist/worker/cms-worker.js +118 -1995
- package/dist/worker/git-sync.d.ts +166 -0
- package/dist/worker/git-sync.js +554 -0
- package/dist/worker/history-rewrite.d.ts +129 -0
- package/dist/worker/history-rewrite.js +216 -0
- package/dist/worker/rebase.d.ts +171 -0
- package/dist/worker/rebase.js +859 -0
- package/dist/worker/task-runner.d.ts +93 -0
- package/dist/worker/task-runner.js +529 -0
- package/dist/worker/worker-context.d.ts +133 -0
- package/dist/worker/worker-context.js +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,859 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { simpleGit } from 'simple-git';
|
|
4
|
+
import { BranchMetadataFileManager, buildMergedBranchUpdate, getBranchMetadataFileManager, } from '../branch-metadata.js';
|
|
5
|
+
import { extractIdFromFilename } from '../content-id-index.js';
|
|
6
|
+
import { invalidateBranchContentCaches } from '../content-index-generation.js';
|
|
7
|
+
import { normalizeFilesystemPath } from '../paths/normalize.js';
|
|
8
|
+
import { ROOT_COLLECTION_ID } from '../paths/types.js';
|
|
9
|
+
import { tryAcquireContentWriteLock } from '../utils/content-write-lock.js';
|
|
10
|
+
import { getErrorMessage, isNodeError, redactCredentials } from '../utils/error.js';
|
|
11
|
+
import { isRebaseInProgress } from '../utils/git.js';
|
|
12
|
+
import { enqueueGitHubPush, forcePublishToLocalRemote, markHistoryRewritten, readPublishedSha, reconcilePendingRewrite, } from './history-rewrite.js';
|
|
13
|
+
import { workerLog, workerLogWarn } from './log.js';
|
|
14
|
+
/**
|
|
15
|
+
* Poll GitHub for a submitted/approved branch's PR resolution.
|
|
16
|
+
*
|
|
17
|
+
* submitted/approved branches sit outside the rebase loop and get no
|
|
18
|
+
* other signal that their PR resolved on GitHub -- nothing pushes a
|
|
19
|
+
* merge/close webhook back into the branch workspace. merged ->
|
|
20
|
+
* auto-archive via buildMergedBranchUpdate (shared with the manual
|
|
21
|
+
* markAsMerged API so both paths produce identical archived-branch
|
|
22
|
+
* metadata). closed-without-merge -> record pullRequestState only; an
|
|
23
|
+
* admin decides the workflow transition from there. Best-effort: any
|
|
24
|
+
* failure here is logged and swallowed, retried next sync cycle.
|
|
25
|
+
*/
|
|
26
|
+
export async function pollMergeState(ctx, branchDir, branchPath, metaFile) {
|
|
27
|
+
const prNumber = metaFile?.branch.pullRequestNumber;
|
|
28
|
+
if (!prNumber)
|
|
29
|
+
return;
|
|
30
|
+
const controller = new AbortController();
|
|
31
|
+
const timer = setTimeout(() => controller.abort(), ctx.taskTimeoutMs);
|
|
32
|
+
try {
|
|
33
|
+
const { data } = await ctx.octokit().pulls.get({
|
|
34
|
+
owner: ctx.githubOwner,
|
|
35
|
+
repo: ctx.githubRepo,
|
|
36
|
+
pull_number: prNumber,
|
|
37
|
+
request: { signal: controller.signal },
|
|
38
|
+
});
|
|
39
|
+
if (data.merged) {
|
|
40
|
+
const meta = getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath);
|
|
41
|
+
// Use GitHub's actual merge time when available; buildMergedBranchUpdate
|
|
42
|
+
// falls back to "now" (its default `now` param) when merged_at is absent.
|
|
43
|
+
await meta.save({
|
|
44
|
+
branch: buildMergedBranchUpdate(branchDir, data.merged_at ? new Date(data.merged_at) : undefined),
|
|
45
|
+
});
|
|
46
|
+
workerLog(` PR #${prNumber} for ${branchDir} is merged -> archived`);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const newState = data.state === 'closed' ? 'closed' : 'open';
|
|
50
|
+
// Re-load fresh (not the loop-top `metaFile` snapshot passed in) -- a
|
|
51
|
+
// concurrent Lambda write (e.g. an editor re-submitting) may have
|
|
52
|
+
// landed since that snapshot was taken.
|
|
53
|
+
const currentMeta = await BranchMetadataFileManager.loadOnly(branchPath);
|
|
54
|
+
if (currentMeta?.branch.pullRequestState === newState)
|
|
55
|
+
return;
|
|
56
|
+
const meta = getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath);
|
|
57
|
+
await meta.save({ branch: { name: branchDir, pullRequestState: newState } });
|
|
58
|
+
workerLog(` PR #${prNumber} for ${branchDir}: pullRequestState -> ${newState}`);
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
// Non-fatal: transient GitHub/network errors are retried next cycle.
|
|
62
|
+
workerLogWarn(` Failed to poll PR #${prNumber} for ${branchDir}: ${getErrorMessage(err)}`);
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
clearTimeout(timer);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Persist a per-branch rebase failure to branch.json (PR-W2), bounded to
|
|
70
|
+
* roughly one save per failing branch per hour: a branch stuck failing
|
|
71
|
+
* every cycle must not turn into unbounded save-per-cycle x N-failing-
|
|
72
|
+
* branches write amplification -- save() eager-regenerates the branch
|
|
73
|
+
* registry (branch-metadata.ts's invalidateRegistry(), O(branch count) EFS
|
|
74
|
+
* reads), the same concern the `alreadyClean` no-op guard above exists
|
|
75
|
+
* for.
|
|
76
|
+
*
|
|
77
|
+
* Best-effort and non-fatal like every other metadata write in this
|
|
78
|
+
* loop's error paths: a corrupt branch.json, a lock-contention error, or
|
|
79
|
+
* any other save failure here must never abort the per-branch iteration.
|
|
80
|
+
* This matters doubly at the two call sites -- one is inside the outer
|
|
81
|
+
* per-branch catch, with no further catch of its own around this call --
|
|
82
|
+
* so the whole method is wrapped, not just the load.
|
|
83
|
+
*/
|
|
84
|
+
export async function recordRebaseFailure(ctx, branchPath, branchDir, message) {
|
|
85
|
+
const RECORD_REFRESH_MS = 60 * 60 * 1000; // 1 hour
|
|
86
|
+
// [REDACT] Defense-in-depth redaction: rebaseFailure.message is
|
|
87
|
+
// persisted to branch.json and served to the browser via the
|
|
88
|
+
// branch-health admin endpoint. Both call sites in rebaseActiveBranches
|
|
89
|
+
// already redact before passing in (the failed.push sites below),
|
|
90
|
+
// redactCredentials is idempotent, so redacting again here is free and
|
|
91
|
+
// keeps this method safe on its own.
|
|
92
|
+
const redactedMessage = redactCredentials(message);
|
|
93
|
+
try {
|
|
94
|
+
const existing = await BranchMetadataFileManager.loadOnly(branchPath);
|
|
95
|
+
const prior = existing?.branch.rebaseFailure;
|
|
96
|
+
const sameMessage = prior?.message === redactedMessage;
|
|
97
|
+
const now = new Date();
|
|
98
|
+
if (sameMessage) {
|
|
99
|
+
const lastAtMs = Date.parse(prior.lastAt);
|
|
100
|
+
if (!Number.isNaN(lastAtMs) && now.getTime() - lastAtMs < RECORD_REFRESH_MS) {
|
|
101
|
+
// Same failure, refreshed within the last hour -- skip the save.
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const nowIso = now.toISOString();
|
|
106
|
+
const firstAt = sameMessage ? prior.firstAt : nowIso;
|
|
107
|
+
const meta = getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath);
|
|
108
|
+
await meta.save({
|
|
109
|
+
branch: {
|
|
110
|
+
name: branchDir,
|
|
111
|
+
rebaseFailure: { message: redactedMessage, firstAt, lastAt: nowIso },
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
// Includes BranchMetadataCorruptError from the load above (a save()
|
|
117
|
+
// against the same corrupt file would just throw again) as well as
|
|
118
|
+
// any other load/save failure -- recording is best-effort
|
|
119
|
+
// observability, never allowed to abort the branch loop.
|
|
120
|
+
workerLogWarn(` Failed to record rebase failure for ${branchDir}: ${getErrorMessage(err)}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/** Safety limit against a rebase that never converges. */
|
|
124
|
+
const MAX_REBASE_ROUNDS = 50;
|
|
125
|
+
/**
|
|
126
|
+
* Drive `git rebase` to a finished state, resolving each conflict in favour of
|
|
127
|
+
* the BRANCH's version, and report what happened.
|
|
128
|
+
*
|
|
129
|
+
* ABORT OWNERSHIP is split across four sites, none of them redundant. Stating
|
|
130
|
+
* it as "the caller owns the abort" would be wrong in a way that invites
|
|
131
|
+
* deleting a live one as a duplicate, so here is which exit each covers:
|
|
132
|
+
*
|
|
133
|
+
* - The **unexpected-error** exit aborts HERE, itself, before breaking. The
|
|
134
|
+
* caller's `!completed` abort is then a caught no-op for that path.
|
|
135
|
+
* - The **lock-compromised** exit leaves the rebase in progress, and is aborted
|
|
136
|
+
* by `rebaseOneBranch`'s OWN `contentLockCompromised && !completed` branch --
|
|
137
|
+
* which returns `skippedLocked` before the `!completed` block below it is
|
|
138
|
+
* ever reached. That abort is not the `!completed` one and cannot be folded
|
|
139
|
+
* into it.
|
|
140
|
+
* - The **conflict-resolution-failure** and **MAX_REBASE_ROUNDS** exits leave
|
|
141
|
+
* the rebase in progress and are the only two the caller's `!completed`
|
|
142
|
+
* abort actually covers.
|
|
143
|
+
*
|
|
144
|
+
* (A fifth abort, in `rebaseOneBranch`'s interrupted-rebase recovery, is a
|
|
145
|
+
* different concern entirely: it cleans up a PREVIOUS run's abandoned rebase
|
|
146
|
+
* before this one starts.)
|
|
147
|
+
*
|
|
148
|
+
* It can also THROW, from two places, and that is likewise covered rather than
|
|
149
|
+
* prevented: `branchGit.status()` is the first statement of the round catch, so
|
|
150
|
+
* a vanished `.git` or an EFS error escapes, as can the test hook beside it.
|
|
151
|
+
* Such a throw unwinds to `rebaseOneBranch`'s outer catch, and the last-resort
|
|
152
|
+
* abort in its `finally` -- the fourth site -- is what stops the clone being
|
|
153
|
+
* left mid-rebase. That `finally` is the backstop for exactly this, and is not
|
|
154
|
+
* dead code.
|
|
155
|
+
*
|
|
156
|
+
* What this function DOES guarantee is narrower: no per-file resolution failure
|
|
157
|
+
* escapes it. `checkout --theirs` on a MODIFY/DELETE conflict used to throw
|
|
158
|
+
* straight out of the round loop, skipping both abort sites and wedging the
|
|
159
|
+
* clone forever; those now set `failureReason` and break instead.
|
|
160
|
+
*
|
|
161
|
+
* `isLockCompromised` is re-read every round rather than passed as a value:
|
|
162
|
+
* [SYNC-C1] the content-write lock can be lost BETWEEN rounds, and
|
|
163
|
+
* `--continue`/`--skip` are as destructive as the initial rebase.
|
|
164
|
+
*/
|
|
165
|
+
async function runRebaseRounds(ctx, branchGit, branchDir, fetchedBaseTip, isLockCompromised) {
|
|
166
|
+
// Resolve-and-continue loop: keep branch version for conflicting files, then continue
|
|
167
|
+
// Non-conflicting files get main's changes; conflicting files keep branch version.
|
|
168
|
+
const conflictedFiles = [];
|
|
169
|
+
let nextAction = 'start';
|
|
170
|
+
let completed = false;
|
|
171
|
+
// PR-W1: captured only on the "unexpected error" exit below, for the
|
|
172
|
+
// failed-summary entry pushed at the `if (!completed)` check.
|
|
173
|
+
let failureReason;
|
|
174
|
+
for (let round = 0; round < MAX_REBASE_ROUNDS && !completed; round++) {
|
|
175
|
+
// Re-checked every round: the compromise can land between rounds,
|
|
176
|
+
// and `--continue`/`--skip` are as destructive as the initial
|
|
177
|
+
// rebase. Handled after the loop so it cannot be mistaken for the
|
|
178
|
+
// `!completed` rebase-FAILURE path below.
|
|
179
|
+
if (isLockCompromised())
|
|
180
|
+
break;
|
|
181
|
+
try {
|
|
182
|
+
if (nextAction === 'start') {
|
|
183
|
+
// The pinned base tip fetched above (single-branch clones have
|
|
184
|
+
// no origin/<base> remote-tracking ref for other branches).
|
|
185
|
+
await branchGit.rebase([fetchedBaseTip]);
|
|
186
|
+
}
|
|
187
|
+
else if (nextAction === 'continue') {
|
|
188
|
+
await branchGit.rebase(['--continue']);
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
await branchGit.rebase(['--skip']);
|
|
192
|
+
}
|
|
193
|
+
completed = true;
|
|
194
|
+
}
|
|
195
|
+
catch (rebaseErr) {
|
|
196
|
+
nextAction = 'continue';
|
|
197
|
+
const st = await branchGit.status();
|
|
198
|
+
if (st.conflicted.length > 0) {
|
|
199
|
+
await ctx.afterConflictDetectedForTesting();
|
|
200
|
+
// During rebase, --theirs = the branch being replayed (editor's work).
|
|
201
|
+
// (git rebase reverses ours/theirs: "ours" is the rebase target, "theirs" is the branch.)
|
|
202
|
+
//
|
|
203
|
+
// MODIFY/DELETE conflicts have no "their version" to check out
|
|
204
|
+
// and must be resolved by staging a delete or an add instead.
|
|
205
|
+
// `git checkout --theirs` on one exits non-zero ("path ... does
|
|
206
|
+
// not have their version") and simple-git throws -- and because
|
|
207
|
+
// this loop body IS the round loop's catch, that throw escapes
|
|
208
|
+
// the round loop entirely, skipping BOTH `rebase --abort` sites
|
|
209
|
+
// below and leaving the clone wedged mid-rebase forever. The
|
|
210
|
+
// index/working-tree code pair identifies which side deleted
|
|
211
|
+
// (verified against real git, not inferred):
|
|
212
|
+
//
|
|
213
|
+
// U/D "deleted by them" -- the BRANCH deleted it, base
|
|
214
|
+
// modified it. Git leaves base's version in the tree.
|
|
215
|
+
// Keep-branch-version means honouring the delete: git rm.
|
|
216
|
+
// D/U "deleted by us" -- base deleted it, the BRANCH
|
|
217
|
+
// modified it. Git leaves the branch's version in the
|
|
218
|
+
// tree. Keep-branch-version means keeping it: git add.
|
|
219
|
+
//
|
|
220
|
+
// Any per-file resolution that STILL fails routes into the
|
|
221
|
+
// `!completed` path below (which aborts and records) instead of
|
|
222
|
+
// escaping -- deliberately NOT a rethrow, since a throw from
|
|
223
|
+
// here is exactly the bug being fixed.
|
|
224
|
+
const conflictKind = new Map(st.files.map((f) => [f.path, `${f.index}${f.working_dir}`]));
|
|
225
|
+
let resolutionFailure;
|
|
226
|
+
for (const file of st.conflicted) {
|
|
227
|
+
const kind = conflictKind.get(file);
|
|
228
|
+
try {
|
|
229
|
+
if (kind === 'UD') {
|
|
230
|
+
await branchGit.raw(['rm', '-f', '--', file]);
|
|
231
|
+
}
|
|
232
|
+
else if (kind === 'DU') {
|
|
233
|
+
await branchGit.add(file);
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
await branchGit.raw(['checkout', '--theirs', file]);
|
|
237
|
+
await branchGit.add(file);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
catch (resolveErr) {
|
|
241
|
+
resolutionFailure =
|
|
242
|
+
`failed to resolve conflicted file '${file}' (status ${kind ?? '??'}): ` +
|
|
243
|
+
getErrorMessage(resolveErr);
|
|
244
|
+
break;
|
|
245
|
+
}
|
|
246
|
+
conflictedFiles.push(file);
|
|
247
|
+
}
|
|
248
|
+
if (resolutionFailure !== undefined) {
|
|
249
|
+
// Same exit shape as the "unexpected error" branch below: set
|
|
250
|
+
// failureReason and break, letting the `!completed` block do
|
|
251
|
+
// the single `rebase --abort` and record the failure once.
|
|
252
|
+
failureReason = resolutionFailure;
|
|
253
|
+
break;
|
|
254
|
+
}
|
|
255
|
+
// nextAction stays 'continue'
|
|
256
|
+
}
|
|
257
|
+
else {
|
|
258
|
+
const msg = rebaseErr instanceof Error ? rebaseErr.message : '';
|
|
259
|
+
if (msg.toLowerCase().includes('nothing to commit') ||
|
|
260
|
+
msg.toLowerCase().includes('apply --skip')) {
|
|
261
|
+
// Empty commit after --theirs resolution — skip it
|
|
262
|
+
nextAction = 'skip';
|
|
263
|
+
}
|
|
264
|
+
else {
|
|
265
|
+
// Unexpected error — abort and leave branch behind.
|
|
266
|
+
// We intentionally don't update conflictStatus/conflictFiles here:
|
|
267
|
+
// the rebase didn't complete so we can't determine the true conflict
|
|
268
|
+
// state. Previous metadata (possibly stale) is preserved until the
|
|
269
|
+
// next successful rebase cycle corrects it.
|
|
270
|
+
workerLogWarn(` Unexpected rebase error in ${branchDir}: ${msg || 'Unknown error'}`);
|
|
271
|
+
failureReason = msg || 'Unknown error';
|
|
272
|
+
await branchGit.rebase(['--abort']).catch(() => { });
|
|
273
|
+
break;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return { completed, conflictedFiles, failureReason };
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Map the file paths a rebase resolved --theirs onto the ContentIds the editor
|
|
282
|
+
* shows as conflicted, deduplicated.
|
|
283
|
+
*
|
|
284
|
+
* IDs rather than paths because they are immutable: a later slug rename must
|
|
285
|
+
* not orphan a conflict marker. Paths that carry no recoverable ID are dropped
|
|
286
|
+
* rather than guessed at.
|
|
287
|
+
*
|
|
288
|
+
* Pure -- the one piece of this loop that can be tested without a git repo.
|
|
289
|
+
*/
|
|
290
|
+
export function conflictFilesToContentIds(conflictedFiles, contentRoot) {
|
|
291
|
+
// Convert file paths to ContentIds — immutable, survives slug renames.
|
|
292
|
+
// Entry files have IDs in their filename (e.g., "post.slug.a1b2c3d4e5f6.mdx").
|
|
293
|
+
// .collection.json files have no ID themselves (extractIdFromFilename returns null
|
|
294
|
+
// for dot-prefixed files), so we extract the ID from the parent directory instead.
|
|
295
|
+
// The root content directory (e.g., "content/", or "cms/content/" for a
|
|
296
|
+
// multi-segment contentRoot) has no embedded ID, so we use ROOT_COLLECTION_ID
|
|
297
|
+
// as a sentinel — but only for the configured contentRoot.
|
|
298
|
+
//
|
|
299
|
+
// Two different notions of "parent" are needed below, and conflating them
|
|
300
|
+
// reproduces the exact bug this comparison guards against (see
|
|
301
|
+
// schema-store.ts's contentRootName doc comment for the same shape elsewhere):
|
|
302
|
+
// - `parentDir` (a basename) recovers a SUB-collection's own embedded ID
|
|
303
|
+
// (e.g. "posts.cNbR5xFm2Kpd" -> "cNbR5xFm2Kpd") — correct as a basename,
|
|
304
|
+
// since a collection directory carries its ID in its own name, one path
|
|
305
|
+
// segment.
|
|
306
|
+
// - `parentPath` (the full relative parent path, normalized) is what must be
|
|
307
|
+
// compared against `contentRoot`, because `contentRoot` is documented
|
|
308
|
+
// (config/helpers.ts) as allowed to span multiple segments (e.g.
|
|
309
|
+
// "cms/content"). Comparing a basename ("content") against that full value
|
|
310
|
+
// is always false, which silently drops the root collection's conflict.
|
|
311
|
+
// Git reports POSIX-style paths and the configured value may be authored
|
|
312
|
+
// with either separator, so both sides go through normalizeFilesystemPath
|
|
313
|
+
// before comparing.
|
|
314
|
+
const normalizedContentRoot = normalizeFilesystemPath(contentRoot);
|
|
315
|
+
const conflictIds = [...new Set(conflictedFiles)]
|
|
316
|
+
.map((f) => {
|
|
317
|
+
const fileId = extractIdFromFilename(path.basename(f));
|
|
318
|
+
if (fileId)
|
|
319
|
+
return fileId;
|
|
320
|
+
const parentDir = path.basename(path.dirname(f));
|
|
321
|
+
const dirId = extractIdFromFilename(parentDir);
|
|
322
|
+
if (dirId)
|
|
323
|
+
return dirId;
|
|
324
|
+
// Only assign ROOT_COLLECTION_ID when the file's parent directory IS the
|
|
325
|
+
// configured content root. Other unrecognized paths are filtered out.
|
|
326
|
+
const parentPath = normalizeFilesystemPath(path.dirname(f));
|
|
327
|
+
if (path.basename(f) === '.collection.json' && parentPath === normalizedContentRoot) {
|
|
328
|
+
return ROOT_COLLECTION_ID;
|
|
329
|
+
}
|
|
330
|
+
return null;
|
|
331
|
+
})
|
|
332
|
+
.filter((id) => id !== null);
|
|
333
|
+
return [...new Set(conflictIds)];
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* [SYNC-H1] Carry a just-rebased history forward into `remote.git`, and queue
|
|
337
|
+
* the GitHub hop.
|
|
338
|
+
*
|
|
339
|
+
* Only runs when the branch's pre-rebase history had ALREADY been published
|
|
340
|
+
* (`publishedSha !== null`); otherwise nothing downstream knows about the
|
|
341
|
+
* commits the rebase replaced and there is nothing to reconcile. Without this,
|
|
342
|
+
* the editor's next submit is simply rejected non-fast-forward forever.
|
|
343
|
+
*
|
|
344
|
+
* `preRebaseHead` and `publishedSha` must both be read BEFORE the rebase --
|
|
345
|
+
* the clone's pre-rebase tip is exactly what the rebase destroys.
|
|
346
|
+
*/
|
|
347
|
+
async function carryForwardRewrittenHistory(ctx, args) {
|
|
348
|
+
const { branchPath, branchDir, branchRef, publishedSha, preRebaseHead } = args;
|
|
349
|
+
// [SYNC-H1] The rebase just rewrote this clone's history. If that
|
|
350
|
+
// history was already published, nothing else will ever reconcile
|
|
351
|
+
// remote.git (and GitHub) with it -- the editor's next submit would
|
|
352
|
+
// simply be rejected non-fast-forward. Carry the rewrite forward.
|
|
353
|
+
if (publishedSha !== null) {
|
|
354
|
+
if (publishedSha === preRebaseHead) {
|
|
355
|
+
// ARMING GUARD. remote.git holds EXACTLY what this clone just
|
|
356
|
+
// rebased away and nothing more, so a lease keyed to it can only
|
|
357
|
+
// undo our own rewrite.
|
|
358
|
+
//
|
|
359
|
+
// The inequality case below is not defensive padding: branch
|
|
360
|
+
// clones never fetch their own branch (GitManager's clone is
|
|
361
|
+
// --single-branch and checkoutBranch only checks out an existing
|
|
362
|
+
// local branch), while reconcileTrackedBranches fast-forwards
|
|
363
|
+
// remote.git to GitHub's tip. So after a reviewer pushes a fixup
|
|
364
|
+
// straight to the PR branch, remote.git legitimately holds a
|
|
365
|
+
// commit this clone has never seen. Leasing on "whatever
|
|
366
|
+
// remote.git currently holds" would be SATISFIED there and would
|
|
367
|
+
// delete that fixup from remote.git and then from GitHub,
|
|
368
|
+
// silently. Keying the lease to the pre-rebase tip turns that
|
|
369
|
+
// case into the visible divergence it should be.
|
|
370
|
+
//
|
|
371
|
+
// Order matters: mark, then push, then queue. A crash after any
|
|
372
|
+
// step leaves the marker set with the work unfinished, which
|
|
373
|
+
// reconcilePendingRewrite() completes on a later cycle. Pushing
|
|
374
|
+
// first would leave remote.git rewritten, GitHub stale and
|
|
375
|
+
// nothing recorded -- unrecoverable, and landing on exactly the
|
|
376
|
+
// false "another deployment" diagnosis this change removes.
|
|
377
|
+
await markHistoryRewritten(ctx, branchPath, branchDir, publishedSha);
|
|
378
|
+
if (await forcePublishToLocalRemote(ctx, branchPath, branchRef, publishedSha)) {
|
|
379
|
+
await enqueueGitHubPush(ctx, branchRef);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
else {
|
|
383
|
+
const divergence = `rebased locally, but remote.git holds ${publishedSha} for ${branchRef}, which this ` +
|
|
384
|
+
`clone never had (a direct push to the branch?). Left untouched -- reconcile it ` +
|
|
385
|
+
`before submitting again.`;
|
|
386
|
+
workerLogWarn(` ${branchDir}: ${divergence}`);
|
|
387
|
+
await recordRebaseFailure(ctx, branchPath, branchDir, divergence);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* Rebase every branch workspace that is behind the base branch, one cycle.
|
|
393
|
+
*
|
|
394
|
+
* The per-branch work is {@link rebaseOneBranch}; what stays here is the walk
|
|
395
|
+
* over `content-branches/`, the structural pre-filter (entries that are not
|
|
396
|
+
* branch workspaces at all, plus the base branch's own clone), and folding
|
|
397
|
+
* each branch's outcome into the summary.
|
|
398
|
+
*
|
|
399
|
+
* Deliberately sequential rather than concurrent: each branch's rebase holds
|
|
400
|
+
* that branch's cross-host content-write lock and runs a series of git
|
|
401
|
+
* subprocesses against a shared filesystem.
|
|
402
|
+
*/
|
|
403
|
+
export async function runRebaseCycle(ctx) {
|
|
404
|
+
// PR-W1: collected across the loop below and returned as a summary
|
|
405
|
+
// (folded into worker-status.json by syncGit()). Purely additive
|
|
406
|
+
// bookkeeping -- doesn't change any control flow or existing logging.
|
|
407
|
+
const rebased = [];
|
|
408
|
+
const skippedDirty = [];
|
|
409
|
+
const skippedLocked = [];
|
|
410
|
+
const failed = [];
|
|
411
|
+
let branchDirs;
|
|
412
|
+
try {
|
|
413
|
+
branchDirs = await fs.readdir(ctx.contentBranchesPath);
|
|
414
|
+
}
|
|
415
|
+
catch {
|
|
416
|
+
return { rebased, skippedDirty, skippedLocked, failed };
|
|
417
|
+
}
|
|
418
|
+
for (const branchDir of branchDirs) {
|
|
419
|
+
// Known structural entries under content-branches/, not branch
|
|
420
|
+
// workspaces: branches.json (registry snapshot) and dot-prefixed
|
|
421
|
+
// entries (.canopy-meta/, transient lock dirs). Skip them silently so
|
|
422
|
+
// the no-.git skip logs below don't fire for them every cycle.
|
|
423
|
+
// Everything else still logs loudly on skip.
|
|
424
|
+
if (branchDir.startsWith('.') || branchDir === 'branches.json') {
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
const branchPath = path.join(ctx.contentBranchesPath, branchDir);
|
|
428
|
+
const gitDir = path.join(branchPath, '.git');
|
|
429
|
+
try {
|
|
430
|
+
const stat = await fs.stat(gitDir);
|
|
431
|
+
if (!stat.isDirectory()) {
|
|
432
|
+
workerLog(` Skipping ${branchDir}: .git is not a directory`);
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
catch {
|
|
437
|
+
workerLog(` Skipping ${branchDir}: no .git directory (not a branch workspace)`);
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
// The base branch's own clone is refreshed ff-only by
|
|
441
|
+
// refreshBaseBranchWorkspace() earlier in syncGit(). Routing it
|
|
442
|
+
// through this conflict-resolution rebase loop could rewrite its
|
|
443
|
+
// history (the --theirs loop below) and stamp meaningless conflict
|
|
444
|
+
// metadata on it. Compare sanitized-vs-sanitized: branchDir is a
|
|
445
|
+
// filesystem name (already sanitized), ctx.baseBranch is raw.
|
|
446
|
+
if (branchDir === ctx.sanitizedBaseBranch) {
|
|
447
|
+
workerLog(` Skipping ${branchDir}: base branch (refreshed separately)`);
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
const outcome = await rebaseOneBranch(ctx, branchDir, branchPath);
|
|
451
|
+
if (outcome.kind === 'rebased')
|
|
452
|
+
rebased.push(branchDir);
|
|
453
|
+
else if (outcome.kind === 'skippedDirty')
|
|
454
|
+
skippedDirty.push(branchDir);
|
|
455
|
+
else if (outcome.kind === 'skippedLocked')
|
|
456
|
+
skippedLocked.push(branchDir);
|
|
457
|
+
else if (outcome.kind === 'failed') {
|
|
458
|
+
// A branch can legitimately land in BOTH buckets -- see the `rebased`
|
|
459
|
+
// rider on BranchRebaseOutcome.
|
|
460
|
+
if (outcome.rebased)
|
|
461
|
+
rebased.push(branchDir);
|
|
462
|
+
failed.push({ branch: branchDir, error: outcome.error });
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
return { rebased, skippedDirty, skippedLocked, failed };
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* Rebase ONE branch workspace onto the base branch, keeping the branch's
|
|
469
|
+
* version of every conflicting file.
|
|
470
|
+
*
|
|
471
|
+
* `branchDir` is the sanitized directory name and `branchPath` its absolute
|
|
472
|
+
* path; the caller has already established that this is a real branch
|
|
473
|
+
* workspace and not the base branch's own clone.
|
|
474
|
+
*
|
|
475
|
+
* Never throws: every failure is caught, recorded on branch metadata and
|
|
476
|
+
* returned as `{ kind: 'failed' }`, so one unreadable branch cannot halt the
|
|
477
|
+
* rest of the cycle ([SYNC-M2]).
|
|
478
|
+
*/
|
|
479
|
+
async function rebaseOneBranch(ctx, branchDir, branchPath) {
|
|
480
|
+
// Set where the original loop pushed to `rebased[]`, and read only by the
|
|
481
|
+
// catch below. See the `rebased` rider on BranchRebaseOutcome.
|
|
482
|
+
let didRebase = false;
|
|
483
|
+
try {
|
|
484
|
+
// Load metadata before any git ops to check branch status
|
|
485
|
+
const metaFile = await BranchMetadataFileManager.loadOnly(branchPath);
|
|
486
|
+
const branchStatus = metaFile?.branch.status;
|
|
487
|
+
// Skip branches that shouldn't be mutated:
|
|
488
|
+
// - submitted/approved: in review, don't rewrite history under an
|
|
489
|
+
// active PR -- but do poll GitHub for the PR's resolution, since
|
|
490
|
+
// nothing else tells the worker a merge/close happened.
|
|
491
|
+
// - archived: already merged, no reason to rebase and no PR left to
|
|
492
|
+
// poll (avoid the wasted API call).
|
|
493
|
+
if (branchStatus === 'submitted' || branchStatus === 'approved') {
|
|
494
|
+
workerLog(` Skipping ${branchDir} (${branchStatus})`);
|
|
495
|
+
await pollMergeState(ctx, branchDir, branchPath, metaFile);
|
|
496
|
+
return { kind: 'none' };
|
|
497
|
+
}
|
|
498
|
+
if (branchStatus === 'archived') {
|
|
499
|
+
workerLog(` Skipping ${branchDir} (${branchStatus})`);
|
|
500
|
+
return { kind: 'none' };
|
|
501
|
+
}
|
|
502
|
+
const branchGit = simpleGit({
|
|
503
|
+
baseDir: branchPath,
|
|
504
|
+
// Keep git non-interactive during rebase/merge so it never blocks on an editor.
|
|
505
|
+
// simple-git >=3.32 blocks setting core.editor unless explicitly opted in; the
|
|
506
|
+
// value here is a hardcoded literal ("true", the shell no-op), not user input,
|
|
507
|
+
// so enabling allowUnsafeEditor carries no injection risk.
|
|
508
|
+
config: ['core.editor=true'],
|
|
509
|
+
unsafe: { allowUnsafeEditor: true },
|
|
510
|
+
});
|
|
511
|
+
// [SYNC-C1] Take the branch's cross-host content-write lock BEFORE the
|
|
512
|
+
// dirty check, and hold it for the whole rebase.
|
|
513
|
+
//
|
|
514
|
+
// The dirty check alone is check-then-act. The old comment here claimed
|
|
515
|
+
// the residual window was safe ("the rebase will fail and the catch
|
|
516
|
+
// block will abort safely"), which only holds for a save landing before
|
|
517
|
+
// `git rebase` STARTS. After that -- a window spanning fetch, replay and
|
|
518
|
+
// N conflict rounds of awaited git subprocesses on EFS -- a save is
|
|
519
|
+
// destroyed two ways: `checkout --theirs` below overwrites the
|
|
520
|
+
// just-saved file with the branch's committed version and the rebase
|
|
521
|
+
// then SUCCEEDS (nothing logs a failure at all), and `rebase --abort`
|
|
522
|
+
// hard-resets the tree. The editor already got its 200 either way.
|
|
523
|
+
//
|
|
524
|
+
// Zero-retry acquisition, deliberately: on contention this branch is
|
|
525
|
+
// skipped and retried on the next sync cycle (~5 min), which is the
|
|
526
|
+
// same principle as the skip-dirty-branches behavior below. Writers get
|
|
527
|
+
// the patient side of the asymmetry -- see utils/content-write-lock.ts.
|
|
528
|
+
//
|
|
529
|
+
// The heartbeat that keeps this lock fresh (proper-lockfile refreshes
|
|
530
|
+
// every `stale`/2 = 15s) is a timer on this event loop; every git step
|
|
531
|
+
// below is an awaited subprocess, never a synchronous block, so the
|
|
532
|
+
// refresh keeps firing for the whole hold.
|
|
533
|
+
let releaseContentLock;
|
|
534
|
+
// [SYNC-C1] If the lock is lost mid-hold, a writer may now be live
|
|
535
|
+
// against this same tree. Every git step below is destructive, so
|
|
536
|
+
// record it and bail before the next one rather than replaying over
|
|
537
|
+
// an editor's concurrent save.
|
|
538
|
+
let contentLockCompromised = false;
|
|
539
|
+
try {
|
|
540
|
+
releaseContentLock = await tryAcquireContentWriteLock(branchPath, (lockErr) => {
|
|
541
|
+
contentLockCompromised = true;
|
|
542
|
+
workerLogWarn(` Content-write lock compromised mid-rebase for ${branchDir}: ${getErrorMessage(lockErr)}`);
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
catch (lockErr) {
|
|
546
|
+
if (isNodeError(lockErr) && lockErr.code === 'ELOCKED') {
|
|
547
|
+
workerLog(` Skipping ${branchDir}: content write in progress (retrying next cycle)`);
|
|
548
|
+
return { kind: 'skippedLocked' };
|
|
549
|
+
}
|
|
550
|
+
// Anything else (ENOENT on a branch dir deleted mid-cycle, EACCES,
|
|
551
|
+
// ...) is a real failure: let the outer catch record it.
|
|
552
|
+
throw lockErr;
|
|
553
|
+
}
|
|
554
|
+
try {
|
|
555
|
+
// Recover an INTERRUPTED rebase before anything else looks at this
|
|
556
|
+
// tree. A clone left with .git/rebase-merge (or rebase-apply) reports
|
|
557
|
+
// uncommitted changes, so without this the dirty check below would
|
|
558
|
+
// classify it `skippedDirty` on every cycle FOREVER while
|
|
559
|
+
// branch-health scanned it as healthy -- and editors would meanwhile
|
|
560
|
+
// read, and be able to save over, conflict-marker content.
|
|
561
|
+
//
|
|
562
|
+
// An in-progress rebase is always this worker's own abandoned work:
|
|
563
|
+
// it is the only thing that ever rebases these clones, and it got
|
|
564
|
+
// here via a crash, an OOM, a spot interruption, or the ASG rolling
|
|
565
|
+
// the instance (which happens on EVERY `cdk deploy`, while `stop()`
|
|
566
|
+
// drains for at most taskTimeoutMs).
|
|
567
|
+
//
|
|
568
|
+
// NOT LOSSLESS, and it is important not to claim otherwise. `git
|
|
569
|
+
// rebase --abort` hard-resets tracked files to the pre-rebase head.
|
|
570
|
+
// While the worker was DOWN nothing held the [SYNC-C1] content-write
|
|
571
|
+
// lock, so an editor could have saved into this wedged clone and
|
|
572
|
+
// received a 200; that save is a working-tree modification, and the
|
|
573
|
+
// abort reverts it. (New, untracked entry files survive; edits to
|
|
574
|
+
// existing ones do not.) Taking the lock here stops any FURTHER save
|
|
575
|
+
// racing the abort, but cannot recover one that already landed.
|
|
576
|
+
//
|
|
577
|
+
// Aborting anyway is still the right call: the alternative is a
|
|
578
|
+
// branch wedged forever whose tree serves conflict-marker content to
|
|
579
|
+
// editors. What must not happen is doing it SILENTLY -- so anything
|
|
580
|
+
// modified beyond the rebase's own conflict state is logged by path
|
|
581
|
+
// first, which is the only record an operator would have.
|
|
582
|
+
if (await isRebaseInProgress(branchPath)) {
|
|
583
|
+
const preAbort = await branchGit.status().catch(() => null);
|
|
584
|
+
// Keyed on the WORKING-TREE column only. The two porcelain columns
|
|
585
|
+
// mean different things here, and conflating them produces a false
|
|
586
|
+
// data-loss report on essentially every conflict-wedged recovery
|
|
587
|
+
// (verified against real git, mid-rebase):
|
|
588
|
+
//
|
|
589
|
+
// `M ` index=M, wd=' ' -- the interrupted replay's own cleanly
|
|
590
|
+
// merged files, already STAGED. These
|
|
591
|
+
// are committed history and survive the
|
|
592
|
+
// abort untouched. Not collateral.
|
|
593
|
+
// ` M` index=' ', wd=M -- a working-tree modification nothing
|
|
594
|
+
// staged: an editor's save landing while
|
|
595
|
+
// the worker was down. The abort
|
|
596
|
+
// discards exactly these.
|
|
597
|
+
// `??` -- untracked; the abort leaves them.
|
|
598
|
+
//
|
|
599
|
+
// KNOWN GAP, stated rather than hidden: a save onto one of the
|
|
600
|
+
// rebase's own conflicted paths (the "saved over conflict-marker
|
|
601
|
+
// content" case) is excluded below, because the file reads `UU`
|
|
602
|
+
// whether or not an editor touched it -- status alone cannot tell
|
|
603
|
+
// the two apart. Those discards go unlogged.
|
|
604
|
+
const collateral = (preAbort?.files ?? [])
|
|
605
|
+
.filter((f) => !preAbort?.conflicted.includes(f.path))
|
|
606
|
+
.filter((f) => f.working_dir !== ' ' && f.working_dir !== '?')
|
|
607
|
+
.map((f) => f.path);
|
|
608
|
+
if (collateral.length > 0) {
|
|
609
|
+
workerLogWarn(` ${branchDir}: aborting the interrupted rebase will DISCARD working-tree changes to ` +
|
|
610
|
+
`${collateral.length} file(s) saved while the worker was down: ${collateral.join(', ')}`);
|
|
611
|
+
}
|
|
612
|
+
workerLogWarn(` ${branchDir}: found an interrupted rebase (this worker's own abandoned work) -- aborting it to recover the branch`);
|
|
613
|
+
try {
|
|
614
|
+
await branchGit.rebase(['--abort']);
|
|
615
|
+
}
|
|
616
|
+
catch (abortErr) {
|
|
617
|
+
// Leave it for the next cycle rather than pressing on: every step
|
|
618
|
+
// below assumes a clean tree.
|
|
619
|
+
const reason = redactCredentials(`could not abort interrupted rebase: ${getErrorMessage(abortErr)}`);
|
|
620
|
+
workerLogWarn(` Skipping ${branchDir}: ${reason}`);
|
|
621
|
+
// Record on branch metadata too, like the other two failure exits
|
|
622
|
+
// (`!completed` and the outer catch). Without this a persistently
|
|
623
|
+
// un-abortable wedge appeared in worker-status.json but never set
|
|
624
|
+
// a `syncFailureReason`, so the admin branch panel showed nothing
|
|
625
|
+
// -- and this is precisely the state that needs an operator,
|
|
626
|
+
// since it is the one the next cycle cannot fix by itself.
|
|
627
|
+
await recordRebaseFailure(ctx, branchPath, branchDir, reason);
|
|
628
|
+
return { kind: 'failed', error: reason };
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
// Skip dirty branches — editor has unsaved changes that can't be rebased.
|
|
632
|
+
// Now inside the lock, so no write can land between this check and the
|
|
633
|
+
// rebase below.
|
|
634
|
+
const dirtyCheck = await branchGit.status();
|
|
635
|
+
if (dirtyCheck.files.length > 0) {
|
|
636
|
+
workerLog(` Skipping ${branchDir}: has uncommitted changes`);
|
|
637
|
+
return { kind: 'skippedDirty' };
|
|
638
|
+
}
|
|
639
|
+
// The clone's own ref name: branchDir is the sanitized DIRECTORY
|
|
640
|
+
// name and need not match it. A literal 'HEAD' means a detached
|
|
641
|
+
// clone (e.g. a crashed rebase left one behind) -- nothing below can
|
|
642
|
+
// safely name a ref then, so every publish path stays disarmed,
|
|
643
|
+
// which is the safe direction.
|
|
644
|
+
const branchRef = (await branchGit.revparse(['--abbrev-ref', 'HEAD'])).trim();
|
|
645
|
+
const canPublish = branchRef.length > 0 && branchRef !== 'HEAD';
|
|
646
|
+
// [SYNC-H1] Self-heal: finish an interrupted publish even when this
|
|
647
|
+
// branch is not behind base. Every crash window in the arming
|
|
648
|
+
// sequence below leaves the marker set with the work unfinished, and
|
|
649
|
+
// this is what completes it -- without it, one lost lease race would
|
|
650
|
+
// strand the branch until the base branch happened to advance again.
|
|
651
|
+
// Gated on the loop-top snapshot, so unmarked branches (nearly all
|
|
652
|
+
// of them, every cycle) cost nothing extra.
|
|
653
|
+
if (canPublish && metaFile?.branch.historyRewrittenFrom) {
|
|
654
|
+
await reconcilePendingRewrite(ctx, {
|
|
655
|
+
branchPath,
|
|
656
|
+
branchDir,
|
|
657
|
+
branchRef,
|
|
658
|
+
headSha: (await branchGit.revparse(['HEAD'])).trim(),
|
|
659
|
+
marker: metaFile.branch.historyRewrittenFrom,
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
await branchGit.fetch('origin', ctx.baseBranch);
|
|
663
|
+
// Use rev-list instead of status.behind — status.behind only works when the
|
|
664
|
+
// branch has an upstream tracking branch configured, which isn't guaranteed
|
|
665
|
+
// (checkoutBranch fallback paths create branches without --track).
|
|
666
|
+
// The just-fetched tip, not origin/<base>: branch clones are
|
|
667
|
+
// --single-branch, so no remote-tracking ref exists for a base branch
|
|
668
|
+
// other than the one they were cloned from (see the base-refresh
|
|
669
|
+
// comment above). Pinned to a SHA immediately — FETCH_HEAD is one
|
|
670
|
+
// shared mutable file per repo, repointed by any concurrent fetch.
|
|
671
|
+
const fetchedBaseTip = (await branchGit.revparse(['FETCH_HEAD'])).trim();
|
|
672
|
+
const behindCount = parseInt((await branchGit.raw(['rev-list', '--count', `HEAD..${fetchedBaseTip}`])).trim(), 10);
|
|
673
|
+
const meta = getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath);
|
|
674
|
+
if (behindCount === 0) {
|
|
675
|
+
// Already in sync. This is the overwhelmingly common outcome per
|
|
676
|
+
// branch per cycle (most branches are caught up most of the
|
|
677
|
+
// time), so skip the save entirely when metadata already reflects
|
|
678
|
+
// a clean state -- every save() now eager-regenerates the branch
|
|
679
|
+
// registry (branch-metadata.ts's invalidateRegistry(), O(branch
|
|
680
|
+
// count) fs reads on EFS), so an unconditional save here turns
|
|
681
|
+
// every rebase cycle into O(N^2) registry work across N branches
|
|
682
|
+
// for what is otherwise a true no-op. Re-load fresh (not the
|
|
683
|
+
// `metaFile` snapshot from before the fetch/rev-list above) so a
|
|
684
|
+
// concurrent editor-driven metadata change during that window
|
|
685
|
+
// isn't clobbered by a stale skip decision.
|
|
686
|
+
const currentMeta = await BranchMetadataFileManager.loadOnly(branchPath);
|
|
687
|
+
const conflictStatus = currentMeta?.branch.conflictStatus;
|
|
688
|
+
const conflictFiles = currentMeta?.branch.conflictFiles;
|
|
689
|
+
const conflictAlreadyClean = (conflictStatus === undefined || conflictStatus === 'clean') &&
|
|
690
|
+
(conflictFiles === undefined || conflictFiles.length === 0);
|
|
691
|
+
// PR-W2: a lingering rebaseFailure must also be cleared once the
|
|
692
|
+
// branch catches up clean -- otherwise it sticks as a stale
|
|
693
|
+
// warning forever (nothing else touches this branch once it's
|
|
694
|
+
// caught up, so no other save site would ever clear it).
|
|
695
|
+
const alreadyClean = conflictAlreadyClean && currentMeta?.branch.rebaseFailure === undefined;
|
|
696
|
+
if (alreadyClean) {
|
|
697
|
+
return { kind: 'none' };
|
|
698
|
+
}
|
|
699
|
+
await meta.save({
|
|
700
|
+
branch: {
|
|
701
|
+
name: branchDir,
|
|
702
|
+
conflictStatus: 'clean',
|
|
703
|
+
conflictFiles: [],
|
|
704
|
+
rebaseFailure: undefined,
|
|
705
|
+
},
|
|
706
|
+
});
|
|
707
|
+
return { kind: 'none' };
|
|
708
|
+
}
|
|
709
|
+
workerLog(`Rebasing ${branchDir} (${behindCount} commits behind)...`);
|
|
710
|
+
// Read BOTH sides before rewriting anything: the arming guard after
|
|
711
|
+
// the rebase compares what remote.git published against what this
|
|
712
|
+
// clone is about to rebase away. Reading them afterwards would be
|
|
713
|
+
// useless -- the clone's pre-rebase tip is exactly what disappears.
|
|
714
|
+
const preRebaseHead = (await branchGit.revparse(['HEAD'])).trim();
|
|
715
|
+
const publishedSha = canPublish ? await readPublishedSha(ctx, branchRef) : null;
|
|
716
|
+
const { completed, conflictedFiles, failureReason } = await runRebaseRounds(ctx, branchGit, branchDir, fetchedBaseTip, () => contentLockCompromised);
|
|
717
|
+
// Outside the round loop's try/catch, so a throwing test hook can
|
|
718
|
+
// never be misread as a rebase error.
|
|
719
|
+
if (completed)
|
|
720
|
+
await ctx.afterRebaseCompletedForTesting();
|
|
721
|
+
// [SYNC-C1] A lost lock is a RETRY, not a rebase failure: nothing is
|
|
722
|
+
// wrong with the branch, we simply can no longer prove we were the
|
|
723
|
+
// only writer. Handled ahead of the `!completed` block so it never
|
|
724
|
+
// records a user-visible rebaseFailure or lands in `failed[]`.
|
|
725
|
+
//
|
|
726
|
+
// ONLY when the rebase did not complete. Bailing out of a COMPLETED
|
|
727
|
+
// rebase would be worse than useless: the history is already
|
|
728
|
+
// rewritten (so `--abort` is a no-op), and skipping the completion
|
|
729
|
+
// path below strands three things the next cycle will never redo,
|
|
730
|
+
// because a caught-up branch short-circuits at the `behindCount === 0`
|
|
731
|
+
// check above -- the [SYNC-H1] `markHistoryRewritten` marker (without
|
|
732
|
+
// which the editor's next submit is rejected non-fast-forward and
|
|
733
|
+
// mis-diagnosed as another deployment), the content-cache
|
|
734
|
+
// invalidation for a tree that DID change, and the conflictStatus
|
|
735
|
+
// save. That converts a transient lock compromise into a permanently
|
|
736
|
+
// wedged published branch.
|
|
737
|
+
//
|
|
738
|
+
// Nor does bailing protect a racing save: a rebase replays COMMITTED
|
|
739
|
+
// history, while a concurrent save is uncommitted working-tree state,
|
|
740
|
+
// and that writer is already told to retry via
|
|
741
|
+
// ContentWriteLockBusyError. So when the rebase completed, log the
|
|
742
|
+
// lost exclusivity loudly and finish the job.
|
|
743
|
+
if (contentLockCompromised && !completed) {
|
|
744
|
+
workerLogWarn(` Skipping ${branchDir}: content-write lock was compromised mid-rebase (retrying next cycle)`);
|
|
745
|
+
// No-op when no rebase is in progress; failure is expected there.
|
|
746
|
+
await branchGit.rebase(['--abort']).catch(() => { });
|
|
747
|
+
return { kind: 'skippedLocked' };
|
|
748
|
+
}
|
|
749
|
+
if (contentLockCompromised) {
|
|
750
|
+
workerLogWarn(` Content-write lock for ${branchDir} was compromised, but its rebase had already completed -- finishing the sync (history is rewritten; skipping now would strand the history-rewrite marker and wedge the branch)`);
|
|
751
|
+
}
|
|
752
|
+
if (!completed) {
|
|
753
|
+
// PR-W2 (M1 rider): failureReason is only set on the "unexpected
|
|
754
|
+
// error" break above -- MAX_REBASE_ROUNDS exhaustion is a distinct exit
|
|
755
|
+
// path with no error message of its own, so the warn text must
|
|
756
|
+
// not conflate the two.
|
|
757
|
+
workerLogWarn(failureReason !== undefined
|
|
758
|
+
? ` Rebase of ${branchDir} aborted due to unexpected error: ${failureReason}`
|
|
759
|
+
: ` Rebase of ${branchDir} did not complete within ${MAX_REBASE_ROUNDS} rounds, aborting`);
|
|
760
|
+
await branchGit.rebase(['--abort']).catch(() => { });
|
|
761
|
+
const rebaseFailureMessage = failureReason ?? `did not complete within ${MAX_REBASE_ROUNDS} rounds`;
|
|
762
|
+
// [REDACT] failed[] folds into worker-status.json's
|
|
763
|
+
// lastGitSync.failed, served to the browser -- failureReason can
|
|
764
|
+
// be an arbitrary git error message that embeds the bot token.
|
|
765
|
+
const redactedRebaseFailureMessage = redactCredentials(rebaseFailureMessage);
|
|
766
|
+
// PR-W2: record once here for the "!completed" exit -- the
|
|
767
|
+
// unexpected-error break above is NOT disjoint from this block (it
|
|
768
|
+
// always falls through here), so recording at the break itself
|
|
769
|
+
// would double-record. The outer catch below is the only other
|
|
770
|
+
// record site (a distinct, non-overlapping failure class: errors
|
|
771
|
+
// outside this round loop, e.g. fetch/rev-list failures).
|
|
772
|
+
await recordRebaseFailure(ctx, branchPath, branchDir, redactedRebaseFailureMessage);
|
|
773
|
+
return { kind: 'failed', error: redactedRebaseFailureMessage };
|
|
774
|
+
}
|
|
775
|
+
// The rebase rewrote the branch clone's working tree — mark ContentStore
|
|
776
|
+
// ID indexes rooted here stale so lookups rebuild from disk, in this
|
|
777
|
+
// process and (via the on-disk generation marker) in the Lambda
|
|
778
|
+
// containers sharing this filesystem.
|
|
779
|
+
await invalidateBranchContentCaches(branchPath);
|
|
780
|
+
const conflictIdsDeduped = conflictFilesToContentIds(conflictedFiles, ctx.contentRoot);
|
|
781
|
+
const hadConflicts = conflictIdsDeduped.length > 0;
|
|
782
|
+
workerLog(hadConflicts
|
|
783
|
+
? ` Rebased ${branchDir} (kept branch version for ${conflictIdsDeduped.length} conflicting file(s))`
|
|
784
|
+
: ` Rebased ${branchDir} successfully`);
|
|
785
|
+
await meta.save({
|
|
786
|
+
branch: {
|
|
787
|
+
name: branchDir,
|
|
788
|
+
conflictStatus: hadConflicts ? 'conflicts-detected' : 'clean',
|
|
789
|
+
conflictFiles: conflictIdsDeduped,
|
|
790
|
+
// PR-W2: the cycle completed successfully -- clear any prior
|
|
791
|
+
// failure record regardless of conflict outcome.
|
|
792
|
+
rebaseFailure: undefined,
|
|
793
|
+
},
|
|
794
|
+
});
|
|
795
|
+
// PR-W1: the branch was behind and the rebase completed (with or
|
|
796
|
+
// without --theirs conflict resolution) -- it moved, so it belongs
|
|
797
|
+
// in the summary. Branches already up to date `continue`d above and
|
|
798
|
+
// are deliberately not listed here.
|
|
799
|
+
didRebase = true;
|
|
800
|
+
await carryForwardRewrittenHistory(ctx, {
|
|
801
|
+
branchPath,
|
|
802
|
+
branchDir,
|
|
803
|
+
branchRef,
|
|
804
|
+
publishedSha,
|
|
805
|
+
preRebaseHead,
|
|
806
|
+
});
|
|
807
|
+
return { kind: 'rebased' };
|
|
808
|
+
}
|
|
809
|
+
finally {
|
|
810
|
+
// Last-resort guarantee that NO exit path leaves this clone
|
|
811
|
+
// mid-rebase -- including an unexpected throw from any git step
|
|
812
|
+
// above, which lands in the outer catch and previously only logged.
|
|
813
|
+
//
|
|
814
|
+
// It must live HERE rather than in that outer catch: the catch runs
|
|
815
|
+
// AFTER this finally has released the content-write lock, so aborting
|
|
816
|
+
// there would hard-reset a working tree an editor's save could
|
|
817
|
+
// already be racing -- precisely the [SYNC-C1] hazard the lock
|
|
818
|
+
// exists to prevent. Inside the finally the lock is still held --
|
|
819
|
+
// EXCEPT on the narrow path where it was compromised mid-hold, in
|
|
820
|
+
// which case a newly-admitted writer may already be live and this
|
|
821
|
+
// abort carries the same exposure as the compromise path's own abort
|
|
822
|
+
// above. Not special-cased: leaving a clone wedged mid-rebase is the
|
|
823
|
+
// worse outcome, and the writer in that window is already being told
|
|
824
|
+
// to retry.
|
|
825
|
+
//
|
|
826
|
+
// Guarded on actual rebase state so the happy path and the `continue`
|
|
827
|
+
// exits cost one stat and do nothing.
|
|
828
|
+
try {
|
|
829
|
+
if (await isRebaseInProgress(branchPath)) {
|
|
830
|
+
workerLogWarn(` ${branchDir}: rebase still in progress on exit -- aborting so the clone is not left wedged`);
|
|
831
|
+
await branchGit.rebase(['--abort']);
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
catch (abortErr) {
|
|
835
|
+
// Best effort: the next cycle's recovery check retries this.
|
|
836
|
+
workerLogWarn(` Failed to abort in-progress rebase for ${branchDir}: ${getErrorMessage(abortErr)}`);
|
|
837
|
+
}
|
|
838
|
+
// [SYNC-C1] Released on EVERY exit -- the `continue`s above, a throw
|
|
839
|
+
// into the outer catch, and the happy path alike. A stranded lock
|
|
840
|
+
// would wedge every write to this branch until it went stale.
|
|
841
|
+
await releaseContentLock?.().catch((releaseErr) => {
|
|
842
|
+
workerLogWarn(` Failed to release content-write lock for ${branchDir}: ${getErrorMessage(releaseErr)}`);
|
|
843
|
+
});
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
catch (err) {
|
|
847
|
+
const message = err instanceof Error ? err.message : 'Unknown error';
|
|
848
|
+
workerLogWarn(` Failed to sync ${branchDir}: ${message}`);
|
|
849
|
+
// [REDACT] Same rationale as the `if (!completed)` push site above --
|
|
850
|
+
// this catches fetch/rev-list/unexpected errors, whose message can
|
|
851
|
+
// embed the bot token.
|
|
852
|
+
const redactedMessage = redactCredentials(message);
|
|
853
|
+
// PR-W2: second (and only other) record site -- see the comment at
|
|
854
|
+
// the `if (!completed)` block above for why these two sites are
|
|
855
|
+
// disjoint.
|
|
856
|
+
await recordRebaseFailure(ctx, branchPath, branchDir, redactedMessage);
|
|
857
|
+
return { kind: 'failed', error: redactedMessage, rebased: didRebase };
|
|
858
|
+
}
|
|
859
|
+
}
|