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
@@ -28,16 +28,19 @@ const MAX_PUSH_RESTARTS = 3;
28
28
  /**
29
29
  * Fetch every GitHub branch into `remote.git`'s tracking namespace (`GITHUB_TRACKING_REF_PREFIX`),
30
30
  * which then holds exactly GitHub's branches. `have` is every tip `remote.git` holds, so the
31
- * bundle carries only what it lacks. Returns GitHub's branches, and what an operator must fix in
32
- * remote.git for the namespace to keep up, if anything (see {@link moveTrackingRefs}).
31
+ * bundle carries only what it lacks. `fresh` takes no GitHub fetch the gateway began before this
32
+ * call. Returns GitHub's branches, when the GitHub fetch they come from began, and what an operator
33
+ * must fix in remote.git for the namespace to keep up, if anything (see {@link moveTrackingRefs}).
33
34
  */
34
- export async function fetchGitHubIntoRemoteGit(ctx, signal) {
35
+ export async function fetchGitHubIntoRemoteGit(ctx, signal, fresh = false) {
35
36
  const gitDir = ctx.remoteGitPath;
36
- const { refs, bundleId } = await ctx.github().fetch({ have: await haveOf(gitDir) }, signal);
37
+ const { refs, bundleId, startedAt } = await ctx
38
+ .github()
39
+ .fetch({ have: await haveOf(gitDir), fresh }, signal);
37
40
  if (bundleId !== null)
38
41
  await unbundle(ctx, gitDir, bundleId, signal);
39
42
  const needsOperator = await moveTrackingRefs(ctx, gitDir, refs, signal);
40
- return { refs, needsOperator };
43
+ return { refs, fetchedAt: startedAt, needsOperator };
41
44
  }
42
45
  /**
43
46
  * Seed the new bare repository at `gitDir`, which must not exist yet, with every GitHub branch as
@@ -46,7 +46,8 @@ export declare function readPublishedSha(ctx: Pick<HistoryRewriteContext, 'remot
46
46
  *
47
47
  * `rewrittenTip` goes in the same write as `lastGitHubTip`, so a push that lands
48
48
  * and dies before its stamp is never read as someone rewriting the branch on
49
- * GitHub (github-commits.ts).
49
+ * GitHub (github-commits.ts), and as `historyRewrittenTo`, which unlike the
50
+ * marker each rewrite advances: it is what the publish sends.
50
51
  *
51
52
  * Resolves whether `branchRef`'s branch.json now holds a marker, set here or kept by set-once.
52
53
  * When it does not, the caller publishes nothing: a rewrite pushed without its marker leaves
@@ -68,9 +69,20 @@ export declare function markHistoryRewritten(ctx: Pick<HistoryRewriteContext, 'c
68
69
  * in safeinsights/canopycms#902.
69
70
  */
70
71
  export declare function clearHistoryRewrittenMarker(ctx: Pick<HistoryRewriteContext, 'contentBranchesPath'>, branchPath: string, branchRef: string): Promise<void>;
72
+ /** How {@link forcePublishToLocalRemote} ended. */
73
+ export type RewritePublish = {
74
+ kind: 'published';
75
+ } | {
76
+ kind: 'moved';
77
+ } | {
78
+ kind: 'failed';
79
+ message: string;
80
+ };
71
81
  /**
72
- * Publish a branch clone's rebased history into `remote.git`, replacing
73
- * EXACTLY `expectedSha` and nothing else.
82
+ * Publish `rewrittenTip`, a branch clone's rebased history, into `remote.git`,
83
+ * replacing EXACTLY `expectedSha` and nothing else. The tip, never the clone's
84
+ * branch ref, which can hold a commit made since that no one has submitted
85
+ * (publish-decision.ts).
74
86
  *
75
87
  * The lease is the entire safety argument: `--force-with-lease=<ref>:<sha>`
76
88
  * refuses unless `remote.git` still stands at `<sha>`, so this can only ever
@@ -79,10 +91,12 @@ export declare function clearHistoryRewrittenMarker(ctx: Pick<HistoryRewriteCont
79
91
  * carryForwardRewrittenHistory for the interleaving where that would silently
80
92
  * delete someone's direct push.
81
93
  *
82
- * Returns whether the push landed. A refused lease means a concurrent Lambda
83
- * push moved the ref; logged and retried by the self-heal pass, never thrown.
94
+ * Resolves `published`, `moved` for a refused lease (a concurrent Lambda push
95
+ * moved the ref; the next cycle judges the branch again), or `failed` with
96
+ * git's message.
97
+ * Never throws.
84
98
  */
85
- export declare function forcePublishToLocalRemote(ctx: Pick<HistoryRewriteContext, 'remoteGitPath' | 'taskTimeoutMs'>, branchPath: string, branchRef: string, expectedSha: string): Promise<boolean>;
99
+ export declare function forcePublishToLocalRemote(ctx: Pick<HistoryRewriteContext, 'remoteGitPath' | 'taskTimeoutMs'>, branchPath: string, branchRef: string, expectedSha: string, rewrittenTip: string): Promise<RewritePublish>;
86
100
  /**
87
101
  * Queue the GitHub hop for a branch whose rewritten history now sits in
88
102
  * `remote.git`, so an open PR's head follows the rebase within a cycle instead
@@ -99,6 +113,18 @@ export declare function forcePublishToLocalRemote(ctx: Pick<HistoryRewriteContex
99
113
  * the policy does not predict; the tasks are idempotent.
100
114
  */
101
115
  export declare function enqueueGitHubPush(ctx: Pick<HistoryRewriteContext, 'taskDir'>, branchRef: string, gatewayPolicy: BranchPolicyDescription | undefined): Promise<void>;
116
+ /**
117
+ * `settled`: `remote.git` no longer stands at the marker, or never will by this pass. `waiting`: a
118
+ * concurrent push moved it this cycle. `failed`: the publish cannot land; `reason` says why.
119
+ */
120
+ export type PendingRewrite = {
121
+ kind: 'settled';
122
+ } | {
123
+ kind: 'waiting';
124
+ } | {
125
+ kind: 'failed';
126
+ reason: string;
127
+ };
102
128
  /**
103
129
  * Complete a rewrite this worker started but did not finish: get the rebased
104
130
  * history into `remote.git` and queued for GitHub.
@@ -108,7 +134,11 @@ export declare function enqueueGitHubPush(ctx: Pick<HistoryRewriteContext, 'task
108
134
  * waiting for the next base-branch advance.
109
135
  *
110
136
  * Always leases on the MARKER, never on remote.git's current tip -- the marker
111
- * is the one commit we know our own rebase replaced.
137
+ * is the one commit we know our own rebase replaced -- and sends the recorded
138
+ * rebased tip, `target`, never `headSha`: a commit made in the clone since the
139
+ * rewrite is no one's submitted work. Resolves {@link PendingRewrite}: while
140
+ * `remote.git` still stands at the marker the branch takes nothing in, since a
141
+ * rebase would replace the rewrite along with any commit made on top of it.
112
142
  */
113
143
  export declare function reconcilePendingRewrite(ctx: HistoryRewriteContext, options: {
114
144
  branchPath: string;
@@ -116,8 +146,10 @@ export declare function reconcilePendingRewrite(ctx: HistoryRewriteContext, opti
116
146
  branchRef: string;
117
147
  headSha: string;
118
148
  marker: string;
149
+ /** `historyRewrittenTo`, the rebased tip the rewrite recorded. */
150
+ target: string | undefined;
119
151
  gatewayPolicy: BranchPolicyDescription | undefined;
120
- }): Promise<void>;
152
+ }): Promise<PendingRewrite>;
121
153
  /**
122
154
  * Whether this branch carries a pending history rewrite -- the rebase loop
123
155
  * rewrote already-published history and the GitHub push has not landed yet
@@ -49,7 +49,8 @@ export async function readPublishedSha(ctx, branchRef) {
49
49
  *
50
50
  * `rewrittenTip` goes in the same write as `lastGitHubTip`, so a push that lands
51
51
  * and dies before its stamp is never read as someone rewriting the branch on
52
- * GitHub (github-commits.ts).
52
+ * GitHub (github-commits.ts), and as `historyRewrittenTo`, which unlike the
53
+ * marker each rewrite advances: it is what the publish sends.
53
54
  *
54
55
  * Resolves whether `branchRef`'s branch.json now holds a marker, set here or kept by set-once.
55
56
  * When it does not, the caller publishes nothing: a rewrite pushed without its marker leaves
@@ -66,6 +67,7 @@ export async function markHistoryRewritten(ctx, branchPath, branchRef, expectedS
66
67
  return {
67
68
  branch: {
68
69
  historyRewrittenFrom: typeof marker === 'string' ? marker : expectedSha,
70
+ historyRewrittenTo: rewrittenTip,
69
71
  lastGitHubTip: rewrittenTip,
70
72
  },
71
73
  };
@@ -92,7 +94,7 @@ export async function clearHistoryRewrittenMarker(ctx, branchPath, branchRef) {
92
94
  // Explicit undefined clears the key -- save()'s merge only overwrites
93
95
  // keys present in the update (same pattern as rebaseFailure). Only a
94
96
  // marker on `branchRef`'s own branch.json is cleared, so none is ever created here.
95
- await meta.saveIf({ branch: { historyRewrittenFrom: undefined } }, (current) => current?.branch.name === branchRef &&
97
+ await meta.saveIf({ branch: { historyRewrittenFrom: undefined, historyRewrittenTo: undefined } }, (current) => current?.branch.name === branchRef &&
96
98
  typeof current.branch.historyRewrittenFrom === 'string');
97
99
  }
98
100
  catch (err) {
@@ -100,8 +102,10 @@ export async function clearHistoryRewrittenMarker(ctx, branchPath, branchRef) {
100
102
  }
101
103
  }
102
104
  /**
103
- * Publish a branch clone's rebased history into `remote.git`, replacing
104
- * EXACTLY `expectedSha` and nothing else.
105
+ * Publish `rewrittenTip`, a branch clone's rebased history, into `remote.git`,
106
+ * replacing EXACTLY `expectedSha` and nothing else. The tip, never the clone's
107
+ * branch ref, which can hold a commit made since that no one has submitted
108
+ * (publish-decision.ts).
105
109
  *
106
110
  * The lease is the entire safety argument: `--force-with-lease=<ref>:<sha>`
107
111
  * refuses unless `remote.git` still stands at `<sha>`, so this can only ever
@@ -110,10 +114,12 @@ export async function clearHistoryRewrittenMarker(ctx, branchPath, branchRef) {
110
114
  * carryForwardRewrittenHistory for the interleaving where that would silently
111
115
  * delete someone's direct push.
112
116
  *
113
- * Returns whether the push landed. A refused lease means a concurrent Lambda
114
- * push moved the ref; logged and retried by the self-heal pass, never thrown.
117
+ * Resolves `published`, `moved` for a refused lease (a concurrent Lambda push
118
+ * moved the ref; the next cycle judges the branch again), or `failed` with
119
+ * git's message.
120
+ * Never throws.
115
121
  */
116
- export async function forcePublishToLocalRemote(ctx, branchPath, branchRef, expectedSha) {
122
+ export async function forcePublishToLocalRemote(ctx, branchPath, branchRef, expectedSha, rewrittenTip) {
117
123
  // sharedRepoGit's env is gitChildEnv's, whose C-locale pin is what keeps
118
124
  // isStaleLeaseRejection below from silently becoming a no-op on a
119
125
  // non-English host.
@@ -121,24 +127,26 @@ export async function forcePublishToLocalRemote(ctx, branchPath, branchRef, expe
121
127
  try {
122
128
  await pushGit.raw([
123
129
  'push',
124
- `--force-with-lease=${branchRef}:${expectedSha}`,
130
+ `--force-with-lease=refs/heads/${branchRef}:${expectedSha}`,
125
131
  // Otherwise remote.git's own hooks run, as the worker.
126
132
  `--receive-pack=${pinnedReceivePack()}`,
127
133
  // Real flags must precede --end-of-options; everything after it is
128
134
  // positional (see GitManager.push() for the same guard).
129
135
  '--end-of-options',
130
136
  ctx.remoteGitPath,
131
- `${branchRef}:${branchRef}`,
137
+ `${rewrittenTip}:refs/heads/${branchRef}`,
132
138
  ]);
133
139
  workerLog(` Published rebased ${branchRef} into remote.git`);
134
- return true;
140
+ return { kind: 'published' };
135
141
  }
136
142
  catch (err) {
137
143
  const message = redactCredentials(getErrorMessage(err));
138
- workerLogWarn(isStaleLeaseRejection(message)
139
- ? ` Did not publish rebased ${branchRef} into remote.git: it moved since this cycle read it (concurrent submit?) -- retrying next cycle`
140
- : ` Failed to publish rebased ${branchRef} into remote.git: ${message}`);
141
- return false;
144
+ if (isStaleLeaseRejection(message)) {
145
+ workerLogWarn(` Did not publish rebased ${branchRef} into remote.git: it moved since this cycle read it (concurrent submit?) -- retrying next cycle`);
146
+ return { kind: 'moved' };
147
+ }
148
+ workerLogWarn(` Failed to publish rebased ${branchRef} into remote.git: ${message}`);
149
+ return { kind: 'failed', message };
142
150
  }
143
151
  }
144
152
  /**
@@ -178,6 +186,10 @@ export async function enqueueGitHubPush(ctx, branchRef, gatewayPolicy) {
178
186
  workerLogWarn(` Failed to queue GitHub push for ${branchRef}: ${getErrorMessage(err)}`);
179
187
  }
180
188
  }
189
+ /** Why a branch whose rewrite was recorded without its rebased tip takes nothing in. */
190
+ const REWRITE_TIP_UNKNOWN = `this branch's rebased history was recorded without the commit to publish, so neither it nor the ` +
191
+ `base is taken in. A developer publishes the rewrite by hand before the branch is submitted again.`;
192
+ const COMMIT_ID = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
181
193
  /**
182
194
  * Complete a rewrite this worker started but did not finish: get the rebased
183
195
  * history into `remote.git` and queued for GitHub.
@@ -187,33 +199,49 @@ export async function enqueueGitHubPush(ctx, branchRef, gatewayPolicy) {
187
199
  * waiting for the next base-branch advance.
188
200
  *
189
201
  * Always leases on the MARKER, never on remote.git's current tip -- the marker
190
- * is the one commit we know our own rebase replaced.
202
+ * is the one commit we know our own rebase replaced -- and sends the recorded
203
+ * rebased tip, `target`, never `headSha`: a commit made in the clone since the
204
+ * rewrite is no one's submitted work. Resolves {@link PendingRewrite}: while
205
+ * `remote.git` still stands at the marker the branch takes nothing in, since a
206
+ * rebase would replace the rewrite along with any commit made on top of it.
191
207
  */
192
208
  export async function reconcilePendingRewrite(ctx, options) {
193
209
  const { branchPath, branchDir, branchRef, headSha, marker, gatewayPolicy } = options;
210
+ const target = options.target !== undefined && COMMIT_ID.test(options.target) ? options.target : undefined;
194
211
  const publishedSha = await readPublishedSha(ctx, branchRef);
195
212
  if (publishedSha === null) {
196
213
  workerLogWarn(` ${branchDir}: a rewritten history is recorded but ${branchRef} is gone from remote.git -- nothing to publish`);
197
- return;
214
+ return { kind: 'settled' };
198
215
  }
199
- if (publishedSha === headSha) {
200
- // remote.git already carries the rewrite; only the GitHub hop is left
201
- // (a crash between the push and the queue, or a task that was lost).
216
+ if (publishedSha === target || publishedSha === headSha) {
217
+ // remote.git already carries the rewrite, or a submit since; only the
218
+ // GitHub hop is left (a crash between the push and the queue, or a task
219
+ // that was lost).
202
220
  await enqueueGitHubPush(ctx, branchRef, gatewayPolicy);
203
- return;
221
+ return { kind: 'settled' };
204
222
  }
205
223
  if (publishedSha === marker) {
206
224
  // The publish into remote.git never landed -- a crash right after the
207
225
  // marker was written, or a lease refused by a concurrent submit.
208
- if (await forcePublishToLocalRemote(ctx, branchPath, branchRef, marker)) {
209
- await enqueueGitHubPush(ctx, branchRef, gatewayPolicy);
226
+ if (target === undefined)
227
+ return { kind: 'failed', reason: REWRITE_TIP_UNKNOWN };
228
+ const publish = await forcePublishToLocalRemote(ctx, branchPath, branchRef, marker, target);
229
+ if (publish.kind === 'moved')
230
+ return { kind: 'waiting' };
231
+ if (publish.kind === 'failed') {
232
+ return {
233
+ kind: 'failed',
234
+ reason: `this branch's rebased history could not be published into the deployment's repository: ${publish.message}`,
235
+ };
210
236
  }
211
- return;
237
+ await enqueueGitHubPush(ctx, branchRef, gatewayPolicy);
238
+ return { kind: 'settled' };
212
239
  }
213
240
  // Neither this branch's rebased tip nor the commit its rewrite replaced:
214
241
  // something else moved remote.git. Never force over that.
215
242
  workerLogWarn(` ${branchDir}: remote.git is at ${publishedSha} for ${branchRef}, which is neither the ` +
216
243
  `rebased tip nor the commit the rewrite replaced -- left untouched`);
244
+ return { kind: 'settled' };
217
245
  }
218
246
  /**
219
247
  * Whether this branch carries a pending history rewrite -- the rebase loop
@@ -0,0 +1,37 @@
1
+ import type { SimpleGit } from 'simple-git';
2
+ /**
3
+ * What the worker may publish for a branch: move its head in `remote.git`, which is all a GitHub
4
+ * push ever sends (task-runner.ts `pushBranchToGitHub`). Work the author has saved but not
5
+ * submitted reaches neither until their submit pushes it, so a commit the worker builds on top of
6
+ * such work stays in the clone. The worker publishes in two places, and each asks here or follows
7
+ * the rule stated here:
8
+ *
9
+ * - a merge it built (github-commits.ts `mergeIntoBranch`): {@link decidePublish};
10
+ * - a rewrite of history already published (history-rewrite.ts): only the rebased tip recorded with
11
+ * the rewrite, never the clone's current head, which can carry a commit made since. While that
12
+ * tip is unpublished the branch takes nothing in (rebase.ts), so nothing is built on it.
13
+ */
14
+ export type PublishDecision =
15
+ /** `remote.git` has no head for the branch: nothing of it was ever published. */
16
+ {
17
+ kind: 'never-published';
18
+ }
19
+ /** Every commit on the branch's side is already in `remote.git`'s head. */
20
+ | {
21
+ kind: 'publish';
22
+ }
23
+ /** The branch's side holds commits the author has not submitted: the merge stays in the clone. */
24
+ | {
25
+ kind: 'hold';
26
+ };
27
+ /**
28
+ * Whether a merge built on `head` may be published over `published`, `remote.git`'s head: only
29
+ * when `remote.git` already holds all of `head`, whatever the branch's status, since a save can be
30
+ * committed in the moment between a submit's push and its status write. Judged from the commits
31
+ * every time, so nothing records a hold: the next submit pushes the clone, held merges included,
32
+ * as a fast-forward. The caller has checked that the clone holds `published`.
33
+ */
34
+ export declare function decidePublish(git: SimpleGit, { head, published }: {
35
+ head: string;
36
+ published: string | null;
37
+ }): Promise<PublishDecision>;
@@ -0,0 +1,16 @@
1
+ import { commitsApart } from './branch-merge.js';
2
+ /**
3
+ * Whether a merge built on `head` may be published over `published`, `remote.git`'s head: only
4
+ * when `remote.git` already holds all of `head`, whatever the branch's status, since a save can be
5
+ * committed in the moment between a submit's push and its status write. Judged from the commits
6
+ * every time, so nothing records a hold: the next submit pushes the clone, held merges included,
7
+ * as a fast-forward. The caller has checked that the clone holds `published`.
8
+ */
9
+ export async function decidePublish(git, { head, published }) {
10
+ if (published === null)
11
+ return { kind: 'never-published' };
12
+ if (published === head)
13
+ return { kind: 'publish' };
14
+ const { onlyA } = await commitsApart(git, head, published);
15
+ return onlyA === 0 ? { kind: 'publish' } : { kind: 'hold' };
16
+ }
@@ -7,8 +7,9 @@ import type { WorkerContext } from './worker-context.js';
7
7
  * The rebase loop: the deepest leaf of the git-sync cluster, and the most
8
8
  * safety-critical part of the worker. `syncGit` (git-sync.ts) calls
9
9
  * `runRebaseCycle` once per cycle; every branch workspace behind the base
10
- * branch is rebased onto it, keeping the BRANCH's version of any conflicting
11
- * file. Three things make it the part most likely to misbehave under real EFS
10
+ * branch takes it in: by a rebase where git and the field merge agree there is
11
+ * nothing to decide, and by a merge (github-commits.ts' `mergeIntoBranch`)
12
+ * wherever there is. Three things make it the part most likely to misbehave under real EFS
12
13
  * and spot conditions, all documented at the point they happen:
13
14
  *
14
15
  * - it holds the [SYNC-C1] cross-host content-write lock for the whole rebase,
@@ -27,7 +28,7 @@ import type { WorkerContext } from './worker-context.js';
27
28
  * but still need their PR's resolution polled, and this loop is the only thing
28
29
  * that walks every branch workspace.
29
30
  */
30
- export type RebaseContext = Pick<WorkerContext, 'baseBranch' | 'sanitizedBaseBranch' | 'contentBranchesPath' | 'contentRoot' | 'remoteGitPath' | 'taskDir' | 'taskTimeoutMs' | 'github' | 'ensureStatusReport' | 'afterConflictDetectedForTesting' | 'afterRebaseCompletedForTesting' | 'ensureSettingsBranch' | 'isDraining'>;
31
+ export type RebaseContext = Pick<WorkerContext, 'baseBranch' | 'sanitizedBaseBranch' | 'contentBranchesPath' | 'contentRoot' | 'remoteGitPath' | 'taskDir' | 'taskTimeoutMs' | 'github' | 'ensureStatusReport' | 'afterConflictDetectedForTesting' | 'afterRebaseCompletedForTesting' | 'ensureSettingsBranch' | 'isDraining' | 'requestBranchSync'>;
31
32
  /**
32
33
  * Per-cycle outcome of `rebaseActiveBranches()`, folded by `syncGit()` into the
33
34
  * worker's self-reported status (`WorkerStatusReport.lastGitSync`, see
@@ -73,16 +74,36 @@ export interface RebaseSummary {
73
74
  * next sync cycle. Resolves GitHub's answer for a pull request not merged.
74
75
  */
75
76
  export declare function pollMergeState(ctx: RebaseContext, branchDir: string, branchPath: string, metaFile: BranchMetadataFile | null): Promise<GitHubPullRequest | null>;
77
+ /**
78
+ * What a request for the latest changes is answered from: the fetch of the base that the branch
79
+ * was brought up to. The worker's cycle takes it from its GitHub fetch; a run in the request
80
+ * ({@link syncBranchNow}) from its own start, its base being `remote.git`'s.
81
+ */
82
+ interface BaseUpdateSource {
83
+ /** When that fetch began. */
84
+ fetchedAt: number;
85
+ /** The base commit it found; undefined when it reported none. */
86
+ baseTip?: string;
87
+ }
76
88
  /**
77
89
  * Rebase every branch workspace that is behind the base branch, one cycle.
78
90
  *
79
- * The per-branch work is {@link rebaseOneBranch}; what stays here is the walk
80
- * over `content-branches/`, the structural pre-filter (entries that are not
81
- * branch workspaces at all, plus the base branch's own clone), and folding
82
- * each branch's outcome into the summary.
91
+ * The per-branch work is {@link syncBranchDir}; what stays here is the walk
92
+ * over `content-branches/` and folding each branch's outcome into the summary.
93
+ * `source` answers requests for the latest changes; the GitHub fetch's by default. `local` is a
94
+ * run with no GitHub (see {@link syncBranchDir}).
83
95
  *
84
96
  * Deliberately sequential rather than concurrent: each branch's rebase holds
85
97
  * that branch's cross-host content-write lock and runs a series of git
86
98
  * subprocesses against a shared filesystem.
87
99
  */
88
- export declare function runRebaseCycle(ctx: RebaseContext, gatewayPolicy?: BranchPolicyDescription, gitHub?: GitHubSnapshot): Promise<RebaseSummary>;
100
+ export declare function runRebaseCycle(ctx: RebaseContext, gatewayPolicy?: BranchPolicyDescription, gitHub?: GitHubSnapshot, requested?: ReadonlySet<string>, source?: BaseUpdateSource | undefined, local?: boolean): Promise<RebaseSummary>;
101
+ /**
102
+ * Bring the base into one branch now and answer its request for the latest changes, for a mode that
103
+ * runs no worker loop (operating-mode `baseUpdateRunsInRequest`). With no GitHub, the base is
104
+ * `remote.git`'s, so the answer is from this run's start. A run that answers nothing writes
105
+ * `failed`, since nothing else ever will: `busy` says a lock held it back, which a retry in a moment
106
+ * gets past. Never throws but for the metadata write of that fallback.
107
+ */
108
+ export declare function syncBranchNow(ctx: RebaseContext, branchName: string): Promise<'answered' | 'busy'>;
109
+ export {};