canopycms 0.12.0 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/CHANGELOG.md +164 -48
  2. package/dist/api/admin.d.ts +4 -4
  3. package/dist/api/branch-review.js +45 -13
  4. package/dist/api/branch-status.d.ts +12 -0
  5. package/dist/api/branch-status.js +13 -0
  6. package/dist/api/branch-update-from-base.d.ts +23 -0
  7. package/dist/api/branch-update-from-base.js +176 -0
  8. package/dist/api/branch.d.ts +5 -0
  9. package/dist/api/branch.js +1 -0
  10. package/dist/api/client.d.ts +14 -1
  11. package/dist/api/client.js +22 -0
  12. package/dist/api/comments.d.ts +2 -2
  13. package/dist/api/content.d.ts +38 -1
  14. package/dist/api/content.js +41 -5
  15. package/dist/api/merge-conflicts.d.ts +226 -0
  16. package/dist/api/merge-conflicts.js +674 -0
  17. package/dist/api/routes.js +2 -0
  18. package/dist/api/types.d.ts +7 -3
  19. package/dist/base-update.d.ts +26 -0
  20. package/dist/base-update.js +31 -0
  21. package/dist/branch-metadata-file.d.ts +51 -0
  22. package/dist/branch-metadata-file.js +11 -0
  23. package/dist/branch-schema-cache.d.ts +12 -0
  24. package/dist/branch-schema-cache.js +27 -2
  25. package/dist/cli/backfill-item-keys.d.ts +77 -0
  26. package/dist/cli/backfill-item-keys.js +320 -0
  27. package/dist/cli/cli.d.ts +1 -1
  28. package/dist/cli/cli.js +9658 -1499
  29. package/dist/cli/generate-ai-content.js +354 -244
  30. package/dist/cli/init.js +6 -0
  31. package/dist/cli/site-config.d.ts +14 -2
  32. package/dist/cli/site-config.js +20 -4
  33. package/dist/comment-store.d.ts +8 -0
  34. package/dist/comment-store.js +28 -0
  35. package/dist/content-index-generation.d.ts +1 -4
  36. package/dist/content-index-generation.js +1 -4
  37. package/dist/content-store.d.ts +39 -3
  38. package/dist/content-store.js +48 -15
  39. package/dist/editor/BranchManager.d.ts +4 -1
  40. package/dist/editor/BranchManager.js +9 -5
  41. package/dist/editor/Editor.js +429 -52
  42. package/dist/editor/EntryNavigator.d.ts +10 -2
  43. package/dist/editor/EntryNavigator.js +12 -4
  44. package/dist/editor/FormRenderer.d.ts +8 -2
  45. package/dist/editor/FormRenderer.js +5 -6
  46. package/dist/editor/base-update-wait.d.ts +74 -0
  47. package/dist/editor/base-update-wait.js +91 -0
  48. package/dist/editor/branch-status.js +1 -1
  49. package/dist/editor/components/BaseUpdateNotice.d.ts +12 -0
  50. package/dist/editor/components/BaseUpdateNotice.js +24 -0
  51. package/dist/editor/components/BranchPill.d.ts +4 -1
  52. package/dist/editor/components/BranchPill.js +5 -2
  53. package/dist/editor/components/ConflictsGroup.d.ts +100 -0
  54. package/dist/editor/components/ConflictsGroup.js +364 -0
  55. package/dist/editor/components/EditorHeader.d.ts +16 -1
  56. package/dist/editor/components/EditorHeader.js +92 -30
  57. package/dist/editor/components/EntryChangesDrawer.d.ts +16 -2
  58. package/dist/editor/components/EntryChangesDrawer.js +33 -167
  59. package/dist/editor/components/EntryHeader.d.ts +3 -1
  60. package/dist/editor/components/EntryHeader.js +2 -2
  61. package/dist/editor/components/GetLatestChangesButton.d.ts +15 -0
  62. package/dist/editor/components/GetLatestChangesButton.js +29 -0
  63. package/dist/editor/components/ReleaseHoldButton.d.ts +20 -0
  64. package/dist/editor/components/ReleaseHoldButton.js +36 -0
  65. package/dist/editor/components/RetryToastMessage.js +1 -1
  66. package/dist/editor/components/change-parts.d.ts +51 -0
  67. package/dist/editor/components/change-parts.js +171 -0
  68. package/dist/editor/conflict-cards.d.ts +90 -0
  69. package/dist/editor/conflict-cards.js +279 -0
  70. package/dist/editor/conflict-choices.d.ts +60 -0
  71. package/dist/editor/conflict-choices.js +108 -0
  72. package/dist/editor/conflict-notice.d.ts +25 -0
  73. package/dist/editor/conflict-notice.js +39 -0
  74. package/dist/editor/copy.d.ts +12 -0
  75. package/dist/editor/copy.js +12 -0
  76. package/dist/editor/entry-diff.d.ts +2 -1
  77. package/dist/editor/entry-diff.js +5 -21
  78. package/dist/editor/hooks/useBaseUpdate.d.ts +36 -0
  79. package/dist/editor/hooks/useBaseUpdate.js +269 -0
  80. package/dist/editor/hooks/useBranchManager.d.ts +6 -1
  81. package/dist/editor/hooks/useBranchManager.js +28 -7
  82. package/dist/editor/hooks/useBranchesData.js +3 -0
  83. package/dist/editor/hooks/useConflictChoices.d.ts +24 -0
  84. package/dist/editor/hooks/useConflictChoices.js +75 -0
  85. package/dist/editor/hooks/useConflictsData.d.ts +26 -0
  86. package/dist/editor/hooks/useConflictsData.js +89 -0
  87. package/dist/editor/hooks/useDraftManager.d.ts +5 -0
  88. package/dist/editor/hooks/useDraftManager.js +24 -8
  89. package/dist/editor/hooks/useEntryManager.d.ts +9 -0
  90. package/dist/editor/hooks/useEntryManager.js +5 -0
  91. package/dist/editor/release-hold.d.ts +12 -0
  92. package/dist/editor/release-hold.js +39 -0
  93. package/dist/editor/server-clock.d.ts +4 -0
  94. package/dist/editor/server-clock.js +15 -0
  95. package/dist/editor/undo-change.d.ts +7 -0
  96. package/dist/editor/undo-change.js +19 -4
  97. package/dist/git-manager.d.ts +5 -0
  98. package/dist/git-manager.js +30 -0
  99. package/dist/item-keys.d.ts +13 -0
  100. package/dist/item-keys.js +36 -14
  101. package/dist/merge/conflict-path.d.ts +40 -0
  102. package/dist/merge/conflict-path.js +128 -0
  103. package/dist/merge/diff3.d.ts +27 -0
  104. package/dist/merge/diff3.js +113 -0
  105. package/dist/merge/entry-merge.d.ts +94 -0
  106. package/dist/merge/entry-merge.js +875 -0
  107. package/dist/merge/list-alignment.d.ts +52 -0
  108. package/dist/merge/list-alignment.js +441 -0
  109. package/dist/merge/text-merge.d.ts +29 -0
  110. package/dist/merge/text-merge.js +59 -0
  111. package/dist/merge/value-equality.d.ts +18 -0
  112. package/dist/merge/value-equality.js +50 -0
  113. package/dist/merge-conflicts.d.ts +232 -0
  114. package/dist/merge-conflicts.js +451 -0
  115. package/dist/operating-mode/client-safe-strategy.d.ts +2 -0
  116. package/dist/operating-mode/client-safe-strategy.js +6 -0
  117. package/dist/operating-mode/types.d.ts +5 -0
  118. package/dist/services.d.ts +16 -1
  119. package/dist/services.js +82 -22
  120. package/dist/task-queue/cms-task-queue.d.ts +3 -1
  121. package/dist/types.d.ts +28 -2
  122. package/dist/utils/content-serialize.d.ts +18 -1
  123. package/dist/utils/content-serialize.js +12 -0
  124. package/dist/utils/content-write-lock.d.ts +3 -3
  125. package/dist/utils/content-write-lock.js +3 -3
  126. package/dist/utils/markdown-body-splice.d.ts +1 -1
  127. package/dist/utils/markdown-body-splice.js +1 -19
  128. package/dist/utils/markdown-parse.d.ts +9 -0
  129. package/dist/utils/markdown-parse.js +27 -0
  130. package/dist/version.d.ts +1 -1
  131. package/dist/version.js +1 -1
  132. package/dist/worker/branch-merge.d.ts +53 -37
  133. package/dist/worker/branch-merge.js +139 -54
  134. package/dist/worker/cms-worker.d.ts +16 -3
  135. package/dist/worker/cms-worker.js +59 -12
  136. package/dist/worker/field-merge.d.ts +65 -0
  137. package/dist/worker/field-merge.js +636 -0
  138. package/dist/worker/git-sync.d.ts +1 -1
  139. package/dist/worker/git-sync.js +7 -6
  140. package/dist/worker/github-commits.d.ts +42 -10
  141. package/dist/worker/github-commits.js +287 -47
  142. package/dist/worker/github-gateway-http.js +17 -9
  143. package/dist/worker/github-gateway-wire.d.ts +21 -12
  144. package/dist/worker/github-gateway-wire.js +5 -1
  145. package/dist/worker/github-gateway.d.ts +10 -1
  146. package/dist/worker/github-gateway.js +18 -6
  147. package/dist/worker/github-transfer.d.ts +5 -3
  148. package/dist/worker/github-transfer.js +8 -5
  149. package/dist/worker/rebase.d.ts +5 -4
  150. package/dist/worker/rebase.js +293 -156
  151. package/dist/worker/task-runner.d.ts +1 -1
  152. package/dist/worker/task-runner.js +14 -2
  153. package/dist/worker/worker-context.d.ts +18 -2
  154. package/package.json +1 -1
@@ -44,9 +44,13 @@ export type GitHubCommitsOutcome =
44
44
  | {
45
45
  kind: 'unpushed';
46
46
  }
47
- /** A merge landed. A rebase now would replace a merge GitHub has not received. */
47
+ /**
48
+ * A merge landed. A rebase now would replace a merge GitHub has not received. `keptEntries`
49
+ * counts the entries with a conflict it left for a person.
50
+ */
48
51
  | {
49
52
  kind: 'merged';
53
+ keptEntries: number;
50
54
  }
51
55
  /** Left as it is until a person acts. `reason` says what to do. */
52
56
  | {
@@ -76,28 +80,56 @@ export declare const SEEN_COMMIT_GONE_PUSH_REFUSAL = "A commit this branch had o
76
80
  * be combined. The failed sync it left goes too, unless {@link awaitsSubmitRetry}.
77
81
  */
78
82
  export declare function rewriteCleared(branch: BranchMetadata): Partial<BranchMetadata>;
83
+ /** A branch this deployment put on GitHub. Any other GitHub branch of that name is not its own. */
84
+ export declare function isPublished({ branch }: BranchMetadataFile): boolean;
85
+ /**
86
+ * Fast-forward the clone to the merge this worker was landing, when `remote.git` holds it on top of
87
+ * the clone, as a crash or a lost lock between publishing it and moving the clone leaves it,
88
+ * whether or not the branch is on GitHub. Resolves why it stopped, or null when there was nothing
89
+ * to catch up with or it caught up.
90
+ */
91
+ export declare function catchUpWithPublished(ctx: GitHubCommitsContext, args: GitHubCommitsArgs): Promise<GitHubCommitsOutcome | null>;
79
92
  /** Bring GitHub's commits into the clone. Throws what git throws; the caller records it. */
80
93
  export declare function bringInGitHubCommits(ctx: GitHubCommitsContext, args: GitHubCommitsArgs): Promise<GitHubCommitsOutcome>;
81
94
  /**
82
- * `other` merged into the branch: built, published to `remote.git`, queued for GitHub, and the
83
- * clone moved to it, in that order, so each step leaves what the next cycle picks up and only the
84
- * last touches the working tree. `fromGitHub` says `other` is GitHub's tip rather than the base.
85
- * `stopOnConflict` leaves a branch in review as it is: nobody is at the editor to see what a kept
86
- * version replaced, and GitHub's own conflict notice stands on the pull request.
95
+ * `other` merged into the branch: built, recorded, published to `remote.git`, queued for GitHub,
96
+ * and the clone moved to it, in that order, so each step leaves what the next cycle picks up and
97
+ * only the last touches the working tree. `fromGitHub` says `other` is GitHub's tip rather than the
98
+ * base. `inReview` takes only a merge with nothing for a person to choose; any other leaves the
99
+ * branch as it is and records nothing, and GitHub's own conflict notice stands on the pull request. A lock lost before the clone moves leaves it where it was: `wait`.
87
100
  */
88
101
  export declare function mergeIntoBranch(ctx: GitHubCommitsContext, args: GitHubCommitsArgs, merge: {
89
102
  head: string;
90
103
  other: string;
91
104
  message: string;
92
105
  fromGitHub: boolean;
93
- stopOnConflict?: boolean;
106
+ inReview?: boolean;
94
107
  }): Promise<GitHubCommitsOutcome>;
95
108
  /**
96
- * Send an approved branch back to review and clear the verdict from the PR body. Before the clone
97
- * moves, and a failed save throws, so a crash or a failure leaves the commits for the next cycle
98
- * to judge again rather than an approval standing over them.
109
+ * Whether `commit` contains `ancestor`. A commit this clone does not hold counts as no. Any other
110
+ * failure throws, since it says nothing about either commit, and the caller's catch retries next
111
+ * cycle: read as no, it would report a rewrite that never happened.
112
+ */
113
+ export declare function contains(git: SimpleGit, commit: string, ancestor: string): Promise<boolean>;
114
+ /**
115
+ * Send an approved branch back to review and clear the verdict from the PR body. Called before the
116
+ * clone moves, where a failed save throws, so a crash or a failure leaves the commits for the next
117
+ * cycle to judge again rather than an approval standing over them.
99
118
  */
100
119
  export declare function returnToReview(ctx: GitHubCommitsContext, { branchPath, branchDir, branchRef, metaFile }: GitHubCommitsArgs): Promise<void>;
120
+ /**
121
+ * Bring merge-conflicts.json in line with the branch's history: a record changed by a merge the
122
+ * branch never got gives back the one it replaced (`withoutMergesGone`), and `branch.json`
123
+ * `conflictFiles` follows the record when the two differ. A merge counts as held while the clone or
124
+ * `remote.git` holds it, since a clone behind its published merge catches up with it; comment
125
+ * threads take a merge's item keys only once the clone holds it. While either can't be read in the
126
+ * clone, nothing is judged. A record that does not parse is moved aside, so the branch's merges
127
+ * carry on with a new one. Each cycle, before the branch takes anything in. A clone with no record
128
+ * has no conflict open, so `conflictFiles` written before the record existed clears. Never throws.
129
+ */
130
+ export declare function reconcileMergeConflicts(ctx: GitHubCommitsContext, { git, branchPath, branchDir, branchRef, }: Pick<GitHubCommitsArgs, 'git' | 'branchPath' | 'branchDir'> & {
131
+ branchRef: string | null;
132
+ }): Promise<void>;
101
133
  /**
102
134
  * Re-render the PR body's review block alone: the worker has no `config.editorUrl`, so a full
103
135
  * sync from here would strip the comment summary's links. Best-effort: a failure leaves the block
@@ -1,13 +1,15 @@
1
1
  import { BranchMetadataFileManager, awaitsSubmitRetry, getBranchMetadataFileManager, } from '../branch-metadata.js';
2
+ import { CommentStore } from '../comment-store.js';
2
3
  import { invalidateBranchContentCaches } from '../content-index-generation.js';
3
4
  import { GITHUB_TRACKING_REF_PREFIX } from '../git-manager.js';
5
+ import { MergeConflictStore, MergeConflictsFileMalformedError, conflictFilesOf, withFoundConflicts, withRemappedItemKeys, withoutMergesGone, } from '../merge-conflicts.js';
4
6
  import { SYNC_PR_COMMENTS_ACTION } from '../pr-comments.js';
5
7
  import { enqueueTask } from '../task-queue/cms-task-queue.js';
6
8
  import { getErrorMessage } from '../utils/error.js';
7
9
  import { escapeForLog } from '../utils/escape-text.js';
8
10
  import { isCanopyInternalPath } from '../utils/git.js';
9
11
  import { publishesBranch } from './branch-policy.js';
10
- import { buildMergeCommit, commitsApart, conflictFilesToContentIds, isCleanMergeOf, publishPlainToLocalRemote, recordConflicts, } from './branch-merge.js';
12
+ import { commitMerge, commitsApart, isCleanMergeOf, planMerge, publishPlainToLocalRemote, } from './branch-merge.js';
11
13
  import { MAX_REPORTED_PATHS, RETIRED_SCHEMA_CACHE_PATH, TRACKED_CANOPY_STATE_FIX, } from './canopy-state.js';
12
14
  import { enqueueGitHubPush, readPublishedSha, recordSeenOnGitHub } from './history-rewrite.js';
13
15
  import { workerLog, workerLogWarn } from './log.js';
@@ -33,9 +35,38 @@ export function rewriteCleared(branch) {
33
35
  : { rewrittenOnGitHub: undefined, syncStatus: 'synced', syncFailureReason: undefined };
34
36
  }
35
37
  /** A branch this deployment put on GitHub. Any other GitHub branch of that name is not its own. */
36
- function isPublished({ branch }) {
38
+ export function isPublished({ branch }) {
37
39
  return branch.pushedToGitHubAt !== undefined || branch.pullRequestNumber !== undefined;
38
40
  }
41
+ /**
42
+ * Fast-forward the clone to the merge this worker was landing, when `remote.git` holds it on top of
43
+ * the clone, as a crash or a lost lock between publishing it and moving the clone leaves it,
44
+ * whether or not the branch is on GitHub. Resolves why it stopped, or null when there was nothing
45
+ * to catch up with or it caught up.
46
+ */
47
+ export async function catchUpWithPublished(ctx, args) {
48
+ const { git, branchPath, branchRef } = args;
49
+ const published = await readPublishedSha(ctx, branchRef);
50
+ if (published === null)
51
+ return null;
52
+ // Only the merge this worker stamped as landing: `remote.git` can also hold a same-named branch,
53
+ // or a reviewer's commit, that it never wrote, which other steps judge.
54
+ const landing = await new MergeConflictStore(branchPath).read().then((record) => record?.landing?.mergeSha, () => undefined);
55
+ if (landing !== published)
56
+ return null;
57
+ const head = (await git.revparse(['HEAD'])).trim();
58
+ if (published === head)
59
+ return null;
60
+ if (!(await holdsCommit(git, published))) {
61
+ await fetchFromRemoteGit(git, ctx.remoteGitPath, `refs/heads/${branchRef}`);
62
+ }
63
+ const parents = (await git.raw(['rev-list', '--parents', '-n', '1', '--end-of-options', published]))
64
+ .trim()
65
+ .split(' ');
66
+ if (parents.length !== 3 || parents[1] !== head)
67
+ return null;
68
+ return fastForward(ctx, args, head, published);
69
+ }
39
70
  /** Bring GitHub's commits into the clone. Throws what git throws; the caller records it. */
40
71
  export async function bringInGitHubCommits(ctx, args) {
41
72
  const { git, branchRef, metaFile, dirty, gatewayPolicy } = args;
@@ -291,6 +322,11 @@ async function fastForward(ctx, args, from, to) {
291
322
  return { kind: 'wait' };
292
323
  }
293
324
  workerLog(` ${branchDir}: brought in commits from GitHub (now at ${to.slice(0, 7)})`);
325
+ // A merge this worker was landing, which the clone now holds, has landed.
326
+ const landing = await new MergeConflictStore(branchPath).read().then((record) => record?.landing?.mergeSha, () => undefined);
327
+ if (landing !== undefined && (await contains(git, to, landing))) {
328
+ await landed(ctx, args, landing).catch((err) => workerLogWarn(` ${branchDir}: what a merge kept is noted on the next cycle: ${getErrorMessage(err)}`));
329
+ }
294
330
  return null;
295
331
  }
296
332
  /**
@@ -304,42 +340,48 @@ async function moveClone(git, branchPath, to) {
304
340
  await invalidateBranchContentCaches(branchPath);
305
341
  }
306
342
  /**
307
- * `other` merged into the branch: built, published to `remote.git`, queued for GitHub, and the
308
- * clone moved to it, in that order, so each step leaves what the next cycle picks up and only the
309
- * last touches the working tree. `fromGitHub` says `other` is GitHub's tip rather than the base.
310
- * `stopOnConflict` leaves a branch in review as it is: nobody is at the editor to see what a kept
311
- * version replaced, and GitHub's own conflict notice stands on the pull request.
343
+ * `other` merged into the branch: built, recorded, published to `remote.git`, queued for GitHub,
344
+ * and the clone moved to it, in that order, so each step leaves what the next cycle picks up and
345
+ * only the last touches the working tree. `fromGitHub` says `other` is GitHub's tip rather than the
346
+ * base. `inReview` takes only a merge with nothing for a person to choose; any other leaves the
347
+ * branch as it is and records nothing, and GitHub's own conflict notice stands on the pull request. A lock lost before the clone moves leaves it where it was: `wait`.
312
348
  */
313
349
  export async function mergeIntoBranch(ctx, args, merge) {
314
350
  const { git, branchPath, branchDir, branchRef, gatewayPolicy, lockLost } = args;
315
- const { head, other, message, fromGitHub, stopOnConflict } = merge;
316
- const built = await buildMergeCommit(git, branchPath, {
351
+ const { head, other, message, fromGitHub, inReview } = merge;
352
+ const plan = await planMerge(git, branchPath, {
317
353
  head,
318
354
  other,
319
- message,
355
+ contentRoot: ctx.contentRoot,
320
356
  timeoutMs: ctx.taskTimeoutMs,
321
357
  });
322
- if (stopOnConflict && (built.kind === 'unsettled' || built.conflicted.length > 0)) {
358
+ // Nobody is at the editor of a branch in review to choose: a value or file kept over the other
359
+ // side's change would stand unseen in the pull request. Held before any commit is written, as a
360
+ // branch can stay held for days.
361
+ if (inReview &&
362
+ (plan.kind === 'unsettled' || plan.conflicts.length > 0 || plan.keptPaths.length > 0)) {
323
363
  workerLog(` ${branchDir}: conflicts with ${other.slice(0, 7)}, so it stays as it is`);
324
364
  return { kind: 'wait' };
325
365
  }
326
- if (built.kind === 'unsettled') {
366
+ if (plan.kind === 'unsettled') {
327
367
  return {
328
368
  kind: 'blocked',
329
- reason: `Git cannot merge ${other} into ${branchRef} by rule: ${built.conflicts.join(', ')}` +
330
- `${built.paths.length > 0 ? ` (${listPaths(built.paths)})` : ''}. Nothing was changed. ` +
369
+ reason: `Git cannot merge ${other} into ${branchRef} by rule: ${plan.conflicts.join(', ')}` +
370
+ `${plan.paths.length > 0 ? ` (${listPaths(plan.paths)})` : ''}. Nothing was changed. ` +
331
371
  `To continue, a developer merges them by hand.`,
332
372
  };
333
373
  }
334
- const { sha, conflicted } = built;
374
+ const built = await commitMerge(git, plan, { head, other, message });
375
+ const { sha, conflicts } = built;
335
376
  const refused = await refuseIncoming(args, head, sha);
336
377
  if (refused)
337
378
  return refused;
338
379
  if (lockLost())
339
380
  return { kind: 'wait' };
340
- if (fromGitHub && (await changesWhatWasReviewed(ctx, args, head, sha))) {
381
+ // A field merge of the base settles what git could not, which no reviewer has seen either.
382
+ // Approving waits for the content-write lock the caller holds, so none lands after this check.
383
+ if (await changesWhatWasReviewed(ctx, args, head, sha))
341
384
  await returnToReview(ctx, args);
342
- }
343
385
  const published = await readPublishedSha(ctx, branchRef);
344
386
  const unpublishable = `This deployment's repository holds commits for ${branchRef} that neither this clone nor ` +
345
387
  `GitHub has, so a merge of the two cannot be published. Nothing was changed. Compare the ` +
@@ -348,9 +390,11 @@ export async function mergeIntoBranch(ctx, args, merge) {
348
390
  return { kind: 'blocked', reason: unpublishable };
349
391
  }
350
392
  // Recorded before the merge is visible anywhere: the next cycle only fast-forwards to it, and
351
- // the conflicts cannot be read back from the merge.
352
- if (conflicted.length > 0)
353
- await recordKeptVersions(ctx, args, conflicted, fromGitHub);
393
+ // the conflicts cannot be read back from the merge. A merge that never lands leaves records the
394
+ // next cycle's reconcile turns back.
395
+ await recordMerge(ctx, args, built, { head, fromGitHub });
396
+ if (lockLost())
397
+ return { kind: 'wait' };
354
398
  // Before `remote.git` holds it, as reconcile does: a crash past this point leaves GitHub's commit
355
399
  // in `remote.git`, and a removal then has to read as one (`rewrittenFrom`).
356
400
  if (fromGitHub)
@@ -361,16 +405,20 @@ export async function mergeIntoBranch(ctx, args, merge) {
361
405
  }
362
406
  await enqueueGitHubPush(ctx, branchRef, gatewayPolicy);
363
407
  }
364
- if (conflicted.length > 0 && fromGitHub) {
365
- await enqueueReviewBlockSync(ctx, branchRef, args.metaFile);
366
- }
408
+ // Published and not yet in the clone: the next cycle brings it in, by `bringInGitHubCommits` or
409
+ // `catchUpWithPublished`.
410
+ if (lockLost())
411
+ return { kind: 'wait' };
367
412
  await moveClone(git, branchPath, sha);
413
+ // Landed: a failure here leaves the remaps and the stamp for the next cycle's reconcile.
414
+ await landed(ctx, args, sha).catch((err) => workerLogWarn(` ${branchDir}: what this merge kept is noted on the next cycle: ${getErrorMessage(err)}`));
368
415
  if (fromGitHub)
369
416
  await recordGitHubTip(ctx, args, other);
370
- workerLog(conflicted.length > 0
371
- ? ` ${branchDir}: merged ${other.slice(0, 7)} (kept this branch's version of ${conflicted.length} file(s))`
417
+ const keptEntries = new Set(conflicts.map(({ contentId }) => contentId)).size;
418
+ workerLog(keptEntries > 0
419
+ ? ` ${branchDir}: merged ${other.slice(0, 7)} (${keptEntries} entr${keptEntries === 1 ? 'y' : 'ies'} with values for a person to choose: ${listPaths(conflicts.map(({ path }) => path))})`
372
420
  : ` ${branchDir}: merged ${other.slice(0, 7)}`);
373
- return { kind: 'merged' };
421
+ return { kind: 'merged', keptEntries };
374
422
  }
375
423
  /**
376
424
  * Whether this clone holds `sha`, on git's word for it. A killed `git` answers nothing at all,
@@ -396,7 +444,7 @@ async function holdsCommit(git, sha) {
396
444
  * failure throws, since it says nothing about either commit, and the caller's catch retries next
397
445
  * cycle: read as no, it would report a rewrite that never happened.
398
446
  */
399
- async function contains(git, commit, ancestor) {
447
+ export async function contains(git, commit, ancestor) {
400
448
  if (!(await holdsCommit(git, commit)) || !(await holdsCommit(git, ancestor)))
401
449
  return false;
402
450
  return (await commitsApart(git, ancestor, commit)).onlyA === 0;
@@ -417,34 +465,226 @@ async function changesWhatWasReviewed(ctx, { git, branchPath }, from, to) {
417
465
  return forkPoint === '' || !(await isCleanMergeOf(git, to, from, forkPoint));
418
466
  }
419
467
  /**
420
- * Send an approved branch back to review and clear the verdict from the PR body. Before the clone
421
- * moves, and a failed save throws, so a crash or a failure leaves the commits for the next cycle
422
- * to judge again rather than an approval standing over them.
468
+ * Send an approved branch back to review and clear the verdict from the PR body. Called before the
469
+ * clone moves, where a failed save throws, so a crash or a failure leaves the commits for the next
470
+ * cycle to judge again rather than an approval standing over them.
423
471
  */
424
472
  export async function returnToReview(ctx, { branchPath, branchDir, branchRef, metaFile }) {
425
473
  const saved = await getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath).saveIf({ branch: { status: 'submitted', review: undefined } }, (current) => current?.branch.name === branchRef && current.branch.status === 'approved');
426
474
  if (saved === null)
427
475
  return;
428
- workerLog(` ${branchDir}: back in review, as commits from GitHub changed what was approved`);
476
+ workerLog(` ${branchDir}: back in review, as what was approved changed`);
429
477
  await enqueueReviewBlockSync(ctx, branchRef, metaFile);
430
478
  }
431
- /** Record what a merge kept, for the editor and, for GitHub's commits, the PR body. */
432
- async function recordKeptVersions(ctx, { branchPath, branchRef }, conflicted, fromGitHub) {
433
- const ids = conflictFilesToContentIds(conflicted, ctx.contentRoot);
434
- await getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath).update((existing) => existing?.branch.name === branchRef
435
- ? {
436
- branch: {
437
- ...recordConflicts(existing.branch.conflictFiles, ids),
438
- ...(fromGitHub
439
- ? {
440
- keptOverGitHub: [
441
- ...new Set([...(existing.branch.keptOverGitHub ?? []), ...conflicted]),
442
- ],
443
- }
444
- : {}),
445
- },
479
+ /**
480
+ * Record what a merge left for a person in merge-conflicts.json, with the item keys it changed for
481
+ * the branch's comment threads to take once it lands and the merge as `landing`, and the content
482
+ * IDs it names in `branch.json` `conflictFiles`. The record is first settled against `head`, which
483
+ * the merge builds on, so a record of a merge that never landed can't carry into this one. Before
484
+ * the merge is visible: one that never lands leaves records the next
485
+ * {@link reconcileMergeConflicts} turns back.
486
+ */
487
+ async function recordMerge(ctx, args, built, merge) {
488
+ const { git, branchPath, branchRef } = args;
489
+ const { sha, conflicts, keptPaths, keyRemaps, paths } = built;
490
+ const inHead = await settleRecord(git, branchPath, merge.head);
491
+ const state = await new MergeConflictStore(branchPath).update((current) => ({
492
+ entries: withFoundConflicts(withRemappedItemKeys(current ? withoutMergesGone(current.entries, inHead) : {}, keyRemaps, sha), conflicts, sha, paths),
493
+ remaps: [
494
+ ...(current?.remaps ?? []),
495
+ ...keyRemaps.map(({ entryPath, remaps }) => ({ mergeSha: sha, entryPath, remaps })),
496
+ ],
497
+ landing: {
498
+ mergeSha: sha,
499
+ ...(merge.fromGitHub && keptPaths.length > 0 && { keptOverGitHub: keptPaths }),
500
+ },
501
+ }));
502
+ if (state === null)
503
+ return;
504
+ await getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath).update((existing) => existing?.branch.name === branchRef ? { branch: conflictFilesOf(state.entries) } : null);
505
+ }
506
+ /**
507
+ * After the clone moved to `sha`, the merge being landed: the remaps of every merge it holds apply,
508
+ * in the order recorded, the paths it kept over GitHub's commits go in `keptOverGitHub` for the PR
509
+ * body, and it is no longer landing. A remap that fails waits for the next cycle, and keeps nothing
510
+ * else from being noted.
511
+ */
512
+ async function landed(ctx, { git, branchPath, branchRef, branchDir, metaFile, }, sha) {
513
+ const store = new MergeConflictStore(branchPath);
514
+ const current = await store.read();
515
+ if (current === null)
516
+ return;
517
+ const [inSha] = await mergesHeld(git, current, [sha]);
518
+ await applyRemaps(branchPath, (mergeSha) => inSha.has(mergeSha)).catch((err) => workerLogWarn(` ${branchDir}: comment threads keep their item keys for now: ${getErrorMessage(err)}`));
519
+ const landing = current.landing;
520
+ if (landing?.mergeSha !== sha)
521
+ return;
522
+ const kept = landing.keptOverGitHub ?? [];
523
+ if (kept.length > 0) {
524
+ await getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath).update((existing) => existing?.branch.name === branchRef
525
+ ? {
526
+ branch: {
527
+ keptOverGitHub: [...new Set([...(existing.branch.keptOverGitHub ?? []), ...kept])],
528
+ },
529
+ }
530
+ : null);
531
+ await enqueueReviewBlockSync(ctx, branchRef, metaFile);
532
+ }
533
+ await store.update((now) => {
534
+ if (now?.landing?.mergeSha !== sha)
535
+ return null;
536
+ const { landing: _done, ...rest } = now;
537
+ return rest;
538
+ });
539
+ }
540
+ /** Every merge a record names, its earlier versions' included. */
541
+ function mergesNamedBy(entry) {
542
+ return [
543
+ entry.mergeSha,
544
+ ...(entry.fields ?? []).map((f) => f.mergeSha),
545
+ ...(entry.previous ? mergesNamedBy(entry.previous) : []),
546
+ ];
547
+ }
548
+ /** The merges merge-conflicts.json names, and which of them each of `tips` holds. */
549
+ async function mergesHeld(git, state, tips) {
550
+ const shas = new Set([
551
+ ...Object.values(state.entries).flatMap(mergesNamedBy),
552
+ ...(state.remaps ?? []).map(({ mergeSha }) => mergeSha),
553
+ ...(state.landing ? [state.landing.mergeSha] : []),
554
+ ]);
555
+ const held = tips.map(() => new Set());
556
+ for (const sha of shas) {
557
+ for (const [n, tip] of tips.entries()) {
558
+ if (await contains(git, tip, sha))
559
+ held[n].add(sha);
446
560
  }
447
- : null);
561
+ }
562
+ return held;
563
+ }
564
+ /**
565
+ * The record as `head` has it: comment threads take the item keys of merges `head` holds, other
566
+ * merges' remaps go, and records changed by merges it lacks give back the ones they replaced.
567
+ * Resolves whether `head` holds a merge the record named then; a merge recorded since counts as
568
+ * held, as nothing settled it.
569
+ */
570
+ async function settleRecord(git, branchPath, head) {
571
+ const store = new MergeConflictStore(branchPath);
572
+ const current = await store.read();
573
+ if (current === null)
574
+ return () => true;
575
+ const [held] = await mergesHeld(git, current, [head]);
576
+ const known = new Set([
577
+ ...Object.values(current.entries).flatMap(mergesNamedBy),
578
+ ...(current.remaps ?? []).map(({ mergeSha }) => mergeSha),
579
+ ]);
580
+ const inHead = (sha) => held.has(sha) || !known.has(sha);
581
+ // The threads can wait: the remaps stay for the next cycle.
582
+ await applyRemaps(branchPath, inHead, (sha) => !inHead(sha)).catch((err) => workerLogWarn(` comment threads keep their item keys for now: ${getErrorMessage(err)}`));
583
+ return inHead;
584
+ }
585
+ /**
586
+ * Point the branch's comment threads at the item keys the merges in `landed` gave their items, in
587
+ * the order they were recorded, then drop those remaps from the record, and those `gone` names
588
+ * unapplied.
589
+ */
590
+ async function applyRemaps(branchPath, landed, gone = () => false) {
591
+ const store = new MergeConflictStore(branchPath);
592
+ const pending = (await store.read())?.remaps ?? [];
593
+ const due = pending.filter(({ mergeSha }) => landed(mergeSha));
594
+ if (due.length === 0 && !pending.some(({ mergeSha }) => gone(mergeSha)))
595
+ return;
596
+ const comments = new CommentStore(branchPath);
597
+ for (const { entryPath, remaps } of due)
598
+ await comments.remapItemKeys(entryPath, remaps);
599
+ await store.update((current) => current && {
600
+ ...current,
601
+ remaps: (current.remaps ?? []).filter(({ mergeSha }) => !landed(mergeSha) && !gone(mergeSha)),
602
+ });
603
+ }
604
+ /** `branch.json` `conflictFiles` as `entries` has them, written only when the two differ. */
605
+ async function followRecord(ctx, branchPath, branchDir, branchRef, entries) {
606
+ const { conflictFiles, conflictStatus } = conflictFilesOf(entries);
607
+ const same = (ids) => JSON.stringify([...(ids ?? [])].sort()) === JSON.stringify(conflictFiles);
608
+ // Read first: the locked update costs every branch a lock each cycle, and nearly all agree.
609
+ const read = (await BranchMetadataFileManager.loadOnly(branchPath))?.branch;
610
+ if (read === undefined || (branchRef !== null && read.name !== branchRef))
611
+ return;
612
+ if (same(read.conflictFiles))
613
+ return;
614
+ const saved = await getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath).update((existing) => existing !== null &&
615
+ (branchRef === null || existing.branch.name === branchRef) &&
616
+ !same(existing.branch.conflictFiles)
617
+ ? { branch: { conflictFiles, conflictStatus } }
618
+ : null, { keepUpdatedAt: true });
619
+ if (saved)
620
+ workerLog(` ${branchDir}: ${conflictFiles.length} entries with conflicts on record`);
621
+ }
622
+ /**
623
+ * Bring merge-conflicts.json in line with the branch's history: a record changed by a merge the
624
+ * branch never got gives back the one it replaced (`withoutMergesGone`), and `branch.json`
625
+ * `conflictFiles` follows the record when the two differ. A merge counts as held while the clone or
626
+ * `remote.git` holds it, since a clone behind its published merge catches up with it; comment
627
+ * threads take a merge's item keys only once the clone holds it. While either can't be read in the
628
+ * clone, nothing is judged. A record that does not parse is moved aside, so the branch's merges
629
+ * carry on with a new one. Each cycle, before the branch takes anything in. A clone with no record
630
+ * has no conflict open, so `conflictFiles` written before the record existed clears. Never throws.
631
+ */
632
+ export async function reconcileMergeConflicts(ctx, { git, branchPath, branchDir, branchRef, }) {
633
+ try {
634
+ const store = new MergeConflictStore(branchPath);
635
+ let current;
636
+ try {
637
+ current = await store.read();
638
+ }
639
+ catch (err) {
640
+ if (!(err instanceof MergeConflictsFileMalformedError))
641
+ throw err;
642
+ const aside = await store.quarantine();
643
+ if (aside !== null) {
644
+ workerLogWarn(` ${branchDir}: its conflict record does not parse; copied it to ${aside}`);
645
+ }
646
+ // The held record, or one another writer left in its place.
647
+ current = await store.read();
648
+ }
649
+ if (current === null) {
650
+ await followRecord(ctx, branchPath, branchDir, branchRef, {});
651
+ return;
652
+ }
653
+ const published = branchRef === null ? null : await readPublishedSha(ctx, branchRef);
654
+ if (published !== null && !(await holdsCommit(git, published))) {
655
+ await fetchFromRemoteGit(git, ctx.remoteGitPath, `refs/heads/${branchRef}`);
656
+ }
657
+ const tips = ['HEAD', ...(published === null ? [] : [published])];
658
+ for (const tip of tips)
659
+ if (!(await holdsCommit(git, tip)))
660
+ return;
661
+ const [inHead, inPublished = new Set()] = await mergesHeld(git, current, tips);
662
+ const held = (sha) => inHead.has(sha) || inPublished.has(sha);
663
+ const entries = (await store.update((now) => {
664
+ if (now === null)
665
+ return null;
666
+ const kept = withoutMergesGone(now.entries, held);
667
+ const { landing, ...rest } = now;
668
+ // A clone catches up only with a merge `remote.git` holds; one it holds lands below.
669
+ // Without `remote.git`'s head to read, a published merge can't be told from a lost one.
670
+ const stays = landing !== undefined && (branchRef === null || held(landing.mergeSha));
671
+ if (JSON.stringify(kept) === JSON.stringify(now.entries) && (stays || !landing)) {
672
+ return null;
673
+ }
674
+ return { ...rest, entries: kept, ...(stays && { landing }) };
675
+ }))?.entries ?? current.entries;
676
+ await followRecord(ctx, branchPath, branchDir, branchRef, entries);
677
+ await applyRemaps(branchPath, (sha) => inHead.has(sha), (sha) => !held(sha));
678
+ if (current.landing && inHead.has(current.landing.mergeSha) && branchRef !== null) {
679
+ const metaFile = await BranchMetadataFileManager.loadOnly(branchPath);
680
+ if (metaFile) {
681
+ await landed(ctx, { git, branchPath, branchRef, branchDir, metaFile }, current.landing.mergeSha);
682
+ }
683
+ }
684
+ }
685
+ catch (err) {
686
+ workerLogWarn(` ${branchDir}: could not reconcile its conflict record: ${getErrorMessage(err)}`);
687
+ }
448
688
  }
449
689
  /**
450
690
  * Re-render the PR body's review block alone: the worker has no `config.editorUrl`, so a full
@@ -219,9 +219,9 @@ class GatewayRequestHandler {
219
219
  return {};
220
220
  },
221
221
  getPullRequest: async ({ number }, signal) => {
222
- const { merged, merged_at, state, node_id, body, mergeable_state, baseRef } = await gateway.getPullRequest(number, signal);
222
+ const { merged, merged_at, state, node_id, body, mergeable_state, baseRef, headSha } = await gateway.getPullRequest(number, signal);
223
223
  // A null body is the gateway withholding it (`GitHubPullRequest.body`), and crosses as one.
224
- return { merged, merged_at, state, node_id, body, mergeable_state, baseRef };
224
+ return { merged, merged_at, state, node_id, body, mergeable_state, baseRef, headSha };
225
225
  },
226
226
  createOrUpdatePullRequest: async (input, signal) => {
227
227
  const { number, url, created } = await gateway.createOrUpdatePullRequest(input, signal);
@@ -409,7 +409,7 @@ class GatewayRequestHandler {
409
409
  }
410
410
  async startFetch(req, res) {
411
411
  await this.prepared;
412
- const { have } = parseRequest(fetchRequest, await readJsonBody(req), 'fetch');
412
+ const { have, fresh } = parseRequest(fetchRequest, await readJsonBody(req), 'fetch');
413
413
  this.evictJobs();
414
414
  const running = [...this.jobs.values()].filter((job) => job.settledAt === null).length;
415
415
  if (running >= MAX_RUNNING_FETCH_JOBS) {
@@ -434,13 +434,19 @@ class GatewayRequestHandler {
434
434
  progress: 0,
435
435
  // No signal: the job outlives the request that started it (see the module doc).
436
436
  settled: this.gateway
437
- .fetch({ have }, undefined, () => {
437
+ .fetch({ have, fresh }, undefined, () => {
438
438
  job.progress++;
439
439
  })
440
- .then(({ refs, bundleId }) => {
440
+ .then(({ refs, bundleId, startedAt }) => {
441
441
  job.answer = {
442
442
  ok: true,
443
- value: { state: 'done', progress: job.progress, refs: [...refs], bundleId },
443
+ value: {
444
+ state: 'done',
445
+ progress: job.progress,
446
+ refs: [...refs],
447
+ bundleId,
448
+ startedAt,
449
+ },
444
450
  };
445
451
  }, (err) => {
446
452
  job.answer = { ok: false, error: encodeError(err) };
@@ -703,11 +709,12 @@ class GitHubGatewayClient {
703
709
  */
704
710
  async fetch(request, signal, onProgress) {
705
711
  const have = [...new Set(request.have)];
712
+ const json = { have, fresh: request.fresh === true };
706
713
  for (let attempt = 1;; attempt++) {
707
714
  const started = await this.send({
708
715
  method: 'POST',
709
716
  path: FETCH_ROUTE,
710
- json: { have },
717
+ json,
711
718
  signal,
712
719
  });
713
720
  const { job } = await readAnswer(started, fetchStarted, signal);
@@ -754,8 +761,9 @@ class GitHubGatewayClient {
754
761
  progress = poll.progress;
755
762
  movedAt = performance.now();
756
763
  }
757
- if (poll.state === 'done')
758
- return { refs: new Map(poll.refs), bundleId: poll.bundleId };
764
+ if (poll.state === 'done') {
765
+ return { refs: new Map(poll.refs), bundleId: poll.bundleId, startedAt: poll.startedAt };
766
+ }
759
767
  if (performance.now() - movedAt >= this.fetchInactivityMs) {
760
768
  throw new GitHubTransientError('github-fetch-failed', `The GitHub gateway's fetch showed no progress for ${this.fetchInactivityMs}ms; it runs on, ` +
761
769
  'and the next fetch joins it');