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
@@ -0,0 +1,227 @@
1
+ import { branchParamSchema } from './validators.js';
2
+ import { toBranchListItem } from './branch.js';
3
+ import { defineEndpoint } from './route-builder.js';
4
+ import { canPerformWorkflowAction, getBranchProtection } from '../authorization/index.js';
5
+ import { BranchMetadataFileManager, getBranchMetadataFileManager } from '../branch-metadata.js';
6
+ import { clientOperatingStrategy, operatingStrategy } from '../operating-mode/index.js';
7
+ import { enqueueTask, listTasks } from '../task-queue/cms-task-queue.js';
8
+ import { getTaskQueueDir } from '../task-queue/task-queue-config.js';
9
+ import { BASE_UPDATE_GIVE_UP_MS, BASE_UPDATE_REQUEST_INTERVAL_MS, SYNC_BRANCH_ACTION, isBaseUpdatePending, } from '../base-update.js';
10
+ import { ContentWriteLockBusyError } from '../utils/content-write-lock.js';
11
+ import { getErrorMessage, redactCredentials } from '../utils/error.js';
12
+ import { baseBranchOf } from '../utils/base-branch.js';
13
+ /**
14
+ * Ask the worker to bring the base branch into this one now, rather than at its next sync
15
+ * (base-update.ts). Saved work is committed first, locally: the sync skips a branch with
16
+ * uncommitted changes, so without the commit the request would do nothing.
17
+ *
18
+ * The request is recorded on branch.json, which is what the worker answers, so a lost task or a
19
+ * worker restart costs only the wait for its next regular cycle. The task only wakes it early.
20
+ * Where the mode runs no worker loop (operating-mode `baseUpdateRunsInRequest`), a new request runs
21
+ * the worker's merge itself and is answered before the response; one it joins is answered by the
22
+ * run that made it.
23
+ * A request joins the last one, answering with its time: while that is unanswered, under
24
+ * BASE_UPDATE_GIVE_UP_MS old and either under BASE_UPDATE_REQUEST_INTERVAL_MS old or still queued;
25
+ * or once it was answered under BASE_UPDATE_REQUEST_INTERVAL_MS ago without failing. Anything
26
+ * else, a Retry after a failure among them, is a new request.
27
+ */
28
+ const updateFromBaseHandler = async (gc, ctx, req, _params) => {
29
+ const { branchContext } = gc;
30
+ const { config } = ctx.services;
31
+ const { branch } = branchContext;
32
+ const runsInRequest = operatingStrategy(config.mode).baseUpdateRunsInRequest();
33
+ if (!clientOperatingStrategy(config.mode).syncsBranchesWithBase()) {
34
+ return {
35
+ ok: false,
36
+ status: 409,
37
+ error: `Getting the latest changes needs the CMS worker, which ${config.mode} mode doesn't run.`,
38
+ };
39
+ }
40
+ const { isProtected } = getBranchProtection(config, branch.name, branch.baseBranch);
41
+ const allowed = canPerformWorkflowAction(branchContext, req.user, config.defaultBranchAccess ?? 'deny', { isProtectedBranch: isProtected });
42
+ if (!allowed) {
43
+ return {
44
+ ok: false,
45
+ status: 403,
46
+ error: 'You do not have permission to get the latest changes for this branch.',
47
+ };
48
+ }
49
+ if (isProtected) {
50
+ return {
51
+ ok: false,
52
+ status: 409,
53
+ error: 'This branch is the one changes come from, so it has no latest changes to get.',
54
+ };
55
+ }
56
+ // Only an editing branch takes the base in; the worker leaves the others as they are.
57
+ if (branch.status !== 'editing') {
58
+ return {
59
+ ok: false,
60
+ status: 409,
61
+ error: branch.status === 'submitted' || branch.status === 'approved'
62
+ ? 'This branch is in review. Withdraw it from review to get the latest changes.'
63
+ : "This branch can't be edited, so it can't get the latest changes.",
64
+ };
65
+ }
66
+ const base = branch.baseBranch ?? baseBranchOf(config);
67
+ const { authPlugin } = ctx;
68
+ try {
69
+ await ctx.services.commitSavedWork({
70
+ context: branchContext,
71
+ message: `Save changes before getting the latest from ${base}`,
72
+ lookupEditor: authPlugin ? (id) => authPlugin.getUserMetadata(id) : undefined,
73
+ });
74
+ }
75
+ catch (err) {
76
+ if (err instanceof ContentWriteLockBusyError) {
77
+ return {
78
+ ok: false,
79
+ status: 409,
80
+ error: err.outcome === 'not-run'
81
+ ? 'This branch is being synced, or a save is in progress. Nothing changed; try again in a moment.'
82
+ : "This branch was being synced while your saved work was prepared, so the latest changes weren't requested. Try again.",
83
+ };
84
+ }
85
+ console.error(`CanopyCMS: Could not commit the saved changes of ${branch.name} before getting the latest:`, redactCredentials(getErrorMessage(err)));
86
+ return {
87
+ ok: false,
88
+ status: 500,
89
+ error: "Your saved changes couldn't be prepared, so the latest changes weren't requested.",
90
+ };
91
+ }
92
+ const taskDir = getTaskQueueDir(config);
93
+ const queued = !runsInRequest &&
94
+ (await listTasks(taskDir, 'pending', Number.MAX_SAFE_INTEGER)).some((t) => t.action === SYNC_BRANCH_ACTION && t.payload.branch === branch.name);
95
+ const now = Date.now();
96
+ let requestedAt = new Date(now).toISOString();
97
+ let joined = false;
98
+ let leftEditing;
99
+ const meta = getBranchMetadataFileManager(branchContext.branchRoot, branchContext.baseRoot);
100
+ const saved = await meta.update((existing) => {
101
+ if (existing?.branch.name !== branch.name)
102
+ return null;
103
+ // A submit since the guard read: the worker would leave the request standing until a withdraw.
104
+ if (existing.branch.status !== 'editing') {
105
+ leftEditing = existing.branch.status;
106
+ return null;
107
+ }
108
+ const last = existing.branch.baseUpdateRequestedAt;
109
+ const age = last === undefined ? Infinity : now - Date.parse(last);
110
+ const answer = existing.branch.lastBaseUpdate;
111
+ const pending = isBaseUpdatePending(existing.branch);
112
+ // An unanswered request is joined until the editor stops waiting on it. An answer under the
113
+ // interval old stands for a new click too, except a failure, whose Retry must ask again, and
114
+ // except where the request runs the merge itself, which costs no worker cycle.
115
+ const joinsPending = pending && age < BASE_UPDATE_GIVE_UP_MS && (queued || age < BASE_UPDATE_REQUEST_INTERVAL_MS);
116
+ const answeredRecently = !runsInRequest &&
117
+ !pending &&
118
+ answer !== undefined &&
119
+ answer.outcome !== 'failed' &&
120
+ now - Date.parse(answer.at) < BASE_UPDATE_REQUEST_INTERVAL_MS;
121
+ if (last !== undefined && (joinsPending || answeredRecently)) {
122
+ requestedAt = last;
123
+ joined = true;
124
+ return null;
125
+ }
126
+ return { branch: { baseUpdateRequestedAt: requestedAt } };
127
+ }, { keepUpdatedAt: true });
128
+ if (leftEditing) {
129
+ return {
130
+ ok: false,
131
+ status: 409,
132
+ error: leftEditing === 'submitted' || leftEditing === 'approved'
133
+ ? 'This branch is in review. Withdraw it from review to get the latest changes.'
134
+ : "This branch can't be edited, so it can't get the latest changes.",
135
+ };
136
+ }
137
+ let current = saved ?? (await BranchMetadataFileManager.loadOnly(branchContext.branchRoot));
138
+ if (current?.branch.name !== branch.name) {
139
+ return { ok: false, status: 404, error: 'This branch no longer exists.' };
140
+ }
141
+ // A joined request is answered by the run that made it.
142
+ if (runsInRequest && !joined) {
143
+ // Answered here where the run couldn't, as nothing else would: an open request holds the form
144
+ // read-only.
145
+ const answerFailed = () => meta
146
+ .update((existing) => existing?.branch.name === branch.name &&
147
+ existing.branch.baseUpdateRequestedAt === requestedAt &&
148
+ isBaseUpdatePending(existing.branch)
149
+ ? { branch: { lastBaseUpdate: { at: requestedAt, outcome: 'failed' } } }
150
+ : null, { keepUpdatedAt: true })
151
+ .catch(() => undefined);
152
+ let run;
153
+ try {
154
+ run = await ctx.services.runBaseUpdateNow(branchContext);
155
+ }
156
+ catch (err) {
157
+ console.error(`CanopyCMS: Could not bring the latest changes into ${branch.name}:`, redactCredentials(getErrorMessage(err)));
158
+ await answerFailed();
159
+ return {
160
+ ok: false,
161
+ status: 500,
162
+ error: "The latest changes couldn't be added. Your work is safe.",
163
+ };
164
+ }
165
+ if (run === 'other-remote') {
166
+ await answerFailed();
167
+ return {
168
+ ok: false,
169
+ status: 409,
170
+ error: "This branch uses a git remote other than the local one, so its latest changes can't be brought in here.",
171
+ };
172
+ }
173
+ if (run === 'busy') {
174
+ return {
175
+ ok: false,
176
+ status: 409,
177
+ error: "This branch is busy with a save or another update, so the latest changes weren't added. Try again in a moment.",
178
+ };
179
+ }
180
+ current = await BranchMetadataFileManager.loadOnly(branchContext.branchRoot);
181
+ if (current?.branch.name !== branch.name) {
182
+ return { ok: false, status: 404, error: 'This branch no longer exists.' };
183
+ }
184
+ }
185
+ // A wake-up already queued serves the new request too.
186
+ if (!runsInRequest && !joined && !queued) {
187
+ try {
188
+ await enqueueTask(taskDir, { action: SYNC_BRANCH_ACTION, payload: { branch: branch.name } });
189
+ }
190
+ catch (err) {
191
+ // The worker's next regular sync answers the recorded request anyway, only later.
192
+ console.warn(`CanopyCMS: Could not queue the request for the latest changes on ${branch.name}:`, getErrorMessage(err));
193
+ }
194
+ }
195
+ return {
196
+ ok: true,
197
+ status: 202,
198
+ data: { requestedAt, branch: toBranchListItem(config, current.branch, req.user) },
199
+ };
200
+ };
201
+ /** Get the latest changes: bring the base branch into this one at the worker's next chance. */
202
+ export const updateFromBase = defineEndpoint({
203
+ namespace: 'workflow',
204
+ name: 'updateFromBase',
205
+ // Commits saved work on the shared filesystem before answering.
206
+ clientTimeout: 'long',
207
+ method: 'POST',
208
+ path: '/:branch/update-from-base',
209
+ params: branchParamSchema,
210
+ responseType: 'UpdateFromBaseResponse',
211
+ response: {},
212
+ defaultMockData: {
213
+ requestedAt: '2024-01-01T00:00:00.000Z',
214
+ branch: {
215
+ name: 'test-branch',
216
+ status: 'editing',
217
+ access: {},
218
+ createdBy: 'user-1',
219
+ createdAt: '2024-01-01',
220
+ updatedAt: '2024-01-01',
221
+ baseUpdateRequestedAt: '2024-01-01T00:00:00.000Z',
222
+ },
223
+ },
224
+ // As Withdraw: the handler's canPerformWorkflowAction decides who may ask.
225
+ guards: ['branch'],
226
+ handler: updateFromBaseHandler,
227
+ });
@@ -60,6 +60,11 @@ export interface BranchListItem extends Omit<BranchMetadata, 'review' | 'keptOve
60
60
  users: number;
61
61
  groups: number;
62
62
  };
63
+ /**
64
+ * The commit a branch in review held when it was listed, which Approve and Request changes send
65
+ * back to say what the reviewer saw (api/branch-review.ts). Only on a listing.
66
+ */
67
+ headSha?: string;
63
68
  }
64
69
  /**
65
70
  * Response type for branch creation and the workflow transitions. Carries the
@@ -82,6 +87,11 @@ export type BranchListResponse = ApiResponse<{
82
87
  defaultBranch?: string;
83
88
  /** Profiles of the listed branches' creators, in no order; unknown ids are absent. */
84
89
  users: UserProfile[];
90
+ /**
91
+ * The server's clock as it answered, in ms since the epoch, so the editor times a wait for the
92
+ * latest changes against the clock that stamped the request rather than the browser's.
93
+ */
94
+ serverTime?: number;
85
95
  }>;
86
96
  /** Response type for branch deletion */
87
97
  export type BranchDeleteResponse = ApiResponse<{
@@ -11,7 +11,7 @@ import { clientOperatingStrategy } from '../operating-mode/index.js';
11
11
  import { DELETE_NEEDS_ADMIN_REASON } from '../editor/copy.js';
12
12
  import { getErrorMessage } from '../utils/error.js';
13
13
  import { filePathExists } from '../utils/fs.js';
14
- import { isNetworkRemoteUrl } from '../utils/git.js';
14
+ import { isNetworkRemoteUrl, readHeadCommitFromFiles } from '../utils/git.js';
15
15
  import { parseCreatedBranchName, sanitizeBranchName, isSettingsBranchName } from '../paths/index.js';
16
16
  import { GitManager } from '../git-manager.js';
17
17
  import { syncDeleteRemoteBranch } from './github-sync.js';
@@ -521,16 +521,42 @@ const branchListResponse = async (ctx, req, branches, defaultBranch) => {
521
521
  console.error('CanopyCMS: Failed to look up branch owners:', getErrorMessage(error));
522
522
  }
523
523
  }
524
+ const heads = await Promise.all(branches.map((context) => reviewedHead(ctx, context)));
524
525
  return {
525
526
  ok: true,
526
527
  status: 200,
527
528
  data: {
528
- branches: branches.map((context) => toBranchListItem(ctx.services.config, context.branch, req.user)),
529
+ branches: branches.map((context, i) => {
530
+ const item = toBranchListItem(ctx.services.config, context.branch, req.user);
531
+ const headSha = heads[i];
532
+ return headSha ? { ...item, headSha } : item;
533
+ }),
529
534
  defaultBranch,
530
535
  users,
536
+ serverTime: Date.now(),
531
537
  },
532
538
  };
533
539
  };
540
+ /**
541
+ * A branch in review's head commit, from the clone's files, so a listing of many spawns no git;
542
+ * git reads only a layout the files can't answer. Undefined when unreadable: the editor then has
543
+ * nothing to send, and the review is refused until a listing reads it.
544
+ */
545
+ const reviewedHead = async (ctx, { branch, branchRoot }) => {
546
+ if (branch.status !== 'submitted' && branch.status !== 'approved')
547
+ return undefined;
548
+ try {
549
+ const fromFiles = await readHeadCommitFromFiles(branchRoot);
550
+ if (fromFiles)
551
+ return fromFiles;
552
+ const git = ctx.services.createGitManagerFor(branchRoot, { baseBranch: branch.baseBranch });
553
+ return await git.headSha();
554
+ }
555
+ catch (err) {
556
+ console.error(`CanopyCMS: Could not read the head of ${branch.name}:`, getErrorMessage(err));
557
+ return undefined;
558
+ }
559
+ };
534
560
  /** A person's delete, as deletion by age never does, can take commits only the branch still has. */
535
561
  const mayHoldLostCommits = (branch) => branch.unpublishedAtMerge === true ||
536
562
  (branch.status === 'archived' && branch.rewrittenOnGitHub !== undefined);
@@ -11,7 +11,8 @@ import type { OperatingMode } from '../operating-mode/types.js';
11
11
  import type { BranchDeleteResponse, BranchListItemResponse, BranchListResponse, CreateBranchBody, UpdateBranchAccessBody } from './branch.js';
12
12
  import type { BranchChangesResponse } from './branch-changes.js';
13
13
  import type { BranchEntryChangeResponse, RestoreEntryBody, RestoreEntryResponse } from './branch-entry-changes.js';
14
- import type { BranchMergeResponse, RequestChangesBody, SubmitBranchBody, SubmitBranchResponse } from './branch-status.js';
14
+ import type { EntryConflictResponse, MergeConflictsResponse, ReleaseConflictsHoldResponse, ResolveEntryConflictBody, ResolveEntryConflictResponse } from './merge-conflicts.js';
15
+ import type { ApproveBody, BranchMergeResponse, RequestChangesBody, SubmitBranchBody, SubmitBranchResponse, UpdateFromBaseResponse } from './branch-status.js';
15
16
  import type { AdminDeleteTaskResponse, AdminRetryTaskResponse, AdminStatusResponse, AdminTasksResponse, BranchHealthResponse, DeleteArchivedBranchesBody, DeleteArchivedBranchesResponse, DismissUnpublishedResponse, PurgeBranchDirResponse, RepairBranchDirResponse, RepairContentDuplicatesResponse } from './admin.js';
16
17
  import type { AddCommentBody, AddCommentResponse, CommentsResponse, ResolveCommentResponse } from './comments.js';
17
18
  import type { ContentReadResponse, ContentWriteResponse, ReferenceValidationResponse, RenameEntryBody, RenameEntryResponse, ValidateReferencesBody, WriteContentBody } from './content.js';
@@ -95,13 +96,25 @@ export declare class CanopyApiClient {
95
96
  /** POST /:branch/restore-entry/...entryPath */
96
97
  restore: (params: Record<string, string>, body: RestoreEntryBody, options?: RequestOptions) => Promise<RestoreEntryResponse>;
97
98
  };
99
+ readonly conflicts: {
100
+ /** GET /:branch/conflicts */
101
+ list: (params: Record<string, string>, options?: RequestOptions) => Promise<MergeConflictsResponse>;
102
+ /** GET /:branch/conflicts/...entryPath */
103
+ entry: (params: Record<string, string>, options?: RequestOptions) => Promise<EntryConflictResponse>;
104
+ /** POST /:branch/resolve-entry/...entryPath */
105
+ resolveEntry: (params: Record<string, string>, body: ResolveEntryConflictBody, options?: RequestOptions) => Promise<ResolveEntryConflictResponse>;
106
+ /** POST /:branch/conflicts/release-hold */
107
+ releaseHold: (params: Record<string, string>, options?: RequestOptions) => Promise<ReleaseConflictsHoldResponse>;
108
+ };
98
109
  readonly workflow: {
99
110
  /** POST /:branch/withdraw */
100
111
  withdraw: (params: Record<string, string>, options?: RequestOptions) => Promise<BranchListItemResponse>;
112
+ /** POST /:branch/update-from-base */
113
+ updateFromBase: (params: Record<string, string>, options?: RequestOptions) => Promise<UpdateFromBaseResponse>;
101
114
  /** POST /:branch/request-changes */
102
115
  requestChanges: (params: Record<string, string>, body: RequestChangesBody, options?: RequestOptions) => Promise<BranchListItemResponse>;
103
116
  /** POST /:branch/approve */
104
- approve: (params: Record<string, string>, options?: RequestOptions) => Promise<BranchListItemResponse>;
117
+ approve: (params: Record<string, string>, body: ApproveBody, options?: RequestOptions) => Promise<BranchListItemResponse>;
105
118
  /** POST /:branch/mark-merged */
106
119
  markMerged: (params: Record<string, string>, options?: RequestOptions) => Promise<BranchMergeResponse>;
107
120
  /** POST /:branch/archive */
@@ -55,18 +55,40 @@ export class CanopyApiClient {
55
55
  return this.request('POST', this.buildPath('/:branch/restore-entry/...entryPath', params), body, 'standard', options);
56
56
  },
57
57
  };
58
+ this.conflicts = {
59
+ /** GET /:branch/conflicts */
60
+ list: (params, options) => {
61
+ return this.request('GET', this.buildPath('/:branch/conflicts', params), undefined, 'standard', options);
62
+ },
63
+ /** GET /:branch/conflicts/...entryPath */
64
+ entry: (params, options) => {
65
+ return this.request('GET', this.buildPath('/:branch/conflicts/...entryPath', params), undefined, 'standard', options);
66
+ },
67
+ /** POST /:branch/resolve-entry/...entryPath */
68
+ resolveEntry: (params, body, options) => {
69
+ return this.request('POST', this.buildPath('/:branch/resolve-entry/...entryPath', params), body, 'standard', options);
70
+ },
71
+ /** POST /:branch/conflicts/release-hold */
72
+ releaseHold: (params, options) => {
73
+ return this.request('POST', this.buildPath('/:branch/conflicts/release-hold', params), undefined, 'standard', options);
74
+ },
75
+ };
58
76
  this.workflow = {
59
77
  /** POST /:branch/withdraw */
60
78
  withdraw: (params, options) => {
61
79
  return this.request('POST', this.buildPath('/:branch/withdraw', params), undefined, 'standard', options);
62
80
  },
81
+ /** POST /:branch/update-from-base */
82
+ updateFromBase: (params, options) => {
83
+ return this.request('POST', this.buildPath('/:branch/update-from-base', params), undefined, 'long', options);
84
+ },
63
85
  /** POST /:branch/request-changes */
64
86
  requestChanges: (params, body, options) => {
65
87
  return this.request('POST', this.buildPath('/:branch/request-changes', params), body, 'standard', options);
66
88
  },
67
89
  /** POST /:branch/approve */
68
- approve: (params, options) => {
69
- return this.request('POST', this.buildPath('/:branch/approve', params), undefined, 'standard', options);
90
+ approve: (params, body, options) => {
91
+ return this.request('POST', this.buildPath('/:branch/approve', params), body, 'standard', options);
70
92
  },
71
93
  /** POST /:branch/mark-merged */
72
94
  markMerged: (params, options) => {
@@ -60,16 +60,16 @@ export declare const COMMENT_ROUTES: {
60
60
  }, "strip", z.ZodTypeAny, {
61
61
  type: "branch" | "entry" | "field";
62
62
  text: string;
63
+ entryPath?: LogicalPath | undefined;
63
64
  threadId?: string | undefined;
64
65
  itemKeys?: string[] | undefined;
65
- entryPath?: LogicalPath | undefined;
66
66
  canopyPath?: string | undefined;
67
67
  }, {
68
68
  type: "branch" | "entry" | "field";
69
69
  text: string;
70
+ entryPath?: LogicalPath | undefined;
70
71
  threadId?: string | undefined;
71
72
  itemKeys?: string[] | undefined;
72
- entryPath?: LogicalPath | undefined;
73
73
  canopyPath?: string | undefined;
74
74
  }>, AddCommentResponse>;
75
75
  readonly resolve: import("./route-builder.js").RouteDefinition<z.ZodObject<{
@@ -1,7 +1,8 @@
1
1
  import { z } from 'zod';
2
2
  import type { ApiResponse } from './types.js';
3
- import type { EntryValidationIssue } from '../config/index.js';
3
+ import type { ContentFormat, EntrySchema, EntryValidationIssue } from '../config/index.js';
4
4
  import { type LogicalPath, type Slug } from '../paths/index.js';
5
+ import type { ResolvedFieldConflict } from '../merge-conflicts.js';
5
6
  export type ContentReadResponse = ApiResponse<{
6
7
  format: string;
7
8
  data: Record<string, unknown>;
@@ -22,6 +23,11 @@ export type ContentWriteResponse = ApiResponse<{
22
23
  * editor has exactly one save-warnings notification to render (useEntryManager.ts).
23
24
  */
24
25
  validationWarnings?: EntryValidationIssue[];
26
+ /**
27
+ * Of the `resolvedConflicts` sent with an update, the ones this save cleared: one sent but not
28
+ * here was stale, unless a warning says the choice wasn't recorded.
29
+ */
30
+ resolvedConflicts?: ResolvedFieldConflict[];
25
31
  }>;
26
32
  export type ReferenceValidationResponse = ApiResponse<{
27
33
  valid: boolean;
@@ -48,6 +54,13 @@ export interface WriteContentBody {
48
54
  * from "lost the token", so an update has to prove which version it read.
49
55
  */
50
56
  expectedVersion?: number | null;
57
+ /**
58
+ * Field conflicts a merge left on this entry that the saved values resolve, each named as
59
+ * `GET /:branch/conflicts/...entryPath` names it. Cleared once the entry is written, under the
60
+ * save's locks; a pair the record no longer holds is ignored. A save that leaves a conflicting
61
+ * field out leaves its conflict open.
62
+ */
63
+ resolvedConflicts?: ResolvedFieldConflict[];
51
64
  }
52
65
  export interface ValidateReferencesBody {
53
66
  data: Record<string, unknown>;
@@ -55,6 +68,12 @@ export interface ValidateReferencesBody {
55
68
  export interface RenameEntryBody {
56
69
  newSlug: string;
57
70
  }
71
+ /**
72
+ * What a save writes for `data`, an entry's form data: an md/mdx file's body-field key dropped
73
+ * (that field is the file's body), references collapsed to IDs, and every list item and block
74
+ * keyed. `backfill-item-keys` writes through this too, so its files are a save's.
75
+ */
76
+ export declare function entryDataAsSaved(fields: EntrySchema, format: ContentFormat, data: Record<string, unknown>): Record<string, unknown>;
58
77
  /**
59
78
  * Exported routes for router registration
60
79
  */
@@ -102,16 +121,34 @@ export declare const CONTENT_ROUTES: {
102
121
  data: z.ZodOptional<z.ZodEffects<z.ZodRecord<z.ZodString, z.ZodUnknown>, Record<string, unknown>, Record<string, unknown>>>;
103
122
  body: z.ZodOptional<z.ZodString>;
104
123
  expectedVersion: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
124
+ resolvedConflicts: z.ZodOptional<z.ZodArray<z.ZodObject<{
125
+ mergeSha: z.ZodString;
126
+ key: z.ZodString;
127
+ }, "strip", z.ZodTypeAny, {
128
+ key: string;
129
+ mergeSha: string;
130
+ }, {
131
+ key: string;
132
+ mergeSha: string;
133
+ }>, "many">>;
105
134
  }, "strip", z.ZodTypeAny, {
106
135
  format: "md" | "mdx" | "json" | "yaml";
107
136
  data?: Record<string, unknown> | undefined;
108
137
  expectedVersion?: number | null | undefined;
109
138
  body?: string | undefined;
139
+ resolvedConflicts?: {
140
+ key: string;
141
+ mergeSha: string;
142
+ }[] | undefined;
110
143
  }, {
111
144
  format: "md" | "mdx" | "json" | "yaml";
112
145
  data?: Record<string, unknown> | undefined;
113
146
  expectedVersion?: number | null | undefined;
114
147
  body?: string | undefined;
148
+ resolvedConflicts?: {
149
+ key: string;
150
+ mergeSha: string;
151
+ }[] | undefined;
115
152
  }>, ContentWriteResponse>;
116
153
  readonly validateReferences: import("./route-builder.js").RouteDefinition<z.ZodObject<{
117
154
  branch: z.ZodType<import("../paths/index.js").BranchName, z.ZodTypeDef, import("../paths/index.js").BranchName>;
@@ -14,6 +14,8 @@ import { getErrorMessage, isNotFoundError, sanitizeErrorMessage } from '../utils
14
14
  import { isDataOnlyFormat } from '../utils/format.js';
15
15
  import { withItemKeys } from '../item-keys.js';
16
16
  import { findBodyFieldName } from '../utils/body-field.js';
17
+ import { canopyLogWarn } from '../utils/logger.js';
18
+ import { clearFieldConflicts, resolvedConflictsSchema } from './merge-conflicts.js';
17
19
  /**
18
20
  * Parse an API path into logical path segments, prepending the content root if needed.
19
21
  * Shared by all content API handlers.
@@ -60,6 +62,7 @@ const writeContentBodySchema = z.object({
60
62
  body: z.string().max(MAX_CONTENT_BODY_CHARS).optional(),
61
63
  // null or omitted = create ("must not already exist"); see WriteContentBody.
62
64
  expectedVersion: z.number().nullish(),
65
+ resolvedConflicts: resolvedConflictsSchema.optional(),
63
66
  });
64
67
  const validateReferencesParamsSchema = z.object({
65
68
  branch: branchNameSchema,
@@ -133,6 +136,15 @@ function withoutKey(record, key) {
133
136
  const { [key]: _dropped, ...rest } = record;
134
137
  return rest;
135
138
  }
139
+ /**
140
+ * What a save writes for `data`, an entry's form data: an md/mdx file's body-field key dropped
141
+ * (that field is the file's body), references collapsed to IDs, and every list item and block
142
+ * keyed. `backfill-item-keys` writes through this too, so its files are a save's.
143
+ */
144
+ export function entryDataAsSaved(fields, format, data) {
145
+ const own = isDataOnlyFormat(format) ? data : withoutKey(data, findBodyFieldName(fields));
146
+ return withItemKeys(fields, normalizeReferenceValues(fields, own), 'content');
147
+ }
136
148
  /** The policy's issues in the stored entry, which a save may keep. */
137
149
  const storedMarkdownSafetyIssues = async (store, collectionPath, slug, fields, siteAllow) => {
138
150
  // A stored file that does not read keeps nothing, so the save's code is refused, not a 500.
@@ -393,9 +405,7 @@ const writeContentHandler = async (gc, ctx, req, params, body) => {
393
405
  // Every list item and block leaves a save keyed (`item-keys.ts`). A key sent is kept, so a
394
406
  // thread naming it still finds its item; one missing is filled from the item's content, as a
395
407
  // read fills one.
396
- const normalizedData = requestData === undefined
397
- ? undefined
398
- : withItemKeys(fields, normalizeReferenceValues(fields, requestData), 'content');
408
+ const normalizedData = requestData === undefined ? undefined : entryDataAsSaved(fields, body.format, requestData);
399
409
  // Computed before the validateEntry hook and the entry-link scan too, not just the write, so
400
410
  // every consumer agrees on the same bytes — otherwise an adopter's hook could see a resolved
401
411
  // object while the file got an ID string, depending on whether the post came from the editor.
@@ -475,10 +485,32 @@ const writeContentHandler = async (gc, ctx, req, params, body) => {
475
485
  body: body.body ?? '',
476
486
  expectedVersion,
477
487
  };
488
+ // Under the write's own locks, so no merge records a conflict between the value written and
489
+ // the record cleared. A failure to clear keeps the save: the conflict stays open, and says so.
490
+ let resolvedConflicts;
491
+ const resolving = expectedVersion === null ? [] : (body.resolvedConflicts ?? []);
492
+ const afterWrite = async ({ id }) => {
493
+ if (!id || resolving.length === 0)
494
+ return;
495
+ try {
496
+ resolvedConflicts = await clearFieldConflicts(ctx.services.config, branchContext, id, resolving);
497
+ }
498
+ catch (err) {
499
+ canopyLogWarn(`CanopyCMS: saved ${logicalPathSegments.join('/')} but couldn't mark its conflicts resolved:`, getErrorMessage(err));
500
+ resolvedConflicts = [];
501
+ validationWarnings = [
502
+ ...(validationWarnings ?? []),
503
+ {
504
+ level: 'warning',
505
+ message: 'Saved, but your choice for its conflicting changes wasn’t recorded. Choose again, then save.',
506
+ },
507
+ ];
508
+ }
509
+ };
478
510
  // Pass the resolved entryTypeName (not the raw, possibly-omitted
479
511
  // params.entryType) so the store's own format check agrees with the type
480
512
  // we just validated against.
481
- const result = await store.write(schemaItem.logicalPath, slug, writeInput, entryTypeName);
513
+ const result = await store.write(schemaItem.logicalPath, slug, writeInput, entryTypeName, undefined, { afterWrite });
482
514
  // Validate entry links in body content (warnings only, don't block save).
483
515
  // Reuses the entry-type fields resolved above for schema validation. Folded into
484
516
  // `validationWarnings` (not a separate `entryLinkWarnings` field) so the editor's one
@@ -494,7 +526,11 @@ const writeContentHandler = async (gc, ctx, req, params, body) => {
494
526
  }));
495
527
  validationWarnings = [...(validationWarnings ?? []), ...linkWarnings];
496
528
  }
497
- return { ok: true, status: 200, data: { ...result, validationWarnings } };
529
+ return {
530
+ ok: true,
531
+ status: 200,
532
+ data: { ...result, validationWarnings, ...(resolvedConflicts && { resolvedConflicts }) },
533
+ };
498
534
  }
499
535
  catch (err) {
500
536
  if (err instanceof ContentConflictError) {