canopycms 0.8.1 → 0.8.2

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 (79) hide show
  1. package/CHANGELOG.md +102 -47
  2. package/dist/api/admin-branch-health.d.ts +11 -0
  3. package/dist/api/admin-branch-health.js +36 -0
  4. package/dist/api/admin.d.ts +8 -1
  5. package/dist/api/branch-merge.js +1 -1
  6. package/dist/api/branch-review.js +20 -3
  7. package/dist/api/branch-status.js +30 -19
  8. package/dist/api/branch.d.ts +8 -3
  9. package/dist/api/branch.js +32 -3
  10. package/dist/api/client.d.ts +3 -1
  11. package/dist/api/client.js +4 -0
  12. package/dist/api/github-sync.d.ts +2 -0
  13. package/dist/api/github-sync.js +6 -1
  14. package/dist/api/pr-comment-sync.js +3 -1
  15. package/dist/branch-metadata-file.d.ts +66 -0
  16. package/dist/branch-metadata-file.js +14 -0
  17. package/dist/branch-metadata.d.ts +4 -2
  18. package/dist/branch-metadata.js +10 -2
  19. package/dist/cli/cli.js +28 -8
  20. package/dist/cli/generate-ai-content.js +13 -3
  21. package/dist/cli/init.js +2 -2
  22. package/dist/editor/BranchManager.d.ts +8 -4
  23. package/dist/editor/BranchManager.js +43 -18
  24. package/dist/editor/Editor.js +3 -2
  25. package/dist/editor/admin/SystemHealthPanel.js +65 -31
  26. package/dist/editor/admin/UnpublishedAtMergeAlert.d.ts +16 -0
  27. package/dist/editor/admin/UnpublishedAtMergeAlert.js +50 -0
  28. package/dist/editor/admin/useSystemHealth.d.ts +2 -0
  29. package/dist/editor/admin/useSystemHealth.js +21 -0
  30. package/dist/editor/branch-lock.d.ts +2 -0
  31. package/dist/editor/branch-lock.js +5 -3
  32. package/dist/editor/branch-status.d.ts +25 -1
  33. package/dist/editor/branch-status.js +75 -0
  34. package/dist/editor/components/EditorHeader.d.ts +6 -2
  35. package/dist/editor/components/EditorHeader.js +51 -30
  36. package/dist/editor/components/EntryHeader.d.ts +3 -1
  37. package/dist/editor/components/EntryHeader.js +2 -1
  38. package/dist/editor/components/TooltipDetail.d.ts +14 -0
  39. package/dist/editor/components/TooltipDetail.js +18 -0
  40. package/dist/editor/copy.d.ts +7 -0
  41. package/dist/editor/copy.js +9 -0
  42. package/dist/editor/hooks/useBranchManager.d.ts +8 -3
  43. package/dist/editor/hooks/useBranchManager.js +48 -5
  44. package/dist/pr-comments.d.ts +2 -0
  45. package/dist/pr-comments.js +20 -14
  46. package/dist/resource-generation.js +2 -3
  47. package/dist/submission-attribution.d.ts +1 -1
  48. package/dist/submission-attribution.js +20 -2
  49. package/dist/task-queue/task-queue.d.ts +5 -1
  50. package/dist/task-queue/task-queue.js +4 -2
  51. package/dist/types.d.ts +61 -0
  52. package/dist/version.d.ts +1 -1
  53. package/dist/version.js +1 -1
  54. package/dist/worker/branch-merge.d.ts +68 -0
  55. package/dist/worker/branch-merge.js +228 -0
  56. package/dist/worker/canopy-state.d.ts +2 -0
  57. package/dist/worker/canopy-state.js +1 -1
  58. package/dist/worker/cms-worker.d.ts +1 -0
  59. package/dist/worker/cms-worker.js +5 -3
  60. package/dist/worker/git-sync.js +15 -8
  61. package/dist/worker/github-commits.d.ts +106 -0
  62. package/dist/worker/github-commits.js +485 -0
  63. package/dist/worker/github-gateway-http.js +3 -2
  64. package/dist/worker/github-gateway-wire.d.ts +22 -13
  65. package/dist/worker/github-gateway-wire.js +4 -1
  66. package/dist/worker/github-gateway.d.ts +16 -5
  67. package/dist/worker/github-gateway.js +22 -5
  68. package/dist/worker/github-mirror.d.ts +8 -0
  69. package/dist/worker/github-mirror.js +12 -1
  70. package/dist/worker/github-transfer.d.ts +3 -1
  71. package/dist/worker/github-transfer.js +3 -2
  72. package/dist/worker/history-rewrite.d.ts +13 -1
  73. package/dist/worker/history-rewrite.js +27 -3
  74. package/dist/worker/rebase.d.ts +13 -7
  75. package/dist/worker/rebase.js +351 -95
  76. package/dist/worker/task-runner.d.ts +1 -1
  77. package/dist/worker/task-runner.js +172 -34
  78. package/dist/worker/worker-context.d.ts +2 -0
  79. package/package.json +1 -1
@@ -19,21 +19,25 @@ export const SYNC_PR_COMMENTS_ACTION = 'sync-pr-comments';
19
19
  export async function renderPrCommentsBody(options) {
20
20
  const original = options.body;
21
21
  let next = original;
22
- try {
23
- const threads = await new CommentStore(options.branchRoot).listThreadsStrict();
24
- next =
25
- mergeCommentsBlock(next, buildCommentsBlock(threads, options.branch, options.editorUrl)) ??
26
- next;
27
- }
28
- catch (err) {
29
- if (!(err instanceof SyntaxError || err instanceof CommentsFileMalformedError))
30
- throw err;
31
- options.warn(`Could not read the editor comments of ${options.branch}; the PR keeps its summary: ${getErrorMessage(err)}`);
22
+ if (!options.reviewBlockOnly) {
23
+ try {
24
+ const threads = await new CommentStore(options.branchRoot).listThreadsStrict();
25
+ next =
26
+ mergeCommentsBlock(next, buildCommentsBlock(threads, options.branch, options.editorUrl)) ??
27
+ next;
28
+ }
29
+ catch (err) {
30
+ if (!(err instanceof SyntaxError || err instanceof CommentsFileMalformedError))
31
+ throw err;
32
+ options.warn(`Could not read the editor comments of ${options.branch}; the PR keeps its summary: ${getErrorMessage(err)}`);
33
+ }
32
34
  }
33
35
  try {
34
36
  const file = await readBranchMetadataFile(options.branchRoot);
35
- if (file)
36
- next = mergeReviewBlock(next, buildReviewBlock(file.branch.review)) ?? next;
37
+ if (file) {
38
+ const block = buildReviewBlock(file.branch.review, file.branch.keptOverGitHub);
39
+ next = mergeReviewBlock(next, block) ?? next;
40
+ }
37
41
  }
38
42
  catch (err) {
39
43
  if (!(err instanceof BranchMetadataCorruptError))
@@ -69,8 +73,10 @@ export async function refreshCommentsBlock(options) {
69
73
  }
70
74
  try {
71
75
  const file = await readBranchMetadataFile(options.branchRoot);
72
- if (file)
73
- section = mergeReviewBlock(section, buildReviewBlock(file.branch.review)) ?? section;
76
+ if (file) {
77
+ const block = buildReviewBlock(file.branch.review, file.branch.keptOverGitHub);
78
+ section = mergeReviewBlock(section, block) ?? section;
79
+ }
74
80
  }
75
81
  catch (err) {
76
82
  options.warn(`Could not re-read the review state of ${options.branch}; the PR gets it from submit time: ${getErrorMessage(err)}`);
@@ -4,6 +4,7 @@ import { randomUUID } from 'node:crypto';
4
4
  import { atomicWriteFile } from './utils/atomic-write.js';
5
5
  import { getErrorMessage, isNodeError } from './utils/error.js';
6
6
  import { createDebugLogger } from './utils/debug.js';
7
+ import { canopyLogWarn } from './utils/logger.js';
7
8
  /**
8
9
  * Cross-process generation marker for an on-disk resource cached under a
9
10
  * branch-clone root: several warm Lambda containers and the EC2 worker share
@@ -54,9 +55,7 @@ export async function bumpResourceGeneration(root, resource, options) {
54
55
  return token;
55
56
  }
56
57
  catch (err) {
57
- log.warn('generation', `Failed to bump ${resource} generation for ${root}`, {
58
- error: getErrorMessage(err),
59
- });
58
+ canopyLogWarn(`CanopyCMS: failed to bump ${resource} generation for ${root}:`, getErrorMessage(err));
60
59
  if (options?.mustSucceed)
61
60
  throw err;
62
61
  return null;
@@ -101,7 +101,7 @@ export interface PrSectionInput {
101
101
  * mention, reference an issue, carry HTML or spell a marker; the id is never written, and a
102
102
  * name that is only the id counts as none.
103
103
  */
104
- export declare function buildReviewBlock(review: BranchReview | undefined): string;
104
+ export declare function buildReviewBlock(review: BranchReview | undefined, keptOverGitHub?: readonly string[]): string;
105
105
  /**
106
106
  * The editor-comment summary between its markers: the unresolved threads, oldest first, or
107
107
  * that every thread is resolved, or that there are none, or that they could not be read.
@@ -243,7 +243,7 @@ function reviewTime(at) {
243
243
  * mention, reference an issue, carry HTML or spell a marker; the id is never written, and a
244
244
  * name that is only the id counts as none.
245
245
  */
246
- export function buildReviewBlock(review) {
246
+ export function buildReviewBlock(review, keptOverGitHub = []) {
247
247
  const verdict = review?.state === 'approved'
248
248
  ? 'Approved'
249
249
  : review?.state === 'changes-requested'
@@ -256,7 +256,25 @@ export function buildReviewBlock(review) {
256
256
  const when = reviewTime(review.at);
257
257
  status = `${verdict} in CanopyCMS by ${name ? codeSpan(name) : 'a reviewer'}${when ? ` on ${when}` : ''}.`;
258
258
  }
259
- return `${PR_REVIEW_START}\n${status}\n${REVIEW_NOT_A_GITHUB_REVIEW}\n${PR_REVIEW_END}`;
259
+ const kept = keptOverGitHubNote(keptOverGitHub);
260
+ return `${PR_REVIEW_START}\n${status}\n${REVIEW_NOT_A_GITHUB_REVIEW}\n${kept}${PR_REVIEW_END}`;
261
+ }
262
+ const MAX_LISTED_KEPT_PATHS = 20;
263
+ const MAX_KEPT_PATH_LENGTH = 120;
264
+ /** The paths as code spans, one per line and bounded: a path comes from a commit on GitHub. */
265
+ function keptOverGitHubNote(paths) {
266
+ if (paths.length === 0)
267
+ return '';
268
+ const listed = paths
269
+ .slice(0, MAX_LISTED_KEPT_PATHS)
270
+ .flatMap((p) => {
271
+ const clean = cleanPath(p);
272
+ return clean ? [`- ${codeSpan(truncate(clean, MAX_KEPT_PATH_LENGTH))}`] : [];
273
+ })
274
+ .join('\n');
275
+ const more = paths.length - MAX_LISTED_KEPT_PATHS;
276
+ return (`\nA commit on GitHub changed these files too. The editor's version was kept, and that ` +
277
+ `change is no longer in them:\n${listed}\n${more > 0 ? `...and ${more} more\n` : ''}`);
260
278
  }
261
279
  const MAX_LISTED_THREADS = 50;
262
280
  const MAX_EXCERPT_LENGTH = 80;
@@ -48,7 +48,11 @@ export declare function failTask(taskDir: string, taskId: string, error: string,
48
48
  * Moves from processing/ back to pending/ with incremented retryCount
49
49
  * and a retryAfter timestamp. Backoff: 5s → 10s → 20s → 40s → 60s cap.
50
50
  */
51
- export declare function retryTask(taskDir: string, taskId: string, error: string, logger?: TaskQueueLogger): Promise<void>;
51
+ export declare function retryTask(taskDir: string, taskId: string, error: string, logger?: TaskQueueLogger,
52
+ /** `delayMs` replaces the backoff, for a retry that waits on something else happening first. */
53
+ options?: {
54
+ delayMs?: number;
55
+ }): Promise<void>;
52
56
  /**
53
57
  * Hand an in-flight task back to pending/ unchanged, with no retry spent and no
54
58
  * backoff, for a consumer abandoning it while shutting down. Safe only for an
@@ -184,7 +184,9 @@ export async function failTask(taskDir, taskId, error, logger = nullLogger) {
184
184
  * Moves from processing/ back to pending/ with incremented retryCount
185
185
  * and a retryAfter timestamp. Backoff: 5s → 10s → 20s → 40s → 60s cap.
186
186
  */
187
- export async function retryTask(taskDir, taskId, error, logger = nullLogger) {
187
+ export async function retryTask(taskDir, taskId, error, logger = nullLogger,
188
+ /** `delayMs` replaces the backoff, for a retry that waits on something else happening first. */
189
+ options = {}) {
188
190
  const processingPath = path.join(taskDir, 'processing', `${taskId}.json`);
189
191
  const pendingDir = path.join(taskDir, 'pending');
190
192
  const pendingPath = path.join(pendingDir, `${taskId}.json`);
@@ -205,7 +207,7 @@ export async function retryTask(taskDir, taskId, error, logger = nullLogger) {
205
207
  throw err;
206
208
  }
207
209
  const retryCount = (task.retryCount ?? 0) + 1;
208
- const backoffMs = Math.min(5000 * Math.pow(2, retryCount - 1), 60_000);
210
+ const backoffMs = options.delayMs ?? Math.min(5000 * Math.pow(2, retryCount - 1), 60_000);
209
211
  task.status = 'pending';
210
212
  task.retryCount = retryCount;
211
213
  task.retryAfter = new Date(Date.now() + backoffMs).toISOString();
package/dist/types.d.ts CHANGED
@@ -34,6 +34,30 @@ export interface BranchReview {
34
34
  /** ISO timestamp of the verdict. */
35
35
  at: string;
36
36
  }
37
+ /** What keeps a pull request behind its base. `base` is the branch GitHub says it targets now. */
38
+ export type PullRequestHold = {
39
+ kind: 'conflicts';
40
+ } | {
41
+ kind: 'retargeted';
42
+ base: string;
43
+ };
44
+ /** What the editor tells a branch's viewers is stuck, and needs a person. */
45
+ export type BranchHold = PullRequestHold | {
46
+ kind: 'rewritten';
47
+ }
48
+ /**
49
+ * `opened`: a pull request's number is on record, from an earlier submit. `notQueued`: no task
50
+ * ran it, so there's nothing to retry.
51
+ */
52
+ | {
53
+ kind: 'submit-unfinished';
54
+ opened: boolean;
55
+ notQueued?: boolean;
56
+ }
57
+ /** Admins see what to do in System health. */
58
+ | {
59
+ kind: 'unpublished-at-merge';
60
+ };
37
61
  export interface BranchMetadata {
38
62
  name: string;
39
63
  title?: string;
@@ -109,6 +133,43 @@ export interface BranchMetadata {
109
133
  * rebase loop's self-heal pass.
110
134
  */
111
135
  historyRewrittenFrom?: string;
136
+ /**
137
+ * The newest commit this deployment has seen on GitHub's branch, or knows it will hold once a
138
+ * queued push of a rewrite lands: the last one it pushed, took in or published, and any
139
+ * `remote.git` took from GitHub, whether or not the clone followed. A tip that no longer contains
140
+ * it was rewritten on GitHub, so it is not merged, which could restore what someone removed
141
+ * (worker/github-commits.ts).
142
+ */
143
+ lastGitHubTip?: string;
144
+ /**
145
+ * Repo-relative paths where a merge of GitHub's commits kept this branch's version over theirs.
146
+ * The PR body lists them, since the person whose change was replaced is not in the editor.
147
+ */
148
+ keptOverGitHub?: string[];
149
+ /**
150
+ * The commit GitHub's branch held and no longer contains, set while a rewrite there stands
151
+ * unresolved. The worker pushes nothing for this branch until GitHub's tip contains a commit
152
+ * it is known to have held, or GitHub no longer has the branch.
153
+ */
154
+ rewrittenOnGitHub?: string;
155
+ /**
156
+ * The last submit failed, so its pull request step hasn't run. Only a retry of that task or a new
157
+ * submit runs it, so nothing else clears the failed sync while the branch is in review.
158
+ */
159
+ submitUnfinished?: boolean;
160
+ /** The last submit's task couldn't be queued, so only a new submit runs it. Set by each submit. */
161
+ submitNotQueued?: boolean;
162
+ /**
163
+ * The pull request merged before a submit finished, so commits from that submit may not be in
164
+ * the merge. Archiving leaves no retry or new submit, so this stays for an admin until dismissed
165
+ * in System health, and only an admin may delete the branch meanwhile.
166
+ */
167
+ unpublishedAtMerge?: boolean;
168
+ /**
169
+ * What GitHub reports keeps this branch's open pull request from being updated from its base,
170
+ * as of the worker's last read while the branch was in review. Read only while it still is.
171
+ */
172
+ pullRequestHold?: PullRequestHold;
112
173
  /**
113
174
  * Short, sanitized reason the last GitHub sync failed, set alongside
114
175
  * `syncStatus: 'sync-failed'` by the worker's updateBranchMetadataOnFailure or a
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const CANOPYCMS_VERSION = "0.8.1";
1
+ export declare const CANOPYCMS_VERSION = "0.8.2";
package/dist/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  // Generated by scripts/bump-version.mjs, its only writer. The package builds with plain
2
2
  // tsc (rootDir: src), so src/ cannot import ../package.json; version.test.ts keeps the two equal.
3
- export const CANOPYCMS_VERSION = '0.8.1';
3
+ export const CANOPYCMS_VERSION = '0.8.2';
@@ -0,0 +1,68 @@
1
+ import type { SimpleGit } from 'simple-git';
2
+ import { type ContentId } from '../paths/types.js';
3
+ import type { ConflictStatus } from '../types.js';
4
+ import type { WorkerContext } from './worker-context.js';
5
+ /** What {@link buildMergeCommit} made of the two commits. */
6
+ export type BuiltMerge =
7
+ /** `sha` is the merge, reachable from no ref yet. `conflicted` kept the branch's version. */
8
+ {
9
+ kind: 'built';
10
+ sha: string;
11
+ conflicted: string[];
12
+ }
13
+ /** Git met conflicts no rule settles safely. Nothing was built. */
14
+ | {
15
+ kind: 'unsettled';
16
+ conflicts: string[];
17
+ paths: string[];
18
+ };
19
+ /**
20
+ * The merge of `other` into `head`, as a commit no ref names, with `head` the first parent. A path
21
+ * git cannot merge keeps `head`'s side, stage 2, at the path git reports, or stays deleted when
22
+ * `head` has no such stage: the rule the rebase loop applies to a conflict, since `head` has the
23
+ * editor's work. The commit takes its identity from the clone's `user.*`, written when the clone
24
+ * is provisioned.
25
+ */
26
+ export declare function buildMergeCommit(git: SimpleGit, clonePath: string, args: {
27
+ head: string;
28
+ other: string;
29
+ message: string;
30
+ timeoutMs: number;
31
+ }): Promise<BuiltMerge>;
32
+ /**
33
+ * Whether `merged` holds exactly what `from` and `other` merge to cleanly, and so nothing else. A
34
+ * conflict counts as no: whoever settled it chose what `merged` holds.
35
+ */
36
+ export declare function isCleanMergeOf(git: SimpleGit, merged: string, from: string, other: string): Promise<boolean>;
37
+ /**
38
+ * Move `refs/heads/<branchRef>` in remote.git to `sha` as a plain push, so it can only ever
39
+ * fast-forward. Resolves false when it would not: under the caller's locks, the only writers left
40
+ * are this loop and a person, so remote.git holds a commit `sha` lacks. Any other failure throws.
41
+ */
42
+ export declare function publishPlainToLocalRemote(ctx: Pick<WorkerContext, 'remoteGitPath' | 'taskTimeoutMs'>, clonePath: string, branchRef: string, sha: string): Promise<boolean>;
43
+ /** Commits unique to each side: `[in a, not b]` and `[in b, not a]`. */
44
+ export declare function commitsApart(git: SimpleGit, a: string, b: string): Promise<{
45
+ onlyA: number;
46
+ onlyB: number;
47
+ }>;
48
+ /**
49
+ * Map the file paths a rebase or merge kept the branch's version of onto the ContentIds the
50
+ * editor shows as conflicted, deduplicated. IDs rather than paths because they are
51
+ * immutable: a later slug rename must not orphan a conflict marker. Paths that
52
+ * carry no recoverable ID are dropped rather than guessed at.
53
+ *
54
+ * Pure.
55
+ */
56
+ export declare function conflictFilesToContentIds(conflictedFiles: readonly string[], contentRoot: string): ContentId[];
57
+ /**
58
+ * The conflict record after a rebase or merge: the IDs already recorded plus
59
+ * its own. A conflict means this branch's version replaced base's change to
60
+ * that entry, which a later clean rebase does not undo, so the record only
61
+ * grows until the branch merges (buildMergedBranchUpdate clears it).
62
+ *
63
+ * Pure.
64
+ */
65
+ export declare function recordConflicts(recorded: readonly ContentId[] | undefined, found: readonly ContentId[]): {
66
+ conflictStatus: ConflictStatus;
67
+ conflictFiles: ContentId[];
68
+ };
@@ -0,0 +1,228 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { extractIdFromFilename } from '../content-id-index.js';
5
+ import { normalizeFilesystemPath } from '../paths/normalize.js';
6
+ import { ROOT_COLLECTION_ID } from '../paths/types.js';
7
+ import { isNonFastForwardRejection } from '../utils/git.js';
8
+ import { getErrorMessage, redactCredentials } from '../utils/error.js';
9
+ import { runGit } from './git-process.js';
10
+ import { pinnedReceivePack, sharedRepoGit, sharedRepoProcessOptions } from './shared-repo-git.js';
11
+ /**
12
+ * Merging into a branch workspace's history without touching its working tree: the merge is built
13
+ * as objects, and the clone only ever fast-forwards to it. So a refused merge changes nothing, and
14
+ * a clone is never left stopped on a merge's conflicts, which would need a second copy of the
15
+ * rebase loop's lossy interrupted-rebase recovery.
16
+ */
17
+ const OBJECT_ID = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
18
+ /**
19
+ * The conflicts {@link buildMergeCommit} settles by keeping `head`'s side of one path. Every other
20
+ * kind (a rename colliding with another, a file meeting a directory, a directory rename) moves or
21
+ * joins paths, so no one side of one path is the branch's version, and settling it by rule loses a
22
+ * file. Messages that are not `CONFLICT` are git's notes, such as `Auto-merging`.
23
+ */
24
+ const SETTLED_BY_KEEPING_HEAD = new Set([
25
+ 'CONFLICT (contents)',
26
+ 'CONFLICT (binary)',
27
+ 'CONFLICT (modify/delete)',
28
+ ]);
29
+ /**
30
+ * Parse `merge-tree --write-tree -z`: the tree, then each stage of each conflicted path, an empty
31
+ * field, then messages of a path count, the paths, a stable kind and a sentence. Reads the output
32
+ * rather than the exit status, since it exits 1 on a conflict with no stderr, which simple-git
33
+ * resolves, and any failure of its own writes stderr and throws.
34
+ */
35
+ function parseMergeTree(output, head, other) {
36
+ const fields = output.split('\0');
37
+ const tree = fields[0];
38
+ if (!OBJECT_ID.test(tree))
39
+ throw new Error(`git merge-tree printed no tree for ${head}, ${other}`);
40
+ const stages = [];
41
+ let i = 1;
42
+ for (; i < fields.length && fields[i] !== ''; i++) {
43
+ const match = /^(\d+) ([0-9a-f]+) (\d)\t([\s\S]*)$/.exec(fields[i]);
44
+ if (!match)
45
+ throw new Error(`Unreadable git merge-tree entry: ${JSON.stringify(fields[i])}`);
46
+ const [, mode, oid, stage, path] = match;
47
+ stages.push({ mode, oid, stage, path });
48
+ }
49
+ const unsettled = new Set();
50
+ for (i++; i < fields.length && fields[i] !== '';) {
51
+ const kind = fields[i + parseInt(fields[i], 10) + 1];
52
+ if (kind?.startsWith('CONFLICT') && !SETTLED_BY_KEEPING_HEAD.has(kind))
53
+ unsettled.add(kind);
54
+ i += parseInt(fields[i], 10) + 3;
55
+ }
56
+ // A path git could not decode as UTF-8 reads back as U+FFFD, which names no file.
57
+ if (stages.some(({ path }) => path.includes('\ufffd')))
58
+ unsettled.add('an unreadable file name');
59
+ return { tree, stages, unsettled: [...unsettled] };
60
+ }
61
+ /**
62
+ * The merge of `other` into `head`, as a commit no ref names, with `head` the first parent. A path
63
+ * git cannot merge keeps `head`'s side, stage 2, at the path git reports, or stays deleted when
64
+ * `head` has no such stage: the rule the rebase loop applies to a conflict, since `head` has the
65
+ * editor's work. The commit takes its identity from the clone's `user.*`, written when the clone
66
+ * is provisioned.
67
+ */
68
+ export async function buildMergeCommit(git, clonePath, args) {
69
+ const { head, other, message, timeoutMs } = args;
70
+ const output = await git.raw([
71
+ 'merge-tree',
72
+ '--write-tree',
73
+ '-z',
74
+ '--end-of-options',
75
+ head,
76
+ other,
77
+ ]);
78
+ const { tree: mergedTree, stages, unsettled } = parseMergeTree(output, head, other);
79
+ const conflicted = [...new Set(stages.map(({ path }) => path))];
80
+ if (unsettled.length > 0)
81
+ return { kind: 'unsettled', conflicts: unsettled, paths: conflicted };
82
+ const tree = stages.length === 0
83
+ ? mergedTree
84
+ : await keepHeadStages(clonePath, { mergedTree, stages, conflicted, timeoutMs });
85
+ const sha = (await git.raw(['commit-tree', '-p', head, '-p', other, '-m', message, '--end-of-options', tree])).trim();
86
+ return { kind: 'built', sha, conflicted };
87
+ }
88
+ /**
89
+ * Whether `merged` holds exactly what `from` and `other` merge to cleanly, and so nothing else. A
90
+ * conflict counts as no: whoever settled it chose what `merged` holds.
91
+ */
92
+ export async function isCleanMergeOf(git, merged, from, other) {
93
+ const output = await git.raw([
94
+ 'merge-tree',
95
+ '--write-tree',
96
+ '-z',
97
+ '--end-of-options',
98
+ from,
99
+ other,
100
+ ]);
101
+ const { tree, stages } = parseMergeTree(output, from, other);
102
+ if (stages.length > 0)
103
+ return false;
104
+ return (await git.revparse(['--verify', '--end-of-options', `${merged}^{tree}`])).trim() === tree;
105
+ }
106
+ /** `mergedTree` with each of `conflicted` as `head` has it, through a throwaway index. */
107
+ async function keepHeadStages(clonePath, args) {
108
+ const { mergedTree, stages, conflicted, timeoutMs } = args;
109
+ const shared = sharedRepoProcessOptions(clonePath, 'worktree');
110
+ const indexFile = path.join(clonePath, '.git', `canopy-merge-index-${randomUUID()}`);
111
+ const options = {
112
+ ...shared,
113
+ env: { ...shared.env, GIT_INDEX_FILE: indexFile },
114
+ inactivityTimeoutMs: timeoutMs,
115
+ };
116
+ const kept = new Map(stages.filter((s) => s.stage === '2').map((s) => [s.path, s]));
117
+ // Mode 0 removes a path: what is left when `head` has no side of it.
118
+ const zeros = '0'.repeat(mergedTree.length);
119
+ const entries = conflicted.map((file) => {
120
+ const entry = kept.get(file);
121
+ return `${entry?.mode ?? '0'} ${entry?.oid ?? zeros}\t${file}\0`;
122
+ });
123
+ try {
124
+ await runGit(['read-tree', '--end-of-options', mergedTree], options);
125
+ await runGit(['update-index', '-z', '--index-info'], { ...options, stdin: entries.join('') });
126
+ return (await runGit(['write-tree'], options)).trim();
127
+ }
128
+ finally {
129
+ await fs.rm(indexFile, { force: true }).catch(() => { });
130
+ }
131
+ }
132
+ /**
133
+ * Move `refs/heads/<branchRef>` in remote.git to `sha` as a plain push, so it can only ever
134
+ * fast-forward. Resolves false when it would not: under the caller's locks, the only writers left
135
+ * are this loop and a person, so remote.git holds a commit `sha` lacks. Any other failure throws.
136
+ */
137
+ export async function publishPlainToLocalRemote(ctx, clonePath, branchRef, sha) {
138
+ const pushGit = sharedRepoGit(clonePath, 'worktree', { timeout: { block: ctx.taskTimeoutMs } });
139
+ try {
140
+ await pushGit.raw([
141
+ 'push',
142
+ // Otherwise remote.git's own hooks run, as the worker.
143
+ `--receive-pack=${pinnedReceivePack()}`,
144
+ '--end-of-options',
145
+ ctx.remoteGitPath,
146
+ `${sha}:refs/heads/${branchRef}`,
147
+ ]);
148
+ return true;
149
+ }
150
+ catch (err) {
151
+ if (isNonFastForwardRejection(redactCredentials(getErrorMessage(err))))
152
+ return false;
153
+ throw err;
154
+ }
155
+ }
156
+ /** Commits unique to each side: `[in a, not b]` and `[in b, not a]`. */
157
+ export async function commitsApart(git, a, b) {
158
+ const counts = (await git.raw(['rev-list', '--left-right', '--count', '--end-of-options', `${a}...${b}`])).trim();
159
+ const [left, right] = counts.split(/\s+/).map((n) => parseInt(n, 10));
160
+ if (!Number.isInteger(left) || !Number.isInteger(right)) {
161
+ throw new Error(`Unreadable rev-list output for ${a}...${b}: ${JSON.stringify(counts)}`);
162
+ }
163
+ return { onlyA: left, onlyB: right };
164
+ }
165
+ /**
166
+ * Map the file paths a rebase or merge kept the branch's version of onto the ContentIds the
167
+ * editor shows as conflicted, deduplicated. IDs rather than paths because they are
168
+ * immutable: a later slug rename must not orphan a conflict marker. Paths that
169
+ * carry no recoverable ID are dropped rather than guessed at.
170
+ *
171
+ * Pure.
172
+ */
173
+ export function conflictFilesToContentIds(conflictedFiles, contentRoot) {
174
+ // Entry files carry their ID in the filename. A `.collection.json` has none
175
+ // of its own (extractIdFromFilename returns null for dot-prefixed files), so
176
+ // the ID comes from the parent directory; the root content directory has no
177
+ // embedded ID at all, so ROOT_COLLECTION_ID stands in -- but only for the
178
+ // configured contentRoot.
179
+ //
180
+ // Two different notions of "parent" are needed, and conflating them is the
181
+ // bug this guards against (see schema-store.ts's contentRootName doc comment
182
+ // for the same shape elsewhere):
183
+ // - `parentDir` (a basename) recovers a SUB-collection's own embedded ID,
184
+ // correct as a basename since a collection directory carries its ID in its
185
+ // own name, one path segment;
186
+ // - `parentPath` (the full normalized relative parent path) is what must be
187
+ // compared against `contentRoot`, which is documented (config/helpers.ts)
188
+ // as allowed to span multiple segments (e.g. "cms/content"). Comparing a
189
+ // basename against that full value is always false, silently dropping the
190
+ // root collection's conflict. Git reports POSIX paths and the configured
191
+ // value may use either separator, so both sides go through
192
+ // normalizeFilesystemPath first.
193
+ const normalizedContentRoot = normalizeFilesystemPath(contentRoot);
194
+ const conflictIds = [...new Set(conflictedFiles)]
195
+ .map((f) => {
196
+ const fileId = extractIdFromFilename(path.basename(f));
197
+ if (fileId)
198
+ return fileId;
199
+ const parentDir = path.basename(path.dirname(f));
200
+ const dirId = extractIdFromFilename(parentDir);
201
+ if (dirId)
202
+ return dirId;
203
+ // Only assign ROOT_COLLECTION_ID when the file's parent directory IS the
204
+ // configured content root. Other unrecognized paths are filtered out.
205
+ const parentPath = normalizeFilesystemPath(path.dirname(f));
206
+ if (path.basename(f) === '.collection.json' && parentPath === normalizedContentRoot) {
207
+ return ROOT_COLLECTION_ID;
208
+ }
209
+ return null;
210
+ })
211
+ .filter((id) => id !== null);
212
+ return [...new Set(conflictIds)];
213
+ }
214
+ /**
215
+ * The conflict record after a rebase or merge: the IDs already recorded plus
216
+ * its own. A conflict means this branch's version replaced base's change to
217
+ * that entry, which a later clean rebase does not undo, so the record only
218
+ * grows until the branch merges (buildMergedBranchUpdate clears it).
219
+ *
220
+ * Pure.
221
+ */
222
+ export function recordConflicts(recorded, found) {
223
+ const conflictFiles = [...new Set([...(recorded ?? []), ...found])];
224
+ return {
225
+ conflictStatus: conflictFiles.length > 0 ? 'conflicts-detected' : 'clean',
226
+ conflictFiles,
227
+ };
228
+ }
@@ -10,6 +10,8 @@ import type { FileStatusResult, SimpleGit, StatusResult } from 'simple-git';
10
10
  export declare const TRACKED_CANOPY_STATE_FIX = "run `git rm -r --cached .canopy-meta` in the site repo, add `.canopy-meta/` to its .gitignore, and commit";
11
11
  /** Paths listed in worker-status.json per category; the admin panel shows them in a tooltip. */
12
12
  export declare const MAX_REPORTED_PATHS = 10;
13
+ /** The schema cache's location in a clone before it moved under `.git/` (branch-schema-cache.ts). */
14
+ export declare const RETIRED_SCHEMA_CACHE_PATH = ".canopy-meta/schema-cache.json";
13
15
  export declare function isUntracked(file: FileStatusResult): boolean;
14
16
  /** Files under `.canopy-meta/` that the clone's index tracks. */
15
17
  export declare function listTrackedCanopyState(git: SimpleGit): Promise<string[]>;
@@ -12,7 +12,7 @@ export const TRACKED_CANOPY_STATE_FIX = 'run `git rm -r --cached .canopy-meta` i
12
12
  /** Paths listed in worker-status.json per category; the admin panel shows them in a tooltip. */
13
13
  export const MAX_REPORTED_PATHS = 10;
14
14
  /** The schema cache's location in a clone before it moved under `.git/` (branch-schema-cache.ts). */
15
- const RETIRED_SCHEMA_CACHE_PATH = `${CANOPY_META_DIR}/${SCHEMA_CACHE_FILE}`;
15
+ export const RETIRED_SCHEMA_CACHE_PATH = `${CANOPY_META_DIR}/${SCHEMA_CACHE_FILE}`;
16
16
  export function isUntracked(file) {
17
17
  return file.index === '?' && file.working_dir === '?';
18
18
  }
@@ -189,6 +189,7 @@ export declare class CmsWorker {
189
189
  private shutdownController;
190
190
  private maxTasksPerCycle;
191
191
  private taskTimeoutMs;
192
+ private gitSyncIntervalMs;
192
193
  private maxRetries;
193
194
  private lockFilePath;
194
195
  private lockStaleMs;
@@ -260,6 +260,7 @@ export class CmsWorker {
260
260
  this.setBaseBranch(config.baseBranch);
261
261
  this.maxTasksPerCycle = config.maxTasksPerCycle ?? 10;
262
262
  this.taskTimeoutMs = config.taskTimeoutMs ?? DEFAULT_TASK_TIMEOUT;
263
+ this.gitSyncIntervalMs = config.gitSyncInterval ?? 5 * 60_000;
263
264
  this.maxRetries = config.maxRetries ?? DEFAULT_MAX_RETRIES;
264
265
  this.lockFilePath = path.join(config.workspacePath, '.tasks', '.worker-lock');
265
266
  this.lockStaleMs = config.lockStaleMs ?? DEFAULT_LOCK_STALE_MS;
@@ -334,6 +335,7 @@ export class CmsWorker {
334
335
  contentRoot: this.contentRoot,
335
336
  schemaHoldMaxMs: this.schemaHoldMaxMs,
336
337
  taskTimeoutMs: this.taskTimeoutMs,
338
+ gitSyncIntervalMs: this.gitSyncIntervalMs,
337
339
  maxTasksPerCycle: this.maxTasksPerCycle,
338
340
  maxRetries: this.maxRetries,
339
341
  log: this.log,
@@ -476,7 +478,7 @@ export class CmsWorker {
476
478
  if (this.stopping)
477
479
  return;
478
480
  const taskInterval = this.config.taskPollInterval ?? 5_000;
479
- const gitInterval = this.config.gitSyncInterval ?? 5 * 60_000;
481
+ const gitInterval = this.gitSyncIntervalMs;
480
482
  this.scheduleLoop('task queue', () => this.processTaskQueue(), taskInterval);
481
483
  this.scheduleLoop('git sync', () => this.syncGit(), gitInterval);
482
484
  if (this.config.refreshAuthCache) {
@@ -1252,8 +1254,8 @@ export class CmsWorker {
1252
1254
  // `rebaseActiveBranches` also has a consumer outside the test files:
1253
1255
  // apps/test-app/app/api/e2e-test/rebase/route.ts drives it from an e2e
1254
1256
  // fixture route. Do not delete it as unused.
1255
- async rebaseActiveBranches(gatewayPolicy) {
1256
- return runRebaseCycle(this.ctx(), gatewayPolicy);
1257
+ async rebaseActiveBranches(gatewayPolicy, gitHub) {
1258
+ return runRebaseCycle(this.ctx(), gatewayPolicy, gitHub);
1257
1259
  }
1258
1260
  // --- Git-sync cluster (worker/git-sync.ts) -----------------------------
1259
1261
  //