canopycms 0.8.1 → 0.8.3

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 (90) hide show
  1. package/CHANGELOG.md +120 -47
  2. package/dist/ai/resolve-branch.js +2 -1
  3. package/dist/api/admin-branch-health.d.ts +11 -0
  4. package/dist/api/admin-branch-health.js +36 -0
  5. package/dist/api/admin.d.ts +8 -1
  6. package/dist/api/branch-merge.js +1 -1
  7. package/dist/api/branch-review.js +20 -3
  8. package/dist/api/branch-status.js +30 -19
  9. package/dist/api/branch.d.ts +8 -3
  10. package/dist/api/branch.js +32 -3
  11. package/dist/api/client.d.ts +3 -1
  12. package/dist/api/client.js +4 -0
  13. package/dist/api/github-sync.d.ts +2 -0
  14. package/dist/api/github-sync.js +6 -1
  15. package/dist/api/pr-comment-sync.js +3 -1
  16. package/dist/authorization/branch.d.ts +10 -4
  17. package/dist/authorization/branch.js +24 -4
  18. package/dist/authorization/types.d.ts +1 -1
  19. package/dist/branch-metadata-file.d.ts +66 -0
  20. package/dist/branch-metadata-file.js +14 -0
  21. package/dist/branch-metadata.d.ts +4 -2
  22. package/dist/branch-metadata.js +10 -2
  23. package/dist/cli/cli.js +1913 -1606
  24. package/dist/cli/configured-asset-store.js +2 -8
  25. package/dist/cli/generate-ai-content.js +17 -4
  26. package/dist/cli/init.js +2 -2
  27. package/dist/cli/site-config.d.ts +9 -0
  28. package/dist/cli/site-config.js +17 -0
  29. package/dist/cli/sync.js +226 -74
  30. package/dist/editor/BranchManager.d.ts +8 -4
  31. package/dist/editor/BranchManager.js +43 -18
  32. package/dist/editor/Editor.js +3 -2
  33. package/dist/editor/admin/SystemHealthPanel.js +65 -31
  34. package/dist/editor/admin/UnpublishedAtMergeAlert.d.ts +16 -0
  35. package/dist/editor/admin/UnpublishedAtMergeAlert.js +50 -0
  36. package/dist/editor/admin/useSystemHealth.d.ts +2 -0
  37. package/dist/editor/admin/useSystemHealth.js +21 -0
  38. package/dist/editor/branch-lock.d.ts +2 -0
  39. package/dist/editor/branch-lock.js +5 -3
  40. package/dist/editor/branch-status.d.ts +25 -1
  41. package/dist/editor/branch-status.js +75 -0
  42. package/dist/editor/components/EditorHeader.d.ts +6 -2
  43. package/dist/editor/components/EditorHeader.js +51 -30
  44. package/dist/editor/components/EntryHeader.d.ts +3 -1
  45. package/dist/editor/components/EntryHeader.js +2 -1
  46. package/dist/editor/components/TooltipDetail.d.ts +14 -0
  47. package/dist/editor/components/TooltipDetail.js +18 -0
  48. package/dist/editor/copy.d.ts +7 -0
  49. package/dist/editor/copy.js +9 -0
  50. package/dist/editor/hooks/useBranchManager.d.ts +8 -3
  51. package/dist/editor/hooks/useBranchManager.js +48 -5
  52. package/dist/pr-comments.d.ts +2 -0
  53. package/dist/pr-comments.js +20 -14
  54. package/dist/resource-generation.js +2 -3
  55. package/dist/services.d.ts +3 -2
  56. package/dist/submission-attribution.d.ts +1 -1
  57. package/dist/submission-attribution.js +20 -2
  58. package/dist/system-users.d.ts +2 -0
  59. package/dist/system-users.js +6 -1
  60. package/dist/task-queue/task-queue.d.ts +5 -1
  61. package/dist/task-queue/task-queue.js +4 -2
  62. package/dist/types.d.ts +61 -0
  63. package/dist/version.d.ts +1 -1
  64. package/dist/version.js +1 -1
  65. package/dist/worker/branch-merge.d.ts +68 -0
  66. package/dist/worker/branch-merge.js +228 -0
  67. package/dist/worker/canopy-state.d.ts +2 -0
  68. package/dist/worker/canopy-state.js +1 -1
  69. package/dist/worker/cms-worker.d.ts +1 -0
  70. package/dist/worker/cms-worker.js +5 -3
  71. package/dist/worker/git-sync.js +15 -8
  72. package/dist/worker/github-commits.d.ts +106 -0
  73. package/dist/worker/github-commits.js +485 -0
  74. package/dist/worker/github-gateway-http.js +3 -2
  75. package/dist/worker/github-gateway-wire.d.ts +22 -13
  76. package/dist/worker/github-gateway-wire.js +4 -1
  77. package/dist/worker/github-gateway.d.ts +16 -5
  78. package/dist/worker/github-gateway.js +22 -5
  79. package/dist/worker/github-mirror.d.ts +8 -0
  80. package/dist/worker/github-mirror.js +12 -1
  81. package/dist/worker/github-transfer.d.ts +3 -1
  82. package/dist/worker/github-transfer.js +3 -2
  83. package/dist/worker/history-rewrite.d.ts +13 -1
  84. package/dist/worker/history-rewrite.js +27 -3
  85. package/dist/worker/rebase.d.ts +13 -7
  86. package/dist/worker/rebase.js +351 -95
  87. package/dist/worker/task-runner.d.ts +1 -1
  88. package/dist/worker/task-runner.js +172 -34
  89. package/dist/worker/worker-context.d.ts +2 -0
  90. package/package.json +1 -1
@@ -13,7 +13,7 @@ import { getErrorMessage, isNodeError, redactCredentials } from '../utils/error.
13
13
  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
- import { hasPendingHistoryRewrite } from './history-rewrite.js';
16
+ import { hasPendingHistoryRewrite, recordSeenOnGitHub } from './history-rewrite.js';
17
17
  import { runRebaseCycle } from './rebase.js';
18
18
  import { describeMove } from './github-gateway.js';
19
19
  import { fetchGitHubIntoRemoteGit, pushToGitHub } from './github-transfer.js';
@@ -145,7 +145,7 @@ export async function pushSettingsBranches(ctx, git, trackedTips) {
145
145
  * stale copy of a branch rebased and force-pushed on GitHub, and is moved to GitHub's tip
146
146
  * (`repointed`); one holding editor work is a real collision (another deployment moved the same
147
147
  * branch name, or GitHub's branch was rewritten under an editor's work), never resolved by picking
148
- * a winner: it stays `diverged`, and the next push is rejected non-fast-forward.
148
+ * a winner: it stays `diverged`, and github-commits.ts decides whether a merge can reconcile it.
149
149
  *
150
150
  * A head GitHub has no branch for is deleted through the same function when it holds no editor work
151
151
  * and shares history with the base (`pruned`), and only when GitHub's answer names the base branch:
@@ -277,13 +277,16 @@ async function reconcileTrackedBranches(ctx, git, githubTips) {
277
277
  }
278
278
  }
279
279
  try {
280
+ if (name !== ctx.baseBranch)
281
+ await recordSeenOnGitHub(ctx, name, trackedSha);
280
282
  await git.raw(['update-ref', '--no-deref', localRef, trackedSha, localSha]);
281
283
  fastForwarded.push(name);
282
284
  }
283
285
  catch (err) {
284
- // Concurrent Lambda push moved the ref since the read above --
285
- // the guard did its job; this branch is simply revisited next cycle.
286
- workerLogWarn(` Tracked-branch reconcile: failed to fast-forward ${escapeForLog(name)} (concurrent update?): ${escapeForLog(getErrorMessage(err))}`);
286
+ // Concurrent Lambda push moved the ref since the read above -- the guard did its job
287
+ // -- or the record couldn't be written. Either way the ref stays, and this branch is
288
+ // simply revisited next cycle.
289
+ workerLogWarn(` Tracked-branch reconcile: failed to fast-forward ${escapeForLog(name)} (concurrent update, or its branch.json?): ${escapeForLog(getErrorMessage(err))}`);
287
290
  }
288
291
  }
289
292
  else if (localBehindCount === 0 && localAheadCount > 0) {
@@ -390,8 +393,8 @@ function divergedWarning({ name, local, github, state, error }) {
390
393
  said: `${local} ${github} ${state}`,
391
394
  line: ` Tracked-branch reconcile: ${escapeForLog(name)} diverged from GitHub and was left untouched, ` +
392
395
  `because ${EDITOR_STATE_WORDS[state]}${error === undefined ? '' : ` (${escapeForLog(error)})`} ` +
393
- `(here ${local}, GitHub ${github}). Publishing it fails until the two are reconciled; ` +
394
- `System health lists it.`,
396
+ `(here ${local}, GitHub ${github}). The next rebase cycle merges commits added on GitHub; ` +
397
+ `otherwise publishing it fails until the two are reconciled. System health lists it.`,
395
398
  };
396
399
  }
397
400
  /**
@@ -464,6 +467,7 @@ export async function syncGit(ctx) {
464
467
  // deadline: the wait for the gateway's GitHub fetch, which runs on detached, then the gateway's
465
468
  // bundle, the unbundle and the ref update; a killed ref update leaves each ref as it was or
466
469
  // fully moved.
470
+ const fetchedAt = Date.now();
467
471
  const { refs: githubTips, needsOperator } = await fetchGitHubIntoRemoteGit(ctx, ctx.shutdownSignal());
468
472
  workerLog('Fetched from GitHub');
469
473
  if (needsOperator !== null)
@@ -507,7 +511,10 @@ export async function syncGit(ctx) {
507
511
  report.gatewayProtectedBranches = branchPolicy.configured.filter((n) => !n.includes('*'));
508
512
  await writeWorkerStatus(ctx.taskDir, report).catch((writeErr) => workerLogError('Failed to write worker status:', getErrorMessage(writeErr)));
509
513
  }
510
- const rebaseSummary = await runRebaseCycle(ctx, branchPolicy);
514
+ const rebaseSummary = await runRebaseCycle(ctx, branchPolicy, {
515
+ tips: githubTips,
516
+ fetchedAt,
517
+ });
511
518
  if (stoppedForDrain(ctx, 'the cleanup sweeps'))
512
519
  return;
513
520
  await cleanupOldTasks(ctx.taskDir, undefined, ctx.log);
@@ -0,0 +1,106 @@
1
+ import type { SimpleGit } from 'simple-git';
2
+ import { type BranchMetadataFile } from '../branch-metadata.js';
3
+ import type { BranchMetadata } from '../types.js';
4
+ import { type BranchPolicyDescription } from './branch-policy.js';
5
+ import type { WorkerContext } from './worker-context.js';
6
+ /**
7
+ * Commits that reach a branch on GitHub without passing through its clone: Update branch, a
8
+ * maintainer's fix, or a push this worker made. `reconcileTrackedBranches` fast-forwards
9
+ * `remote.git` to them, but branch clones never fetch their own branch, so without this step the
10
+ * clone's next rebase or commit diverges from `remote.git` and every submit after that is refused.
11
+ *
12
+ * Runs once per cycle for every branch this deployment has published, in every status but
13
+ * archived, under the provisioning and content-write locks the caller holds, ahead of the rebase.
14
+ * It never forces anything: a clone behind GitHub fast-forwards, and a clone with commits GitHub
15
+ * lacks gets a real merge (branch-merge.ts), which keeps every commit on both sides.
16
+ *
17
+ * Two commits are compared. `remote.git`'s head is what the clone must contain for its next push,
18
+ * and it stays behind GitHub only while a head is diverged; GitHub's tip, from this cycle's fetch,
19
+ * is the one a diverged head is reconciled against.
20
+ *
21
+ * A merge commit in a branch's history, or a commit touching canopycms state, is why the rebase
22
+ * loop merges base into it rather than rebasing (rebase.ts): `git rebase` drops merge commits, and
23
+ * with them whatever they settled, and replays commits over the live files.
24
+ */
25
+ export type GitHubCommitsContext = Pick<WorkerContext, 'baseBranch' | 'contentBranchesPath' | 'contentRoot' | 'remoteGitPath' | 'taskDir' | 'taskTimeoutMs' | 'ensureStatusReport'>;
26
+ /** GitHub's branches as a sync cycle's fetch reported them, and when that fetch began. */
27
+ export interface GitHubSnapshot {
28
+ tips: ReadonlyMap<string, string>;
29
+ fetchedAt: number;
30
+ }
31
+ export type GitHubCommitsOutcome =
32
+ /** GitHub's commits are in the clone, or there are none to bring in. */
33
+ {
34
+ kind: 'in-step';
35
+ }
36
+ /** Looked at again next cycle. Nothing else in this cycle may move the branch. */
37
+ | {
38
+ kind: 'wait';
39
+ }
40
+ /**
41
+ * `remote.git` holds commits GitHub lacks, whose push failed for good. A rebase would replace
42
+ * commits GitHub never received, so the base comes in by a merge, which keeps them.
43
+ */
44
+ | {
45
+ kind: 'unpushed';
46
+ }
47
+ /** A merge landed. A rebase now would replace a merge GitHub has not received. */
48
+ | {
49
+ kind: 'merged';
50
+ }
51
+ /** Left as it is until a person acts. `reason` says what to do. */
52
+ | {
53
+ kind: 'blocked';
54
+ reason: string;
55
+ };
56
+ export interface GitHubCommitsArgs {
57
+ git: SimpleGit;
58
+ branchPath: string;
59
+ branchDir: string;
60
+ branchRef: string;
61
+ metaFile: BranchMetadataFile;
62
+ gitHub: GitHubSnapshot | undefined;
63
+ /** Whether the working tree holds saves, which only a fast-forward can pass. */
64
+ dirty: boolean;
65
+ gatewayPolicy: BranchPolicyDescription | undefined;
66
+ /** Whether a lock the caller holds was lost; checked before each step that moves a ref. */
67
+ lockLost: () => boolean;
68
+ }
69
+ export declare const COMMIT_ID: RegExp;
70
+ /** Why a push was refused while a rewrite stands (task-runner.ts); editors read it as a failed sync. */
71
+ export declare const REWRITTEN_PUSH_REFUSAL = "This branch was changed on GitHub in a way CanopyCMS can't combine with the work here, so nothing was pushed. Nothing is lost.";
72
+ /** Why a push was refused before any sync had seen the rewrite (task-runner.ts). */
73
+ export declare const SEEN_COMMIT_GONE_PUSH_REFUSAL = "A commit this branch had on GitHub is no longer there, so nothing was pushed. Nothing is lost.";
74
+ /**
75
+ * What clears a rewrite: the flag, and the refusal's text, which would go on saying the work can't
76
+ * be combined. The failed sync it left goes too, unless {@link awaitsSubmitRetry}.
77
+ */
78
+ export declare function rewriteCleared(branch: BranchMetadata): Partial<BranchMetadata>;
79
+ /** Bring GitHub's commits into the clone. Throws what git throws; the caller records it. */
80
+ export declare function bringInGitHubCommits(ctx: GitHubCommitsContext, args: GitHubCommitsArgs): Promise<GitHubCommitsOutcome>;
81
+ /**
82
+ * `other` merged into the branch: built, published to `remote.git`, queued for GitHub, and the
83
+ * clone moved to it, in that order, so each step leaves what the next cycle picks up and only the
84
+ * last touches the working tree. `fromGitHub` says `other` is GitHub's tip rather than the base.
85
+ * `stopOnConflict` leaves a branch in review as it is: nobody is at the editor to see what a kept
86
+ * version replaced, and GitHub's own conflict notice stands on the pull request.
87
+ */
88
+ export declare function mergeIntoBranch(ctx: GitHubCommitsContext, args: GitHubCommitsArgs, merge: {
89
+ head: string;
90
+ other: string;
91
+ message: string;
92
+ fromGitHub: boolean;
93
+ stopOnConflict?: boolean;
94
+ }): Promise<GitHubCommitsOutcome>;
95
+ /**
96
+ * Send an approved branch back to review and clear the verdict from the PR body. Before the clone
97
+ * moves, and a failed save throws, so a crash or a failure leaves the commits for the next cycle
98
+ * to judge again rather than an approval standing over them.
99
+ */
100
+ export declare function returnToReview(ctx: GitHubCommitsContext, { branchPath, branchDir, branchRef, metaFile }: GitHubCommitsArgs): Promise<void>;
101
+ /**
102
+ * Re-render the PR body's review block alone: the worker has no `config.editorUrl`, so a full
103
+ * sync from here would strip the comment summary's links. Best-effort: a failure leaves the block
104
+ * until the branch's next sync.
105
+ */
106
+ export declare function enqueueReviewBlockSync(ctx: Pick<GitHubCommitsContext, 'taskDir'>, branchRef: string, { branch }: BranchMetadataFile): Promise<void>;
@@ -0,0 +1,485 @@
1
+ import { BranchMetadataFileManager, awaitsSubmitRetry, getBranchMetadataFileManager, } from '../branch-metadata.js';
2
+ import { invalidateBranchContentCaches } from '../content-index-generation.js';
3
+ import { GITHUB_TRACKING_REF_PREFIX } from '../git-manager.js';
4
+ import { SYNC_PR_COMMENTS_ACTION } from '../pr-comments.js';
5
+ import { enqueueTask } from '../task-queue/cms-task-queue.js';
6
+ import { getErrorMessage } from '../utils/error.js';
7
+ import { escapeForLog } from '../utils/escape-text.js';
8
+ import { isCanopyInternalPath } from '../utils/git.js';
9
+ import { publishesBranch } from './branch-policy.js';
10
+ import { buildMergeCommit, commitsApart, conflictFilesToContentIds, isCleanMergeOf, publishPlainToLocalRemote, recordConflicts, } from './branch-merge.js';
11
+ import { MAX_REPORTED_PATHS, RETIRED_SCHEMA_CACHE_PATH, TRACKED_CANOPY_STATE_FIX, } from './canopy-state.js';
12
+ import { enqueueGitHubPush, readPublishedSha, recordSeenOnGitHub } from './history-rewrite.js';
13
+ import { workerLog, workerLogWarn } from './log.js';
14
+ import { assertNoIncomingSubmodules, fetchFromRemoteGit } from './shared-repo-git.js';
15
+ export const COMMIT_ID = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
16
+ const MAX_LISTED_PATH_LENGTH = 200;
17
+ /** Why a push was refused while a rewrite stands (task-runner.ts); editors read it as a failed sync. */
18
+ export const REWRITTEN_PUSH_REFUSAL = "This branch was changed on GitHub in a way CanopyCMS can't combine with the work here, so nothing was pushed. Nothing is lost.";
19
+ /** Why a push was refused before any sync had seen the rewrite (task-runner.ts). */
20
+ export const SEEN_COMMIT_GONE_PUSH_REFUSAL = 'A commit this branch had on GitHub is no longer there, so nothing was pushed. Nothing is lost.';
21
+ /**
22
+ * What clears a rewrite: the flag, and the refusal's text, which would go on saying the work can't
23
+ * be combined. The failed sync it left goes too, unless {@link awaitsSubmitRetry}.
24
+ */
25
+ export function rewriteCleared(branch) {
26
+ const reason = branch.syncFailureReason;
27
+ if (!reason?.includes(REWRITTEN_PUSH_REFUSAL) &&
28
+ !reason?.includes(SEEN_COMMIT_GONE_PUSH_REFUSAL)) {
29
+ return { rewrittenOnGitHub: undefined };
30
+ }
31
+ return awaitsSubmitRetry(branch)
32
+ ? { rewrittenOnGitHub: undefined, syncFailureReason: undefined }
33
+ : { rewrittenOnGitHub: undefined, syncStatus: 'synced', syncFailureReason: undefined };
34
+ }
35
+ /** A branch this deployment put on GitHub. Any other GitHub branch of that name is not its own. */
36
+ function isPublished({ branch }) {
37
+ return branch.pushedToGitHubAt !== undefined || branch.pullRequestNumber !== undefined;
38
+ }
39
+ /** Bring GitHub's commits into the clone. Throws what git throws; the caller records it. */
40
+ export async function bringInGitHubCommits(ctx, args) {
41
+ const { git, branchRef, metaFile, dirty, gatewayPolicy } = args;
42
+ const { branch } = metaFile;
43
+ // A branch.json naming another ref is that branch's, which shares this directory's name.
44
+ if (branch.name !== branchRef || !isPublished(metaFile))
45
+ return { kind: 'in-step' };
46
+ const gitHubTip = args.gitHub?.tips.get(branchRef);
47
+ // No GitHub branch leaves nothing a push could restore.
48
+ if (args.gitHub !== undefined && gitHubTip === undefined && branch.rewrittenOnGitHub) {
49
+ await clearRewritten(ctx, args);
50
+ }
51
+ const published = await readPublishedSha(ctx, branchRef);
52
+ const tip = gitHubTip ?? published;
53
+ if (tip === null)
54
+ return { kind: 'in-step' };
55
+ let head = (await git.revparse(['HEAD'])).trim();
56
+ if (tip === head && (published === null || published === head)) {
57
+ await recordGitHubTip(ctx, args, tip);
58
+ return { kind: 'in-step' };
59
+ }
60
+ // Holding base keeps content the serving editor has no schema for out of every clone, and a
61
+ // merge of base on GitHub would carry it in here.
62
+ if (ctx.ensureStatusReport().baseHold)
63
+ return { kind: 'wait' };
64
+ if (published !== null && published !== head) {
65
+ await fetchFromRemoteGit(git, ctx.remoteGitPath, `refs/heads/${branchRef}`);
66
+ }
67
+ if (tip !== head && tip !== published) {
68
+ await fetchFromRemoteGit(git, ctx.remoteGitPath, `${GITHUB_TRACKING_REF_PREFIX}${branchRef}`);
69
+ }
70
+ // First, before any step reads this tip as commits to take in or a push to make: both would
71
+ // restore what someone removed.
72
+ let rewriteResolved = false;
73
+ if (args.gitHub !== undefined && gitHubTip !== undefined) {
74
+ // Unreadable means the tip's objects did not reach this clone, which says nothing about GitHub.
75
+ if (!(await holdsCommit(git, tip)))
76
+ return { kind: 'wait' };
77
+ const lastSeen = await rewrittenFrom(args, tip, head, published);
78
+ if (lastSeen !== null) {
79
+ return (await pushedSinceFetch(args, args.gitHub.fetchedAt))
80
+ ? { kind: 'wait' }
81
+ : blockAsRewritten(ctx, args, lastSeen);
82
+ }
83
+ if (branch.rewrittenOnGitHub !== undefined) {
84
+ await clearRewritten(ctx, args);
85
+ rewriteResolved = true;
86
+ }
87
+ }
88
+ // A crash after a merge reached remote.git leaves the clone behind it, like any fast-forward
89
+ // reconcile made, and the check above has ruled out a head GitHub dropped. Taken first, so a merge
90
+ // below builds on remote.git's head whenever it can.
91
+ if (published !== null && published !== head) {
92
+ if ((await commitsApart(git, head, published)).onlyA === 0) {
93
+ const stopped = await fastForward(ctx, args, head, published);
94
+ if (stopped)
95
+ return stopped;
96
+ head = published;
97
+ }
98
+ }
99
+ if (tip !== head) {
100
+ const { onlyA, onlyB } = await commitsApart(git, head, tip);
101
+ if (onlyB > 0 && onlyA === 0) {
102
+ // Reconcile didn't take this tip in. The clone stays level with `remote.git`, or the next
103
+ // force-push would find a commit in the clone that neither the record nor `remote.git` names.
104
+ if (gitHubTip !== undefined && published !== null && published !== tip) {
105
+ return { kind: 'wait' };
106
+ }
107
+ const stopped = await fastForward(ctx, args, head, tip);
108
+ if (stopped)
109
+ return stopped;
110
+ head = tip;
111
+ }
112
+ else if (onlyB > 0) {
113
+ // The worker's own rewrite with its push still on the way: GitHub stands at the commit it
114
+ // replaced, and history-rewrite.ts completes that hop.
115
+ if (branch.historyRewrittenFrom === tip) {
116
+ if (rewriteResolved)
117
+ await enqueueGitHubPush(ctx, branchRef, gatewayPolicy);
118
+ return { kind: 'in-step' };
119
+ }
120
+ if (dirty)
121
+ return { kind: 'wait' };
122
+ // Nothing it made could reach GitHub, and each base move would make another.
123
+ if (gatewayPolicy && !publishesBranch(gatewayPolicy, branchRef))
124
+ return { kind: 'in-step' };
125
+ return mergeIntoBranch(ctx, args, {
126
+ head,
127
+ other: tip,
128
+ message: `Merge commits from GitHub into ${branchRef}`,
129
+ fromGitHub: true,
130
+ });
131
+ }
132
+ }
133
+ await recordGitHubTip(ctx, args, tip);
134
+ // A merge or a submit that reached remote.git with no GitHub push queued, as after a crash
135
+ // between the two. A rebase now would strand the push, which leases on a commit GitHub never
136
+ // held, and read as a rewrite on GitHub once it failed. So the branch waits for the push, or takes
137
+ // the base by a merge when the push failed for good, which isn't queued again unless a cleared
138
+ // rewrite was all that stopped it: a new attempt would fail the same way every cycle.
139
+ if (gitHubTip !== undefined &&
140
+ published !== null &&
141
+ published !== tip &&
142
+ (!gatewayPolicy || publishesBranch(gatewayPolicy, branchRef)) &&
143
+ (await contains(git, published, tip))) {
144
+ if (branch.syncStatus === 'sync-failed' && !rewriteResolved)
145
+ return { kind: 'unpushed' };
146
+ await enqueueGitHubPush(ctx, branchRef, gatewayPolicy);
147
+ return { kind: 'wait' };
148
+ }
149
+ return { kind: 'in-step' };
150
+ }
151
+ /**
152
+ * Whether the task loop pushed this branch after the cycle's fetch began, which makes the cycle's
153
+ * tip old news rather than a rewrite. Read fresh: the caller's metadata is from the cycle's start.
154
+ */
155
+ async function pushedSinceFetch({ branchPath }, fetchedAt) {
156
+ const pushedAt = (await BranchMetadataFileManager.loadOnly(branchPath))?.branch.pushedToGitHubAt;
157
+ return pushedAt !== undefined && Date.parse(pushedAt) >= fetchedAt;
158
+ }
159
+ /**
160
+ * The commit a rewrite on GitHub removed, or null. A commit removed on GitHub stays removed: its
161
+ * clone and `remote.git` may still hold it, and nothing here may put it back. Any commit GitHub is
162
+ * known to hold counts: the last tip seen, and the commit a pending rewrite of the worker's own
163
+ * replaced, since GitHub stays there until that push lands. A tip holding everything the clone and
164
+ * `remote.git` hold has nothing to restore, whatever the record says. A commit in neither was
165
+ * never taken in, so `remote.git`'s head has to be part of that test: the clone can lag it for a
166
+ * cycle, and the fast-forward below would then bring back what GitHub dropped.
167
+ */
168
+ async function rewrittenFrom({ git, metaFile }, tip, head, published) {
169
+ const { lastGitHubTip, historyRewrittenFrom } = metaFile.branch;
170
+ // A branch with no record yet: a `remote.git` head the clone lags, diverged from GitHub, is a
171
+ // commit reconcile took in before a force-push, barring a crash mid-merge on that first cycle.
172
+ const unrecorded = lastGitHubTip === undefined &&
173
+ published !== null &&
174
+ published !== head &&
175
+ (await contains(git, published, head)) &&
176
+ !(await contains(git, published, tip))
177
+ ? published
178
+ : undefined;
179
+ // The marker first: what is named here must be a commit GitHub really held, and a rewrite's
180
+ // stamp may not have reached it yet.
181
+ const known = [historyRewrittenFrom, lastGitHubTip ?? unrecorded].filter((sha) => sha !== undefined && COMMIT_ID.test(sha));
182
+ if (known.length === 0)
183
+ return null;
184
+ if ((await contains(git, tip, head)) &&
185
+ (published === null || (await contains(git, tip, published)))) {
186
+ return null;
187
+ }
188
+ for (const sha of known)
189
+ if (await contains(git, tip, sha))
190
+ return null;
191
+ // Git says a commit is missing when a pack it lives in can't be read, so only a full read of the
192
+ // tip's history makes "not contained" an answer.
193
+ const walked = await git.raw(['rev-list', '--count', '--end-of-options', tip]);
194
+ if (walked.trim() === '')
195
+ throw new Error(`No answer reading the history of ${tip}`);
196
+ return known[0];
197
+ }
198
+ /** Record the rewrite, which refuses pushes (task-runner.ts) for as long as it stands. */
199
+ async function blockAsRewritten(ctx, { branchPath, branchRef, metaFile }, lastSeen) {
200
+ if (metaFile.branch.rewrittenOnGitHub !== lastSeen) {
201
+ await getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath).saveIf({ branch: { rewrittenOnGitHub: lastSeen } }, (current) => current?.branch.name === branchRef);
202
+ }
203
+ return {
204
+ kind: 'blocked',
205
+ reason: `GitHub's ${branchRef} no longer contains ${lastSeen}, the last commit this deployment saw ` +
206
+ `there: it was force-pushed or rewritten. Nothing was merged or pushed, so the rewrite ` +
207
+ `stands, and this branch's changes cannot reach GitHub. To continue, a developer merges ` +
208
+ `that commit back into the branch on GitHub.`,
209
+ };
210
+ }
211
+ async function clearRewritten(ctx, { branchPath, branchRef }) {
212
+ await getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath).update((current) => current?.branch.name === branchRef ? { branch: rewriteCleared(current.branch) } : null);
213
+ }
214
+ /** A short, one-line list of paths from a commit, safe in a log line and on branch metadata. */
215
+ function listPaths(paths) {
216
+ return paths
217
+ .slice(0, MAX_REPORTED_PATHS)
218
+ .map((file) => escapeForLog(file).slice(0, MAX_LISTED_PATH_LENGTH))
219
+ .join(', ');
220
+ }
221
+ /**
222
+ * What stops `to` entering this clone, or null. `.git/info/exclude` hides canopycms's own state, and
223
+ * git overwrites an ignored file without asking, so a commit that changes it would replace the live
224
+ * branch.json and comments. A deletion counts: taken in, it would delete the live file, and
225
+ * untracking it first leaves the branch holding a commit that does.
226
+ */
227
+ async function refuseIncoming({ git, branchPath }, from, to) {
228
+ await assertNoIncomingSubmodules(git, branchPath, from, to);
229
+ const fields = (await git.raw([
230
+ 'diff-tree',
231
+ '-r',
232
+ '-z',
233
+ '--name-status',
234
+ '--no-renames',
235
+ '--end-of-options',
236
+ from,
237
+ to,
238
+ ])).split('\0');
239
+ const deleted = [];
240
+ const changed = [];
241
+ for (let i = 0; i + 1 < fields.length; i += 2) {
242
+ const file = fields[i + 1];
243
+ // Nothing reads the retired cache, so its deletion, which an adopter's fix makes, is harmless.
244
+ if (!isCanopyInternalPath(file))
245
+ continue;
246
+ if (fields[i] !== 'D')
247
+ changed.push(file);
248
+ else if (file !== RETIRED_SCHEMA_CACHE_PATH)
249
+ deleted.push(file);
250
+ }
251
+ if (changed.length > 0) {
252
+ return {
253
+ kind: 'blocked',
254
+ reason: `Commits coming into this branch change canopycms's own state, which must not be committed ` +
255
+ `(${listPaths(changed)}). Nothing was brought in. To fix, ${TRACKED_CANOPY_STATE_FIX}.`,
256
+ };
257
+ }
258
+ if (deleted.length > 0) {
259
+ return {
260
+ kind: 'blocked',
261
+ reason: `Commits coming into this branch stop tracking canopycms state this clone still tracks ` +
262
+ `(${listPaths(deleted)}). Nothing was brought in; the clone needs manual repair.`,
263
+ };
264
+ }
265
+ return null;
266
+ }
267
+ /**
268
+ * Move the clone to `to`, or say why it did not. Saves that touch a file the commits change make
269
+ * git refuse before it writes anything, and those commits wait for the submit that commits them.
270
+ * Any other failure throws: the message is git's, in the C locale `sharedRepoGit` pins.
271
+ */
272
+ async function fastForward(ctx, args, from, to) {
273
+ const { git, branchPath, branchDir, lockLost } = args;
274
+ const refused = await refuseIncoming(args, from, to);
275
+ if (refused)
276
+ return refused;
277
+ if (lockLost())
278
+ return { kind: 'wait' };
279
+ if (await changesWhatWasReviewed(ctx, args, from, to))
280
+ await returnToReview(ctx, args);
281
+ try {
282
+ await moveClone(git, branchPath, to);
283
+ }
284
+ catch (err) {
285
+ if (!/would be overwritten/.test(getErrorMessage(err)))
286
+ throw err;
287
+ workerLog(` ${branchDir}: local changes overlap commits from GitHub, which wait for a submit`);
288
+ return { kind: 'wait' };
289
+ }
290
+ workerLog(` ${branchDir}: brought in commits from GitHub (now at ${to.slice(0, 7)})`);
291
+ return null;
292
+ }
293
+ /**
294
+ * Fast-forward the clone to `to`. The caches' markers move after it, as for any bulk mutation, and
295
+ * before it too: nothing repairs a marker lost after the move, as the next cycle sees a clone in
296
+ * step, and the earlier bump leaves only a read racing the move able to stamp the old tree.
297
+ */
298
+ async function moveClone(git, branchPath, to) {
299
+ await invalidateBranchContentCaches(branchPath);
300
+ await git.raw(['merge', '--ff-only', '--end-of-options', to]);
301
+ await invalidateBranchContentCaches(branchPath);
302
+ }
303
+ /**
304
+ * `other` merged into the branch: built, published to `remote.git`, queued for GitHub, and the
305
+ * clone moved to it, in that order, so each step leaves what the next cycle picks up and only the
306
+ * last touches the working tree. `fromGitHub` says `other` is GitHub's tip rather than the base.
307
+ * `stopOnConflict` leaves a branch in review as it is: nobody is at the editor to see what a kept
308
+ * version replaced, and GitHub's own conflict notice stands on the pull request.
309
+ */
310
+ export async function mergeIntoBranch(ctx, args, merge) {
311
+ const { git, branchPath, branchDir, branchRef, gatewayPolicy, lockLost } = args;
312
+ const { head, other, message, fromGitHub, stopOnConflict } = merge;
313
+ const built = await buildMergeCommit(git, branchPath, {
314
+ head,
315
+ other,
316
+ message,
317
+ timeoutMs: ctx.taskTimeoutMs,
318
+ });
319
+ if (stopOnConflict && (built.kind === 'unsettled' || built.conflicted.length > 0)) {
320
+ workerLog(` ${branchDir}: conflicts with ${other.slice(0, 7)}, so it stays as it is`);
321
+ return { kind: 'wait' };
322
+ }
323
+ if (built.kind === 'unsettled') {
324
+ return {
325
+ kind: 'blocked',
326
+ reason: `Git cannot merge ${other} into ${branchRef} by rule: ${built.conflicts.join(', ')}` +
327
+ `${built.paths.length > 0 ? ` (${listPaths(built.paths)})` : ''}. Nothing was changed. ` +
328
+ `To continue, a developer merges them by hand.`,
329
+ };
330
+ }
331
+ const { sha, conflicted } = built;
332
+ const refused = await refuseIncoming(args, head, sha);
333
+ if (refused)
334
+ return refused;
335
+ if (lockLost())
336
+ return { kind: 'wait' };
337
+ if (fromGitHub && (await changesWhatWasReviewed(ctx, args, head, sha))) {
338
+ await returnToReview(ctx, args);
339
+ }
340
+ const published = await readPublishedSha(ctx, branchRef);
341
+ const unpublishable = `This deployment's repository holds commits for ${branchRef} that neither this clone nor ` +
342
+ `GitHub has, so a merge of the two cannot be published. Nothing was changed. Compare the ` +
343
+ `copies with a developer before submitting again.`;
344
+ if (published !== null && !(await contains(git, sha, published))) {
345
+ return { kind: 'blocked', reason: unpublishable };
346
+ }
347
+ // Recorded before the merge is visible anywhere: the next cycle only fast-forwards to it, and
348
+ // the conflicts cannot be read back from the merge.
349
+ if (conflicted.length > 0)
350
+ await recordKeptVersions(ctx, args, conflicted, fromGitHub);
351
+ // Before `remote.git` holds it, as reconcile does: a crash past this point leaves GitHub's commit
352
+ // in `remote.git`, and a removal then has to read as one (`rewrittenFrom`).
353
+ if (fromGitHub)
354
+ await recordSeenOnGitHub(ctx, branchRef, other);
355
+ if (published !== null) {
356
+ if (!(await publishPlainToLocalRemote(ctx, branchPath, branchRef, sha))) {
357
+ return { kind: 'blocked', reason: unpublishable };
358
+ }
359
+ await enqueueGitHubPush(ctx, branchRef, gatewayPolicy);
360
+ }
361
+ if (conflicted.length > 0 && fromGitHub) {
362
+ await enqueueReviewBlockSync(ctx, branchRef, args.metaFile);
363
+ }
364
+ await moveClone(git, branchPath, sha);
365
+ if (fromGitHub)
366
+ await recordGitHubTip(ctx, args, other);
367
+ workerLog(conflicted.length > 0
368
+ ? ` ${branchDir}: merged ${other.slice(0, 7)} (kept this branch's version of ${conflicted.length} file(s))`
369
+ : ` ${branchDir}: merged ${other.slice(0, 7)}`);
370
+ return { kind: 'merged' };
371
+ }
372
+ /**
373
+ * Whether this clone holds `sha`, on git's word for it. A killed `git` answers nothing at all,
374
+ * which simple-git resolves empty, and that throws. Git says the same of a commit in a pack it
375
+ * can't read, so a caller that acts on "no" reads the history too, as `rewrittenFrom` does.
376
+ */
377
+ async function holdsCommit(git, sha) {
378
+ let found;
379
+ try {
380
+ found = await git.raw(['rev-parse', '--verify', '--end-of-options', `${sha}^{commit}`]);
381
+ }
382
+ catch (err) {
383
+ if (/Needed a single revision/.test(getErrorMessage(err)))
384
+ return false;
385
+ throw err;
386
+ }
387
+ if (found.trim() === '')
388
+ throw new Error(`No answer looking for ${sha}`);
389
+ return true;
390
+ }
391
+ /**
392
+ * Whether `commit` contains `ancestor`. A commit this clone does not hold counts as no. Any other
393
+ * failure throws, since it says nothing about either commit, and the caller's catch retries next
394
+ * cycle: read as no, it would report a rewrite that never happened.
395
+ */
396
+ async function contains(git, commit, ancestor) {
397
+ if (!(await holdsCommit(git, commit)) || !(await holdsCommit(git, ancestor)))
398
+ return false;
399
+ return (await commitsApart(git, ancestor, commit)).onlyA === 0;
400
+ }
401
+ /**
402
+ * Whether moving an approved branch from `from` to `to` changes what was approved: yes unless `to`
403
+ * is exactly `from` with the base up to where `to` has it, which leaves the pull request's diff as
404
+ * it was reviewed. A hand-made change inside a merge commit therefore counts. Read fresh, so an
405
+ * approval given since the cycle's start counts too.
406
+ */
407
+ async function changesWhatWasReviewed(ctx, { git, branchPath }, from, to) {
408
+ const fresh = (await BranchMetadataFileManager.loadOnly(branchPath))?.branch;
409
+ if (fresh?.status !== 'approved')
410
+ return false;
411
+ await fetchFromRemoteGit(git, ctx.remoteGitPath, fresh.baseBranch ?? ctx.baseBranch);
412
+ const base = (await git.revparse(['FETCH_HEAD'])).trim();
413
+ const forkPoint = (await git.raw(['merge-base', '--end-of-options', to, base])).trim();
414
+ return forkPoint === '' || !(await isCleanMergeOf(git, to, from, forkPoint));
415
+ }
416
+ /**
417
+ * Send an approved branch back to review and clear the verdict from the PR body. Before the clone
418
+ * moves, and a failed save throws, so a crash or a failure leaves the commits for the next cycle
419
+ * to judge again rather than an approval standing over them.
420
+ */
421
+ export async function returnToReview(ctx, { branchPath, branchDir, branchRef, metaFile }) {
422
+ const saved = await getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath).saveIf({ branch: { status: 'submitted', review: undefined } }, (current) => current?.branch.name === branchRef && current.branch.status === 'approved');
423
+ if (saved === null)
424
+ return;
425
+ workerLog(` ${branchDir}: back in review, as commits from GitHub changed what was approved`);
426
+ await enqueueReviewBlockSync(ctx, branchRef, metaFile);
427
+ }
428
+ /** Record what a merge kept, for the editor and, for GitHub's commits, the PR body. */
429
+ async function recordKeptVersions(ctx, { branchPath, branchRef }, conflicted, fromGitHub) {
430
+ const ids = conflictFilesToContentIds(conflicted, ctx.contentRoot);
431
+ await getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath).update((existing) => existing?.branch.name === branchRef
432
+ ? {
433
+ branch: {
434
+ ...recordConflicts(existing.branch.conflictFiles, ids),
435
+ ...(fromGitHub
436
+ ? {
437
+ keptOverGitHub: [
438
+ ...new Set([...(existing.branch.keptOverGitHub ?? []), ...conflicted]),
439
+ ],
440
+ }
441
+ : {}),
442
+ },
443
+ }
444
+ : null);
445
+ }
446
+ /**
447
+ * Re-render the PR body's review block alone: the worker has no `config.editorUrl`, so a full
448
+ * sync from here would strip the comment summary's links. Best-effort: a failure leaves the block
449
+ * until the branch's next sync.
450
+ */
451
+ export async function enqueueReviewBlockSync(ctx, branchRef, { branch }) {
452
+ if (branch.pullRequestNumber === undefined)
453
+ return;
454
+ try {
455
+ await enqueueTask(ctx.taskDir, {
456
+ action: SYNC_PR_COMMENTS_ACTION,
457
+ payload: {
458
+ branch: branchRef,
459
+ pullRequestNumber: branch.pullRequestNumber,
460
+ reviewBlockOnly: true,
461
+ },
462
+ });
463
+ }
464
+ catch (err) {
465
+ workerLogWarn(` Failed to queue a PR body update for ${branchRef}: ${getErrorMessage(err)}`);
466
+ }
467
+ }
468
+ /**
469
+ * Skipped when nothing changes: every save regenerates the branch registry. Bookkeeping, so it
470
+ * leaves `updatedAt`, which the card shows as an edit.
471
+ */
472
+ async function recordGitHubTip(ctx, { branchPath, branchRef, metaFile, gitHub }, tip) {
473
+ const { lastGitHubTip, rewrittenOnGitHub } = metaFile.branch;
474
+ if (gitHub?.tips.get(branchRef) !== tip)
475
+ return;
476
+ if (lastGitHubTip === tip && rewrittenOnGitHub === undefined)
477
+ return;
478
+ await getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath).update((current) => {
479
+ if (current?.branch.name !== branchRef)
480
+ return null;
481
+ // A stamp newer than this cycle's fetch is a push that landed since, which GitHub holds.
482
+ const seen = current.branch.lastGitHubTip === lastGitHubTip ? { lastGitHubTip: tip } : {};
483
+ return { branch: { ...seen, ...rewriteCleared(current.branch) } };
484
+ }, { keepUpdatedAt: true });
485
+ }