canopycms 0.12.1 → 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 (93) hide show
  1. package/CHANGELOG.md +211 -49
  2. package/dist/api/branch-review.d.ts +20 -2
  3. package/dist/api/branch-review.js +112 -23
  4. package/dist/api/branch-status.d.ts +11 -2
  5. package/dist/api/branch-status.js +3 -2
  6. package/dist/api/branch-update-from-base.js +57 -6
  7. package/dist/api/branch.d.ts +5 -0
  8. package/dist/api/branch.js +27 -2
  9. package/dist/api/client.d.ts +2 -2
  10. package/dist/api/client.js +2 -2
  11. package/dist/api/content.js +1 -1
  12. package/dist/api/merge-conflicts.d.ts +10 -1
  13. package/dist/api/merge-conflicts.js +42 -7
  14. package/dist/api/types.d.ts +3 -2
  15. package/dist/base-update.d.ts +6 -0
  16. package/dist/branch-metadata-file.d.ts +15 -0
  17. package/dist/branch-metadata-file.js +5 -1
  18. package/dist/cli/cli.js +238 -108
  19. package/dist/cli/generate-ai-content.js +20 -6
  20. package/dist/cli/github-app-manifest.d.ts +4 -1
  21. package/dist/cli/github-app-manifest.js +5 -1
  22. package/dist/cli/init.js +7 -1
  23. package/dist/editor/Editor.js +39 -16
  24. package/dist/editor/admin/ConflictsStatusAlert.d.ts +8 -0
  25. package/dist/editor/admin/ConflictsStatusAlert.js +22 -0
  26. package/dist/editor/admin/SystemHealthPanel.js +4 -3
  27. package/dist/editor/base-update-wait.d.ts +4 -0
  28. package/dist/editor/base-update-wait.js +7 -0
  29. package/dist/editor/components/ConflictsGroup.d.ts +13 -1
  30. package/dist/editor/components/ConflictsGroup.js +52 -12
  31. package/dist/editor/components/EditorHeader.d.ts +3 -1
  32. package/dist/editor/components/EditorHeader.js +11 -7
  33. package/dist/editor/components/EntryChangesDrawer.d.ts +1 -1
  34. package/dist/editor/components/EntryChangesDrawer.js +58 -6
  35. package/dist/editor/conflict-cards.d.ts +4 -1
  36. package/dist/editor/conflict-cards.js +14 -9
  37. package/dist/editor/hooks/useBaseUpdate.d.ts +2 -0
  38. package/dist/editor/hooks/useBaseUpdate.js +12 -2
  39. package/dist/editor/hooks/useBranchChangesData.d.ts +7 -1
  40. package/dist/editor/hooks/useBranchChangesData.js +13 -0
  41. package/dist/editor/hooks/useBranchManager.d.ts +2 -0
  42. package/dist/editor/hooks/useBranchManager.js +80 -6
  43. package/dist/editor/hooks/useDraftManager.d.ts +8 -1
  44. package/dist/editor/hooks/useDraftManager.js +16 -1
  45. package/dist/editor/hooks/useEntryManager.d.ts +8 -3
  46. package/dist/editor/hooks/useEntryManager.js +6 -5
  47. package/dist/git-manager.d.ts +1 -0
  48. package/dist/git-manager.js +8 -3
  49. package/dist/merge/entry-merge.js +4 -3
  50. package/dist/merge/list-alignment.d.ts +9 -6
  51. package/dist/merge/list-alignment.js +65 -16
  52. package/dist/merge/rekey.d.ts +11 -0
  53. package/dist/merge/rekey.js +75 -0
  54. package/dist/operating-mode/client-safe-strategy.js +1 -1
  55. package/dist/operating-mode/client-unsafe-strategy.js +6 -0
  56. package/dist/operating-mode/types.d.ts +10 -2
  57. package/dist/services.d.ts +12 -4
  58. package/dist/services.js +56 -0
  59. package/dist/types.d.ts +35 -3
  60. package/dist/utils/git.d.ts +7 -0
  61. package/dist/utils/git.js +35 -0
  62. package/dist/version.d.ts +1 -1
  63. package/dist/version.js +1 -1
  64. package/dist/worker/branch-merge.d.ts +27 -3
  65. package/dist/worker/branch-merge.js +106 -5
  66. package/dist/worker/branch-policy.d.ts +18 -2
  67. package/dist/worker/branch-policy.js +36 -0
  68. package/dist/worker/cms-worker.d.ts +21 -0
  69. package/dist/worker/cms-worker.js +29 -4
  70. package/dist/worker/conflicts-status.d.ts +20 -0
  71. package/dist/worker/conflicts-status.js +204 -0
  72. package/dist/worker/field-merge.d.ts +17 -2
  73. package/dist/worker/field-merge.js +95 -2
  74. package/dist/worker/git-sync.d.ts +3 -2
  75. package/dist/worker/git-sync.js +9 -0
  76. package/dist/worker/github-commits.d.ts +3 -3
  77. package/dist/worker/github-commits.js +192 -40
  78. package/dist/worker/github-errors.d.ts +15 -0
  79. package/dist/worker/github-errors.js +47 -0
  80. package/dist/worker/github-gateway-http.js +7 -0
  81. package/dist/worker/github-gateway-wire.d.ts +30 -10
  82. package/dist/worker/github-gateway-wire.js +14 -2
  83. package/dist/worker/github-gateway.d.ts +17 -0
  84. package/dist/worker/github-gateway.js +37 -1
  85. package/dist/worker/history-rewrite.d.ts +40 -8
  86. package/dist/worker/history-rewrite.js +51 -23
  87. package/dist/worker/publish-decision.d.ts +37 -0
  88. package/dist/worker/publish-decision.js +16 -0
  89. package/dist/worker/rebase.d.ts +25 -5
  90. package/dist/worker/rebase.js +225 -81
  91. package/dist/worker/task-runner.js +16 -39
  92. package/dist/worker/worker-context.d.ts +6 -0
  93. package/package.json +1 -1
@@ -0,0 +1,204 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { BranchMetadataFileManager } from '../branch-metadata.js';
4
+ import { BranchMetadataCorruptError } from '../branch-metadata-file.js';
5
+ import { editorDeepLink, resolveEditorUrl } from '../editor-url.js';
6
+ import { MergeConflictStore, MergeConflictsFileFormatError, MergeConflictsFileMalformedError, openConflictCount, } from '../merge-conflicts.js';
7
+ import { getErrorMessage, isNodeError, sanitizeErrorMessage } from '../utils/error.js';
8
+ import { escapeForLog } from '../utils/escape-text.js';
9
+ import { GatewayRefusalError, MAX_COMMIT_STATUS_URL_LENGTH } from './branch-policy.js';
10
+ import { getHttpStatus, isPermissionDenial } from './github-errors.js';
11
+ import { workerLog, workerLogWarn } from './log.js';
12
+ /** The most branch names a failure report lists. */
13
+ const MAX_REPORTED_BRANCHES = 50;
14
+ /**
15
+ * What `branchRoot`'s conflict record calls for: failure with the number of choices open, or while
16
+ * the record is held after a damaged one was set aside, or does not parse; success otherwise.
17
+ * @internal Exported for tests.
18
+ */
19
+ export async function conflictsStatusOf(branchRoot) {
20
+ try {
21
+ const record = await new MergeConflictStore(branchRoot).read();
22
+ if (record?.held) {
23
+ return {
24
+ state: 'failure',
25
+ description: 'Conflict record set aside: an admin must release the branch in the editor',
26
+ };
27
+ }
28
+ const open = record ? openConflictCount(record.entries) : 0;
29
+ if (open === 0)
30
+ return { state: 'success', description: 'No conflicting changes to resolve' };
31
+ return {
32
+ state: 'failure',
33
+ description: open === 1
34
+ ? '1 conflicting change to resolve in the editor'
35
+ : `${open} conflicting changes to resolve in the editor`,
36
+ };
37
+ }
38
+ catch (err) {
39
+ if (err instanceof MergeConflictsFileMalformedError ||
40
+ err instanceof MergeConflictsFileFormatError) {
41
+ return { state: 'failure', description: "Couldn't read this branch's conflicts" };
42
+ }
43
+ throw err;
44
+ }
45
+ }
46
+ /**
47
+ * Set the conflicts status on every pull request whose head, as `githubTips` (this cycle's GitHub
48
+ * fetch) holds it, lacks the one its branch calls for. Returns the failure to report, undefined
49
+ * when every status was set, or null when the stage could not say: the branches could not be
50
+ * listed, or a drain cut it off. Never throws.
51
+ */
52
+ export async function setConflictsStatuses(ctx, githubTips) {
53
+ let due;
54
+ let unreadable;
55
+ try {
56
+ ;
57
+ ({ due, unreadable } = await dueStatuses(ctx, githubTips));
58
+ }
59
+ catch (err) {
60
+ workerLogWarn(`Could not read the branches' conflicts for their status: ${getErrorMessage(err)}`);
61
+ return null;
62
+ }
63
+ const set = ctx.conflictsStatusesSet();
64
+ const failed = [];
65
+ let failure;
66
+ for (const [index, { branch, status, key }] of due.entries()) {
67
+ if (ctx.shutdownSignal().aborted)
68
+ return null;
69
+ try {
70
+ // Bounded, so a GitHub that stalls holds the sync for one timeout, not one per branch.
71
+ await ctx
72
+ .github()
73
+ .setConflictsStatus(status, AbortSignal.any([ctx.shutdownSignal(), AbortSignal.timeout(ctx.taskTimeoutMs)]));
74
+ set.set(branch, key);
75
+ workerLog(`Set the conflicts status of PR #${status.pullRequest} (${escapeForLog(branch)}) at ` +
76
+ `${status.sha.slice(0, 7)}: ${status.state}`);
77
+ }
78
+ catch (err) {
79
+ if (ctx.shutdownSignal().aborted)
80
+ return null;
81
+ if (err instanceof GatewayRefusalError && err.reason === 'not-head') {
82
+ // GitHub moves a pull request's head a moment after the push, so this cycle's tip can be
83
+ // ahead of it: one more sync for that tip, and no loop if the two never agree.
84
+ const asked = `not-head:${branch}`;
85
+ if (set.get(asked) !== status.sha) {
86
+ set.set(asked, status.sha);
87
+ ctx.requestBranchSync(branch);
88
+ }
89
+ continue;
90
+ }
91
+ const message = sanitizeErrorMessage(getErrorMessage(err));
92
+ failed.push(branch);
93
+ workerLogWarn(`Could not set the conflicts status of ${escapeForLog(branch)}: ${message}`);
94
+ // The gateway's refusals for the pull request itself report as `other`, retried each cycle.
95
+ const reason = err instanceof GatewayRefusalError
96
+ ? 'other'
97
+ : isPermissionDenial(err)
98
+ ? 'permission'
99
+ : isUnreachable(err)
100
+ ? 'unreachable'
101
+ : 'other';
102
+ // A refused credential names its remedy over any other failure.
103
+ if (reason === 'permission' || !failure)
104
+ failure = { reason, message };
105
+ // Every later write would meet the same GitHub; the next cycle tries again.
106
+ if (reason !== 'other') {
107
+ failed.push(...due.slice(index + 1).map((d) => d.branch));
108
+ break;
109
+ }
110
+ }
111
+ }
112
+ // After GitHub's answers, whose reason and remedy come first.
113
+ for (const u of unreadable)
114
+ failed.push(u.branch);
115
+ failure ??= unreadable[0] && { reason: 'other', message: unreadable[0].message };
116
+ if (!failure)
117
+ return undefined;
118
+ return {
119
+ ...failure,
120
+ branches: failed.slice(0, MAX_REPORTED_BRANCHES),
121
+ count: failed.length,
122
+ at: new Date().toISOString(),
123
+ };
124
+ }
125
+ /**
126
+ * A failure to reach GitHub or the gateway, or GitHub busy: no status at all, a timeout, a rate
127
+ * limit (a 403 that is not a permission denial), or a server error.
128
+ */
129
+ function isUnreachable(err) {
130
+ const status = getHttpStatus(err);
131
+ return status === null || status === 403 || status === 408 || status === 429 || status >= 500;
132
+ }
133
+ /**
134
+ * Each branch with a pull request whose head lacks the status it calls for, and each whose conflict
135
+ * record could not be read, which costs only its own status. A branch.json that cannot be read says
136
+ * nothing of a pull request, so it is logged and skipped. Throws only when the branches directory
137
+ * cannot be listed.
138
+ */
139
+ async function dueStatuses(ctx, githubTips) {
140
+ let entries;
141
+ try {
142
+ entries = await fs.readdir(ctx.contentBranchesPath, { withFileTypes: true });
143
+ }
144
+ catch (err) {
145
+ if (isNodeError(err) && err.code === 'ENOENT')
146
+ return { due: [], unreadable: [] };
147
+ throw err;
148
+ }
149
+ const set = ctx.conflictsStatusesSet();
150
+ const due = [];
151
+ const unreadable = [];
152
+ const cannotRead = (branch, err) => {
153
+ const message = sanitizeErrorMessage(getErrorMessage(err));
154
+ workerLogWarn(`Could not read ${escapeForLog(branch)} for its conflicts status: ${message}`);
155
+ unreadable.push({ branch, message });
156
+ };
157
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
158
+ const dir = entry.name;
159
+ // The base branch has no pull request; `.trash-*` directories hold purged branches.
160
+ if (!entry.isDirectory() || dir.startsWith('.') || dir === ctx.sanitizedBaseBranch)
161
+ continue;
162
+ const branchRoot = path.join(ctx.contentBranchesPath, dir);
163
+ let meta;
164
+ try {
165
+ meta = await BranchMetadataFileManager.loadOnly(branchRoot);
166
+ }
167
+ catch (err) {
168
+ if (!(err instanceof BranchMetadataCorruptError) &&
169
+ !(isNodeError(err) && err.code === 'ENOENT'))
170
+ workerLogWarn(`Could not read ${escapeForLog(dir)}'s branch.json for its conflicts status: ` +
171
+ sanitizeErrorMessage(getErrorMessage(err)));
172
+ continue;
173
+ }
174
+ const branch = meta?.branch;
175
+ if (!branch || branch.status === 'archived' || branch.pullRequestNumber === undefined)
176
+ continue;
177
+ if (branch.pullRequestState === 'merged' || branch.pullRequestState === 'closed')
178
+ continue;
179
+ const sha = githubTips.get(branch.name);
180
+ if (sha === undefined)
181
+ continue;
182
+ let text;
183
+ try {
184
+ text = await conflictsStatusOf(branchRoot);
185
+ }
186
+ catch (err) {
187
+ cannotRead(branch.name, err);
188
+ continue;
189
+ }
190
+ const editorUrl = branch.editorUrl && resolveEditorUrl({ configured: branch.editorUrl });
191
+ const link = editorUrl && editorDeepLink(editorUrl, branch.name);
192
+ const status = {
193
+ pullRequest: branch.pullRequestNumber,
194
+ sha,
195
+ ...text,
196
+ // A link the gateway would refuse is left off, so it never costs the status.
197
+ ...(link && link.length <= MAX_COMMIT_STATUS_URL_LENGTH && { targetUrl: link }),
198
+ };
199
+ const key = JSON.stringify(status);
200
+ if (set.get(branch.name) !== key)
201
+ due.push({ branch: branch.name, status, key });
202
+ }
203
+ return { due, unreadable };
204
+ }
@@ -17,7 +17,7 @@ import type { ContentId } from '../paths/types.js';
17
17
  * detection put something else there, the pairing by ID puts it right.
18
18
  */
19
19
  /** A file in one version of the tree. */
20
- interface TreeFile {
20
+ export interface TreeFile {
21
21
  path: string;
22
22
  oid: string;
23
23
  mode: string;
@@ -56,10 +56,25 @@ export interface FieldMergeArgs {
56
56
  /** The content ID a `.collection.json` path belongs to. */
57
57
  collectionIdOf: (file: string) => ContentId | undefined;
58
58
  timeoutMs: number;
59
+ /**
60
+ * Git's merge stands, and only the item keys of an entry both sides changed change: to the field
61
+ * merge's, where it holds the same content with no conflict ({@link rekeyAsMerged}).
62
+ */
63
+ rekeyOnly?: boolean;
59
64
  }
65
+ /** `diff-tree --raw` from `from` to `to`, without rename detection: each path's two files. */
66
+ export declare function changedFiles(git: SimpleGit, from: string, to: string): Promise<Array<{
67
+ path: string;
68
+ before?: TreeFile;
69
+ after?: TreeFile;
70
+ }>>;
71
+ /**
72
+ * Whether two versions of one entry file under `contentRoot` differ in item keys alone: what a
73
+ * re-key changes. False for anything else, a file's mode or path included.
74
+ */
75
+ export declare function differsInItemKeysOnly(git: SimpleGit, x: TreeFile, y: TreeFile, contentRoot: string): Promise<boolean>;
60
76
  /**
61
77
  * Decide every entry file and `.collection.json` the merge touches. Nothing is staged here: the
62
78
  * result says which blobs replace git's, and which conflicts a person must resolve.
63
79
  */
64
80
  export declare function resolveFieldMerge(args: FieldMergeArgs): Promise<FieldMergeResult>;
65
- export {};
@@ -1,8 +1,10 @@
1
1
  import path from 'node:path';
2
2
  import { parse as yamlParse } from 'yaml';
3
3
  import { entryLogicalPathOfFile, extractIdFromFilename } from '../content-id-index.js';
4
+ import { ITEM_KEY } from '../item-keys.js';
4
5
  import { parseEntryParts } from '../content-listing.js';
5
6
  import { mergeCollectionMeta, mergeEntry, } from '../merge/entry-merge.js';
7
+ import { rekeyAsMerged } from '../merge/rekey.js';
6
8
  import { exactToken } from '../merge/value-equality.js';
7
9
  import { LOCATION_CONFLICT_KEY, } from '../merge-conflicts.js';
8
10
  import { normalizeFilesystemPath } from '../paths/normalize.js';
@@ -38,7 +40,7 @@ function unitKeyOf(file, root, collectionIdOf) {
38
40
  return { key: collection ? `meta:${collection}` : `meta-at:${file}` };
39
41
  }
40
42
  /** `diff-tree --raw` from `from` to `to`, without rename detection: each path's two files. */
41
- async function changedFiles(git, from, to) {
43
+ export async function changedFiles(git, from, to) {
42
44
  const out = await git.raw([
43
45
  'diff-tree',
44
46
  '-r',
@@ -322,6 +324,44 @@ const LOCATION = {
322
324
  sidePaths: {},
323
325
  kind: 'changed',
324
326
  };
327
+ /** Whether `text` reads back as `merged`, item keys and all. */
328
+ function readsAs(text, format, merged) {
329
+ try {
330
+ if (!holdsRecord(text, format))
331
+ return false;
332
+ const back = parseEntryParts(text, format);
333
+ return sameContent({ data: back.data, ...(back.body !== undefined && { body: back.body }) }, merged);
334
+ }
335
+ catch {
336
+ return false;
337
+ }
338
+ }
339
+ /** `value` with every item key dropped, and nothing else changed. */
340
+ const withoutItemKeys = (value) => {
341
+ if (Array.isArray(value))
342
+ return value.map(withoutItemKeys);
343
+ if (!isPlainRecord(value))
344
+ return value;
345
+ return Object.fromEntries(Object.entries(value)
346
+ .filter(([name]) => name !== ITEM_KEY)
347
+ .map(([name, v]) => [name, withoutItemKeys(v)]));
348
+ };
349
+ /**
350
+ * Whether two versions of one entry file under `contentRoot` differ in item keys alone: what a
351
+ * re-key changes. False for anything else, a file's mode or path included.
352
+ */
353
+ export async function differsInItemKeysOnly(git, x, y, contentRoot) {
354
+ const format = formatOf(y.path);
355
+ if (!format || x.path !== y.path || x.mode !== y.mode)
356
+ return false;
357
+ if (entryLogicalPathOfFile(y.path, normalizeFilesystemPath(contentRoot)) === null)
358
+ return false;
359
+ const [a, b] = await Promise.all([readEntry(git, x, format), readEntry(git, y, format)]);
360
+ return (!!a &&
361
+ !!b &&
362
+ a.side.body === b.side.body &&
363
+ exactToken(withoutItemKeys(a.side.data)) === exactToken(withoutItemKeys(b.side.data)));
364
+ }
325
365
  class Resolver {
326
366
  constructor(args, moves) {
327
367
  this.args = args;
@@ -473,6 +513,52 @@ class Resolver {
473
513
  ...(merged.keyRemaps.length > 0 && { remaps: merged.keyRemaps }),
474
514
  };
475
515
  }
516
+ /** In {@link FieldMergeArgs.rekeyOnly}: git's merged entry under the field merge's item keys. */
517
+ async rekeyed(unit, gitFile) {
518
+ const { git } = this.args;
519
+ const [o, a, b] = [unit.ancestor, sideFile(unit, 'base'), sideFile(unit, 'branch')];
520
+ if (!a || !b || !gitFile || a.oid === b.oid)
521
+ return null;
522
+ const format = formatOf(gitFile.path);
523
+ if (!format || [o, a, b].some((f) => f && formatOf(f.path) !== format))
524
+ return null;
525
+ const [oSide, aSide, bSide, gitSide] = await Promise.all([o, a, b, gitFile].map((f) => (f ? readEntry(git, f, format) : undefined)));
526
+ if (!aSide || !bSide || !gitSide || (o && !oSide))
527
+ return null;
528
+ const fields = fieldsFor(this.args.flatSchema, b.path, this.root);
529
+ let result;
530
+ try {
531
+ result = mergeEntry({
532
+ format,
533
+ ancestor: oSide?.side ?? { data: {} },
534
+ base: aSide.side,
535
+ branch: bSide.side,
536
+ ...(fields && { fields }),
537
+ });
538
+ }
539
+ catch {
540
+ return null;
541
+ }
542
+ if (result.kind !== 'merged' || result.conflicts.length > 0)
543
+ return null;
544
+ if (result.body !== gitSide.side.body)
545
+ return null;
546
+ const data = rekeyAsMerged(gitSide.side.data, result.data);
547
+ if (!isPlainRecord(data) || exactToken(data) === exactToken(gitSide.side.data))
548
+ return null;
549
+ const merged = { data, ...(result.body !== undefined && { body: result.body }) };
550
+ const file = await this.bytesFor({
551
+ base: { file: gitFile, text: gitSide.text },
552
+ branch: gitFile,
553
+ isBase: false,
554
+ isBranch: false,
555
+ reads: (text) => readsAs(text, format, merged),
556
+ serialize: (onto) => serializeEntry(format, merged, onto),
557
+ }, gitFile.path);
558
+ if (!file)
559
+ return null;
560
+ return { file, ...(result.keyRemaps.length > 0 && { remaps: result.keyRemaps }) };
561
+ }
476
562
  /** A `.collection.json` both sides changed and both still have; else git's rule. */
477
563
  async collectionMeta(unit, gitClean, gitFile) {
478
564
  const { git } = this.args;
@@ -581,7 +667,14 @@ export async function resolveFieldMerge(args) {
581
667
  const gitClean = ![...paths].some((p) => args.conflicted.has(p));
582
668
  const only = gitHeld.size === 1 ? [...gitHeld.values()][0] : undefined;
583
669
  let resolution;
584
- if (unit.touched.base && unit.touched.branch) {
670
+ if (args.rekeyOnly) {
671
+ if (unit.kind !== 'entry' || !unit.touched.base || !unit.touched.branch)
672
+ continue;
673
+ resolution = await resolver.rekeyed(unit, only);
674
+ if (resolution === null)
675
+ continue;
676
+ }
677
+ else if (unit.touched.base && unit.touched.branch) {
585
678
  resolution =
586
679
  unit.kind === 'entry'
587
680
  ? await resolver.entry(unit, gitClean, only)
@@ -16,7 +16,8 @@ import type { WorkerContext } from './worker-context.js';
16
16
  * says the serving editor lacks a schema the incoming content names), push this
17
17
  * deployment's own settings
18
18
  * branch, fast-forward the base branch's workspace, ask the gateway for its branch policy,
19
- * rebase every branch that is behind it (rebase.ts), then sweep old tasks, expired trashed
19
+ * rebase every branch that is behind it (rebase.ts), set each pull request's conflicts status
20
+ * (conflicts-status.ts), then sweep old tasks, expired trashed
20
21
  * branch directories and, where retention is on, expired archived branches.
21
22
  *
22
23
  * One ordering is load-bearing and nothing enforces it: `runRebaseCycle` MUST
@@ -32,7 +33,7 @@ import type { WorkerContext } from './worker-context.js';
32
33
  * The whole cycle is wrapped so both outcomes record a worker-status.json
33
34
  * snapshot, since an operator's only view of this loop is the admin panel.
34
35
  */
35
- export type GitSyncContext = Pick<WorkerContext, 'baseBranch' | 'sanitizedBaseBranch' | 'contentBranchesPath' | 'remoteGitPath' | 'schemaHoldMaxMs' | 'archivedBranchRetentionDays' | 'taskDir' | 'taskTimeoutMs' | 'log' | 'github' | 'ensureSettingsBranch' | 'ensureStatusReport' | 'warnedDivergences' | 'isRunning' | 'takeBranchSyncRequests' | 'isDraining' | 'shutdownSignal'> & RebaseContext;
36
+ export type GitSyncContext = Pick<WorkerContext, 'baseBranch' | 'sanitizedBaseBranch' | 'contentBranchesPath' | 'remoteGitPath' | 'schemaHoldMaxMs' | 'archivedBranchRetentionDays' | 'taskDir' | 'taskTimeoutMs' | 'log' | 'github' | 'ensureSettingsBranch' | 'ensureStatusReport' | 'warnedDivergences' | 'conflictsStatusesSet' | 'isRunning' | 'takeBranchSyncRequests' | 'isDraining' | 'shutdownSignal'> & RebaseContext;
36
37
  /**
37
38
  * Push THIS deployment's own settings branch (`ensureSettingsBranch()`) from
38
39
  * remote.git to GitHub. Non-fatal. Skipped when `trackedTips`, GitHub's tips
@@ -14,6 +14,7 @@ import { escapeForLog } from '../utils/escape-text.js';
14
14
  import { CANOPY_META_DIR, isCanopyInternalPath, isNonFastForwardRejection } from '../utils/git.js';
15
15
  import { MAX_REPORTED_PATHS, TRACKED_CANOPY_STATE_FIX, isUntracked, listTrackedCanopyState, restoreRetiredSchemaCache, splitByUpstreamTracking, untrackInIndex, } from './canopy-state.js';
16
16
  import { sweepArchivedBranches } from './archived-sweep.js';
17
+ import { setConflictsStatuses } from './conflicts-status.js';
17
18
  import { hasPendingHistoryRewrite, recordSeenOnGitHub } from './history-rewrite.js';
18
19
  import { runRebaseCycle } from './rebase.js';
19
20
  import { describeMove } from './github-gateway.js';
@@ -519,6 +520,9 @@ export async function syncGit(ctx) {
519
520
  const rebaseSummary = await runRebaseCycle(ctx, branchPolicy, { tips: githubTips, fetchedAt }, requested.branches);
520
521
  if (stoppedForDrain(ctx, 'the cleanup sweeps'))
521
522
  return;
523
+ // After the rebase cycle, whose merges can change a branch's conflicts. Heads the cycle's own
524
+ // pushes move are set by the cycle each push wakes.
525
+ const conflictsStatusFailure = await setConflictsStatuses(ctx, githubTips);
522
526
  await cleanupOldTasks(ctx.taskDir, undefined, ctx.log);
523
527
  // For System health: the branches the gateway would refuse to publish, read after the rebase
524
528
  // cycle, which archives merged branches and restores renamed ones. Without a policy, a fatal
@@ -551,6 +555,11 @@ export async function syncGit(ctx) {
551
555
  const report = ctx.ensureStatusReport();
552
556
  report.lastGitSyncAt = new Date().toISOString();
553
557
  delete report.lastGitSyncError;
558
+ // null: the stage could not say, so the last report stands.
559
+ if (conflictsStatusFailure)
560
+ report.conflictsStatusFailure = conflictsStatusFailure;
561
+ else if (conflictsStatusFailure === undefined)
562
+ delete report.conflictsStatusFailure;
554
563
  // Recorded once the cycle has done everything it can, so System health shows it.
555
564
  if (needsOperator !== null) {
556
565
  report.lastGitSyncError = { message: needsOperator, at: report.lastGitSyncAt };
@@ -92,9 +92,9 @@ export declare function catchUpWithPublished(ctx: GitHubCommitsContext, args: Gi
92
92
  /** Bring GitHub's commits into the clone. Throws what git throws; the caller records it. */
93
93
  export declare function bringInGitHubCommits(ctx: GitHubCommitsContext, args: GitHubCommitsArgs): Promise<GitHubCommitsOutcome>;
94
94
  /**
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
95
+ * `other` merged into the branch: built, recorded, published to `remote.git` and queued for GitHub
96
+ * unless it holds unsubmitted work (publish-decision.ts), and the clone moved to it, in that order,
97
+ * so each step leaves what the next cycle picks up and only the last touches the working tree. `fromGitHub` says `other` is GitHub's tip rather than the
98
98
  * base. `inReview` takes only a merge with nothing for a person to choose; any other leaves the
99
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`.
100
100
  */