canopycms 0.12.0 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/CHANGELOG.md +164 -48
  2. package/dist/api/admin.d.ts +4 -4
  3. package/dist/api/branch-review.js +45 -13
  4. package/dist/api/branch-status.d.ts +12 -0
  5. package/dist/api/branch-status.js +13 -0
  6. package/dist/api/branch-update-from-base.d.ts +23 -0
  7. package/dist/api/branch-update-from-base.js +176 -0
  8. package/dist/api/branch.d.ts +5 -0
  9. package/dist/api/branch.js +1 -0
  10. package/dist/api/client.d.ts +14 -1
  11. package/dist/api/client.js +22 -0
  12. package/dist/api/comments.d.ts +2 -2
  13. package/dist/api/content.d.ts +38 -1
  14. package/dist/api/content.js +41 -5
  15. package/dist/api/merge-conflicts.d.ts +226 -0
  16. package/dist/api/merge-conflicts.js +674 -0
  17. package/dist/api/routes.js +2 -0
  18. package/dist/api/types.d.ts +7 -3
  19. package/dist/base-update.d.ts +26 -0
  20. package/dist/base-update.js +31 -0
  21. package/dist/branch-metadata-file.d.ts +51 -0
  22. package/dist/branch-metadata-file.js +11 -0
  23. package/dist/branch-schema-cache.d.ts +12 -0
  24. package/dist/branch-schema-cache.js +27 -2
  25. package/dist/cli/backfill-item-keys.d.ts +77 -0
  26. package/dist/cli/backfill-item-keys.js +320 -0
  27. package/dist/cli/cli.d.ts +1 -1
  28. package/dist/cli/cli.js +9658 -1499
  29. package/dist/cli/generate-ai-content.js +354 -244
  30. package/dist/cli/init.js +6 -0
  31. package/dist/cli/site-config.d.ts +14 -2
  32. package/dist/cli/site-config.js +20 -4
  33. package/dist/comment-store.d.ts +8 -0
  34. package/dist/comment-store.js +28 -0
  35. package/dist/content-index-generation.d.ts +1 -4
  36. package/dist/content-index-generation.js +1 -4
  37. package/dist/content-store.d.ts +39 -3
  38. package/dist/content-store.js +48 -15
  39. package/dist/editor/BranchManager.d.ts +4 -1
  40. package/dist/editor/BranchManager.js +9 -5
  41. package/dist/editor/Editor.js +429 -52
  42. package/dist/editor/EntryNavigator.d.ts +10 -2
  43. package/dist/editor/EntryNavigator.js +12 -4
  44. package/dist/editor/FormRenderer.d.ts +8 -2
  45. package/dist/editor/FormRenderer.js +5 -6
  46. package/dist/editor/base-update-wait.d.ts +74 -0
  47. package/dist/editor/base-update-wait.js +91 -0
  48. package/dist/editor/branch-status.js +1 -1
  49. package/dist/editor/components/BaseUpdateNotice.d.ts +12 -0
  50. package/dist/editor/components/BaseUpdateNotice.js +24 -0
  51. package/dist/editor/components/BranchPill.d.ts +4 -1
  52. package/dist/editor/components/BranchPill.js +5 -2
  53. package/dist/editor/components/ConflictsGroup.d.ts +100 -0
  54. package/dist/editor/components/ConflictsGroup.js +364 -0
  55. package/dist/editor/components/EditorHeader.d.ts +16 -1
  56. package/dist/editor/components/EditorHeader.js +92 -30
  57. package/dist/editor/components/EntryChangesDrawer.d.ts +16 -2
  58. package/dist/editor/components/EntryChangesDrawer.js +33 -167
  59. package/dist/editor/components/EntryHeader.d.ts +3 -1
  60. package/dist/editor/components/EntryHeader.js +2 -2
  61. package/dist/editor/components/GetLatestChangesButton.d.ts +15 -0
  62. package/dist/editor/components/GetLatestChangesButton.js +29 -0
  63. package/dist/editor/components/ReleaseHoldButton.d.ts +20 -0
  64. package/dist/editor/components/ReleaseHoldButton.js +36 -0
  65. package/dist/editor/components/RetryToastMessage.js +1 -1
  66. package/dist/editor/components/change-parts.d.ts +51 -0
  67. package/dist/editor/components/change-parts.js +171 -0
  68. package/dist/editor/conflict-cards.d.ts +90 -0
  69. package/dist/editor/conflict-cards.js +279 -0
  70. package/dist/editor/conflict-choices.d.ts +60 -0
  71. package/dist/editor/conflict-choices.js +108 -0
  72. package/dist/editor/conflict-notice.d.ts +25 -0
  73. package/dist/editor/conflict-notice.js +39 -0
  74. package/dist/editor/copy.d.ts +12 -0
  75. package/dist/editor/copy.js +12 -0
  76. package/dist/editor/entry-diff.d.ts +2 -1
  77. package/dist/editor/entry-diff.js +5 -21
  78. package/dist/editor/hooks/useBaseUpdate.d.ts +36 -0
  79. package/dist/editor/hooks/useBaseUpdate.js +269 -0
  80. package/dist/editor/hooks/useBranchManager.d.ts +6 -1
  81. package/dist/editor/hooks/useBranchManager.js +28 -7
  82. package/dist/editor/hooks/useBranchesData.js +3 -0
  83. package/dist/editor/hooks/useConflictChoices.d.ts +24 -0
  84. package/dist/editor/hooks/useConflictChoices.js +75 -0
  85. package/dist/editor/hooks/useConflictsData.d.ts +26 -0
  86. package/dist/editor/hooks/useConflictsData.js +89 -0
  87. package/dist/editor/hooks/useDraftManager.d.ts +5 -0
  88. package/dist/editor/hooks/useDraftManager.js +24 -8
  89. package/dist/editor/hooks/useEntryManager.d.ts +9 -0
  90. package/dist/editor/hooks/useEntryManager.js +5 -0
  91. package/dist/editor/release-hold.d.ts +12 -0
  92. package/dist/editor/release-hold.js +39 -0
  93. package/dist/editor/server-clock.d.ts +4 -0
  94. package/dist/editor/server-clock.js +15 -0
  95. package/dist/editor/undo-change.d.ts +7 -0
  96. package/dist/editor/undo-change.js +19 -4
  97. package/dist/git-manager.d.ts +5 -0
  98. package/dist/git-manager.js +30 -0
  99. package/dist/item-keys.d.ts +13 -0
  100. package/dist/item-keys.js +36 -14
  101. package/dist/merge/conflict-path.d.ts +40 -0
  102. package/dist/merge/conflict-path.js +128 -0
  103. package/dist/merge/diff3.d.ts +27 -0
  104. package/dist/merge/diff3.js +113 -0
  105. package/dist/merge/entry-merge.d.ts +94 -0
  106. package/dist/merge/entry-merge.js +875 -0
  107. package/dist/merge/list-alignment.d.ts +52 -0
  108. package/dist/merge/list-alignment.js +441 -0
  109. package/dist/merge/text-merge.d.ts +29 -0
  110. package/dist/merge/text-merge.js +59 -0
  111. package/dist/merge/value-equality.d.ts +18 -0
  112. package/dist/merge/value-equality.js +50 -0
  113. package/dist/merge-conflicts.d.ts +232 -0
  114. package/dist/merge-conflicts.js +451 -0
  115. package/dist/operating-mode/client-safe-strategy.d.ts +2 -0
  116. package/dist/operating-mode/client-safe-strategy.js +6 -0
  117. package/dist/operating-mode/types.d.ts +5 -0
  118. package/dist/services.d.ts +16 -1
  119. package/dist/services.js +82 -22
  120. package/dist/task-queue/cms-task-queue.d.ts +3 -1
  121. package/dist/types.d.ts +28 -2
  122. package/dist/utils/content-serialize.d.ts +18 -1
  123. package/dist/utils/content-serialize.js +12 -0
  124. package/dist/utils/content-write-lock.d.ts +3 -3
  125. package/dist/utils/content-write-lock.js +3 -3
  126. package/dist/utils/markdown-body-splice.d.ts +1 -1
  127. package/dist/utils/markdown-body-splice.js +1 -19
  128. package/dist/utils/markdown-parse.d.ts +9 -0
  129. package/dist/utils/markdown-parse.js +27 -0
  130. package/dist/version.d.ts +1 -1
  131. package/dist/version.js +1 -1
  132. package/dist/worker/branch-merge.d.ts +53 -37
  133. package/dist/worker/branch-merge.js +139 -54
  134. package/dist/worker/cms-worker.d.ts +16 -3
  135. package/dist/worker/cms-worker.js +59 -12
  136. package/dist/worker/field-merge.d.ts +65 -0
  137. package/dist/worker/field-merge.js +636 -0
  138. package/dist/worker/git-sync.d.ts +1 -1
  139. package/dist/worker/git-sync.js +7 -6
  140. package/dist/worker/github-commits.d.ts +42 -10
  141. package/dist/worker/github-commits.js +287 -47
  142. package/dist/worker/github-gateway-http.js +17 -9
  143. package/dist/worker/github-gateway-wire.d.ts +21 -12
  144. package/dist/worker/github-gateway-wire.js +5 -1
  145. package/dist/worker/github-gateway.d.ts +10 -1
  146. package/dist/worker/github-gateway.js +18 -6
  147. package/dist/worker/github-transfer.d.ts +5 -3
  148. package/dist/worker/github-transfer.js +8 -5
  149. package/dist/worker/rebase.d.ts +5 -4
  150. package/dist/worker/rebase.js +293 -156
  151. package/dist/worker/task-runner.d.ts +1 -1
  152. package/dist/worker/task-runner.js +14 -2
  153. package/dist/worker/worker-context.d.ts +18 -2
  154. package/package.json +1 -1
@@ -2,7 +2,9 @@ import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { BranchMetadataFileManager, buildMergedBranchUpdate, getBranchMetadataFileManager, } from '../branch-metadata.js';
4
4
  import { invalidateBranchContentCaches } from '../content-index-generation.js';
5
+ import { MergeConflictStore } from '../merge-conflicts.js';
5
6
  import { isSettingsBranchName, sanitizeBranchName } from '../paths/branch-name.js';
7
+ import { BASE_UPDATE_CLOCK_SKEW_MS, BASE_UPDATE_EARLY_RETRY_MS, isBaseUpdatePending, } from '../base-update.js';
6
8
  import { tryAcquireContentWriteLock } from '../utils/content-write-lock.js';
7
9
  import { holdProvisionedWorkspace, releaseProvisionedWorkspace, } from './provisioned-workspace.js';
8
10
  import { getErrorMessage, isNodeError, redactCredentials } from '../utils/error.js';
@@ -11,8 +13,8 @@ import { CANOPY_META_DIR, isCanopyInternalPath, isRebaseInProgress } from '../ut
11
13
  import { TRACKED_CANOPY_STATE_FIX, restoreRetiredSchemaCache, splitByUpstreamTracking, trackedCanopyStateChanges, } from './canopy-state.js';
12
14
  import { enqueueGitHubPush, forcePublishToLocalRemote, markHistoryRewritten, readPublishedSha, reconcilePendingRewrite, } from './history-rewrite.js';
13
15
  import { publishesBranch } from './branch-policy.js';
14
- import { commitsApart, conflictFilesToContentIds, recordConflicts } from './branch-merge.js';
15
- import { bringInGitHubCommits, mergeIntoBranch, returnToReview, rewriteCleared, } from './github-commits.js';
16
+ import { commitsApart, mergesAsGitDoes, planMerge } from './branch-merge.js';
17
+ import { bringInGitHubCommits, catchUpWithPublished, contains, isPublished, mergeIntoBranch, reconcileMergeConflicts, returnToReview, rewriteCleared, } from './github-commits.js';
16
18
  import { workerLog, workerLogWarn } from './log.js';
17
19
  import { SHARED_REPO_STATUS_ARGS, assertNoIncomingSubmodules, assertSharedRepoConfig, fetchFromRemoteGit, sharedRepoGit, } from './shared-repo-git.js';
18
20
  /**
@@ -46,8 +48,12 @@ export async function pollMergeState(ctx, branchDir, branchPath, metaFile) {
46
48
  branch: buildMergedBranchUpdate(current.branch, data.merged_at ? new Date(data.merged_at) : undefined),
47
49
  }
48
50
  : null);
49
- if (saved)
51
+ if (saved) {
50
52
  workerLog(` PR #${prNumber} for ${branchDir} is merged -> archived`);
53
+ // The base most likely moved with it, after this cycle's fetch: the next cycle brings
54
+ // that into the other branches without waiting out the interval.
55
+ ctx.requestBranchSync();
56
+ }
51
57
  return null;
52
58
  }
53
59
  const newState = data.state === 'closed' ? 'closed' : 'open';
@@ -131,44 +137,44 @@ async function abortRebase(git, branchPath) {
131
137
  }
132
138
  }
133
139
  /**
134
- * Drive `git rebase` to a finished state, resolving each conflict in favour of
135
- * the BRANCH's version, and report what happened.
140
+ * Drive `git rebase` to a finished state, stopping at the first conflict, and
141
+ * report what happened. A conflict is never resolved here: settling one by rule
142
+ * in a round's replay reverts base's change, and the merge the caller falls back
143
+ * to decides each value against the fork point.
136
144
  *
137
- * ABORT OWNERSHIP is split across FOUR sites, none redundant; "the caller owns
138
- * the abort" would be wrong in a way that invites deleting a live one:
145
+ * ABORT OWNERSHIP is split across several sites, none redundant; "the caller
146
+ * owns the abort" would be wrong in a way that invites deleting a live one:
139
147
  * - the **unexpected-error** exit aborts HERE, before breaking, so the caller's
140
148
  * `!completed` abort is a caught no-op for that path;
149
+ * - the **conflict** exit aborts HERE too, and the caller's `conflicted` branch,
150
+ * which returns before `!completed`, calls `abortRebase` again: a no-op abort
151
+ * that tells the caches the replay moved the tree back;
141
152
  * - the **lock-compromised** exit leaves the rebase in progress and is aborted
142
153
  * by `rebaseOneBranch`'s own `lockCompromised() && !completed` branch,
143
154
  * which returns `skippedLocked` before the `!completed` block below it is
144
155
  * reached -- a distinct site that cannot be folded into that one;
145
- * - the **conflict-resolution-failure** and **MAX_REBASE_ROUNDS** exits are the
146
- * only two the caller's `!completed` abort actually covers;
156
+ * - the **MAX_REBASE_ROUNDS** exit is the only one the caller's `!completed`
157
+ * abort actually covers;
147
158
  * - a THROW (`branchGit.status()` is the first statement of the round catch, so
148
159
  * a vanished `.git` or an EFS error escapes, as can the test hook beside it)
149
160
  * unwinds to `rebaseOneBranch`'s outer catch, where the last-resort abort in
150
161
  * its `finally` is the backstop -- not dead code.
151
162
  * (A fifth abort, in the interrupted-rebase recovery, cleans up a PREVIOUS run.)
152
163
  *
153
- * What this function DOES guarantee is narrower: no per-file resolution failure
154
- * escapes it. `checkout --theirs` on a MODIFY/DELETE conflict throws straight
155
- * out of the round loop, skipping both abort sites and wedging the clone
156
- * forever, so those set `failureReason` and break instead.
157
- *
158
164
  * `isLockCompromised` is re-read every round rather than passed as a value:
159
- * [SYNC-C1] the content-write lock can be lost BETWEEN rounds, and
160
- * `--continue`/`--skip` are as destructive as the initial rebase.
165
+ * [SYNC-C1] the content-write lock can be lost BETWEEN rounds, and `--skip` is
166
+ * as destructive as the initial rebase.
161
167
  */
162
168
  async function runRebaseRounds(ctx, branchGit, branchDir, fetchedBaseTip, isLockCompromised) {
163
- const conflictedFiles = [];
164
169
  let nextAction = 'start';
165
170
  let completed = false;
171
+ let conflicted = false;
166
172
  // Captured only on the "unexpected error" exit below, for the failed-summary
167
173
  // entry pushed at the `if (!completed)` check.
168
174
  let failureReason;
169
175
  for (let round = 0; round < MAX_REBASE_ROUNDS && !completed; round++) {
170
176
  // Re-checked every round: the compromise can land between rounds, and
171
- // `--continue`/`--skip` are as destructive as the initial rebase. Handled
177
+ // `--skip` is as destructive as the initial rebase. Handled
172
178
  // after the loop so it cannot be mistaken for the `!completed`
173
179
  // rebase-FAILURE path below.
174
180
  if (isLockCompromised())
@@ -179,95 +185,38 @@ async function runRebaseRounds(ctx, branchGit, branchDir, fetchedBaseTip, isLock
179
185
  // remote-tracking ref.
180
186
  await branchGit.rebase([fetchedBaseTip]);
181
187
  }
182
- else if (nextAction === 'continue') {
183
- await branchGit.rebase(['--continue']);
184
- }
185
188
  else {
186
189
  await branchGit.rebase(['--skip']);
187
190
  }
188
191
  completed = true;
189
192
  }
190
193
  catch (rebaseErr) {
191
- nextAction = 'continue';
192
194
  const st = await branchGit.status([...SHARED_REPO_STATUS_ARGS]);
193
195
  if (st.conflicted.length > 0) {
194
196
  await ctx.afterConflictDetectedForTesting();
195
- // During rebase, --theirs = the branch being replayed (the editor's
196
- // work): git rebase reverses ours/theirs, "ours" being the target.
197
- //
198
- // MODIFY/DELETE conflicts have no "their version" to check out and must
199
- // be resolved by staging a delete or an add instead. `git checkout
200
- // --theirs` on one exits non-zero and simple-git throws -- and because
201
- // this loop body IS the round loop's catch, that throw escapes the
202
- // round loop entirely, skipping BOTH `rebase --abort` sites below and
203
- // wedging the clone mid-rebase forever. The index/working-tree code
204
- // pair identifies which side deleted (verified against real git):
205
- //
206
- // U/D "deleted by them" -- the BRANCH deleted it, base modified it,
207
- // and git leaves base's version in the tree. Keeping the branch
208
- // version means honouring the delete: git rm.
209
- // D/U "deleted by us" -- base deleted it, the BRANCH modified it,
210
- // and git leaves the branch's version in the tree. Keeping the
211
- // branch version means keeping it: git add.
212
- //
213
- // A per-file resolution that STILL fails routes into the `!completed`
214
- // path below, which aborts and records, rather than escaping.
215
- //
216
- // `--sparse` on add/rm: a conflicted path outside a sparse clone's cone
217
- // is materialised for the conflict, but git refuses to stage it without.
218
- const conflictKind = new Map(st.files.map((f) => [f.path, `${f.index}${f.working_dir}`]));
219
- let resolutionFailure;
220
- for (const file of st.conflicted) {
221
- const kind = conflictKind.get(file);
222
- try {
223
- if (kind === 'UD') {
224
- await branchGit.raw(['rm', '-f', '--sparse', '--', file]);
225
- }
226
- else if (kind === 'DU') {
227
- await branchGit.raw(['add', '--sparse', '--', file]);
228
- }
229
- else {
230
- await branchGit.raw(['checkout', '--theirs', '--', file]);
231
- await branchGit.raw(['add', '--sparse', '--', file]);
232
- }
233
- }
234
- catch (resolveErr) {
235
- resolutionFailure =
236
- `failed to resolve conflicted file '${file}' (status ${kind ?? '??'}): ` +
237
- getErrorMessage(resolveErr);
238
- break;
239
- }
240
- conflictedFiles.push(file);
241
- }
242
- if (resolutionFailure !== undefined) {
243
- // Same exit shape as the "unexpected error" branch below: set
244
- // failureReason and break, letting the `!completed` block do the
245
- // single `rebase --abort` and record the failure once.
246
- failureReason = resolutionFailure;
247
- break;
248
- }
249
- // nextAction stays 'continue'
197
+ workerLog(` ${branchDir}: a replayed commit conflicts, so the base comes in by a merge`);
198
+ conflicted = true;
199
+ await branchGit.rebase(['--abort']).catch(() => { });
200
+ break;
201
+ }
202
+ const msg = rebaseErr instanceof Error ? rebaseErr.message : '';
203
+ if (msg.toLowerCase().includes('nothing to commit') ||
204
+ msg.toLowerCase().includes('apply --skip')) {
205
+ // A replayed commit base already holds: skip it.
206
+ nextAction = 'skip';
250
207
  }
251
208
  else {
252
- const msg = rebaseErr instanceof Error ? rebaseErr.message : '';
253
- if (msg.toLowerCase().includes('nothing to commit') ||
254
- msg.toLowerCase().includes('apply --skip')) {
255
- // Empty commit after --theirs resolution — skip it
256
- nextAction = 'skip';
257
- }
258
- else {
259
- // Unexpected error — abort and leave the branch behind.
260
- // conflictStatus/conflictFiles are deliberately not updated: the
261
- // rebase did not complete, so it replaced nothing to record.
262
- workerLogWarn(` Unexpected rebase error in ${branchDir}: ${msg || 'Unknown error'}`);
263
- failureReason = msg || 'Unknown error';
264
- await branchGit.rebase(['--abort']).catch(() => { });
265
- break;
266
- }
209
+ // Unexpected error — abort and leave the branch behind.
210
+ // conflictStatus/conflictFiles are deliberately not updated: the
211
+ // rebase did not complete, so it replaced nothing to record.
212
+ workerLogWarn(` Unexpected rebase error in ${branchDir}: ${msg || 'Unknown error'}`);
213
+ failureReason = msg || 'Unknown error';
214
+ await branchGit.rebase(['--abort']).catch(() => { });
215
+ break;
267
216
  }
268
217
  }
269
218
  }
270
- return { completed, conflictedFiles, failureReason };
219
+ return { completed, conflicted, failureReason };
271
220
  }
272
221
  /**
273
222
  * [SYNC-H1] Carry a just-rebased history forward into `remote.git`, and queue
@@ -324,6 +273,83 @@ async function carryForwardRewrittenHistory(ctx, args) {
324
273
  }
325
274
  }
326
275
  }
276
+ /** What an outcome answers a request with; undefined when it settles nothing. */
277
+ function answerFor(outcome, probe) {
278
+ // `rebased` on a failure: the base came in, and only publishing the rewrite failed.
279
+ if (outcome.kind === 'rebased' || (outcome.kind === 'failed' && outcome.rebased)) {
280
+ return probe.keptEntries ? 'conflicts' : 'merged';
281
+ }
282
+ if (outcome.kind === 'none')
283
+ return probe.upToDate ? 'up-to-date' : undefined;
284
+ // A save since the request, which the sync never commits: the editor's Retry commits it.
285
+ if (outcome.kind === 'failed' || outcome.kind === 'skippedDirty')
286
+ return 'failed';
287
+ return undefined;
288
+ }
289
+ /** Whether a request is unanswered and recent enough for the worker to chase with early cycles. */
290
+ function worthAnotherCycle(branch) {
291
+ const age = Date.now() - Date.parse(branch.baseUpdateRequestedAt ?? '');
292
+ return (branch.status === 'editing' &&
293
+ isBaseUpdatePending(branch) &&
294
+ age >= -BASE_UPDATE_CLOCK_SKEW_MS &&
295
+ age < BASE_UPDATE_EARLY_RETRY_MS);
296
+ }
297
+ /**
298
+ * Answer the branch's request for the latest changes in `lastBaseUpdate`, which ends the editor's
299
+ * wait. Only from a cycle whose GitHub fetch began at or after the request, and only once the
300
+ * branch holds the base that fetch found: an earlier fetch may predate the changes asked for, and a
301
+ * base ref that didn't move (a held or failed update) isn't the latest. While the schema gate holds
302
+ * the base the request stands. Anything else that settles nothing, a lock or a GitHub step on its
303
+ * way among them, asks for another early cycle while the request is recent. Never throws.
304
+ */
305
+ async function answerBaseUpdate(ctx, branchDir, branchPath, probe, outcome, gitHub, requested) {
306
+ const { request } = probe;
307
+ if (!request) {
308
+ // Skipped before its request was read: a provisioning lock held elsewhere.
309
+ if (requested && outcome.kind === 'skippedLocked') {
310
+ const meta = await BranchMetadataFileManager.loadOnly(branchPath).catch(() => null);
311
+ if (meta && worthAnotherCycle(meta.branch))
312
+ ctx.requestBranchSync(branchDir);
313
+ }
314
+ return;
315
+ }
316
+ if (!gitHub || ctx.ensureStatusReport().baseHold)
317
+ return;
318
+ const askAgain = () => {
319
+ if (worthAnotherCycle({ status: 'editing', baseUpdateRequestedAt: request.requestedAt })) {
320
+ ctx.requestBranchSync(request.branchRef);
321
+ }
322
+ };
323
+ // The request's time is the Lambda's clock and the fetch's this host's: both keep NTP time.
324
+ if (gitHub.fetchedAt < Date.parse(request.requestedAt))
325
+ return askAgain();
326
+ const answer = answerFor(outcome, probe);
327
+ if (answer === undefined)
328
+ return askAgain();
329
+ const gitHubBase = gitHub.tips.get(ctx.baseBranch);
330
+ if (answer !== 'failed' && gitHubBase !== undefined && probe.baseSha !== gitHubBase) {
331
+ return askAgain();
332
+ }
333
+ const lastBaseUpdate = {
334
+ at: new Date(gitHub.fetchedAt).toISOString(),
335
+ outcome: answer,
336
+ ...(probe.baseSha !== undefined && { baseSha: probe.baseSha }),
337
+ ...(answer === 'conflicts' && { keptEntries: probe.keptEntries }),
338
+ };
339
+ try {
340
+ // A newer request, made after this fetch began, waits for the next cycle's answer.
341
+ const saved = await getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath).update((existing) => existing?.branch.name === request.branchRef &&
342
+ existing.branch.status === 'editing' &&
343
+ existing.branch.baseUpdateRequestedAt === request.requestedAt
344
+ ? { branch: { lastBaseUpdate } }
345
+ : null, { keepUpdatedAt: true });
346
+ if (saved)
347
+ workerLog(` ${branchDir}: answered the request for the latest changes (${answer})`);
348
+ }
349
+ catch (err) {
350
+ workerLogWarn(` ${branchDir}: could not record the answer to its request for the latest changes: ${getErrorMessage(err)}`);
351
+ }
352
+ }
327
353
  /**
328
354
  * Rebase every branch workspace that is behind the base branch, one cycle.
329
355
  *
@@ -336,7 +362,7 @@ async function carryForwardRewrittenHistory(ctx, args) {
336
362
  * that branch's cross-host content-write lock and runs a series of git
337
363
  * subprocesses against a shared filesystem.
338
364
  */
339
- export async function runRebaseCycle(ctx, gatewayPolicy, gitHub) {
365
+ export async function runRebaseCycle(ctx, gatewayPolicy, gitHub, requested = new Set()) {
340
366
  const rebased = [];
341
367
  const skippedDirty = [];
342
368
  const skippedLocked = [];
@@ -348,6 +374,8 @@ export async function runRebaseCycle(ctx, gatewayPolicy, gitHub) {
348
374
  catch {
349
375
  return { rebased, skippedDirty, skippedLocked, failed };
350
376
  }
377
+ // Branches someone is waiting on go first; the sort is stable, so the rest keep their order.
378
+ branchDirs.sort((a, b) => Number(requested.has(b)) - Number(requested.has(a)));
351
379
  for (const branchDir of branchDirs) {
352
380
  // A draining worker finishes the branch it is rebasing and starts no other:
353
381
  // a rebase killed mid-branch is recovered lossily on the next boot.
@@ -378,8 +406,8 @@ export async function runRebaseCycle(ctx, gatewayPolicy, gitHub) {
378
406
  }
379
407
  // The base branch's own clone is refreshed ff-only by
380
408
  // refreshBaseBranchWorkspace() earlier in syncGit(). Routing it through
381
- // this conflict-resolution loop could rewrite its history (the --theirs
382
- // loop below) and stamp meaningless conflict metadata on it. Compare
409
+ // this loop could rewrite its history (the rebase below) and stamp
410
+ // meaningless conflict metadata on it. Compare
383
411
  // sanitized-vs-sanitized: branchDir is a filesystem name, ctx.baseBranch
384
412
  // is raw.
385
413
  if (branchDir === ctx.sanitizedBaseBranch) {
@@ -393,7 +421,9 @@ export async function runRebaseCycle(ctx, gatewayPolicy, gitHub) {
393
421
  workerLogWarn(` Skipping ${branchDir}: settings branch, never a content workspace`);
394
422
  continue;
395
423
  }
396
- const outcome = await holdAndRebaseOneBranch(ctx, branchDir, branchPath, gatewayPolicy, gitHub);
424
+ const probe = {};
425
+ const outcome = await holdAndRebaseOneBranch(ctx, branchDir, branchPath, gatewayPolicy, gitHub, probe);
426
+ await answerBaseUpdate(ctx, branchDir, branchPath, probe, outcome, gitHub, requested.has(branchDir));
397
427
  if (outcome.kind === 'rebased')
398
428
  rebased.push(branchDir);
399
429
  else if (outcome.kind === 'skippedDirty')
@@ -461,7 +491,7 @@ async function restoreRenamedBranch(ctx, branchDir, branchPath, metaFile, namesp
461
491
  * content-write lock inside it; see `provisioned-workspace.ts` for why that order cannot deadlock.
462
492
  * Never throws, like rebaseOneBranch.
463
493
  */
464
- async function holdAndRebaseOneBranch(ctx, branchDir, branchPath, gatewayPolicy, gitHub) {
494
+ async function holdAndRebaseOneBranch(ctx, branchDir, branchPath, gatewayPolicy, gitHub, probe) {
465
495
  let hold;
466
496
  try {
467
497
  hold = await holdProvisionedWorkspace(ctx.contentBranchesPath, branchDir);
@@ -481,7 +511,7 @@ async function holdAndRebaseOneBranch(ctx, branchDir, branchPath, gatewayPolicy,
481
511
  if (hold.kind === 'not-provisioned')
482
512
  return { kind: 'none' };
483
513
  try {
484
- return await rebaseOneBranch(ctx, branchDir, branchPath, hold.isCompromised, gatewayPolicy, gitHub);
514
+ return await rebaseOneBranch(ctx, branchDir, branchPath, hold.isCompromised, gatewayPolicy, gitHub, probe);
485
515
  }
486
516
  finally {
487
517
  await releaseProvisionedWorkspace(hold.release, branchDir);
@@ -510,6 +540,32 @@ async function lacksPublishedCommits(ctx, git, branchRef) {
510
540
  await fetchFromRemoteGit(git, ctx.remoteGitPath, `refs/heads/${branchRef}`);
511
541
  return (await commitsApart(git, 'HEAD', published)).onlyB > 0;
512
542
  }
543
+ /**
544
+ * {@link reconcileMergeConflicts} for a submitted or approved branch, whose merges of GitHub's
545
+ * commits can leave records too, under the content-write lock as for a branch being edited. Only
546
+ * for a clone with a record, or with `conflictFiles` to clear, so others cost one stat. Never
547
+ * throws.
548
+ */
549
+ async function reconcileInReview(ctx, branchDir, branchPath, metaFile, isProvisioningLockCompromised) {
550
+ try {
551
+ const flagged = (metaFile.branch.conflictFiles?.length ?? 0) > 0;
552
+ if (!flagged && !(await new MergeConflictStore(branchPath).exists()))
553
+ return;
554
+ await assertSharedRepoConfig(branchPath, 'worktree');
555
+ await withContentLockInReview(branchDir, branchPath, isProvisioningLockCompromised, async () => {
556
+ await reconcileMergeConflicts(ctx, {
557
+ git: sharedRepoGit(branchPath, 'worktree'),
558
+ branchPath,
559
+ branchDir,
560
+ branchRef: await readCloneRef(branchPath),
561
+ });
562
+ return { kind: 'none' };
563
+ });
564
+ }
565
+ catch (err) {
566
+ workerLogWarn(` ${branchDir}: could not reconcile its conflict record: ${getErrorMessage(err)}`);
567
+ }
568
+ }
513
569
  /**
514
570
  * {@link bringInGitHubCommits} for a submitted or approved branch, which skips the rest of the
515
571
  * rebase loop. Under the content-write lock, zero-retry, as a save or a submit can hold it. A
@@ -599,14 +655,20 @@ function pullRequestHoldOf(pullRequest, recordedBase) {
599
655
  /**
600
656
  * Record {@link pullRequestHoldOf} for the branch card, when it changed: every save regenerates the
601
657
  * branch registry. It's bookkeeping, so it leaves `updatedAt`, which the card shows as an edit.
602
- * Best-effort: a failure leaves the card as it was until the next cycle.
658
+ * `tookBase` says this cycle merged the base in, which GitHub measured the pull request before, so
659
+ * nothing holds it. Best-effort: a failure leaves the card as it was until the next cycle.
603
660
  */
604
- async function recordPullRequestHold(ctx, branchDir, branchPath, metaFile, pullRequest) {
661
+ async function recordPullRequestHold(ctx, branchDir, branchPath, metaFile, pullRequest, tookBase) {
605
662
  const { name, baseBranch, pullRequestHold, submittedAt } = metaFile.branch;
606
- const hold = pullRequestHoldOf(pullRequest, baseBranch ?? ctx.baseBranch);
607
- if (hold === null || JSON.stringify(hold) === JSON.stringify(pullRequestHold))
608
- return;
609
663
  try {
664
+ let hold = tookBase ? undefined : pullRequestHoldOf(pullRequest, baseBranch ?? ctx.baseBranch);
665
+ // A conflict counts only on the commit the clone holds: one measured on an older head, as while
666
+ // the worker's own merge is on its way to GitHub, says nothing about the branch now.
667
+ if (hold?.kind === 'conflicts' && pullRequest.headSha !== (await cloneHead(branchPath))) {
668
+ hold = pullRequestHold?.kind === 'conflicts' ? null : undefined;
669
+ }
670
+ if (hold === null || JSON.stringify(hold) === JSON.stringify(pullRequestHold))
671
+ return;
610
672
  await getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath).update(
611
673
  // A newer submit's pull request isn't the one measured.
612
674
  (current) => current?.branch.name === name && current.branch.submittedAt === submittedAt
@@ -617,17 +679,24 @@ async function recordPullRequestHold(ctx, branchDir, branchPath, metaFile, pullR
617
679
  workerLogWarn(` ${branchDir}: could not record the PR's state: ${getErrorMessage(err)}`);
618
680
  }
619
681
  }
682
+ /** The commit a branch clone holds. */
683
+ async function cloneHead(branchPath) {
684
+ await assertSharedRepoConfig(branchPath, 'worktree');
685
+ return (await sharedRepoGit(branchPath, 'worktree').revparse(['HEAD'])).trim();
686
+ }
620
687
  /**
621
688
  * Merge the base into a submitted or approved branch whose open pull request GitHub reports
622
- * `behind`, so it can merge. It keeps the approval and the PR body: the pull request's diff is what
623
- * was reviewed. Only from a clone, `remote.git` and GitHub all at one commit, since anything else
624
- * is work still on its way, and only for the base GitHub measured against: a pull request someone
625
- * retargeted would gain another branch's commits under its approval. A branch with a failed sync
626
- * is left for its next submit or a retry of the task, as a push here would clear that failure with
627
- * the PR still stale. A conflict leaves the branch as it is; withdrawing it brings the rebase
628
- * loop's rule into play, where an editor can see the result. A failure goes in the cycle's summary
629
- * only: the step before this one clears `rebaseFailure` each cycle, so recording it would reset its
630
- * age every time.
689
+ * `behind` or `dirty`, so it can merge. A submitted branch stays submitted: the pull request's diff
690
+ * is still the author's changes. An approved branch keeps its approval only for git's own clean
691
+ * merge; any merge the field merge settled sends it back to review (`mergeIntoBranch`). Only from a
692
+ * clone, `remote.git` and GitHub all at one commit, since anything else is work still on its way,
693
+ * and only for the base GitHub measured against: a pull request someone retargeted would gain
694
+ * another branch's commits under its approval. A branch with a failed sync is left for its next
695
+ * submit or a retry of the task, as a push here would clear that failure with the PR still stale. A
696
+ * merge that leaves a value or file for a person leaves the branch as it is (`mergeIntoBranch`'s
697
+ * `inReview`), and withdrawing it lets the merge for a branch being edited record the conflicts,
698
+ * where an editor can resolve them. A failure goes in the cycle's summary only: the step before
699
+ * this one clears `rebaseFailure` each cycle, so recording it would reset its age every time.
631
700
  * Never throws.
632
701
  */
633
702
  async function updateBranchInReviewFromBase(ctx, branchDir, branchPath, pullRequest, isProvisioningLockCompromised, gatewayPolicy, gitHub) {
@@ -682,7 +751,7 @@ async function updateBranchInReviewFromBase(ctx, branchDir, branchPath, pullRequ
682
751
  other: baseTip,
683
752
  message: `Merge ${base} into ${branchRef}`,
684
753
  fromGitHub: false,
685
- stopOnConflict: true,
754
+ inReview: true,
686
755
  });
687
756
  if (outcome.kind === 'blocked') {
688
757
  const error = redactCredentials(outcome.reason);
@@ -728,8 +797,9 @@ async function withContentLockInReview(branchDir, branchPath, isProvisioningLock
728
797
  }
729
798
  }
730
799
  /**
731
- * Rebase ONE branch workspace onto the base branch, keeping the branch's
732
- * version of every conflicting file.
800
+ * Bring the base branch into ONE branch workspace: by a rebase where nothing
801
+ * needs a person, else by a merge that leaves each value both sides changed
802
+ * differently for one.
733
803
  *
734
804
  * `branchDir` is the sanitized directory name and `branchPath` its absolute
735
805
  * path; the caller has already established that this is a real branch
@@ -739,7 +809,7 @@ async function withContentLockInReview(branchDir, branchPath, isProvisioningLock
739
809
  * returned as `{ kind: 'failed' }`, so one unreadable branch cannot halt the
740
810
  * rest of the cycle ([SYNC-M2]).
741
811
  */
742
- async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCompromised, gatewayPolicy, gitHub) {
812
+ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCompromised, gatewayPolicy, gitHub, probe = {}) {
743
813
  // Read only by the catch below. See the `rebased` rider on
744
814
  // BranchRebaseOutcome.
745
815
  let didRebase = false;
@@ -747,6 +817,14 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
747
817
  // Restored first: everything below this cycle compares `name`.
748
818
  const metaFile = await restoreRenamedBranch(ctx, branchDir, branchPath, await BranchMetadataFileManager.loadOnly(branchPath), gatewayPolicy?.namespace);
749
819
  const branchStatus = metaFile?.branch.status;
820
+ // Only an editing branch takes the base in, so only its request can be answered.
821
+ const requestedAt = metaFile?.branch.baseUpdateRequestedAt;
822
+ if (metaFile !== null &&
823
+ branchStatus === 'editing' &&
824
+ requestedAt !== undefined &&
825
+ isBaseUpdatePending(metaFile.branch)) {
826
+ probe.request = { branchRef: metaFile.branch.name, requestedAt };
827
+ }
750
828
  // Skip branches that must not be mutated:
751
829
  // - submitted/approved: in review, so never rewrite history under an active
752
830
  // PR -- but do poll GitHub for the PR's resolution, since nothing else
@@ -754,19 +832,21 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
754
832
  // - archived: finished, with no PR left to poll.
755
833
  if (metaFile !== null && (branchStatus === 'submitted' || branchStatus === 'approved')) {
756
834
  workerLog(` Skipping ${branchDir} (${branchStatus})`);
835
+ await reconcileInReview(ctx, branchDir, branchPath, metaFile, isProvisioningLockCompromised);
757
836
  const outcome = await bringInCommitsForBranchInReview(ctx, branchDir, branchPath, metaFile, isProvisioningLockCompromised, gatewayPolicy, gitHub);
758
837
  const pullRequest = await pollMergeState(ctx, branchDir, branchPath, metaFile);
759
- if (pullRequest !== null) {
760
- await recordPullRequestHold(ctx, branchDir, branchPath, metaFile, pullRequest);
761
- }
762
- // GitHub reports `behind` only where up-to-date branches are required, so a repository
763
- // that doesn't require them gets no commits or CI runs from this.
764
- if (outcome.kind === 'none' &&
838
+ // GitHub reports `behind` only where up-to-date branches are required, and `dirty` only where
839
+ // git's merge conflicts, so a pull request that merges as it is gets no commits or CI runs.
840
+ const updated = outcome.kind === 'none' &&
765
841
  pullRequest?.state === 'open' &&
766
- pullRequest.mergeable_state === 'behind') {
767
- return updateBranchInReviewFromBase(ctx, branchDir, branchPath, pullRequest, isProvisioningLockCompromised, gatewayPolicy, gitHub);
842
+ (pullRequest.mergeable_state === 'behind' || pullRequest.mergeable_state === 'dirty')
843
+ ? await updateBranchInReviewFromBase(ctx, branchDir, branchPath, pullRequest, isProvisioningLockCompromised, gatewayPolicy, gitHub)
844
+ : outcome;
845
+ if (pullRequest !== null) {
846
+ const tookBase = updated.kind === 'rebased';
847
+ await recordPullRequestHold(ctx, branchDir, branchPath, metaFile, pullRequest, tookBase);
768
848
  }
769
- return outcome;
849
+ return updated;
770
850
  }
771
851
  if (branchStatus === 'archived') {
772
852
  workerLog(` Skipping ${branchDir} (${branchStatus})`);
@@ -778,13 +858,11 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
778
858
  // The pins also keep git non-interactive (core.editor=true): a rebase never waits on an editor.
779
859
  const branchGit = sharedRepoGit(branchPath, 'worktree');
780
860
  // [SYNC-C1] Take the branch's cross-host content-write lock BEFORE the
781
- // dirty check, and hold it for the whole rebase. The dirty check alone is
782
- // check-then-act, and its residual window spans fetch, replay and N
783
- // conflict rounds of awaited git subprocesses on EFS. A save landing in
784
- // there is destroyed two ways: `checkout --theirs` below overwrites the
785
- // just-saved file with the branch's committed version and the rebase then
786
- // SUCCEEDS (nothing logs a failure at all), and `rebase --abort`
787
- // hard-resets the tree. The editor already got its 200 either way.
861
+ // dirty check, and hold it for the whole rebase or merge. The dirty check
862
+ // alone is check-then-act, and its residual window spans fetch, replay and
863
+ // the merge's awaited git subprocesses on EFS. A save landing in there makes
864
+ // the next replayed commit fail, and the `rebase --abort` that follows
865
+ // hard-resets the tree, discarding it. The editor already got its 200.
788
866
  //
789
867
  // Zero-retry acquisition, deliberately: on contention this branch is
790
868
  // skipped and retried next sync cycle (~5 min), the same principle as the
@@ -902,6 +980,12 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
902
980
  // every publish path stays disarmed -- the safe direction.
903
981
  const branchRef = (await branchGit.revparse(['--abbrev-ref', 'HEAD'])).trim();
904
982
  const canPublish = branchRef.length > 0 && branchRef !== 'HEAD';
983
+ await reconcileMergeConflicts(ctx, {
984
+ git: branchGit,
985
+ branchPath,
986
+ branchDir,
987
+ branchRef: canPublish ? branchRef : null,
988
+ });
905
989
  const commitArgs = canPublish && metaFile !== null
906
990
  ? {
907
991
  git: branchGit,
@@ -927,6 +1011,13 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
927
1011
  else if (outcome.kind !== 'in-step') {
928
1012
  return editorDirt.length > 0 ? { kind: 'skippedDirty' } : { kind: 'none' };
929
1013
  }
1014
+ // A merge of the base this worker published and never moved the clone to comes first.
1015
+ const caughtUp = await catchUpWithPublished(ctx, commitArgs);
1016
+ if (caughtUp?.kind === 'blocked') {
1017
+ return recordBlocked(ctx, branchDir, branchPath, branchRef, caughtUp.reason);
1018
+ }
1019
+ if (caughtUp)
1020
+ return editorDirt.length > 0 ? { kind: 'skippedDirty' } : { kind: 'none' };
930
1021
  }
931
1022
  if (editorDirt.length > 0) {
932
1023
  workerLog(` Skipping ${branchDir}: has uncommitted changes`);
@@ -977,7 +1068,9 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
977
1068
  const fetchedBaseTip = (await branchGit.revparse(['FETCH_HEAD'])).trim();
978
1069
  const behindCount = parseInt((await branchGit.raw(['rev-list', '--count', `HEAD..${fetchedBaseTip}`])).trim(), 10);
979
1070
  const meta = getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath);
1071
+ probe.baseSha = fetchedBaseTip;
980
1072
  if (behindCount === 0) {
1073
+ probe.upToDate = true;
981
1074
  // Already in sync -- the overwhelmingly common outcome per branch per
982
1075
  // cycle, so skip the save entirely when metadata already reflects a
983
1076
  // clean state. save() eager-regenerates the branch registry
@@ -988,9 +1081,8 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
988
1081
  // concurrent editor-driven metadata change in that window is not
989
1082
  // clobbered by a stale skip decision.
990
1083
  //
991
- // Catching up leaves the conflict record alone: it names entries whose
992
- // base change this branch's version replaced, which stays true until
993
- // the branch merges (see recordConflicts). A lingering rebaseFailure
1084
+ // Catching up leaves the conflict record alone: it names values a person
1085
+ // has yet to choose, which stays true until they do. A lingering rebaseFailure
994
1086
  // is cleared, or it sticks as a stale warning forever: nothing else
995
1087
  // touches a caught-up branch, so no other save site clears it. One
996
1088
  // whose cause stands stays: remote.git still holds a commit this clone
@@ -1007,17 +1099,42 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
1007
1099
  return { kind: 'none' };
1008
1100
  }
1009
1101
  // `git rebase` drops merge commits and replays what they merged, so whatever one settled
1010
- // (a kept version, a maintainer's conflict resolution) would be undone, then force-published.
1011
- // A branch holding one merges the base instead. So does one whose commits touch canopycms
1012
- // state: a replay writes their bytes over the live files, and a pair that adds then removes
1013
- // a file deletes it.
1102
+ // (a value a person chose, a maintainer's conflict resolution) would be undone, then
1103
+ // force-published. A branch holding one merges the base instead. So does one whose commits
1104
+ // touch canopycms state: a replay writes their bytes over the live files, and a pair that
1105
+ // adds then removes a file deletes it.
1014
1106
  const aboveBase = `${fetchedBaseTip}..HEAD`;
1015
1107
  const countAboveBase = async (args) => (await branchGit.raw(['rev-list', '--count', ...args])).trim();
1016
1108
  const cannotReplay = (await countAboveBase(['--merges', '--end-of-options', aboveBase])) !== '0' ||
1017
1109
  (await countAboveBase(['--end-of-options', aboveBase, '--', CANOPY_META_DIR])) !== '0';
1018
- if (commitArgs !== null && (cannotReplay || unpushed)) {
1110
+ const head = (await branchGit.revparse(['HEAD'])).trim();
1111
+ // Commits on top of what this branch published, as "Get latest changes" makes from saved work
1112
+ // on a branch submitted once, or a submit whose push failed: a rebase would rewrite the
1113
+ // published commit under them, which reads as a push this clone never saw. A merge publishes
1114
+ // them as a fast-forward. A same-named ref at a commit the base holds is no work of this branch.
1115
+ const published = canPublish ? await readPublishedSha(ctx, branchRef) : null;
1116
+ const aheadOfPublished = published !== null &&
1117
+ published !== head &&
1118
+ metaFile?.branch.name === branchRef &&
1119
+ isPublished(metaFile) &&
1120
+ (await contains(branchGit, head, published)) &&
1121
+ !(await contains(branchGit, fetchedBaseTip, published));
1122
+ /**
1123
+ * The base taken in by a merge (github-commits.ts), which decides each value against the fork
1124
+ * point and leaves what both sides changed differently for a person, rather than a rebase,
1125
+ * whose rounds can only settle a conflict by rule. A clone with no branch checked out, or no
1126
+ * `branch.json`, cannot name the ref the merge publishes, so it stays behind.
1127
+ */
1128
+ const takeBaseByMerge = async () => {
1129
+ if (commitArgs === null) {
1130
+ const reason = `the base conflicts with this clone's work, and the clone has no branch checked out, ` +
1131
+ `or no branch.json, to merge it into`;
1132
+ workerLogWarn(` Skipping ${branchDir}: ${reason}`);
1133
+ await recordRebaseFailure(ctx, branchPath, branchDir, reason);
1134
+ return { kind: 'failed', error: reason };
1135
+ }
1019
1136
  const outcome = await mergeIntoBranch(ctx, commitArgs, {
1020
- head: (await branchGit.revparse(['HEAD'])).trim(),
1137
+ head,
1021
1138
  other: fetchedBaseTip,
1022
1139
  message: `Merge ${ctx.baseBranch} into ${branchRef}`,
1023
1140
  fromGitHub: false,
@@ -1025,20 +1142,39 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
1025
1142
  if (outcome.kind === 'blocked') {
1026
1143
  return recordBlocked(ctx, branchDir, branchPath, branchRef, outcome.reason);
1027
1144
  }
1028
- if (outcome.kind !== 'merged')
1029
- return { kind: 'none' };
1145
+ if (outcome.kind !== 'merged') {
1146
+ if (!lockCompromised())
1147
+ return { kind: 'none' };
1148
+ workerLogWarn(` Skipping ${branchDir}: a lock it held was lost mid-merge (retrying next cycle)`);
1149
+ return { kind: 'skippedLocked' };
1150
+ }
1151
+ probe.keptEntries = outcome.keptEntries;
1030
1152
  await clearRebaseFailure(ctx, branchPath, branchRef);
1031
1153
  return { kind: 'rebased' };
1154
+ };
1155
+ if (commitArgs !== null && (cannotReplay || unpushed || aheadOfPublished)) {
1156
+ return await takeBaseByMerge();
1157
+ }
1158
+ // A rebase only where it takes in exactly what a merge would, with nothing for a person: git
1159
+ // conflicts nowhere, and the field merge decides no file otherwise than git does.
1160
+ if (commitArgs !== null &&
1161
+ !mergesAsGitDoes(await planMerge(branchGit, branchPath, {
1162
+ head,
1163
+ other: fetchedBaseTip,
1164
+ contentRoot: ctx.contentRoot,
1165
+ timeoutMs: ctx.taskTimeoutMs,
1166
+ }))) {
1167
+ return await takeBaseByMerge();
1032
1168
  }
1033
1169
  workerLog(`Rebasing ${branchDir} (${behindCount} commits behind)...`);
1034
1170
  // Read BOTH sides before rewriting anything: the arming guard after the
1035
1171
  // rebase compares what remote.git published against what this clone is
1036
1172
  // about to rebase away, and the clone's pre-rebase tip is exactly what
1037
1173
  // the rebase makes unreadable.
1038
- const preRebaseHead = (await branchGit.revparse(['HEAD'])).trim();
1039
- const publishedSha = canPublish ? await readPublishedSha(ctx, branchRef) : null;
1174
+ const preRebaseHead = head;
1175
+ const publishedSha = published;
1040
1176
  await assertNoIncomingSubmodules(branchGit, branchPath, preRebaseHead, fetchedBaseTip);
1041
- const { completed, conflictedFiles, failureReason } = await runRebaseRounds(ctx, branchGit, branchDir, fetchedBaseTip, lockCompromised);
1177
+ const { completed, conflicted, failureReason } = await runRebaseRounds(ctx, branchGit, branchDir, fetchedBaseTip, lockCompromised);
1042
1178
  // Outside the round loop's try/catch, so a throwing test hook can
1043
1179
  // never be misread as a rebase error.
1044
1180
  if (completed)
@@ -1072,6 +1208,11 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
1072
1208
  if (lockCompromised()) {
1073
1209
  workerLogWarn(` A lock held 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)`);
1074
1210
  }
1211
+ if (conflicted) {
1212
+ // The round loop aborted the rebase; this tells the caches the tree moved back.
1213
+ await abortRebase(branchGit, branchPath).catch(() => { });
1214
+ return await takeBaseByMerge();
1215
+ }
1075
1216
  if (!completed) {
1076
1217
  // failureReason is only set on the "unexpected error" break above;
1077
1218
  // MAX_REBASE_ROUNDS exhaustion is a distinct exit with no message of
@@ -1098,18 +1239,14 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
1098
1239
  // and (via the on-disk generation marker) in the Lambda containers
1099
1240
  // sharing this filesystem.
1100
1241
  await invalidateBranchContentCaches(branchPath);
1101
- const conflictIdsDeduped = conflictFilesToContentIds(conflictedFiles, ctx.contentRoot);
1102
- const hadConflicts = conflictIdsDeduped.length > 0;
1103
- workerLog(hadConflicts
1104
- ? ` Rebased ${branchDir} (kept branch version for ${conflictIdsDeduped.length} conflicting file(s))`
1105
- : ` Rebased ${branchDir} successfully`);
1106
- // Recorded only on a branch.json that still names the branch this cycle read.
1242
+ probe.keptEntries = 0;
1243
+ workerLog(` Rebased ${branchDir} successfully`);
1244
+ // Recorded only on a branch.json that still names the branch this cycle read. The cycle
1245
+ // completed, so any prior failure record goes, whatever conflicts stay on record.
1107
1246
  await meta.update((existing) => existing !== null && existing.branch.name === metaFile?.branch.name
1108
1247
  ? {
1109
1248
  branch: {
1110
- ...recordConflicts(existing.branch.conflictFiles, conflictIdsDeduped),
1111
- // The cycle completed, so clear any prior failure record regardless
1112
- // of the conflict outcome.
1249
+ conflictStatus: (existing.branch.conflictFiles?.length ?? 0) > 0 ? 'conflicts-detected' : 'clean',
1113
1250
  rebaseFailure: undefined,
1114
1251
  },
1115
1252
  }