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.
Files changed (60) hide show
  1. package/README.md +1 -3
  2. package/dist/ai/generate.d.ts +13 -0
  3. package/dist/ai/generate.js +9 -3
  4. package/dist/ai/to-plain-text.js +4 -0
  5. package/dist/ai/types.d.ts +20 -1
  6. package/dist/api/admin-branch-health.js +13 -12
  7. package/dist/api/branch.js +2 -2
  8. package/dist/branch-health.js +1 -1
  9. package/dist/branch-metadata-file.d.ts +58 -0
  10. package/dist/branch-metadata-file.js +65 -0
  11. package/dist/branch-metadata.d.ts +2 -26
  12. package/dist/branch-metadata.js +6 -33
  13. package/dist/branch-registry.js +4 -2
  14. package/dist/build/generate-ai-content.js +103 -0
  15. package/dist/cli/cli.js +432 -317
  16. package/dist/cli/generate-ai-content.js +264 -160
  17. package/dist/content-id-index.js +22 -0
  18. package/dist/content-listing.js +3 -0
  19. package/dist/content-reader.d.ts +25 -1
  20. package/dist/content-reader.js +15 -0
  21. package/dist/content-store.d.ts +50 -1
  22. package/dist/content-store.js +102 -4
  23. package/dist/context.d.ts +36 -4
  24. package/dist/context.js +15 -4
  25. package/dist/editor/admin/SystemHealthPanel.js +2 -2
  26. package/dist/entry-schema.d.ts +22 -0
  27. package/dist/git-manager.d.ts +9 -8
  28. package/dist/git-manager.js +35 -9
  29. package/dist/github-service.d.ts +1 -1
  30. package/dist/github-service.js +1 -1
  31. package/dist/paths/branch-name.d.ts +1 -1
  32. package/dist/paths/branch-name.js +1 -1
  33. package/dist/schema/index.d.ts +1 -1
  34. package/dist/schema/index.js +1 -1
  35. package/dist/schema/meta-loader.js +1 -1
  36. package/dist/services.d.ts +22 -0
  37. package/dist/types.d.ts +1 -1
  38. package/dist/url-exclusivity-fixtures.d.ts +76 -0
  39. package/dist/url-exclusivity-fixtures.js +119 -0
  40. package/dist/url-path-resolver.js +8 -4
  41. package/dist/utils/content-write-lock.d.ts +2 -1
  42. package/dist/utils/content-write-lock.js +2 -1
  43. package/dist/utils/error.d.ts +17 -0
  44. package/dist/utils/error.js +17 -0
  45. package/dist/utils/git.d.ts +1 -1
  46. package/dist/utils/git.js +1 -1
  47. package/dist/utils/occ-json-write.js +1 -1
  48. package/dist/worker/cms-worker.d.ts +22 -288
  49. package/dist/worker/cms-worker.js +118 -1995
  50. package/dist/worker/git-sync.d.ts +166 -0
  51. package/dist/worker/git-sync.js +554 -0
  52. package/dist/worker/history-rewrite.d.ts +129 -0
  53. package/dist/worker/history-rewrite.js +216 -0
  54. package/dist/worker/rebase.d.ts +171 -0
  55. package/dist/worker/rebase.js +859 -0
  56. package/dist/worker/task-runner.d.ts +93 -0
  57. package/dist/worker/task-runner.js +529 -0
  58. package/dist/worker/worker-context.d.ts +133 -0
  59. package/dist/worker/worker-context.js +1 -0
  60. 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
+ }