canopycms 0.12.0 → 0.13.0

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 (177) hide show
  1. package/CHANGELOG.md +326 -48
  2. package/dist/api/admin.d.ts +4 -4
  3. package/dist/api/branch-review.d.ts +20 -2
  4. package/dist/api/branch-review.js +153 -32
  5. package/dist/api/branch-status.d.ts +23 -2
  6. package/dist/api/branch-status.js +16 -2
  7. package/dist/api/branch-update-from-base.d.ts +23 -0
  8. package/dist/api/branch-update-from-base.js +227 -0
  9. package/dist/api/branch.d.ts +10 -0
  10. package/dist/api/branch.js +28 -2
  11. package/dist/api/client.d.ts +15 -2
  12. package/dist/api/client.js +24 -2
  13. package/dist/api/comments.d.ts +2 -2
  14. package/dist/api/content.d.ts +38 -1
  15. package/dist/api/content.js +41 -5
  16. package/dist/api/merge-conflicts.d.ts +235 -0
  17. package/dist/api/merge-conflicts.js +709 -0
  18. package/dist/api/routes.js +2 -0
  19. package/dist/api/types.d.ts +8 -3
  20. package/dist/base-update.d.ts +32 -0
  21. package/dist/base-update.js +31 -0
  22. package/dist/branch-metadata-file.d.ts +66 -0
  23. package/dist/branch-metadata-file.js +16 -1
  24. package/dist/branch-schema-cache.d.ts +12 -0
  25. package/dist/branch-schema-cache.js +27 -2
  26. package/dist/cli/backfill-item-keys.d.ts +77 -0
  27. package/dist/cli/backfill-item-keys.js +320 -0
  28. package/dist/cli/cli.d.ts +1 -1
  29. package/dist/cli/cli.js +9798 -1509
  30. package/dist/cli/generate-ai-content.js +373 -249
  31. package/dist/cli/github-app-manifest.d.ts +4 -1
  32. package/dist/cli/github-app-manifest.js +5 -1
  33. package/dist/cli/init.js +12 -0
  34. package/dist/cli/site-config.d.ts +14 -2
  35. package/dist/cli/site-config.js +20 -4
  36. package/dist/comment-store.d.ts +8 -0
  37. package/dist/comment-store.js +28 -0
  38. package/dist/content-index-generation.d.ts +1 -4
  39. package/dist/content-index-generation.js +1 -4
  40. package/dist/content-store.d.ts +39 -3
  41. package/dist/content-store.js +48 -15
  42. package/dist/editor/BranchManager.d.ts +4 -1
  43. package/dist/editor/BranchManager.js +9 -5
  44. package/dist/editor/Editor.js +462 -62
  45. package/dist/editor/EntryNavigator.d.ts +10 -2
  46. package/dist/editor/EntryNavigator.js +12 -4
  47. package/dist/editor/FormRenderer.d.ts +8 -2
  48. package/dist/editor/FormRenderer.js +5 -6
  49. package/dist/editor/admin/ConflictsStatusAlert.d.ts +8 -0
  50. package/dist/editor/admin/ConflictsStatusAlert.js +22 -0
  51. package/dist/editor/admin/SystemHealthPanel.js +4 -3
  52. package/dist/editor/base-update-wait.d.ts +78 -0
  53. package/dist/editor/base-update-wait.js +98 -0
  54. package/dist/editor/branch-status.js +1 -1
  55. package/dist/editor/components/BaseUpdateNotice.d.ts +12 -0
  56. package/dist/editor/components/BaseUpdateNotice.js +24 -0
  57. package/dist/editor/components/BranchPill.d.ts +4 -1
  58. package/dist/editor/components/BranchPill.js +5 -2
  59. package/dist/editor/components/ConflictsGroup.d.ts +112 -0
  60. package/dist/editor/components/ConflictsGroup.js +404 -0
  61. package/dist/editor/components/EditorHeader.d.ts +18 -1
  62. package/dist/editor/components/EditorHeader.js +99 -33
  63. package/dist/editor/components/EntryChangesDrawer.d.ts +16 -2
  64. package/dist/editor/components/EntryChangesDrawer.js +85 -167
  65. package/dist/editor/components/EntryHeader.d.ts +3 -1
  66. package/dist/editor/components/EntryHeader.js +2 -2
  67. package/dist/editor/components/GetLatestChangesButton.d.ts +15 -0
  68. package/dist/editor/components/GetLatestChangesButton.js +29 -0
  69. package/dist/editor/components/ReleaseHoldButton.d.ts +20 -0
  70. package/dist/editor/components/ReleaseHoldButton.js +36 -0
  71. package/dist/editor/components/RetryToastMessage.js +1 -1
  72. package/dist/editor/components/change-parts.d.ts +51 -0
  73. package/dist/editor/components/change-parts.js +171 -0
  74. package/dist/editor/conflict-cards.d.ts +93 -0
  75. package/dist/editor/conflict-cards.js +284 -0
  76. package/dist/editor/conflict-choices.d.ts +60 -0
  77. package/dist/editor/conflict-choices.js +108 -0
  78. package/dist/editor/conflict-notice.d.ts +25 -0
  79. package/dist/editor/conflict-notice.js +39 -0
  80. package/dist/editor/copy.d.ts +12 -0
  81. package/dist/editor/copy.js +12 -0
  82. package/dist/editor/entry-diff.d.ts +2 -1
  83. package/dist/editor/entry-diff.js +5 -21
  84. package/dist/editor/hooks/useBaseUpdate.d.ts +38 -0
  85. package/dist/editor/hooks/useBaseUpdate.js +279 -0
  86. package/dist/editor/hooks/useBranchChangesData.d.ts +7 -1
  87. package/dist/editor/hooks/useBranchChangesData.js +13 -0
  88. package/dist/editor/hooks/useBranchManager.d.ts +8 -1
  89. package/dist/editor/hooks/useBranchManager.js +108 -13
  90. package/dist/editor/hooks/useBranchesData.js +3 -0
  91. package/dist/editor/hooks/useConflictChoices.d.ts +24 -0
  92. package/dist/editor/hooks/useConflictChoices.js +75 -0
  93. package/dist/editor/hooks/useConflictsData.d.ts +26 -0
  94. package/dist/editor/hooks/useConflictsData.js +89 -0
  95. package/dist/editor/hooks/useDraftManager.d.ts +13 -1
  96. package/dist/editor/hooks/useDraftManager.js +40 -9
  97. package/dist/editor/hooks/useEntryManager.d.ts +17 -3
  98. package/dist/editor/hooks/useEntryManager.js +11 -5
  99. package/dist/editor/release-hold.d.ts +12 -0
  100. package/dist/editor/release-hold.js +39 -0
  101. package/dist/editor/server-clock.d.ts +4 -0
  102. package/dist/editor/server-clock.js +15 -0
  103. package/dist/editor/undo-change.d.ts +7 -0
  104. package/dist/editor/undo-change.js +19 -4
  105. package/dist/git-manager.d.ts +6 -0
  106. package/dist/git-manager.js +38 -3
  107. package/dist/item-keys.d.ts +13 -0
  108. package/dist/item-keys.js +36 -14
  109. package/dist/merge/conflict-path.d.ts +40 -0
  110. package/dist/merge/conflict-path.js +128 -0
  111. package/dist/merge/diff3.d.ts +27 -0
  112. package/dist/merge/diff3.js +113 -0
  113. package/dist/merge/entry-merge.d.ts +94 -0
  114. package/dist/merge/entry-merge.js +876 -0
  115. package/dist/merge/list-alignment.d.ts +55 -0
  116. package/dist/merge/list-alignment.js +490 -0
  117. package/dist/merge/rekey.d.ts +11 -0
  118. package/dist/merge/rekey.js +75 -0
  119. package/dist/merge/text-merge.d.ts +29 -0
  120. package/dist/merge/text-merge.js +59 -0
  121. package/dist/merge/value-equality.d.ts +18 -0
  122. package/dist/merge/value-equality.js +50 -0
  123. package/dist/merge-conflicts.d.ts +232 -0
  124. package/dist/merge-conflicts.js +451 -0
  125. package/dist/operating-mode/client-safe-strategy.d.ts +2 -0
  126. package/dist/operating-mode/client-safe-strategy.js +6 -0
  127. package/dist/operating-mode/client-unsafe-strategy.js +6 -0
  128. package/dist/operating-mode/types.d.ts +13 -0
  129. package/dist/services.d.ts +24 -1
  130. package/dist/services.js +138 -22
  131. package/dist/task-queue/cms-task-queue.d.ts +3 -1
  132. package/dist/types.d.ts +63 -5
  133. package/dist/utils/content-serialize.d.ts +18 -1
  134. package/dist/utils/content-serialize.js +12 -0
  135. package/dist/utils/content-write-lock.d.ts +3 -3
  136. package/dist/utils/content-write-lock.js +3 -3
  137. package/dist/utils/git.d.ts +7 -0
  138. package/dist/utils/git.js +35 -0
  139. package/dist/utils/markdown-body-splice.d.ts +1 -1
  140. package/dist/utils/markdown-body-splice.js +1 -19
  141. package/dist/utils/markdown-parse.d.ts +9 -0
  142. package/dist/utils/markdown-parse.js +27 -0
  143. package/dist/version.d.ts +1 -1
  144. package/dist/version.js +1 -1
  145. package/dist/worker/branch-merge.d.ts +80 -40
  146. package/dist/worker/branch-merge.js +244 -58
  147. package/dist/worker/branch-policy.d.ts +18 -2
  148. package/dist/worker/branch-policy.js +36 -0
  149. package/dist/worker/cms-worker.d.ts +37 -3
  150. package/dist/worker/cms-worker.js +88 -16
  151. package/dist/worker/conflicts-status.d.ts +20 -0
  152. package/dist/worker/conflicts-status.js +204 -0
  153. package/dist/worker/field-merge.d.ts +80 -0
  154. package/dist/worker/field-merge.js +729 -0
  155. package/dist/worker/git-sync.d.ts +3 -2
  156. package/dist/worker/git-sync.js +16 -6
  157. package/dist/worker/github-commits.d.ts +42 -10
  158. package/dist/worker/github-commits.js +444 -52
  159. package/dist/worker/github-errors.d.ts +15 -0
  160. package/dist/worker/github-errors.js +47 -0
  161. package/dist/worker/github-gateway-http.js +24 -9
  162. package/dist/worker/github-gateway-wire.d.ts +49 -20
  163. package/dist/worker/github-gateway-wire.js +19 -3
  164. package/dist/worker/github-gateway.d.ts +27 -1
  165. package/dist/worker/github-gateway.js +55 -7
  166. package/dist/worker/github-transfer.d.ts +5 -3
  167. package/dist/worker/github-transfer.js +8 -5
  168. package/dist/worker/history-rewrite.d.ts +40 -8
  169. package/dist/worker/history-rewrite.js +51 -23
  170. package/dist/worker/publish-decision.d.ts +37 -0
  171. package/dist/worker/publish-decision.js +16 -0
  172. package/dist/worker/rebase.d.ts +29 -8
  173. package/dist/worker/rebase.js +492 -211
  174. package/dist/worker/task-runner.d.ts +1 -1
  175. package/dist/worker/task-runner.js +30 -41
  176. package/dist/worker/worker-context.d.ts +24 -2
  177. 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
@@ -311,9 +260,14 @@ async function carryForwardRewrittenHistory(ctx, args) {
311
260
  `${branchRef}, so the rewrite is not published`);
312
261
  return;
313
262
  }
314
- if (await forcePublishToLocalRemote(ctx, branchPath, branchRef, publishedSha)) {
263
+ const publish = await forcePublishToLocalRemote(ctx, branchPath, branchRef, publishedSha, rebasedHead);
264
+ if (publish.kind === 'published') {
315
265
  await enqueueGitHubPush(ctx, branchRef, gatewayPolicy);
316
266
  }
267
+ else if (publish.kind === 'failed') {
268
+ const reason = redactCredentials(`this branch's rebased history could not be published into the deployment's repository: ${publish.message}`);
269
+ await recordRebaseFailure(ctx, branchPath, branchDir, reason, branchRef);
270
+ }
317
271
  }
318
272
  else {
319
273
  const divergence = `rebased locally, but remote.git holds ${publishedSha} for ${branchRef}, which this ` +
@@ -324,19 +278,108 @@ async function carryForwardRewrittenHistory(ctx, args) {
324
278
  }
325
279
  }
326
280
  }
281
+ /** What an outcome answers a request with; undefined when it settles nothing. */
282
+ function answerFor(outcome, probe) {
283
+ // `rebased` on a failure: the base came in, and only publishing the rewrite failed.
284
+ if (outcome.kind === 'rebased' || (outcome.kind === 'failed' && outcome.rebased)) {
285
+ return probe.keptEntries ? 'conflicts' : 'merged';
286
+ }
287
+ if (outcome.kind === 'none')
288
+ return probe.upToDate ? 'up-to-date' : undefined;
289
+ // A save since the request, which the sync never commits: the editor's Retry commits it.
290
+ if (outcome.kind === 'failed' || outcome.kind === 'skippedDirty')
291
+ return 'failed';
292
+ return undefined;
293
+ }
294
+ /** Whether a request is unanswered and recent enough for the worker to chase with early cycles. */
295
+ function worthAnotherCycle(branch) {
296
+ const age = Date.now() - Date.parse(branch.baseUpdateRequestedAt ?? '');
297
+ return (branch.status === 'editing' &&
298
+ isBaseUpdatePending(branch) &&
299
+ age >= -BASE_UPDATE_CLOCK_SKEW_MS &&
300
+ age < BASE_UPDATE_EARLY_RETRY_MS);
301
+ }
302
+ /**
303
+ * Answer the branch's request for the latest changes in `lastBaseUpdate`, which ends the editor's
304
+ * wait. Only from a fetch begun at or after the request, and only once the branch holds the base
305
+ * that fetch found: an earlier fetch may predate the changes asked for, and a base ref that didn't
306
+ * move (a held or failed update) isn't the latest. While the schema gate holds the base the request
307
+ * stands. Anything else that settles nothing, a lock or a GitHub step on its way among them, asks
308
+ * for another early cycle while the request is recent. Never throws.
309
+ */
310
+ async function answerBaseUpdate(ctx, branchDir, branchPath, probe, outcome, source, requested) {
311
+ const { request } = probe;
312
+ if (!request) {
313
+ // Skipped before its request was read: a provisioning lock held elsewhere.
314
+ if (requested && outcome.kind === 'skippedLocked') {
315
+ const meta = await BranchMetadataFileManager.loadOnly(branchPath).catch(() => null);
316
+ if (meta && worthAnotherCycle(meta.branch))
317
+ ctx.requestBranchSync(branchDir);
318
+ }
319
+ return;
320
+ }
321
+ if (!source || ctx.ensureStatusReport().baseHold)
322
+ return;
323
+ const askAgain = () => {
324
+ if (worthAnotherCycle({ status: 'editing', baseUpdateRequestedAt: request.requestedAt })) {
325
+ ctx.requestBranchSync(request.branchRef);
326
+ }
327
+ };
328
+ // The request's time is the Lambda's clock and the fetch's this host's: both keep NTP time.
329
+ if (source.fetchedAt < Date.parse(request.requestedAt))
330
+ return askAgain();
331
+ const answer = answerFor(outcome, probe);
332
+ if (answer === undefined)
333
+ return askAgain();
334
+ if (answer !== 'failed' && source.baseTip !== undefined && probe.baseSha !== source.baseTip) {
335
+ return askAgain();
336
+ }
337
+ const lastBaseUpdate = {
338
+ at: new Date(source.fetchedAt).toISOString(),
339
+ outcome: answer,
340
+ ...(probe.baseSha !== undefined && { baseSha: probe.baseSha }),
341
+ ...(answer === 'conflicts' && { keptEntries: probe.keptEntries }),
342
+ };
343
+ try {
344
+ // A newer request, made after this fetch began, waits for the next cycle's answer.
345
+ const saved = await getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath).update((existing) => existing?.branch.name === request.branchRef &&
346
+ existing.branch.status === 'editing' &&
347
+ existing.branch.baseUpdateRequestedAt === request.requestedAt
348
+ ? { branch: { lastBaseUpdate } }
349
+ : null, { keepUpdatedAt: true });
350
+ if (saved)
351
+ workerLog(` ${branchDir}: answered the request for the latest changes (${answer})`);
352
+ }
353
+ catch (err) {
354
+ workerLogWarn(` ${branchDir}: could not record the answer to its request for the latest changes: ${getErrorMessage(err)}`);
355
+ }
356
+ }
357
+ /** A run with no GitHub publishes nothing there: an empty namespace is a gateway that pushes nothing. */
358
+ const NO_GITHUB_POLICY = {
359
+ configured: [],
360
+ githubDefault: null,
361
+ namespace: '',
362
+ legacy: [],
363
+ settingsBranch: null,
364
+ };
365
+ /** The GitHub fetch's answer source: when it began, and the base it found. */
366
+ const sourceOf = (ctx, gitHub) => ({
367
+ fetchedAt: gitHub.fetchedAt,
368
+ baseTip: gitHub.tips.get(ctx.baseBranch),
369
+ });
327
370
  /**
328
371
  * Rebase every branch workspace that is behind the base branch, one cycle.
329
372
  *
330
- * The per-branch work is {@link rebaseOneBranch}; what stays here is the walk
331
- * over `content-branches/`, the structural pre-filter (entries that are not
332
- * branch workspaces at all, plus the base branch's own clone), and folding
333
- * each branch's outcome into the summary.
373
+ * The per-branch work is {@link syncBranchDir}; what stays here is the walk
374
+ * over `content-branches/` and folding each branch's outcome into the summary.
375
+ * `source` answers requests for the latest changes; the GitHub fetch's by default. `local` is a
376
+ * run with no GitHub (see {@link syncBranchDir}).
334
377
  *
335
378
  * Deliberately sequential rather than concurrent: each branch's rebase holds
336
379
  * that branch's cross-host content-write lock and runs a series of git
337
380
  * subprocesses against a shared filesystem.
338
381
  */
339
- export async function runRebaseCycle(ctx, gatewayPolicy, gitHub) {
382
+ export async function runRebaseCycle(ctx, gatewayPolicy, gitHub, requested = new Set(), source = gitHub && sourceOf(ctx, gitHub), local = false) {
340
383
  const rebased = [];
341
384
  const skippedDirty = [];
342
385
  const skippedLocked = [];
@@ -348,6 +391,8 @@ export async function runRebaseCycle(ctx, gatewayPolicy, gitHub) {
348
391
  catch {
349
392
  return { rebased, skippedDirty, skippedLocked, failed };
350
393
  }
394
+ // Branches someone is waiting on go first; the sort is stable, so the rest keep their order.
395
+ branchDirs.sort((a, b) => Number(requested.has(b)) - Number(requested.has(a)));
351
396
  for (const branchDir of branchDirs) {
352
397
  // A draining worker finishes the branch it is rebasing and starts no other:
353
398
  // a rebase killed mid-branch is recovered lossily on the next boot.
@@ -355,45 +400,9 @@ export async function runRebaseCycle(ctx, gatewayPolicy, gitHub) {
355
400
  workerLog(' Rebase cycle stopped: the worker is draining');
356
401
  break;
357
402
  }
358
- // Known structural entries under content-branches/, not branch workspaces:
359
- // branches.json (registry snapshot) and dot-prefixed entries
360
- // (.canopy-meta/, transient lock dirs). Skipped SILENTLY so the no-.git
361
- // skip logs below don't fire for them every cycle; everything else still
362
- // logs loudly on skip.
363
- if (branchDir.startsWith('.') || branchDir === 'branches.json') {
364
- continue;
365
- }
366
- const branchPath = path.join(ctx.contentBranchesPath, branchDir);
367
- const gitDir = path.join(branchPath, '.git');
368
- try {
369
- const stat = await fs.stat(gitDir);
370
- if (!stat.isDirectory()) {
371
- workerLog(` Skipping ${branchDir}: .git is not a directory`);
372
- continue;
373
- }
374
- }
375
- catch {
376
- workerLog(` Skipping ${branchDir}: no .git directory (not a branch workspace)`);
377
- continue;
378
- }
379
- // The base branch's own clone is refreshed ff-only by
380
- // 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
383
- // sanitized-vs-sanitized: branchDir is a filesystem name, ctx.baseBranch
384
- // is raw.
385
- if (branchDir === ctx.sanitizedBaseBranch) {
386
- workerLog(` Skipping ${branchDir}: base branch (refreshed separately)`);
387
- continue;
388
- }
389
- // A settings branch is an orphan: rebasing it onto the base would give it content
390
- // history. The API refuses to provision one, so a directory under that name is a
391
- // leftover and is left alone.
392
- if (isSettingsBranchName(branchDir, ctx.ensureSettingsBranch())) {
393
- workerLogWarn(` Skipping ${branchDir}: settings branch, never a content workspace`);
403
+ const outcome = await syncBranchDir(ctx, branchDir, gatewayPolicy, gitHub, source, requested.has(branchDir), {}, local);
404
+ if (outcome === null)
394
405
  continue;
395
- }
396
- const outcome = await holdAndRebaseOneBranch(ctx, branchDir, branchPath, gatewayPolicy, gitHub);
397
406
  if (outcome.kind === 'rebased')
398
407
  rebased.push(branchDir);
399
408
  else if (outcome.kind === 'skippedDirty')
@@ -410,6 +419,82 @@ export async function runRebaseCycle(ctx, gatewayPolicy, gitHub) {
410
419
  }
411
420
  return { rebased, skippedDirty, skippedLocked, failed };
412
421
  }
422
+ /**
423
+ * One entry of `content-branches/`: the structural pre-filter (entries that are not branch
424
+ * workspaces at all, the base branch's own clone, a settings branch), then
425
+ * {@link holdAndRebaseOneBranch} and the answer to its request. Null for an entry skipped before
426
+ * any of that. Never throws. A `local` run has no GitHub: it publishes nothing there, and
427
+ * `remote.git` is where a branch's work is published.
428
+ */
429
+ async function syncBranchDir(ctx, branchDir, gatewayPolicy, gitHub, source, requested, probe, local = false) {
430
+ // Known structural entries under content-branches/, not branch workspaces:
431
+ // branches.json (registry snapshot) and dot-prefixed entries
432
+ // (.canopy-meta/, transient lock dirs). Skipped SILENTLY so the no-.git
433
+ // skip logs below don't fire for them every cycle; everything else still
434
+ // logs loudly on skip.
435
+ if (branchDir.startsWith('.') || branchDir === 'branches.json')
436
+ return null;
437
+ const branchPath = path.join(ctx.contentBranchesPath, branchDir);
438
+ const gitDir = path.join(branchPath, '.git');
439
+ try {
440
+ const stat = await fs.stat(gitDir);
441
+ if (!stat.isDirectory()) {
442
+ workerLog(` Skipping ${branchDir}: .git is not a directory`);
443
+ return null;
444
+ }
445
+ }
446
+ catch {
447
+ workerLog(` Skipping ${branchDir}: no .git directory (not a branch workspace)`);
448
+ return null;
449
+ }
450
+ // The base branch's own clone is refreshed ff-only by
451
+ // refreshBaseBranchWorkspace() earlier in syncGit(). Routing it through
452
+ // this loop could rewrite its history (the rebase below) and stamp
453
+ // meaningless conflict metadata on it. Compare
454
+ // sanitized-vs-sanitized: branchDir is a filesystem name, ctx.baseBranch
455
+ // is raw.
456
+ if (branchDir === ctx.sanitizedBaseBranch) {
457
+ workerLog(` Skipping ${branchDir}: base branch (refreshed separately)`);
458
+ return null;
459
+ }
460
+ // A settings branch is an orphan: rebasing it onto the base would give it content
461
+ // history. The API refuses to provision one, so a directory under that name is a
462
+ // leftover and is left alone.
463
+ if (isSettingsBranchName(branchDir, ctx.ensureSettingsBranch())) {
464
+ workerLogWarn(` Skipping ${branchDir}: settings branch, never a content workspace`);
465
+ return null;
466
+ }
467
+ const outcome = await holdAndRebaseOneBranch(ctx, branchDir, branchPath, local ? NO_GITHUB_POLICY : gatewayPolicy, gitHub, probe, local);
468
+ await answerBaseUpdate(ctx, branchDir, branchPath, probe, outcome, source, requested);
469
+ return outcome;
470
+ }
471
+ /**
472
+ * Bring the base into one branch now and answer its request for the latest changes, for a mode that
473
+ * runs no worker loop (operating-mode `baseUpdateRunsInRequest`). With no GitHub, the base is
474
+ * `remote.git`'s, so the answer is from this run's start. A run that answers nothing writes
475
+ * `failed`, since nothing else ever will: `busy` says a lock held it back, which a retry in a moment
476
+ * gets past. Never throws but for the metadata write of that fallback.
477
+ */
478
+ export async function syncBranchNow(ctx, branchName) {
479
+ const startedAt = Date.now();
480
+ const branchDir = sanitizeBranchName(branchName);
481
+ const probe = {};
482
+ const outcome = await syncBranchDir(ctx, branchDir, undefined, undefined, { fetchedAt: startedAt }, true, probe, true);
483
+ const busy = outcome?.kind === 'skippedLocked';
484
+ const branchPath = path.join(ctx.contentBranchesPath, branchDir);
485
+ await getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath).update((existing) => existing?.branch.name === branchName &&
486
+ existing.branch.status === 'editing' &&
487
+ existing.branch.baseUpdateRequestedAt !== undefined &&
488
+ Date.parse(existing.branch.baseUpdateRequestedAt) <= startedAt &&
489
+ isBaseUpdatePending(existing.branch)
490
+ ? {
491
+ branch: {
492
+ lastBaseUpdate: { at: new Date(startedAt).toISOString(), outcome: 'failed' },
493
+ },
494
+ }
495
+ : null, { keepUpdatedAt: true });
496
+ return busy ? 'busy' : 'answered';
497
+ }
413
498
  /**
414
499
  * The ref a branch clone's HEAD names, or null for a detached or unreadable one. Read from the
415
500
  * file rather than through git, so it runs before `assertSharedRepoConfig` vets the clone.
@@ -461,7 +546,7 @@ async function restoreRenamedBranch(ctx, branchDir, branchPath, metaFile, namesp
461
546
  * content-write lock inside it; see `provisioned-workspace.ts` for why that order cannot deadlock.
462
547
  * Never throws, like rebaseOneBranch.
463
548
  */
464
- async function holdAndRebaseOneBranch(ctx, branchDir, branchPath, gatewayPolicy, gitHub) {
549
+ async function holdAndRebaseOneBranch(ctx, branchDir, branchPath, gatewayPolicy, gitHub, probe, local) {
465
550
  let hold;
466
551
  try {
467
552
  hold = await holdProvisionedWorkspace(ctx.contentBranchesPath, branchDir);
@@ -481,18 +566,62 @@ async function holdAndRebaseOneBranch(ctx, branchDir, branchPath, gatewayPolicy,
481
566
  if (hold.kind === 'not-provisioned')
482
567
  return { kind: 'none' };
483
568
  try {
484
- return await rebaseOneBranch(ctx, branchDir, branchPath, hold.isCompromised, gatewayPolicy, gitHub);
569
+ return await rebaseOneBranch(ctx, branchDir, branchPath, hold.isCompromised, gatewayPolicy, gitHub, probe, local);
485
570
  }
486
571
  finally {
487
572
  await releaseProvisionedWorkspace(hold.release, branchDir);
488
573
  }
489
574
  }
490
- /** Clear a recorded failure and any recorded rewrite: the caller knows neither stands. */
491
- async function clearRebaseFailure(ctx, branchPath, branchRef) {
575
+ /**
576
+ * Clear a recorded failure and any recorded rewrite, the caller knowing neither stands, with
577
+ * `alsoClear` in the same write.
578
+ */
579
+ async function clearRebaseFailure(ctx, branchPath, branchRef, alsoClear = {}) {
492
580
  await getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath).update((current) => current?.branch.name === branchRef
493
- ? { branch: { rebaseFailure: undefined, ...rewriteCleared(current.branch) } }
581
+ ? { branch: { rebaseFailure: undefined, ...rewriteCleared(current.branch), ...alsoClear } }
494
582
  : null);
495
583
  }
584
+ /**
585
+ * {@link recordLatestChangesWaiting} for a branch the sync skips for its saved work: marked while it
586
+ * lacks the base `remote.git` holds. Read from `remote.git` rather than fetched, so the skip moves no
587
+ * ref. Best-effort, so the skip stays a skip.
588
+ */
589
+ async function noteLatestChangesWaiting(ctx, git, branchPath, metaFile) {
590
+ if (metaFile.branch.status !== 'editing')
591
+ return;
592
+ let baseTip;
593
+ let behind;
594
+ try {
595
+ baseTip = await readPublishedSha(ctx, ctx.baseBranch);
596
+ behind = baseTip !== null && !(await contains(git, 'HEAD', baseTip));
597
+ }
598
+ catch (err) {
599
+ workerLogWarn(` Could not compare ${escapeForLog(metaFile.branch.name)} with the base: ${getErrorMessage(err)}`);
600
+ return;
601
+ }
602
+ await recordLatestChangesWaiting(ctx, branchPath, metaFile, behind ? (baseTip ?? undefined) : undefined);
603
+ }
604
+ /**
605
+ * Mark the branch `metaFile` names as lacking `baseSha`, the base commit the sync first left out, or
606
+ * clear the mark when undefined: the automatic sync never commits saved work, so a branch holding
607
+ * some waits for its author's "Get latest changes" (base-update.ts). Written only when the mark comes
608
+ * or goes, as every save regenerates the branch registry, and as bookkeeping, which leaves
609
+ * `updatedAt`. Best-effort: a failure leaves the hint as it was until the next cycle.
610
+ */
611
+ async function recordLatestChangesWaiting(ctx, branchPath, metaFile, baseSha) {
612
+ const { name } = metaFile.branch;
613
+ const flips = (current) => (current === undefined) !== (baseSha === undefined);
614
+ if (!flips(metaFile.branch.latestChangesWaiting))
615
+ return;
616
+ try {
617
+ await getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath).update((current) => current?.branch.name === name && flips(current.branch.latestChangesWaiting)
618
+ ? { branch: { latestChangesWaiting: baseSha } }
619
+ : null, { keepUpdatedAt: true });
620
+ }
621
+ catch (err) {
622
+ workerLogWarn(` Could not record the latest changes waiting for ${escapeForLog(name)}: ${getErrorMessage(err)}`);
623
+ }
624
+ }
496
625
  /** A branch github-commits.ts left for a person: logged, recorded, and in the cycle's summary. */
497
626
  async function recordBlocked(ctx, branchDir, branchPath, branchRef, reason) {
498
627
  // [REDACT] failed[] folds into worker-status.json, served to the browser, and these reasons name
@@ -510,6 +639,32 @@ async function lacksPublishedCommits(ctx, git, branchRef) {
510
639
  await fetchFromRemoteGit(git, ctx.remoteGitPath, `refs/heads/${branchRef}`);
511
640
  return (await commitsApart(git, 'HEAD', published)).onlyB > 0;
512
641
  }
642
+ /**
643
+ * {@link reconcileMergeConflicts} for a submitted or approved branch, whose merges of GitHub's
644
+ * commits can leave records too, under the content-write lock as for a branch being edited. Only
645
+ * for a clone with a record, or with `conflictFiles` to clear, so others cost one stat. Never
646
+ * throws.
647
+ */
648
+ async function reconcileInReview(ctx, branchDir, branchPath, metaFile, isProvisioningLockCompromised) {
649
+ try {
650
+ const flagged = (metaFile.branch.conflictFiles?.length ?? 0) > 0;
651
+ if (!flagged && !(await new MergeConflictStore(branchPath).exists()))
652
+ return;
653
+ await assertSharedRepoConfig(branchPath, 'worktree');
654
+ await withContentLockInReview(branchDir, branchPath, isProvisioningLockCompromised, async () => {
655
+ await reconcileMergeConflicts(ctx, {
656
+ git: sharedRepoGit(branchPath, 'worktree'),
657
+ branchPath,
658
+ branchDir,
659
+ branchRef: await readCloneRef(branchPath),
660
+ });
661
+ return { kind: 'none' };
662
+ });
663
+ }
664
+ catch (err) {
665
+ workerLogWarn(` ${branchDir}: could not reconcile its conflict record: ${getErrorMessage(err)}`);
666
+ }
667
+ }
513
668
  /**
514
669
  * {@link bringInGitHubCommits} for a submitted or approved branch, which skips the rest of the
515
670
  * rebase loop. Under the content-write lock, zero-retry, as a save or a submit can hold it. A
@@ -599,14 +754,20 @@ function pullRequestHoldOf(pullRequest, recordedBase) {
599
754
  /**
600
755
  * Record {@link pullRequestHoldOf} for the branch card, when it changed: every save regenerates the
601
756
  * 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.
757
+ * `tookBase` says this cycle merged the base in, which GitHub measured the pull request before, so
758
+ * nothing holds it. Best-effort: a failure leaves the card as it was until the next cycle.
603
759
  */
604
- async function recordPullRequestHold(ctx, branchDir, branchPath, metaFile, pullRequest) {
760
+ async function recordPullRequestHold(ctx, branchDir, branchPath, metaFile, pullRequest, tookBase) {
605
761
  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
762
  try {
763
+ let hold = tookBase ? undefined : pullRequestHoldOf(pullRequest, baseBranch ?? ctx.baseBranch);
764
+ // A conflict counts only on the commit the clone holds: one measured on an older head, as while
765
+ // the worker's own merge is on its way to GitHub, says nothing about the branch now.
766
+ if (hold?.kind === 'conflicts' && pullRequest.headSha !== (await cloneHead(branchPath))) {
767
+ hold = pullRequestHold?.kind === 'conflicts' ? null : undefined;
768
+ }
769
+ if (hold === null || JSON.stringify(hold) === JSON.stringify(pullRequestHold))
770
+ return;
610
771
  await getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath).update(
611
772
  // A newer submit's pull request isn't the one measured.
612
773
  (current) => current?.branch.name === name && current.branch.submittedAt === submittedAt
@@ -617,17 +778,24 @@ async function recordPullRequestHold(ctx, branchDir, branchPath, metaFile, pullR
617
778
  workerLogWarn(` ${branchDir}: could not record the PR's state: ${getErrorMessage(err)}`);
618
779
  }
619
780
  }
781
+ /** The commit a branch clone holds. */
782
+ async function cloneHead(branchPath) {
783
+ await assertSharedRepoConfig(branchPath, 'worktree');
784
+ return (await sharedRepoGit(branchPath, 'worktree').revparse(['HEAD'])).trim();
785
+ }
620
786
  /**
621
787
  * 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.
788
+ * `behind` or `dirty`, so it can merge. A submitted branch stays submitted: the pull request's diff
789
+ * is still the author's changes. An approved branch keeps its approval only for git's own clean
790
+ * merge; any merge the field merge settled sends it back to review (`mergeIntoBranch`). Only from a
791
+ * clone, `remote.git` and GitHub all at one commit, since anything else is work still on its way,
792
+ * and only for the base GitHub measured against: a pull request someone retargeted would gain
793
+ * another branch's commits under its approval. A branch with a failed sync is left for its next
794
+ * submit or a retry of the task, as a push here would clear that failure with the PR still stale. A
795
+ * merge that leaves a value or file for a person leaves the branch as it is (`mergeIntoBranch`'s
796
+ * `inReview`), and withdrawing it lets the merge for a branch being edited record the conflicts,
797
+ * where an editor can resolve them. A failure goes in the cycle's summary only: the step before
798
+ * this one clears `rebaseFailure` each cycle, so recording it would reset its age every time.
631
799
  * Never throws.
632
800
  */
633
801
  async function updateBranchInReviewFromBase(ctx, branchDir, branchPath, pullRequest, isProvisioningLockCompromised, gatewayPolicy, gitHub) {
@@ -682,7 +850,7 @@ async function updateBranchInReviewFromBase(ctx, branchDir, branchPath, pullRequ
682
850
  other: baseTip,
683
851
  message: `Merge ${base} into ${branchRef}`,
684
852
  fromGitHub: false,
685
- stopOnConflict: true,
853
+ inReview: true,
686
854
  });
687
855
  if (outcome.kind === 'blocked') {
688
856
  const error = redactCredentials(outcome.reason);
@@ -728,8 +896,9 @@ async function withContentLockInReview(branchDir, branchPath, isProvisioningLock
728
896
  }
729
897
  }
730
898
  /**
731
- * Rebase ONE branch workspace onto the base branch, keeping the branch's
732
- * version of every conflicting file.
899
+ * Bring the base branch into ONE branch workspace: by a rebase where nothing
900
+ * needs a person, else by a merge that leaves each value both sides changed
901
+ * differently for one.
733
902
  *
734
903
  * `branchDir` is the sanitized directory name and `branchPath` its absolute
735
904
  * path; the caller has already established that this is a real branch
@@ -739,7 +908,7 @@ async function withContentLockInReview(branchDir, branchPath, isProvisioningLock
739
908
  * returned as `{ kind: 'failed' }`, so one unreadable branch cannot halt the
740
909
  * rest of the cycle ([SYNC-M2]).
741
910
  */
742
- async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCompromised, gatewayPolicy, gitHub) {
911
+ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCompromised, gatewayPolicy, gitHub, probe = {}, local = false) {
743
912
  // Read only by the catch below. See the `rebased` rider on
744
913
  // BranchRebaseOutcome.
745
914
  let didRebase = false;
@@ -747,6 +916,14 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
747
916
  // Restored first: everything below this cycle compares `name`.
748
917
  const metaFile = await restoreRenamedBranch(ctx, branchDir, branchPath, await BranchMetadataFileManager.loadOnly(branchPath), gatewayPolicy?.namespace);
749
918
  const branchStatus = metaFile?.branch.status;
919
+ // Only an editing branch takes the base in, so only its request can be answered.
920
+ const requestedAt = metaFile?.branch.baseUpdateRequestedAt;
921
+ if (metaFile !== null &&
922
+ branchStatus === 'editing' &&
923
+ requestedAt !== undefined &&
924
+ isBaseUpdatePending(metaFile.branch)) {
925
+ probe.request = { branchRef: metaFile.branch.name, requestedAt };
926
+ }
750
927
  // Skip branches that must not be mutated:
751
928
  // - submitted/approved: in review, so never rewrite history under an active
752
929
  // PR -- but do poll GitHub for the PR's resolution, since nothing else
@@ -754,19 +931,23 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
754
931
  // - archived: finished, with no PR left to poll.
755
932
  if (metaFile !== null && (branchStatus === 'submitted' || branchStatus === 'approved')) {
756
933
  workerLog(` Skipping ${branchDir} (${branchStatus})`);
934
+ // Submit committed the saved work; a withdraw lets the next skip mark it again.
935
+ await recordLatestChangesWaiting(ctx, branchPath, metaFile);
936
+ await reconcileInReview(ctx, branchDir, branchPath, metaFile, isProvisioningLockCompromised);
757
937
  const outcome = await bringInCommitsForBranchInReview(ctx, branchDir, branchPath, metaFile, isProvisioningLockCompromised, gatewayPolicy, gitHub);
758
938
  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' &&
939
+ // GitHub reports `behind` only where up-to-date branches are required, and `dirty` only where
940
+ // git's merge conflicts, so a pull request that merges as it is gets no commits or CI runs.
941
+ const updated = outcome.kind === 'none' &&
765
942
  pullRequest?.state === 'open' &&
766
- pullRequest.mergeable_state === 'behind') {
767
- return updateBranchInReviewFromBase(ctx, branchDir, branchPath, pullRequest, isProvisioningLockCompromised, gatewayPolicy, gitHub);
943
+ (pullRequest.mergeable_state === 'behind' || pullRequest.mergeable_state === 'dirty')
944
+ ? await updateBranchInReviewFromBase(ctx, branchDir, branchPath, pullRequest, isProvisioningLockCompromised, gatewayPolicy, gitHub)
945
+ : outcome;
946
+ if (pullRequest !== null) {
947
+ const tookBase = updated.kind === 'rebased';
948
+ await recordPullRequestHold(ctx, branchDir, branchPath, metaFile, pullRequest, tookBase);
768
949
  }
769
- return outcome;
950
+ return updated;
770
951
  }
771
952
  if (branchStatus === 'archived') {
772
953
  workerLog(` Skipping ${branchDir} (${branchStatus})`);
@@ -778,13 +959,11 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
778
959
  // The pins also keep git non-interactive (core.editor=true): a rebase never waits on an editor.
779
960
  const branchGit = sharedRepoGit(branchPath, 'worktree');
780
961
  // [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.
962
+ // dirty check, and hold it for the whole rebase or merge. The dirty check
963
+ // alone is check-then-act, and its residual window spans fetch, replay and
964
+ // the merge's awaited git subprocesses on EFS. A save landing in there makes
965
+ // the next replayed commit fail, and the `rebase --abort` that follows
966
+ // hard-resets the tree, discarding it. The editor already got its 200.
788
967
  //
789
968
  // Zero-retry acquisition, deliberately: on contention this branch is
790
969
  // skipped and retried next sync cycle (~5 min), the same principle as the
@@ -902,6 +1081,12 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
902
1081
  // every publish path stays disarmed -- the safe direction.
903
1082
  const branchRef = (await branchGit.revparse(['--abbrev-ref', 'HEAD'])).trim();
904
1083
  const canPublish = branchRef.length > 0 && branchRef !== 'HEAD';
1084
+ await reconcileMergeConflicts(ctx, {
1085
+ git: branchGit,
1086
+ branchPath,
1087
+ branchDir,
1088
+ branchRef: canPublish ? branchRef : null,
1089
+ });
905
1090
  const commitArgs = canPublish && metaFile !== null
906
1091
  ? {
907
1092
  git: branchGit,
@@ -915,6 +1100,27 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
915
1100
  lockLost: lockCompromised,
916
1101
  }
917
1102
  : null;
1103
+ const skipIfDirty = async () => {
1104
+ if (editorDirt.length === 0)
1105
+ return { kind: 'none' };
1106
+ if (metaFile !== null)
1107
+ await noteLatestChangesWaiting(ctx, branchGit, branchPath, metaFile);
1108
+ return { kind: 'skippedDirty' };
1109
+ };
1110
+ // A merge of GitHub's commits whose result holds the base `remote.git` holds answers a request
1111
+ // for the latest changes as a merge of the base does, conflicts included.
1112
+ const tookInByGitHubMerge = async (keptEntries) => {
1113
+ if (editorDirt.length > 0)
1114
+ return await skipIfDirty();
1115
+ const baseTip = await readPublishedSha(ctx, ctx.baseBranch).catch(() => null);
1116
+ const holdsBase = baseTip !== null && (await contains(branchGit, 'HEAD', baseTip).catch(() => false));
1117
+ if (!holdsBase)
1118
+ return { kind: 'none' };
1119
+ probe.baseSha = baseTip;
1120
+ probe.keptEntries = keptEntries;
1121
+ await clearRebaseFailure(ctx, branchPath, branchRef, { latestChangesWaiting: undefined });
1122
+ return { kind: 'rebased' };
1123
+ };
918
1124
  // Before the dirty return: a fast-forward passes saves that touch other files.
919
1125
  let unpushed = false;
920
1126
  if (commitArgs !== null) {
@@ -924,13 +1130,21 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
924
1130
  }
925
1131
  if (outcome.kind === 'unpushed')
926
1132
  unpushed = true;
927
- else if (outcome.kind !== 'in-step') {
928
- return editorDirt.length > 0 ? { kind: 'skippedDirty' } : { kind: 'none' };
1133
+ else if (outcome.kind === 'merged')
1134
+ return await tookInByGitHubMerge(outcome.keptEntries);
1135
+ else if (outcome.kind !== 'in-step')
1136
+ return await skipIfDirty();
1137
+ // A merge of the base this worker published and never moved the clone to comes first.
1138
+ const caughtUp = await catchUpWithPublished(ctx, commitArgs);
1139
+ if (caughtUp?.kind === 'blocked') {
1140
+ return recordBlocked(ctx, branchDir, branchPath, branchRef, caughtUp.reason);
929
1141
  }
1142
+ if (caughtUp)
1143
+ return await skipIfDirty();
930
1144
  }
931
1145
  if (editorDirt.length > 0) {
932
1146
  workerLog(` Skipping ${branchDir}: has uncommitted changes`);
933
- return { kind: 'skippedDirty' };
1147
+ return await skipIfDirty();
934
1148
  }
935
1149
  // `git rebase` refuses to start over ANY modified tracked file, so
936
1150
  // tracked canopycms state blocks. The worker never untracks it here:
@@ -952,21 +1166,32 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
952
1166
  await recordRebaseFailure(ctx, branchPath, branchDir, reason);
953
1167
  return { kind: 'failed', error: reason };
954
1168
  }
955
- // [SYNC-H1] Self-heal: finish an interrupted publish even when this
956
- // branch is not behind base. Every crash window in the arming sequence
957
- // below leaves the marker set with the work unfinished, and this is what
958
- // completes it -- without it one lost lease race strands the branch until
959
- // the base happens to advance again. Gated on the loop-top snapshot, so
960
- // unmarked branches (nearly all of them, every cycle) cost nothing.
1169
+ // [SYNC-H1] Self-heal: finish a publish that never left remote.git at
1170
+ // the marker's commit, even when this branch is not behind base -- a
1171
+ // crash in the arming sequence below, or a failed push. Gated on the
1172
+ // loop-top snapshot, so unmarked branches (nearly all of them, every
1173
+ // cycle) cost nothing.
961
1174
  if (canPublish && metaFile?.branch.historyRewrittenFrom) {
962
- await reconcilePendingRewrite(ctx, {
1175
+ const pending = await reconcilePendingRewrite(ctx, {
963
1176
  branchPath,
964
1177
  branchDir,
965
1178
  branchRef,
966
1179
  headSha: (await branchGit.revparse(['HEAD'])).trim(),
967
1180
  marker: metaFile.branch.historyRewrittenFrom,
1181
+ target: metaFile.branch.historyRewrittenTo,
968
1182
  gatewayPolicy,
969
1183
  });
1184
+ // A rebase now would replace the rewrite remote.git still waits for, along with any commit
1185
+ // made on top of it since, which only a submit may publish.
1186
+ if (pending.kind === 'failed') {
1187
+ // [REDACT] failed[] is served to the browser via worker-status.json.
1188
+ const reason = redactCredentials(pending.reason);
1189
+ workerLogWarn(` Skipping ${branchDir}: ${reason}`);
1190
+ await recordRebaseFailure(ctx, branchPath, branchDir, reason, branchRef);
1191
+ return { kind: 'failed', error: reason };
1192
+ }
1193
+ if (pending.kind === 'waiting')
1194
+ return { kind: 'none' };
970
1195
  }
971
1196
  await fetchFromRemoteGit(branchGit, ctx.remoteGitPath, ctx.baseBranch);
972
1197
  // rev-list, not status.behind, which needs an upstream tracking branch
@@ -977,7 +1202,9 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
977
1202
  const fetchedBaseTip = (await branchGit.revparse(['FETCH_HEAD'])).trim();
978
1203
  const behindCount = parseInt((await branchGit.raw(['rev-list', '--count', `HEAD..${fetchedBaseTip}`])).trim(), 10);
979
1204
  const meta = getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath);
1205
+ probe.baseSha = fetchedBaseTip;
980
1206
  if (behindCount === 0) {
1207
+ probe.upToDate = true;
981
1208
  // Already in sync -- the overwhelmingly common outcome per branch per
982
1209
  // cycle, so skip the save entirely when metadata already reflects a
983
1210
  // clean state. save() eager-regenerates the branch registry
@@ -988,14 +1215,15 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
988
1215
  // concurrent editor-driven metadata change in that window is not
989
1216
  // clobbered by a stale skip decision.
990
1217
  //
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
1218
+ // Catching up leaves the conflict record alone: it names values a person
1219
+ // has yet to choose, which stays true until they do. A lingering rebaseFailure
994
1220
  // is cleared, or it sticks as a stale warning forever: nothing else
995
1221
  // touches a caught-up branch, so no other save site clears it. One
996
1222
  // whose cause stands stays: remote.git still holds a commit this clone
997
1223
  // lacks, and the next submit is refused for it.
998
1224
  const currentMeta = await BranchMetadataFileManager.loadOnly(branchPath);
1225
+ if (currentMeta !== null)
1226
+ await recordLatestChangesWaiting(ctx, branchPath, currentMeta);
999
1227
  if (currentMeta?.branch.rebaseFailure === undefined) {
1000
1228
  return { kind: 'none' };
1001
1229
  }
@@ -1007,17 +1235,46 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
1007
1235
  return { kind: 'none' };
1008
1236
  }
1009
1237
  // `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.
1238
+ // (a value a person chose, a maintainer's conflict resolution) would be undone, then
1239
+ // force-published. A branch holding one merges the base instead. So does one whose commits
1240
+ // touch canopycms state: a replay writes their bytes over the live files, and a pair that
1241
+ // adds then removes a file deletes it.
1014
1242
  const aboveBase = `${fetchedBaseTip}..HEAD`;
1015
1243
  const countAboveBase = async (args) => (await branchGit.raw(['rev-list', '--count', ...args])).trim();
1016
1244
  const cannotReplay = (await countAboveBase(['--merges', '--end-of-options', aboveBase])) !== '0' ||
1017
1245
  (await countAboveBase(['--end-of-options', aboveBase, '--', CANOPY_META_DIR])) !== '0';
1018
- if (commitArgs !== null && (cannotReplay || unpushed)) {
1246
+ const head = (await branchGit.revparse(['HEAD'])).trim();
1247
+ // Commits on top of what this branch published, as "Get latest changes" makes from saved work
1248
+ // on a branch submitted once, or a submit whose push failed: a rebase would rewrite the
1249
+ // published commit under them, which reads as a push this clone never saw. A merge keeps
1250
+ // them, unpublished until the submit pushes it (publish-decision.ts). A same-named ref at a
1251
+ // commit the base holds is no work of this branch.
1252
+ const published = canPublish ? await readPublishedSha(ctx, branchRef) : null;
1253
+ // With no GitHub, a commit of the branch's in remote.git is published however it got there.
1254
+ const aheadOfPublished = published !== null &&
1255
+ metaFile?.branch.name === branchRef &&
1256
+ (local ||
1257
+ (published !== head &&
1258
+ // A rewrite's tip is this branch's own publish, on GitHub or not.
1259
+ (isPublished(metaFile) || published === metaFile.branch.historyRewrittenTo))) &&
1260
+ (await contains(branchGit, head, published)) &&
1261
+ !(await contains(branchGit, fetchedBaseTip, published));
1262
+ /**
1263
+ * The base taken in by a merge (github-commits.ts), which decides each value against the fork
1264
+ * point and leaves what both sides changed differently for a person, rather than a rebase,
1265
+ * whose rounds can only settle a conflict by rule. A clone with no branch checked out, or no
1266
+ * `branch.json`, cannot name the ref the merge publishes, so it stays behind.
1267
+ */
1268
+ const takeBaseByMerge = async () => {
1269
+ if (commitArgs === null) {
1270
+ const reason = `the base conflicts with this clone's work, and the clone has no branch checked out, ` +
1271
+ `or no branch.json, to merge it into`;
1272
+ workerLogWarn(` Skipping ${branchDir}: ${reason}`);
1273
+ await recordRebaseFailure(ctx, branchPath, branchDir, reason);
1274
+ return { kind: 'failed', error: reason };
1275
+ }
1019
1276
  const outcome = await mergeIntoBranch(ctx, commitArgs, {
1020
- head: (await branchGit.revparse(['HEAD'])).trim(),
1277
+ head,
1021
1278
  other: fetchedBaseTip,
1022
1279
  message: `Merge ${ctx.baseBranch} into ${branchRef}`,
1023
1280
  fromGitHub: false,
@@ -1025,20 +1282,42 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
1025
1282
  if (outcome.kind === 'blocked') {
1026
1283
  return recordBlocked(ctx, branchDir, branchPath, branchRef, outcome.reason);
1027
1284
  }
1028
- if (outcome.kind !== 'merged')
1029
- return { kind: 'none' };
1030
- await clearRebaseFailure(ctx, branchPath, branchRef);
1285
+ if (outcome.kind !== 'merged') {
1286
+ if (!lockCompromised())
1287
+ return { kind: 'none' };
1288
+ workerLogWarn(` Skipping ${branchDir}: a lock it held was lost mid-merge (retrying next cycle)`);
1289
+ return { kind: 'skippedLocked' };
1290
+ }
1291
+ probe.keptEntries = outcome.keptEntries;
1292
+ await clearRebaseFailure(ctx, branchPath, branchRef, { latestChangesWaiting: undefined });
1031
1293
  return { kind: 'rebased' };
1294
+ };
1295
+ if (commitArgs !== null && (cannotReplay || unpushed || aheadOfPublished)) {
1296
+ return await takeBaseByMerge();
1297
+ }
1298
+ // A rebase only where it takes in exactly what a merge would, with nothing for a person: git
1299
+ // conflicts nowhere, and the field merge decides no file otherwise than git does.
1300
+ if (commitArgs !== null &&
1301
+ !mergesAsGitDoes(await planMerge(branchGit, branchPath, {
1302
+ head,
1303
+ other: fetchedBaseTip,
1304
+ contentRoot: ctx.contentRoot,
1305
+ timeoutMs: ctx.taskTimeoutMs,
1306
+ }))) {
1307
+ return await takeBaseByMerge();
1032
1308
  }
1033
1309
  workerLog(`Rebasing ${branchDir} (${behindCount} commits behind)...`);
1034
1310
  // Read BOTH sides before rewriting anything: the arming guard after the
1035
1311
  // rebase compares what remote.git published against what this clone is
1036
1312
  // about to rebase away, and the clone's pre-rebase tip is exactly what
1037
1313
  // the rebase makes unreadable.
1038
- const preRebaseHead = (await branchGit.revparse(['HEAD'])).trim();
1039
- const publishedSha = canPublish ? await readPublishedSha(ctx, branchRef) : null;
1314
+ const preRebaseHead = head;
1315
+ const publishedSha = published;
1040
1316
  await assertNoIncomingSubmodules(branchGit, branchPath, preRebaseHead, fetchedBaseTip);
1041
- const { completed, conflictedFiles, failureReason } = await runRebaseRounds(ctx, branchGit, branchDir, fetchedBaseTip, lockCompromised);
1317
+ const { completed, conflicted, failureReason } = await runRebaseRounds(ctx, branchGit, branchDir, fetchedBaseTip, lockCompromised);
1318
+ // The tip the rebase made, read before anything else awaits: past a lost lock a commit can
1319
+ // land on it, and only this tip may be published as the rewrite.
1320
+ const rebasedHead = (await branchGit.revparse(['HEAD'])).trim();
1042
1321
  // Outside the round loop's try/catch, so a throwing test hook can
1043
1322
  // never be misread as a rebase error.
1044
1323
  if (completed)
@@ -1072,6 +1351,11 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
1072
1351
  if (lockCompromised()) {
1073
1352
  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
1353
  }
1354
+ if (conflicted) {
1355
+ // The round loop aborted the rebase; this tells the caches the tree moved back.
1356
+ await abortRebase(branchGit, branchPath).catch(() => { });
1357
+ return await takeBaseByMerge();
1358
+ }
1075
1359
  if (!completed) {
1076
1360
  // failureReason is only set on the "unexpected error" break above;
1077
1361
  // MAX_REBASE_ROUNDS exhaustion is a distinct exit with no message of
@@ -1098,19 +1382,16 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
1098
1382
  // and (via the on-disk generation marker) in the Lambda containers
1099
1383
  // sharing this filesystem.
1100
1384
  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.
1385
+ probe.keptEntries = 0;
1386
+ workerLog(` Rebased ${branchDir} successfully`);
1387
+ // Recorded only on a branch.json that still names the branch this cycle read. The cycle
1388
+ // completed, so any prior failure record goes, whatever conflicts stay on record.
1107
1389
  await meta.update((existing) => existing !== null && existing.branch.name === metaFile?.branch.name
1108
1390
  ? {
1109
1391
  branch: {
1110
- ...recordConflicts(existing.branch.conflictFiles, conflictIdsDeduped),
1111
- // The cycle completed, so clear any prior failure record regardless
1112
- // of the conflict outcome.
1392
+ conflictStatus: (existing.branch.conflictFiles?.length ?? 0) > 0 ? 'conflicts-detected' : 'clean',
1113
1393
  rebaseFailure: undefined,
1394
+ latestChangesWaiting: undefined,
1114
1395
  },
1115
1396
  }
1116
1397
  : null);
@@ -1121,7 +1402,7 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
1121
1402
  branchRef,
1122
1403
  publishedSha,
1123
1404
  preRebaseHead,
1124
- rebasedHead: (await branchGit.revparse(['HEAD'])).trim(),
1405
+ rebasedHead,
1125
1406
  gatewayPolicy,
1126
1407
  });
1127
1408
  return { kind: 'rebased' };