canopycms 0.8.0 → 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 (86) hide show
  1. package/CHANGELOG.md +112 -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/branch-schema-cache.d.ts +22 -2
  20. package/dist/branch-schema-cache.js +88 -12
  21. package/dist/cli/cli.js +91 -18
  22. package/dist/cli/generate-ai-content.js +72 -11
  23. package/dist/cli/init.js +2 -2
  24. package/dist/content-reader.d.ts +24 -10
  25. package/dist/content-reader.js +43 -6
  26. package/dist/context.js +43 -41
  27. package/dist/editor/BranchManager.d.ts +8 -4
  28. package/dist/editor/BranchManager.js +43 -18
  29. package/dist/editor/Editor.js +3 -2
  30. package/dist/editor/admin/SystemHealthPanel.js +65 -31
  31. package/dist/editor/admin/UnpublishedAtMergeAlert.d.ts +16 -0
  32. package/dist/editor/admin/UnpublishedAtMergeAlert.js +50 -0
  33. package/dist/editor/admin/useSystemHealth.d.ts +2 -0
  34. package/dist/editor/admin/useSystemHealth.js +21 -0
  35. package/dist/editor/branch-lock.d.ts +2 -0
  36. package/dist/editor/branch-lock.js +5 -3
  37. package/dist/editor/branch-status.d.ts +25 -1
  38. package/dist/editor/branch-status.js +75 -0
  39. package/dist/editor/components/EditorHeader.d.ts +6 -2
  40. package/dist/editor/components/EditorHeader.js +51 -30
  41. package/dist/editor/components/EntryHeader.d.ts +3 -1
  42. package/dist/editor/components/EntryHeader.js +2 -1
  43. package/dist/editor/components/TooltipDetail.d.ts +14 -0
  44. package/dist/editor/components/TooltipDetail.js +18 -0
  45. package/dist/editor/copy.d.ts +7 -0
  46. package/dist/editor/copy.js +9 -0
  47. package/dist/editor/hooks/useBranchManager.d.ts +8 -3
  48. package/dist/editor/hooks/useBranchManager.js +48 -5
  49. package/dist/pr-comments.d.ts +2 -0
  50. package/dist/pr-comments.js +20 -14
  51. package/dist/resource-generation.js +2 -3
  52. package/dist/server.d.ts +6 -1
  53. package/dist/server.js +6 -1
  54. package/dist/submission-attribution.d.ts +1 -1
  55. package/dist/submission-attribution.js +20 -2
  56. package/dist/task-queue/task-queue.d.ts +5 -1
  57. package/dist/task-queue/task-queue.js +4 -2
  58. package/dist/types.d.ts +61 -0
  59. package/dist/version.d.ts +1 -1
  60. package/dist/version.js +1 -1
  61. package/dist/worker/branch-merge.d.ts +68 -0
  62. package/dist/worker/branch-merge.js +228 -0
  63. package/dist/worker/canopy-state.d.ts +2 -0
  64. package/dist/worker/canopy-state.js +1 -1
  65. package/dist/worker/cms-worker.d.ts +1 -0
  66. package/dist/worker/cms-worker.js +5 -3
  67. package/dist/worker/git-sync.js +15 -8
  68. package/dist/worker/github-commits.d.ts +106 -0
  69. package/dist/worker/github-commits.js +485 -0
  70. package/dist/worker/github-gateway-http.js +3 -2
  71. package/dist/worker/github-gateway-wire.d.ts +24 -15
  72. package/dist/worker/github-gateway-wire.js +4 -1
  73. package/dist/worker/github-gateway.d.ts +16 -5
  74. package/dist/worker/github-gateway.js +22 -5
  75. package/dist/worker/github-mirror.d.ts +8 -0
  76. package/dist/worker/github-mirror.js +12 -1
  77. package/dist/worker/github-transfer.d.ts +3 -1
  78. package/dist/worker/github-transfer.js +3 -2
  79. package/dist/worker/history-rewrite.d.ts +13 -1
  80. package/dist/worker/history-rewrite.js +27 -3
  81. package/dist/worker/rebase.d.ts +13 -7
  82. package/dist/worker/rebase.js +351 -95
  83. package/dist/worker/task-runner.d.ts +1 -1
  84. package/dist/worker/task-runner.js +172 -34
  85. package/dist/worker/worker-context.d.ts +2 -0
  86. package/package.json +1 -1
@@ -1,18 +1,18 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { BranchMetadataFileManager, buildMergedBranchUpdate, getBranchMetadataFileManager, } from '../branch-metadata.js';
4
- import { extractIdFromFilename } from '../content-id-index.js';
5
4
  import { invalidateBranchContentCaches } from '../content-index-generation.js';
6
5
  import { isSettingsBranchName, sanitizeBranchName } from '../paths/branch-name.js';
7
- import { normalizeFilesystemPath } from '../paths/normalize.js';
8
- import { ROOT_COLLECTION_ID } from '../paths/types.js';
9
6
  import { tryAcquireContentWriteLock } from '../utils/content-write-lock.js';
10
7
  import { holdProvisionedWorkspace, releaseProvisionedWorkspace, } from './provisioned-workspace.js';
11
8
  import { getErrorMessage, isNodeError, redactCredentials } from '../utils/error.js';
12
9
  import { escapeForLog } from '../utils/escape-text.js';
13
- import { isCanopyInternalPath, isRebaseInProgress } from '../utils/git.js';
10
+ import { CANOPY_META_DIR, isCanopyInternalPath, isRebaseInProgress } from '../utils/git.js';
14
11
  import { TRACKED_CANOPY_STATE_FIX, restoreRetiredSchemaCache, splitByUpstreamTracking, trackedCanopyStateChanges, } from './canopy-state.js';
15
12
  import { enqueueGitHubPush, forcePublishToLocalRemote, markHistoryRewritten, readPublishedSha, reconcilePendingRewrite, } from './history-rewrite.js';
13
+ import { publishesBranch } from './branch-policy.js';
14
+ import { commitsApart, conflictFilesToContentIds, recordConflicts } from './branch-merge.js';
15
+ import { bringInGitHubCommits, mergeIntoBranch, returnToReview, rewriteCleared, } from './github-commits.js';
16
16
  import { workerLog, workerLogWarn } from './log.js';
17
17
  import { SHARED_REPO_STATUS_ARGS, assertNoIncomingSubmodules, assertSharedRepoConfig, fetchFromRemoteGit, sharedRepoGit, } from './shared-repo-git.js';
18
18
  /**
@@ -24,12 +24,12 @@ import { SHARED_REPO_STATUS_ARGS, assertNoIncomingSubmodules, assertSharedRepoCo
24
24
  * markAsMerged API so both paths produce identical archived-branch metadata.
25
25
  * closed-without-merge -> record pullRequestState only; an admin decides the
26
26
  * workflow transition. Best-effort: a failure is logged, swallowed and retried
27
- * next sync cycle.
27
+ * next sync cycle. Resolves GitHub's answer for a pull request not merged.
28
28
  */
29
29
  export async function pollMergeState(ctx, branchDir, branchPath, metaFile) {
30
30
  const prNumber = metaFile?.branch.pullRequestNumber;
31
31
  if (!prNumber)
32
- return;
32
+ return null;
33
33
  // Recorded only on a branch.json that still names the branch this poll read: never a missing
34
34
  // one, nor another branch's that shares the key.
35
35
  const sameBranch = (current) => current?.branch.name === metaFile?.branch.name;
@@ -41,27 +41,31 @@ export async function pollMergeState(ctx, branchDir, branchPath, metaFile) {
41
41
  const meta = getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath);
42
42
  // GitHub's actual merge time when available; buildMergedBranchUpdate
43
43
  // falls back to "now" when merged_at is absent.
44
- const saved = await meta.saveIf({
45
- branch: buildMergedBranchUpdate(data.merged_at ? new Date(data.merged_at) : undefined),
46
- }, sameBranch);
44
+ const saved = await meta.update((current) => current !== null && sameBranch(current)
45
+ ? {
46
+ branch: buildMergedBranchUpdate(current.branch, data.merged_at ? new Date(data.merged_at) : undefined),
47
+ }
48
+ : null);
47
49
  if (saved)
48
50
  workerLog(` PR #${prNumber} for ${branchDir} is merged -> archived`);
49
- return;
51
+ return null;
50
52
  }
51
53
  const newState = data.state === 'closed' ? 'closed' : 'open';
52
54
  // Re-load fresh, not the `metaFile` snapshot passed in: a concurrent Lambda
53
55
  // write (an editor re-submitting) may have landed since it was taken.
54
56
  const currentMeta = await BranchMetadataFileManager.loadOnly(branchPath);
55
57
  if (currentMeta?.branch.pullRequestState === newState)
56
- return;
58
+ return data;
57
59
  const meta = getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath);
58
60
  const saved = await meta.saveIf({ branch: { pullRequestState: newState } }, sameBranch);
59
61
  if (saved)
60
62
  workerLog(` PR #${prNumber} for ${branchDir}: pullRequestState -> ${newState}`);
63
+ return data;
61
64
  }
62
65
  catch (err) {
63
66
  // Non-fatal: transient GitHub/network errors are retried next cycle.
64
67
  workerLogWarn(` Failed to poll PR #${prNumber} for ${branchDir}: ${getErrorMessage(err)}`);
68
+ return null;
65
69
  }
66
70
  finally {
67
71
  clearTimeout(timer);
@@ -114,6 +118,18 @@ async function recordRebaseFailure(ctx, branchPath, branchDir, message, branchRe
114
118
  }
115
119
  /** Safety limit against a rebase that never converges. */
116
120
  const MAX_REBASE_ROUNDS = 50;
121
+ /**
122
+ * Abort a rebase. It moves the tree back under any snapshot a read resolved mid-replay, which the
123
+ * caches' markers must invalidate like any other working-tree move, however the abort ended.
124
+ */
125
+ async function abortRebase(git, branchPath) {
126
+ try {
127
+ await git.rebase(['--abort']);
128
+ }
129
+ finally {
130
+ await invalidateBranchContentCaches(branchPath);
131
+ }
132
+ }
117
133
  /**
118
134
  * Drive `git rebase` to a finished state, resolving each conflict in favour of
119
135
  * the BRANCH's version, and report what happened.
@@ -253,70 +269,6 @@ async function runRebaseRounds(ctx, branchGit, branchDir, fetchedBaseTip, isLock
253
269
  }
254
270
  return { completed, conflictedFiles, failureReason };
255
271
  }
256
- /**
257
- * Map the file paths a rebase resolved --theirs onto the ContentIds the editor
258
- * shows as conflicted, deduplicated. IDs rather than paths because they are
259
- * immutable: a later slug rename must not orphan a conflict marker. Paths that
260
- * carry no recoverable ID are dropped rather than guessed at.
261
- *
262
- * Pure.
263
- */
264
- function conflictFilesToContentIds(conflictedFiles, contentRoot) {
265
- // Entry files carry their ID in the filename. A `.collection.json` has none
266
- // of its own (extractIdFromFilename returns null for dot-prefixed files), so
267
- // the ID comes from the parent directory; the root content directory has no
268
- // embedded ID at all, so ROOT_COLLECTION_ID stands in -- but only for the
269
- // configured contentRoot.
270
- //
271
- // Two different notions of "parent" are needed, and conflating them is the
272
- // bug this guards against (see schema-store.ts's contentRootName doc comment
273
- // for the same shape elsewhere):
274
- // - `parentDir` (a basename) recovers a SUB-collection's own embedded ID,
275
- // correct as a basename since a collection directory carries its ID in its
276
- // own name, one path segment;
277
- // - `parentPath` (the full normalized relative parent path) is what must be
278
- // compared against `contentRoot`, which is documented (config/helpers.ts)
279
- // as allowed to span multiple segments (e.g. "cms/content"). Comparing a
280
- // basename against that full value is always false, silently dropping the
281
- // root collection's conflict. Git reports POSIX paths and the configured
282
- // value may use either separator, so both sides go through
283
- // normalizeFilesystemPath first.
284
- const normalizedContentRoot = normalizeFilesystemPath(contentRoot);
285
- const conflictIds = [...new Set(conflictedFiles)]
286
- .map((f) => {
287
- const fileId = extractIdFromFilename(path.basename(f));
288
- if (fileId)
289
- return fileId;
290
- const parentDir = path.basename(path.dirname(f));
291
- const dirId = extractIdFromFilename(parentDir);
292
- if (dirId)
293
- return dirId;
294
- // Only assign ROOT_COLLECTION_ID when the file's parent directory IS the
295
- // configured content root. Other unrecognized paths are filtered out.
296
- const parentPath = normalizeFilesystemPath(path.dirname(f));
297
- if (path.basename(f) === '.collection.json' && parentPath === normalizedContentRoot) {
298
- return ROOT_COLLECTION_ID;
299
- }
300
- return null;
301
- })
302
- .filter((id) => id !== null);
303
- return [...new Set(conflictIds)];
304
- }
305
- /**
306
- * The conflict record after a rebase: the IDs already recorded plus this
307
- * rebase's. A conflict means this branch's version replaced base's change to
308
- * that entry, which a later clean rebase does not undo, so the record only
309
- * grows until the branch merges (buildMergedBranchUpdate clears it).
310
- *
311
- * Pure.
312
- */
313
- function recordConflicts(recorded, found) {
314
- const conflictFiles = [...new Set([...(recorded ?? []), ...found])];
315
- return {
316
- conflictStatus: conflictFiles.length > 0 ? 'conflicts-detected' : 'clean',
317
- conflictFiles,
318
- };
319
- }
320
272
  /**
321
273
  * [SYNC-H1] Carry a just-rebased history forward into `remote.git`, and queue
322
274
  * the GitHub hop.
@@ -331,6 +283,7 @@ function recordConflicts(recorded, found) {
331
283
  */
332
284
  async function carryForwardRewrittenHistory(ctx, args) {
333
285
  const { branchPath, branchDir, branchRef, publishedSha, preRebaseHead, gatewayPolicy } = args;
286
+ const { rebasedHead } = args;
334
287
  if (publishedSha !== null) {
335
288
  if (publishedSha === preRebaseHead) {
336
289
  // ARMING GUARD: remote.git holds EXACTLY what this clone just rebased
@@ -353,7 +306,7 @@ async function carryForwardRewrittenHistory(ctx, args) {
353
306
  // reconcilePendingRewrite() completes on a later cycle. Pushing first
354
307
  // would leave remote.git rewritten, GitHub stale and nothing recorded --
355
308
  // unrecoverable, and diagnosed as a false "another deployment" collision.
356
- if (!(await markHistoryRewritten(ctx, branchPath, branchRef, publishedSha))) {
309
+ if (!(await markHistoryRewritten(ctx, branchPath, branchRef, publishedSha, rebasedHead))) {
357
310
  workerLogWarn(` ${branchDir}: rebased, but its branch.json is gone or names another branch than ` +
358
311
  `${branchRef}, so the rewrite is not published`);
359
312
  return;
@@ -383,7 +336,7 @@ async function carryForwardRewrittenHistory(ctx, args) {
383
336
  * that branch's cross-host content-write lock and runs a series of git
384
337
  * subprocesses against a shared filesystem.
385
338
  */
386
- export async function runRebaseCycle(ctx, gatewayPolicy) {
339
+ export async function runRebaseCycle(ctx, gatewayPolicy, gitHub) {
387
340
  const rebased = [];
388
341
  const skippedDirty = [];
389
342
  const skippedLocked = [];
@@ -440,7 +393,7 @@ export async function runRebaseCycle(ctx, gatewayPolicy) {
440
393
  workerLogWarn(` Skipping ${branchDir}: settings branch, never a content workspace`);
441
394
  continue;
442
395
  }
443
- const outcome = await holdAndRebaseOneBranch(ctx, branchDir, branchPath, gatewayPolicy);
396
+ const outcome = await holdAndRebaseOneBranch(ctx, branchDir, branchPath, gatewayPolicy, gitHub);
444
397
  if (outcome.kind === 'rebased')
445
398
  rebased.push(branchDir);
446
399
  else if (outcome.kind === 'skippedDirty')
@@ -508,7 +461,7 @@ async function restoreRenamedBranch(ctx, branchDir, branchPath, metaFile, namesp
508
461
  * content-write lock inside it; see `provisioned-workspace.ts` for why that order cannot deadlock.
509
462
  * Never throws, like rebaseOneBranch.
510
463
  */
511
- async function holdAndRebaseOneBranch(ctx, branchDir, branchPath, gatewayPolicy) {
464
+ async function holdAndRebaseOneBranch(ctx, branchDir, branchPath, gatewayPolicy, gitHub) {
512
465
  let hold;
513
466
  try {
514
467
  hold = await holdProvisionedWorkspace(ctx.contentBranchesPath, branchDir);
@@ -528,12 +481,248 @@ async function holdAndRebaseOneBranch(ctx, branchDir, branchPath, gatewayPolicy)
528
481
  if (hold.kind === 'not-provisioned')
529
482
  return { kind: 'none' };
530
483
  try {
531
- return await rebaseOneBranch(ctx, branchDir, branchPath, hold.isCompromised, gatewayPolicy);
484
+ return await rebaseOneBranch(ctx, branchDir, branchPath, hold.isCompromised, gatewayPolicy, gitHub);
532
485
  }
533
486
  finally {
534
487
  await releaseProvisionedWorkspace(hold.release, branchDir);
535
488
  }
536
489
  }
490
+ /** Clear a recorded failure and any recorded rewrite: the caller knows neither stands. */
491
+ async function clearRebaseFailure(ctx, branchPath, branchRef) {
492
+ await getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath).update((current) => current?.branch.name === branchRef
493
+ ? { branch: { rebaseFailure: undefined, ...rewriteCleared(current.branch) } }
494
+ : null);
495
+ }
496
+ /** A branch github-commits.ts left for a person: logged, recorded, and in the cycle's summary. */
497
+ async function recordBlocked(ctx, branchDir, branchPath, branchRef, reason) {
498
+ // [REDACT] failed[] folds into worker-status.json, served to the browser, and these reasons name
499
+ // paths from commits.
500
+ const error = redactCredentials(reason);
501
+ workerLogWarn(` ${branchDir}: ${error}`);
502
+ await recordRebaseFailure(ctx, branchPath, branchDir, error, branchRef);
503
+ return { kind: 'failed', error };
504
+ }
505
+ /** Whether `remote.git` holds commits for `branchRef` that this clone lacks. */
506
+ async function lacksPublishedCommits(ctx, git, branchRef) {
507
+ const published = await readPublishedSha(ctx, branchRef);
508
+ if (published === null)
509
+ return false;
510
+ await fetchFromRemoteGit(git, ctx.remoteGitPath, `refs/heads/${branchRef}`);
511
+ return (await commitsApart(git, 'HEAD', published)).onlyB > 0;
512
+ }
513
+ /**
514
+ * {@link bringInGitHubCommits} for a submitted or approved branch, which skips the rest of the
515
+ * rebase loop. Under the content-write lock, zero-retry, as a save or a submit can hold it. A
516
+ * branch GitHub holds as it is costs no lock. Never throws, so the caller's merge poll still runs.
517
+ */
518
+ async function bringInCommitsForBranchInReview(ctx, branchDir, branchPath, metaFile, isProvisioningLockCompromised, gatewayPolicy, gitHub) {
519
+ try {
520
+ const branchRef = await readCloneRef(branchPath);
521
+ if (branchRef === null)
522
+ return { kind: 'none' };
523
+ const gitHubTip = gitHub?.tips.get(branchRef);
524
+ // No branch on GitHub leaves nothing to bring in, and nothing a past failure can still be about.
525
+ if (gitHubTip === undefined) {
526
+ if (gitHub === undefined)
527
+ return { kind: 'none' };
528
+ const { rebaseFailure, rewrittenOnGitHub } = metaFile.branch;
529
+ if (rebaseFailure !== undefined || rewrittenOnGitHub !== undefined) {
530
+ await clearRebaseFailure(ctx, branchPath, branchRef);
531
+ }
532
+ return { kind: 'none' };
533
+ }
534
+ await assertSharedRepoConfig(branchPath, 'worktree');
535
+ const branchGit = sharedRepoGit(branchPath, 'worktree');
536
+ const head = (await branchGit.revparse(['HEAD'])).trim();
537
+ if (head === gitHubTip &&
538
+ metaFile.branch.lastGitHubTip === gitHubTip &&
539
+ metaFile.branch.rebaseFailure === undefined &&
540
+ metaFile.branch.rewrittenOnGitHub === undefined &&
541
+ (await readPublishedSha(ctx, branchRef)) === gitHubTip) {
542
+ return { kind: 'none' };
543
+ }
544
+ return await withContentLockInReview(branchDir, branchPath, isProvisioningLockCompromised, async (lockLost) => {
545
+ const status = await branchGit.status([...SHARED_REPO_STATUS_ARGS]);
546
+ const commitArgs = {
547
+ git: branchGit,
548
+ branchPath,
549
+ branchDir,
550
+ branchRef,
551
+ metaFile,
552
+ gitHub,
553
+ dirty: status.files.some((f) => !isCanopyInternalPath(f.path)),
554
+ gatewayPolicy,
555
+ lockLost,
556
+ };
557
+ const outcome = await bringInGitHubCommits(ctx, commitArgs);
558
+ if (outcome.kind === 'blocked') {
559
+ // What is on GitHub is not what was approved, and the verdict must not say otherwise.
560
+ await returnToReview(ctx, commitArgs);
561
+ return recordBlocked(ctx, branchDir, branchPath, branchRef, outcome.reason);
562
+ }
563
+ if (outcome.kind === 'in-step' && metaFile.branch.rebaseFailure !== undefined) {
564
+ await clearRebaseFailure(ctx, branchPath, branchRef);
565
+ }
566
+ return { kind: 'none' };
567
+ });
568
+ }
569
+ catch (err) {
570
+ const message = redactCredentials(getErrorMessage(err));
571
+ workerLogWarn(` ${branchDir}: could not bring in commits from GitHub: ${message}`);
572
+ await recordRebaseFailure(ctx, branchPath, branchDir, message);
573
+ return { kind: 'failed', error: message };
574
+ }
575
+ }
576
+ /**
577
+ * What GitHub's answer says keeps this pull request behind its base and needs a person, undefined
578
+ * for nothing, or null for no information: GitHub answers `unknown` until it has computed the
579
+ * state, which it redoes after every push to the base. A base other than the branch's own comes
580
+ * first, because withdrawing resolves nothing there: the worker rebases onto the recorded base.
581
+ * Pure.
582
+ */
583
+ function pullRequestHoldOf(pullRequest, recordedBase) {
584
+ const { state, mergeable_state: mergeState, baseRef } = pullRequest;
585
+ if (state !== 'open')
586
+ return undefined;
587
+ if (mergeState === undefined || mergeState === 'unknown')
588
+ return null;
589
+ if (mergeState !== 'behind' && mergeState !== 'dirty')
590
+ return undefined;
591
+ if (baseRef !== undefined && baseRef !== recordedBase)
592
+ return { kind: 'retargeted', base: baseRef };
593
+ return mergeState === 'dirty' ? { kind: 'conflicts' } : undefined;
594
+ }
595
+ /**
596
+ * Record {@link pullRequestHoldOf} for the branch card, when it changed: every save regenerates the
597
+ * branch registry. It's bookkeeping, so it leaves `updatedAt`, which the card shows as an edit.
598
+ * Best-effort: a failure leaves the card as it was until the next cycle.
599
+ */
600
+ async function recordPullRequestHold(ctx, branchDir, branchPath, metaFile, pullRequest) {
601
+ const { name, baseBranch, pullRequestHold, submittedAt } = metaFile.branch;
602
+ const hold = pullRequestHoldOf(pullRequest, baseBranch ?? ctx.baseBranch);
603
+ if (hold === null || JSON.stringify(hold) === JSON.stringify(pullRequestHold))
604
+ return;
605
+ try {
606
+ await getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath).update(
607
+ // A newer submit's pull request isn't the one measured.
608
+ (current) => current?.branch.name === name && current.branch.submittedAt === submittedAt
609
+ ? { branch: { pullRequestHold: hold } }
610
+ : null, { keepUpdatedAt: true });
611
+ }
612
+ catch (err) {
613
+ workerLogWarn(` ${branchDir}: could not record the PR's state: ${getErrorMessage(err)}`);
614
+ }
615
+ }
616
+ /**
617
+ * Merge the base into a submitted or approved branch whose open pull request GitHub reports
618
+ * `behind`, so it can merge. It keeps the approval and the PR body: the pull request's diff is what
619
+ * was reviewed. Only from a clone, `remote.git` and GitHub all at one commit, since anything else
620
+ * is work still on its way, and only for the base GitHub measured against: a pull request someone
621
+ * retargeted would gain another branch's commits under its approval. A branch with a failed sync
622
+ * is left for its next submit or a retry of the task, as a push here would clear that failure with
623
+ * the PR still stale. A conflict leaves the branch as it is; withdrawing it brings the rebase
624
+ * loop's rule into play, where an editor can see the result. A failure goes in the cycle's summary
625
+ * only: the step before this one clears `rebaseFailure` each cycle, so recording it would reset its
626
+ * age every time.
627
+ * Never throws.
628
+ */
629
+ async function updateBranchInReviewFromBase(ctx, branchDir, branchPath, pullRequest, isProvisioningLockCompromised, gatewayPolicy, gitHub) {
630
+ try {
631
+ const branchRef = await readCloneRef(branchPath);
632
+ const gitHubTip = branchRef === null ? undefined : gitHub?.tips.get(branchRef);
633
+ if (branchRef === null || gitHubTip === undefined)
634
+ return { kind: 'none' };
635
+ if (ctx.ensureStatusReport().baseHold)
636
+ return { kind: 'none' };
637
+ if (gatewayPolicy && !publishesBranch(gatewayPolicy, branchRef))
638
+ return { kind: 'none' };
639
+ await assertSharedRepoConfig(branchPath, 'worktree');
640
+ const branchGit = sharedRepoGit(branchPath, 'worktree');
641
+ return await withContentLockInReview(branchDir, branchPath, isProvisioningLockCompromised, async (lockLost) => {
642
+ // Read fresh: the poll above can have archived it, and a person withdrawn it.
643
+ const metaFile = await BranchMetadataFileManager.loadOnly(branchPath);
644
+ const { status: state, name, baseBranch, syncStatus } = metaFile?.branch ?? {};
645
+ if (metaFile === null || name !== branchRef)
646
+ return { kind: 'none' };
647
+ if (state !== 'submitted' && state !== 'approved')
648
+ return { kind: 'none' };
649
+ if (syncStatus === 'sync-failed')
650
+ return { kind: 'none' };
651
+ const base = baseBranch ?? ctx.baseBranch;
652
+ if (pullRequest.baseRef !== base)
653
+ return { kind: 'none' };
654
+ const head = (await branchGit.revparse(['HEAD'])).trim();
655
+ if (head !== gitHubTip || (await readPublishedSha(ctx, branchRef)) !== head) {
656
+ return { kind: 'none' };
657
+ }
658
+ const status = await branchGit.status([...SHARED_REPO_STATUS_ARGS]);
659
+ if (status.files.some((f) => !isCanopyInternalPath(f.path)))
660
+ return { kind: 'none' };
661
+ await fetchFromRemoteGit(branchGit, ctx.remoteGitPath, base);
662
+ const baseTip = (await branchGit.revparse(['FETCH_HEAD'])).trim();
663
+ if ((await commitsApart(branchGit, head, baseTip)).onlyB === 0)
664
+ return { kind: 'none' };
665
+ const commitArgs = {
666
+ git: branchGit,
667
+ branchPath,
668
+ branchDir,
669
+ branchRef,
670
+ metaFile,
671
+ gitHub,
672
+ dirty: false,
673
+ gatewayPolicy,
674
+ lockLost,
675
+ };
676
+ const outcome = await mergeIntoBranch(ctx, commitArgs, {
677
+ head,
678
+ other: baseTip,
679
+ message: `Merge ${base} into ${branchRef}`,
680
+ fromGitHub: false,
681
+ stopOnConflict: true,
682
+ });
683
+ if (outcome.kind === 'blocked') {
684
+ const error = redactCredentials(outcome.reason);
685
+ workerLogWarn(` ${branchDir}: ${error}`);
686
+ return { kind: 'failed', error };
687
+ }
688
+ return outcome.kind === 'merged' ? { kind: 'rebased' } : { kind: 'none' };
689
+ });
690
+ }
691
+ catch (err) {
692
+ const message = redactCredentials(getErrorMessage(err));
693
+ workerLogWarn(` ${branchDir}: could not update it from the base: ${message}`);
694
+ return { kind: 'failed', error: message };
695
+ }
696
+ }
697
+ /**
698
+ * Run `work` under a branch's content-write lock, taken zero-retry, as a save or a submit can hold
699
+ * it: busy is `skippedLocked`, retried next cycle. `work` gets whether either lock was lost.
700
+ */
701
+ async function withContentLockInReview(branchDir, branchPath, isProvisioningLockCompromised, work) {
702
+ let contentLockCompromised = false;
703
+ let releaseContentLock;
704
+ try {
705
+ releaseContentLock = await tryAcquireContentWriteLock(branchPath, (lockErr) => {
706
+ contentLockCompromised = true;
707
+ workerLogWarn(` Content-write lock lost for ${branchDir}: ${getErrorMessage(lockErr)}`);
708
+ });
709
+ }
710
+ catch (lockErr) {
711
+ if (isNodeError(lockErr) && lockErr.code === 'ELOCKED') {
712
+ workerLog(` Skipping ${branchDir}: content write in progress (retrying next cycle)`);
713
+ return { kind: 'skippedLocked' };
714
+ }
715
+ throw lockErr;
716
+ }
717
+ try {
718
+ return await work(() => contentLockCompromised || isProvisioningLockCompromised());
719
+ }
720
+ finally {
721
+ await releaseContentLock().catch((releaseErr) => {
722
+ workerLogWarn(` Failed to release content-write lock for ${branchDir}: ${getErrorMessage(releaseErr)}`);
723
+ });
724
+ }
725
+ }
537
726
  /**
538
727
  * Rebase ONE branch workspace onto the base branch, keeping the branch's
539
728
  * version of every conflicting file.
@@ -546,7 +735,7 @@ async function holdAndRebaseOneBranch(ctx, branchDir, branchPath, gatewayPolicy)
546
735
  * returned as `{ kind: 'failed' }`, so one unreadable branch cannot halt the
547
736
  * rest of the cycle ([SYNC-M2]).
548
737
  */
549
- async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCompromised, gatewayPolicy) {
738
+ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCompromised, gatewayPolicy, gitHub) {
550
739
  // Read only by the catch below. See the `rebased` rider on
551
740
  // BranchRebaseOutcome.
552
741
  let didRebase = false;
@@ -559,10 +748,21 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
559
748
  // PR -- but do poll GitHub for the PR's resolution, since nothing else
560
749
  // tells the worker a merge/close happened;
561
750
  // - archived: already merged, with no PR left to poll.
562
- if (branchStatus === 'submitted' || branchStatus === 'approved') {
751
+ if (metaFile !== null && (branchStatus === 'submitted' || branchStatus === 'approved')) {
563
752
  workerLog(` Skipping ${branchDir} (${branchStatus})`);
564
- await pollMergeState(ctx, branchDir, branchPath, metaFile);
565
- return { kind: 'none' };
753
+ const outcome = await bringInCommitsForBranchInReview(ctx, branchDir, branchPath, metaFile, isProvisioningLockCompromised, gatewayPolicy, gitHub);
754
+ const pullRequest = await pollMergeState(ctx, branchDir, branchPath, metaFile);
755
+ if (pullRequest !== null) {
756
+ await recordPullRequestHold(ctx, branchDir, branchPath, metaFile, pullRequest);
757
+ }
758
+ // GitHub reports `behind` only where up-to-date branches are required, so a repository
759
+ // that doesn't require them gets no commits or CI runs from this.
760
+ if (outcome.kind === 'none' &&
761
+ pullRequest?.state === 'open' &&
762
+ pullRequest.mergeable_state === 'behind') {
763
+ return updateBranchInReviewFromBase(ctx, branchDir, branchPath, pullRequest, isProvisioningLockCompromised, gatewayPolicy, gitHub);
764
+ }
765
+ return outcome;
566
766
  }
567
767
  if (branchStatus === 'archived') {
568
768
  workerLog(` Skipping ${branchDir} (${branchStatus})`);
@@ -667,7 +867,7 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
667
867
  }
668
868
  workerLogWarn(` ${branchDir}: found an interrupted rebase (this worker's own abandoned work) -- aborting it to recover the branch`);
669
869
  try {
670
- await branchGit.rebase(['--abort']);
870
+ await abortRebase(branchGit, branchPath);
671
871
  }
672
872
  catch (abortErr) {
673
873
  // Leave it for the next cycle rather than pressing on: every step
@@ -692,6 +892,38 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
692
892
  dirtyCheck = await branchGit.status([...SHARED_REPO_STATUS_ARGS]);
693
893
  }
694
894
  const editorDirt = dirtyCheck.files.filter((f) => !isCanopyInternalPath(f.path));
895
+ // The clone's own ref name: branchDir is the sanitized DIRECTORY name and
896
+ // need not match it. A literal 'HEAD' means a detached clone (a crashed
897
+ // rebase can leave one), where nothing below can safely name a ref, so
898
+ // every publish path stays disarmed -- the safe direction.
899
+ const branchRef = (await branchGit.revparse(['--abbrev-ref', 'HEAD'])).trim();
900
+ const canPublish = branchRef.length > 0 && branchRef !== 'HEAD';
901
+ const commitArgs = canPublish && metaFile !== null
902
+ ? {
903
+ git: branchGit,
904
+ branchPath,
905
+ branchDir,
906
+ branchRef,
907
+ metaFile,
908
+ gitHub,
909
+ dirty: editorDirt.length > 0,
910
+ gatewayPolicy,
911
+ lockLost: lockCompromised,
912
+ }
913
+ : null;
914
+ // Before the dirty return: a fast-forward passes saves that touch other files.
915
+ let unpushed = false;
916
+ if (commitArgs !== null) {
917
+ const outcome = await bringInGitHubCommits(ctx, commitArgs);
918
+ if (outcome.kind === 'blocked') {
919
+ return recordBlocked(ctx, branchDir, branchPath, branchRef, outcome.reason);
920
+ }
921
+ if (outcome.kind === 'unpushed')
922
+ unpushed = true;
923
+ else if (outcome.kind !== 'in-step') {
924
+ return editorDirt.length > 0 ? { kind: 'skippedDirty' } : { kind: 'none' };
925
+ }
926
+ }
695
927
  if (editorDirt.length > 0) {
696
928
  workerLog(` Skipping ${branchDir}: has uncommitted changes`);
697
929
  return { kind: 'skippedDirty' };
@@ -716,12 +948,6 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
716
948
  await recordRebaseFailure(ctx, branchPath, branchDir, reason);
717
949
  return { kind: 'failed', error: reason };
718
950
  }
719
- // The clone's own ref name: branchDir is the sanitized DIRECTORY name and
720
- // need not match it. A literal 'HEAD' means a detached clone (a crashed
721
- // rebase can leave one), where nothing below can safely name a ref, so
722
- // every publish path stays disarmed -- the safe direction.
723
- const branchRef = (await branchGit.revparse(['--abbrev-ref', 'HEAD'])).trim();
724
- const canPublish = branchRef.length > 0 && branchRef !== 'HEAD';
725
951
  // [SYNC-H1] Self-heal: finish an interrupted publish even when this
726
952
  // branch is not behind base. Every crash window in the arming sequence
727
953
  // below leaves the marker set with the work unfinished, and this is what
@@ -762,15 +988,44 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
762
988
  // base change this branch's version replaced, which stays true until
763
989
  // the branch merges (see recordConflicts). A lingering rebaseFailure
764
990
  // is cleared, or it sticks as a stale warning forever: nothing else
765
- // touches a caught-up branch, so no other save site clears it.
991
+ // touches a caught-up branch, so no other save site clears it. One
992
+ // whose cause stands stays: remote.git still holds a commit this clone
993
+ // lacks, and the next submit is refused for it.
766
994
  const currentMeta = await BranchMetadataFileManager.loadOnly(branchPath);
767
995
  if (currentMeta?.branch.rebaseFailure === undefined) {
768
996
  return { kind: 'none' };
769
997
  }
998
+ if (canPublish && (await lacksPublishedCommits(ctx, branchGit, branchRef))) {
999
+ return { kind: 'none' };
1000
+ }
770
1001
  const storedName = currentMeta.branch.name;
771
1002
  await meta.saveIf({ branch: { rebaseFailure: undefined } }, (existing) => existing?.branch.name === storedName);
772
1003
  return { kind: 'none' };
773
1004
  }
1005
+ // `git rebase` drops merge commits and replays what they merged, so whatever one settled
1006
+ // (a kept version, a maintainer's conflict resolution) would be undone, then force-published.
1007
+ // A branch holding one merges the base instead. So does one whose commits touch canopycms
1008
+ // state: a replay writes their bytes over the live files, and a pair that adds then removes
1009
+ // a file deletes it.
1010
+ const aboveBase = `${fetchedBaseTip}..HEAD`;
1011
+ const countAboveBase = async (args) => (await branchGit.raw(['rev-list', '--count', ...args])).trim();
1012
+ const cannotReplay = (await countAboveBase(['--merges', '--end-of-options', aboveBase])) !== '0' ||
1013
+ (await countAboveBase(['--end-of-options', aboveBase, '--', CANOPY_META_DIR])) !== '0';
1014
+ if (commitArgs !== null && (cannotReplay || unpushed)) {
1015
+ const outcome = await mergeIntoBranch(ctx, commitArgs, {
1016
+ head: (await branchGit.revparse(['HEAD'])).trim(),
1017
+ other: fetchedBaseTip,
1018
+ message: `Merge ${ctx.baseBranch} into ${branchRef}`,
1019
+ fromGitHub: false,
1020
+ });
1021
+ if (outcome.kind === 'blocked') {
1022
+ return recordBlocked(ctx, branchDir, branchPath, branchRef, outcome.reason);
1023
+ }
1024
+ if (outcome.kind !== 'merged')
1025
+ return { kind: 'none' };
1026
+ await clearRebaseFailure(ctx, branchPath, branchRef);
1027
+ return { kind: 'rebased' };
1028
+ }
774
1029
  workerLog(`Rebasing ${branchDir} (${behindCount} commits behind)...`);
775
1030
  // Read BOTH sides before rewriting anything: the arming guard after the
776
1031
  // rebase compares what remote.git published against what this clone is
@@ -807,7 +1062,7 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
807
1062
  if (lockCompromised() && !completed) {
808
1063
  workerLogWarn(` Skipping ${branchDir}: a lock it held was compromised mid-rebase (retrying next cycle)`);
809
1064
  // No-op when no rebase is in progress; failure is expected there.
810
- await branchGit.rebase(['--abort']).catch(() => { });
1065
+ await abortRebase(branchGit, branchPath).catch(() => { });
811
1066
  return { kind: 'skippedLocked' };
812
1067
  }
813
1068
  if (lockCompromised()) {
@@ -820,7 +1075,7 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
820
1075
  workerLogWarn(failureReason !== undefined
821
1076
  ? ` Rebase of ${branchDir} aborted due to unexpected error: ${failureReason}`
822
1077
  : ` Rebase of ${branchDir} did not complete within ${MAX_REBASE_ROUNDS} rounds, aborting`);
823
- await branchGit.rebase(['--abort']).catch(() => { });
1078
+ await abortRebase(branchGit, branchPath).catch(() => { });
824
1079
  const rebaseFailureMessage = failureReason ?? `did not complete within ${MAX_REBASE_ROUNDS} rounds`;
825
1080
  // [REDACT] failed[] folds into worker-status.json's lastGitSync.failed,
826
1081
  // served to the browser -- failureReason can be an arbitrary git error
@@ -862,6 +1117,7 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
862
1117
  branchRef,
863
1118
  publishedSha,
864
1119
  preRebaseHead,
1120
+ rebasedHead: (await branchGit.revparse(['HEAD'])).trim(),
865
1121
  gatewayPolicy,
866
1122
  });
867
1123
  return { kind: 'rebased' };
@@ -885,7 +1141,7 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
885
1141
  try {
886
1142
  if (await isRebaseInProgress(branchPath)) {
887
1143
  workerLogWarn(` ${branchDir}: rebase still in progress on exit -- aborting so the clone is not left wedged`);
888
- await branchGit.rebase(['--abort']);
1144
+ await abortRebase(branchGit, branchPath);
889
1145
  }
890
1146
  }
891
1147
  catch (abortErr) {
@@ -15,7 +15,7 @@ import type { WorkerContext } from './worker-context.js';
15
15
  * here that reads branch metadata written by the rebase loop MUST re-read it
16
16
  * rather than trust a snapshot.
17
17
  */
18
- export type TaskRunnerContext = Pick<WorkerContext, 'baseBranch' | 'sanitizedBaseBranch' | 'taskDir' | 'remoteGitPath' | 'contentBranchesPath' | 'taskTimeoutMs' | 'maxTasksPerCycle' | 'maxRetries' | 'log' | 'github' | 'branchWorkspacePath' | 'executeTask' | 'pushBranchToGitHub' | 'isRunning' | 'isDraining' | 'shutdownSignal' | 'ensureStatusReport'>;
18
+ export type TaskRunnerContext = Pick<WorkerContext, 'baseBranch' | 'sanitizedBaseBranch' | 'taskDir' | 'remoteGitPath' | 'contentBranchesPath' | 'taskTimeoutMs' | 'gitSyncIntervalMs' | 'maxTasksPerCycle' | 'maxRetries' | 'log' | 'github' | 'branchWorkspacePath' | 'executeTask' | 'pushBranchToGitHub' | 'isRunning' | 'isDraining' | 'shutdownSignal' | 'ensureStatusReport'>;
19
19
  /**
20
20
  * An error inherent to the task itself (malformed payload, unknown action):
21
21
  * retrying can never succeed, so the task should fail fast instead of