canopycms 0.7.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/CHANGELOG.md +187 -47
  2. package/dist/api/admin-branch-health.js +47 -20
  3. package/dist/api/admin.d.ts +13 -1
  4. package/dist/api/admin.js +14 -24
  5. package/dist/api/branch-changes.d.ts +87 -0
  6. package/dist/api/branch-changes.js +269 -0
  7. package/dist/api/branch-entry-changes.d.ts +83 -0
  8. package/dist/api/branch-entry-changes.js +262 -0
  9. package/dist/api/branch-merge.js +2 -2
  10. package/dist/api/branch-review.js +35 -4
  11. package/dist/api/branch-status.js +14 -5
  12. package/dist/api/branch-withdraw.js +7 -0
  13. package/dist/api/branch.d.ts +13 -1
  14. package/dist/api/branch.js +171 -97
  15. package/dist/api/client.d.ts +15 -0
  16. package/dist/api/client.js +13 -1
  17. package/dist/api/content.d.ts +18 -18
  18. package/dist/api/content.js +1 -1
  19. package/dist/api/github-sync.js +7 -3
  20. package/dist/api/groups.d.ts +4 -4
  21. package/dist/api/pr-comment-sync.d.ts +9 -8
  22. package/dist/api/pr-comment-sync.js +23 -13
  23. package/dist/api/routes.js +4 -0
  24. package/dist/api/types.d.ts +3 -1
  25. package/dist/authorization/groups/schema.d.ts +4 -4
  26. package/dist/authorization/protected-branch.d.ts +3 -3
  27. package/dist/authorization/protected-branch.js +3 -3
  28. package/dist/branch-metadata-file.d.ts +84 -0
  29. package/dist/branch-metadata-file.js +9 -0
  30. package/dist/branch-metadata.d.ts +4 -4
  31. package/dist/branch-metadata.js +4 -5
  32. package/dist/branch-param.d.ts +24 -0
  33. package/dist/branch-param.js +87 -0
  34. package/dist/branch-provisioning.d.ts +6 -3
  35. package/dist/branch-provisioning.js +12 -10
  36. package/dist/branch-registry.d.ts +6 -0
  37. package/dist/branch-registry.js +12 -3
  38. package/dist/branch-workspace.js +22 -15
  39. package/dist/cli/cli.js +1060 -172
  40. package/dist/cli/generate-ai-content.js +732 -101
  41. package/dist/cli/init.js +126 -0
  42. package/dist/comment-store.d.ts +3 -0
  43. package/dist/comment-store.js +4 -1
  44. package/dist/config/schemas/config.d.ts +3 -0
  45. package/dist/config/schemas/config.js +2 -0
  46. package/dist/config/types.d.ts +11 -0
  47. package/dist/content-id-index.d.ts +5 -0
  48. package/dist/content-id-index.js +1 -1
  49. package/dist/content-listing.d.ts +53 -0
  50. package/dist/content-listing.js +123 -27
  51. package/dist/content-reader.js +23 -16
  52. package/dist/context.js +8 -7
  53. package/dist/editor/BranchManager.d.ts +7 -3
  54. package/dist/editor/BranchManager.js +51 -6
  55. package/dist/editor/Editor.js +166 -34
  56. package/dist/editor/EntryNavigator.d.ts +34 -10
  57. package/dist/editor/EntryNavigator.js +185 -29
  58. package/dist/editor/admin/SystemHealthPanel.js +36 -1
  59. package/dist/editor/admin/useSystemHealth.js +1 -0
  60. package/dist/editor/branch-names.d.ts +43 -0
  61. package/dist/editor/branch-names.js +75 -0
  62. package/dist/editor/components/BranchPill.d.ts +2 -2
  63. package/dist/editor/components/BranchPill.js +2 -2
  64. package/dist/editor/components/EditorHeader.d.ts +3 -1
  65. package/dist/editor/components/EditorHeader.js +2 -2
  66. package/dist/editor/components/EntryChangesDrawer.d.ts +51 -0
  67. package/dist/editor/components/EntryChangesDrawer.js +363 -0
  68. package/dist/editor/components/EntryChangesSkeleton.d.ts +9 -0
  69. package/dist/editor/components/EntryChangesSkeleton.js +7 -0
  70. package/dist/editor/components/EntryHeader.d.ts +9 -1
  71. package/dist/editor/components/EntryHeader.js +25 -3
  72. package/dist/editor/components/LazyEntryChangesDrawer.d.ts +16 -0
  73. package/dist/editor/components/LazyEntryChangesDrawer.js +45 -0
  74. package/dist/editor/components/ReviewDialogs.js +3 -3
  75. package/dist/editor/context/ApiClientContext.d.ts +9 -0
  76. package/dist/editor/context/ApiClientContext.js +26 -2
  77. package/dist/editor/context/index.d.ts +1 -1
  78. package/dist/editor/context/index.js +1 -1
  79. package/dist/editor/copy.d.ts +1 -0
  80. package/dist/editor/copy.js +1 -0
  81. package/dist/editor/editor-utils.d.ts +2 -0
  82. package/dist/editor/editor-utils.js +15 -0
  83. package/dist/editor/entry-diff.d.ts +143 -0
  84. package/dist/editor/entry-diff.js +593 -0
  85. package/dist/editor/hooks/create-branch-request.d.ts +11 -1
  86. package/dist/editor/hooks/create-branch-request.js +16 -6
  87. package/dist/editor/hooks/useBranchActions.d.ts +19 -2
  88. package/dist/editor/hooks/useBranchActions.js +29 -20
  89. package/dist/editor/hooks/useBranchChangesData.d.ts +57 -0
  90. package/dist/editor/hooks/useBranchChangesData.js +134 -0
  91. package/dist/editor/hooks/useBranchManager.d.ts +16 -0
  92. package/dist/editor/hooks/useBranchManager.js +142 -54
  93. package/dist/editor/hooks/useDraftManager.js +6 -2
  94. package/dist/editor/hooks/useEntryManager.d.ts +18 -2
  95. package/dist/editor/hooks/useEntryManager.js +36 -10
  96. package/dist/editor/utils/editor-errors.d.ts +5 -2
  97. package/dist/git-manager.d.ts +71 -0
  98. package/dist/git-manager.js +270 -1
  99. package/dist/http/handler.js +11 -9
  100. package/dist/operating-mode/branch-namespace-fixtures.d.ts +17 -0
  101. package/dist/operating-mode/branch-namespace-fixtures.js +65 -0
  102. package/dist/operating-mode/branch-namespace.d.ts +34 -0
  103. package/dist/operating-mode/branch-namespace.js +121 -0
  104. package/dist/operating-mode/client-unsafe-strategy.js +7 -0
  105. package/dist/operating-mode/types.d.ts +8 -0
  106. package/dist/paths/branch-name.d.ts +11 -19
  107. package/dist/paths/branch-name.js +17 -27
  108. package/dist/paths/index.d.ts +2 -2
  109. package/dist/paths/index.js +2 -2
  110. package/dist/paths/resolve.d.ts +0 -1
  111. package/dist/paths/resolve.js +0 -1
  112. package/dist/paths/validation.d.ts +40 -1
  113. package/dist/paths/validation.js +83 -4
  114. package/dist/pr-comments.d.ts +17 -9
  115. package/dist/pr-comments.js +53 -15
  116. package/dist/services.js +2 -1
  117. package/dist/submission-attribution.d.ts +22 -0
  118. package/dist/submission-attribution.js +62 -6
  119. package/dist/task-queue/worker-status.d.ts +6 -1
  120. package/dist/task-queue/worker-status.js +23 -2
  121. package/dist/types.d.ts +59 -0
  122. package/dist/version.d.ts +1 -1
  123. package/dist/version.js +1 -1
  124. package/dist/worker/branch-policy.d.ts +111 -16
  125. package/dist/worker/branch-policy.js +222 -17
  126. package/dist/worker/cms-worker.d.ts +24 -2
  127. package/dist/worker/cms-worker.js +35 -24
  128. package/dist/worker/git-sync.d.ts +3 -3
  129. package/dist/worker/git-sync.js +42 -1
  130. package/dist/worker/github-gateway-http.js +2 -2
  131. package/dist/worker/github-gateway-wire.d.ts +40 -25
  132. package/dist/worker/github-gateway-wire.js +14 -3
  133. package/dist/worker/github-gateway.d.ts +17 -6
  134. package/dist/worker/github-gateway.js +62 -6
  135. package/dist/worker/github-mirror.d.ts +7 -4
  136. package/dist/worker/github-mirror.js +27 -16
  137. package/dist/worker/history-rewrite.d.ts +23 -10
  138. package/dist/worker/history-rewrite.js +43 -20
  139. package/dist/worker/legacy-branch-fixtures.d.ts +21 -0
  140. package/dist/worker/legacy-branch-fixtures.js +71 -0
  141. package/dist/worker/rebase.d.ts +2 -1
  142. package/dist/worker/rebase.js +93 -36
  143. package/dist/worker/task-runner.d.ts +4 -1
  144. package/dist/worker/task-runner.js +77 -48
  145. package/dist/worker/unpublishable-branches.d.ts +34 -0
  146. package/dist/worker/unpublishable-branches.js +182 -0
  147. package/package.json +2 -1
@@ -2,8 +2,10 @@ import path from 'node:path';
2
2
  import { BranchMetadataFileManager, getBranchMetadataFileManager } from '../branch-metadata.js';
3
3
  import { sanitizeBranchName } from '../paths/branch-name.js';
4
4
  import { getErrorMessage, redactCredentials } from '../utils/error.js';
5
+ import { escapeForLog } from '../utils/escape-text.js';
5
6
  import { isStaleLeaseRejection } from '../utils/git.js';
6
7
  import { enqueueTask, listTasks } from '../task-queue/cms-task-queue.js';
8
+ import { publishesBranch } from './branch-policy.js';
7
9
  import { pinnedReceivePack, sharedRepoGit } from './shared-repo-git.js';
8
10
  import { workerLog, workerLogWarn } from './log.js';
9
11
  /**
@@ -31,24 +33,34 @@ export async function readPublishedSha(ctx, branchRef) {
31
33
  }
32
34
  }
33
35
  /**
34
- * Record that this worker rewrote `expectedSha` out of a branch's already
35
- * published history (see BranchMetadata.historyRewrittenFrom).
36
+ * Record that this worker rewrote `expectedSha` out of `branchRef`'s already
37
+ * published history (see BranchMetadata.historyRewrittenFrom), on a branch.json
38
+ * whose `name` is `branchRef` only: it never creates one, nor marks another
39
+ * branch's that shares the key.
36
40
  *
37
41
  * Set-once: an existing marker is LEFT ALONE. Across two rebases before any
38
42
  * GitHub push lands, GitHub still holds the commit the FIRST rebase replaced,
39
43
  * so advancing the marker would aim the lease at a commit GitHub never had and
40
44
  * permanently wedge the branch.
41
45
  *
42
- * Re-reads metadata rather than trusting the caller's snapshot: the task loop
43
- * runs concurrently with syncGit() and may have cleared the marker while this
44
- * branch was rebasing.
46
+ * Decided against the metadata on disk inside the write's lock, not the
47
+ * caller's snapshot: the task loop runs concurrently with syncGit() and may
48
+ * have cleared the marker while this branch was rebasing.
49
+ *
50
+ * Resolves whether `branchRef`'s branch.json now holds a marker, set here or kept by set-once.
51
+ * When it does not, the caller publishes nothing: a rewrite pushed without its marker leaves
52
+ * remote.git rewritten and the queued GitHub push failing with a false divergence diagnosis.
45
53
  */
46
- export async function markHistoryRewritten(ctx, branchPath, branchDir, expectedSha) {
47
- const current = await BranchMetadataFileManager.loadOnly(branchPath);
48
- if (typeof current?.branch.historyRewrittenFrom === 'string')
49
- return;
54
+ export async function markHistoryRewritten(ctx, branchPath, branchRef, expectedSha) {
50
55
  const meta = getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath);
51
- await meta.save({ branch: { name: branchDir, historyRewrittenFrom: expectedSha } });
56
+ let marked = false;
57
+ await meta.update((current) => {
58
+ marked = current?.branch.name === branchRef;
59
+ if (!marked || typeof current?.branch.historyRewrittenFrom === 'string')
60
+ return null;
61
+ return { branch: { historyRewrittenFrom: expectedSha } };
62
+ });
63
+ return marked;
52
64
  }
53
65
  /**
54
66
  * Clear the marker once GitHub is confirmed to hold the rewritten history.
@@ -64,15 +76,17 @@ export async function markHistoryRewritten(ctx, branchPath, branchDir, expectedS
64
76
  * Tracked, with the concurrent-clear race that can drop a marker mid-arming,
65
77
  * in safeinsights/canopycms#902.
66
78
  */
67
- export async function clearHistoryRewrittenMarker(ctx, branchPath, branchDir) {
79
+ export async function clearHistoryRewrittenMarker(ctx, branchPath, branchRef) {
68
80
  try {
69
81
  const meta = getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath);
70
82
  // Explicit undefined clears the key -- save()'s merge only overwrites
71
- // keys present in the update (same pattern as rebaseFailure).
72
- await meta.save({ branch: { name: branchDir, historyRewrittenFrom: undefined } });
83
+ // keys present in the update (same pattern as rebaseFailure). Only a
84
+ // marker on `branchRef`'s own branch.json is cleared, so none is ever created here.
85
+ await meta.saveIf({ branch: { historyRewrittenFrom: undefined } }, (current) => current?.branch.name === branchRef &&
86
+ typeof current.branch.historyRewrittenFrom === 'string');
73
87
  }
74
88
  catch (err) {
75
- workerLogWarn(` Failed to clear history-rewrite marker for ${branchDir}: ${getErrorMessage(err)}`);
89
+ workerLogWarn(` Failed to clear history-rewrite marker for ${branchRef}: ${getErrorMessage(err)}`);
76
90
  }
77
91
  }
78
92
  /**
@@ -124,10 +138,19 @@ export async function forcePublishToLocalRemote(ctx, branchPath, branchRef, expe
124
138
  *
125
139
  * Deliberately NOT skipped when a marker is already set: inferring "a task must
126
140
  * already be queued" from the marker starves this hop whenever a task was lost,
127
- * failed permanently, or was never written. Duplicate push tasks are bounded by
128
- * base-branch advances and are idempotent.
141
+ * failed permanently, or was never written. Skipped instead when `gatewayPolicy`,
142
+ * the gateway's own description, says it would refuse the branch: the marker
143
+ * clears only on a push that lands, so such a task would fail permanently on
144
+ * every cycle, and System health's unpublishable alert already names the branch.
145
+ * Without a description every hop is queued. So while a marker stands, at most
146
+ * one push task is in flight and one is added per sync cycle, only for a failure
147
+ * the policy does not predict; the tasks are idempotent.
129
148
  */
130
- export async function enqueueGitHubPush(ctx, branchRef) {
149
+ export async function enqueueGitHubPush(ctx, branchRef, gatewayPolicy) {
150
+ if (gatewayPolicy && !publishesBranch(gatewayPolicy, branchRef)) {
151
+ workerLog(` Not queueing a GitHub push for ${escapeForLog(branchRef)}: the GitHub gateway would refuse it`);
152
+ return;
153
+ }
131
154
  try {
132
155
  // Dedupe against tasks actually in flight rather than against the
133
156
  // marker: a branch whose GitHub push keeps failing would otherwise
@@ -157,7 +180,7 @@ export async function enqueueGitHubPush(ctx, branchRef) {
157
180
  * is the one commit we know our own rebase replaced.
158
181
  */
159
182
  export async function reconcilePendingRewrite(ctx, options) {
160
- const { branchPath, branchDir, branchRef, headSha, marker } = options;
183
+ const { branchPath, branchDir, branchRef, headSha, marker, gatewayPolicy } = options;
161
184
  const publishedSha = await readPublishedSha(ctx, branchRef);
162
185
  if (publishedSha === null) {
163
186
  workerLogWarn(` ${branchDir}: a rewritten history is recorded but ${branchRef} is gone from remote.git -- nothing to publish`);
@@ -166,14 +189,14 @@ export async function reconcilePendingRewrite(ctx, options) {
166
189
  if (publishedSha === headSha) {
167
190
  // remote.git already carries the rewrite; only the GitHub hop is left
168
191
  // (a crash between the push and the queue, or a task that was lost).
169
- await enqueueGitHubPush(ctx, branchRef);
192
+ await enqueueGitHubPush(ctx, branchRef, gatewayPolicy);
170
193
  return;
171
194
  }
172
195
  if (publishedSha === marker) {
173
196
  // The publish into remote.git never landed -- a crash right after the
174
197
  // marker was written, or a lease refused by a concurrent submit.
175
198
  if (await forcePublishToLocalRemote(ctx, branchPath, branchRef, marker)) {
176
- await enqueueGitHubPush(ctx, branchRef);
199
+ await enqueueGitHubPush(ctx, branchRef, gatewayPolicy);
177
200
  }
178
201
  return;
179
202
  }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Shared `legacyBranches` test fixture.
3
+ *
4
+ * The rules deciding whether a `legacyBranches` list is usable exist TWICE by necessity: at
5
+ * runtime (`branch-policy.ts`'s `legacyBranchProblem` and the `BranchPolicy` constructor) and at
6
+ * synth time (`canopycms-cdk`'s `constructs/cms-service.ts`, which does not import core). A rule
7
+ * tightened at runtime but not at synth synths clean and then crash-loops the gateway at boot, so
8
+ * the CDK suite (`cms-deploy.test.ts`) requires both copies' verdict on every list here. Add a
9
+ * case when you change either copy of the rule.
10
+ *
11
+ * Dependency-free (plain arrays, no vitest import) so the CDK suite can import it across the
12
+ * package boundary. Each case is a resolved namespace and the list checked against it.
13
+ */
14
+ /** Lists both copies must ACCEPT: `[label, namespace, entries]`. */
15
+ export declare const VALID_LEGACY_BRANCH_LISTS: readonly (readonly [string, string, readonly string[]])[];
16
+ /** Lists both copies must REJECT: `[label, namespace, entries]`. */
17
+ export declare const INVALID_LEGACY_BRANCH_LISTS: readonly (readonly [
18
+ string,
19
+ string,
20
+ readonly string[]
21
+ ])[];
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Shared `legacyBranches` test fixture.
3
+ *
4
+ * The rules deciding whether a `legacyBranches` list is usable exist TWICE by necessity: at
5
+ * runtime (`branch-policy.ts`'s `legacyBranchProblem` and the `BranchPolicy` constructor) and at
6
+ * synth time (`canopycms-cdk`'s `constructs/cms-service.ts`, which does not import core). A rule
7
+ * tightened at runtime but not at synth synths clean and then crash-loops the gateway at boot, so
8
+ * the CDK suite (`cms-deploy.test.ts`) requires both copies' verdict on every list here. Add a
9
+ * case when you change either copy of the rule.
10
+ *
11
+ * Dependency-free (plain arrays, no vitest import) so the CDK suite can import it across the
12
+ * package boundary. Each case is a resolved namespace and the list checked against it.
13
+ */
14
+ const DEFAULT_NAMESPACE = 'canopycms/prod/';
15
+ /** Lists both copies must ACCEPT: `[label, namespace, entries]`. */
16
+ export const VALID_LEGACY_BRANCH_LISTS = [
17
+ ['no entries', DEFAULT_NAMESPACE, []],
18
+ ['plain names', DEFAULT_NAMESPACE, ['spring', 'feature/x', 'v1.0', 'Release-2']],
19
+ ['a name of 255 characters, the most a branch name has', DEFAULT_NAMESPACE, ['a'.repeat(255)]],
20
+ ['a dash where the namespace has a slash', DEFAULT_NAMESPACE, ['canopycms-spring']],
21
+ ['a sibling of the namespace', DEFAULT_NAMESPACE, ['canopycms/prodigy', 'canopycms/staging/x']],
22
+ ['a name that only sanitizes into the namespace', DEFAULT_NAMESPACE, ['canopycms-prod-x']],
23
+ ['another namespace', 'cms/', ['canopycms/prod/x', 'cmsx']],
24
+ ['a sibling inside the parent of a custom namespace', 'cms/a/', ['cms/b']],
25
+ ['1024 entries', DEFAULT_NAMESPACE, Array.from({ length: 1024 }, (_, i) => `r${i}`)],
26
+ ];
27
+ /** Lists both copies must REJECT: `[label, namespace, entries]`. */
28
+ export const INVALID_LEGACY_BRANCH_LISTS = [
29
+ ['an entry in the namespace', DEFAULT_NAMESPACE, ['canopycms/prod/fix']],
30
+ ['an entry in a custom namespace', 'cms/', ['cms/fix']],
31
+ ['an entry named after the namespace', DEFAULT_NAMESPACE, ['canopycms/prod']],
32
+ ['an entry named after part of the namespace', DEFAULT_NAMESPACE, ['canopycms']],
33
+ ['an entry named after part of a custom namespace', 'edits/prod/drafts/', ['edits/prod']],
34
+ ['a wildcard', DEFAULT_NAMESPACE, ['release/*']],
35
+ ['a lone wildcard, which would switch the namespace off', DEFAULT_NAMESPACE, ['*']],
36
+ ['a comma', DEFAULT_NAMESPACE, ['a,b']],
37
+ ['a settings branch', DEFAULT_NAMESPACE, ['canopycms-settings-prod']],
38
+ ['an empty entry', DEFAULT_NAMESPACE, ['']],
39
+ ['a space', DEFAULT_NAMESPACE, ['a b']],
40
+ ['a control character', DEFAULT_NAMESPACE, ['a\tb']],
41
+ ['non-ASCII', DEFAULT_NAMESPACE, ['café']],
42
+ ['a percent sign', DEFAULT_NAMESPACE, ['a%2fb']],
43
+ ['a double quote', DEFAULT_NAMESPACE, ['a"b']],
44
+ ['a brace', DEFAULT_NAMESPACE, ['a{b']],
45
+ ['a closing brace', DEFAULT_NAMESPACE, ['a}b']],
46
+ ['a caret', DEFAULT_NAMESPACE, ['a^b']],
47
+ ['a colon', DEFAULT_NAMESPACE, ['a:b']],
48
+ ['an opening bracket', DEFAULT_NAMESPACE, ['a[b']],
49
+ ['DEL', DEFAULT_NAMESPACE, ['a\x7fb']],
50
+ ['a leading dash', DEFAULT_NAMESPACE, ['-x']],
51
+ ['a leading refs/', DEFAULT_NAMESPACE, ['refs/heads/main']],
52
+ ['a leading tags/ in another case', DEFAULT_NAMESPACE, ['Tags/x']],
53
+ ['a leading heads/', DEFAULT_NAMESPACE, ['heads/x']],
54
+ ['a leading remotes/', DEFAULT_NAMESPACE, ['remotes/origin/x']],
55
+ ['a git-forbidden character', DEFAULT_NAMESPACE, ['release-?']],
56
+ ['a tilde', DEFAULT_NAMESPACE, ['a~1']],
57
+ ['a backslash', DEFAULT_NAMESPACE, ['a\\b']],
58
+ ['dot-dot', DEFAULT_NAMESPACE, ['a..b']],
59
+ ['a reflog selector', DEFAULT_NAMESPACE, ['a@{1}']],
60
+ ['an empty path component', DEFAULT_NAMESPACE, ['a//b']],
61
+ ['a leading slash', DEFAULT_NAMESPACE, ['/a']],
62
+ ['a trailing slash', DEFAULT_NAMESPACE, ['a/']],
63
+ ['a trailing dot', DEFAULT_NAMESPACE, ['a.']],
64
+ ['a dot-led component', DEFAULT_NAMESPACE, ['a/.b']],
65
+ ['a .lock component', DEFAULT_NAMESPACE, ['a.lock/b']],
66
+ ['HEAD', DEFAULT_NAMESPACE, ['HEAD']],
67
+ ['a lone @', DEFAULT_NAMESPACE, ['@']],
68
+ ['a name of 256 characters, one over the most', DEFAULT_NAMESPACE, ['a'.repeat(256)]],
69
+ ['an entry listed twice', DEFAULT_NAMESPACE, ['spring', 'summer', 'spring']],
70
+ ['1025 entries', DEFAULT_NAMESPACE, Array.from({ length: 1025 }, (_, i) => `r${i}`)],
71
+ ];
@@ -1,4 +1,5 @@
1
1
  import { type BranchMetadataFile } from '../branch-metadata.js';
2
+ import type { BranchPolicyDescription } from './branch-policy.js';
2
3
  import type { WorkerContext } from './worker-context.js';
3
4
  /**
4
5
  * The rebase loop: the deepest leaf of the git-sync cluster, and the most
@@ -78,4 +79,4 @@ export declare function pollMergeState(ctx: RebaseContext, branchDir: string, br
78
79
  * that branch's cross-host content-write lock and runs a series of git
79
80
  * subprocesses against a shared filesystem.
80
81
  */
81
- export declare function runRebaseCycle(ctx: RebaseContext): Promise<RebaseSummary>;
82
+ export declare function runRebaseCycle(ctx: RebaseContext, gatewayPolicy?: BranchPolicyDescription): Promise<RebaseSummary>;
@@ -3,12 +3,13 @@ import path from 'node:path';
3
3
  import { BranchMetadataFileManager, buildMergedBranchUpdate, getBranchMetadataFileManager, } from '../branch-metadata.js';
4
4
  import { extractIdFromFilename } from '../content-id-index.js';
5
5
  import { invalidateBranchContentCaches } from '../content-index-generation.js';
6
- import { isSettingsBranchName } from '../paths/branch-name.js';
6
+ import { isSettingsBranchName, sanitizeBranchName } from '../paths/branch-name.js';
7
7
  import { normalizeFilesystemPath } from '../paths/normalize.js';
8
8
  import { ROOT_COLLECTION_ID } from '../paths/types.js';
9
9
  import { tryAcquireContentWriteLock } from '../utils/content-write-lock.js';
10
10
  import { holdProvisionedWorkspace, releaseProvisionedWorkspace, } from './provisioned-workspace.js';
11
11
  import { getErrorMessage, isNodeError, redactCredentials } from '../utils/error.js';
12
+ import { escapeForLog } from '../utils/escape-text.js';
12
13
  import { isCanopyInternalPath, isRebaseInProgress } from '../utils/git.js';
13
14
  import { TRACKED_CANOPY_STATE_FIX, restoreRetiredSchemaCache, splitByUpstreamTracking, trackedCanopyStateChanges, } from './canopy-state.js';
14
15
  import { enqueueGitHubPush, forcePublishToLocalRemote, markHistoryRewritten, readPublishedSha, reconcilePendingRewrite, } from './history-rewrite.js';
@@ -29,6 +30,9 @@ export async function pollMergeState(ctx, branchDir, branchPath, metaFile) {
29
30
  const prNumber = metaFile?.branch.pullRequestNumber;
30
31
  if (!prNumber)
31
32
  return;
33
+ // Recorded only on a branch.json that still names the branch this poll read: never a missing
34
+ // one, nor another branch's that shares the key.
35
+ const sameBranch = (current) => current?.branch.name === metaFile?.branch.name;
32
36
  const controller = new AbortController();
33
37
  const timer = setTimeout(() => controller.abort(), ctx.taskTimeoutMs);
34
38
  try {
@@ -37,10 +41,11 @@ export async function pollMergeState(ctx, branchDir, branchPath, metaFile) {
37
41
  const meta = getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath);
38
42
  // GitHub's actual merge time when available; buildMergedBranchUpdate
39
43
  // falls back to "now" when merged_at is absent.
40
- await meta.save({
41
- branch: buildMergedBranchUpdate(branchDir, data.merged_at ? new Date(data.merged_at) : undefined),
42
- });
43
- workerLog(` PR #${prNumber} for ${branchDir} is merged -> archived`);
44
+ const saved = await meta.saveIf({
45
+ branch: buildMergedBranchUpdate(data.merged_at ? new Date(data.merged_at) : undefined),
46
+ }, sameBranch);
47
+ if (saved)
48
+ workerLog(` PR #${prNumber} for ${branchDir} is merged -> archived`);
44
49
  return;
45
50
  }
46
51
  const newState = data.state === 'closed' ? 'closed' : 'open';
@@ -50,8 +55,9 @@ export async function pollMergeState(ctx, branchDir, branchPath, metaFile) {
50
55
  if (currentMeta?.branch.pullRequestState === newState)
51
56
  return;
52
57
  const meta = getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath);
53
- await meta.save({ branch: { name: branchDir, pullRequestState: newState } });
54
- workerLog(` PR #${prNumber} for ${branchDir}: pullRequestState -> ${newState}`);
58
+ const saved = await meta.saveIf({ branch: { pullRequestState: newState } }, sameBranch);
59
+ if (saved)
60
+ workerLog(` PR #${prNumber} for ${branchDir}: pullRequestState -> ${newState}`);
55
61
  }
56
62
  catch (err) {
57
63
  // Non-fatal: transient GitHub/network errors are retried next cycle.
@@ -72,9 +78,10 @@ export async function pollMergeState(ctx, branchDir, branchPath, metaFile) {
72
78
  * paths: a corrupt branch.json, lock contention or any other save failure must
73
79
  * never abort the per-branch iteration. One of the two call sites is inside the
74
80
  * outer per-branch catch with no further catch around it, so the WHOLE method
75
- * is wrapped, not just the load.
81
+ * is wrapped, not just the load. It updates an existing branch.json only, never creating one,
82
+ * and, given `branchRef`, only the one whose `name` is that ref.
76
83
  */
77
- async function recordRebaseFailure(ctx, branchPath, branchDir, message) {
84
+ async function recordRebaseFailure(ctx, branchPath, branchDir, message, branchRef) {
78
85
  const RECORD_REFRESH_MS = 60 * 60 * 1000; // 1 hour
79
86
  // [REDACT] Defense in depth: rebaseFailure.message is persisted to
80
87
  // branch.json and served to the browser by the branch-health admin endpoint.
@@ -96,12 +103,7 @@ async function recordRebaseFailure(ctx, branchPath, branchDir, message) {
96
103
  const nowIso = now.toISOString();
97
104
  const firstAt = sameMessage ? prior.firstAt : nowIso;
98
105
  const meta = getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath);
99
- await meta.save({
100
- branch: {
101
- name: branchDir,
102
- rebaseFailure: { message: redactedMessage, firstAt, lastAt: nowIso },
103
- },
104
- });
106
+ await meta.saveIf({ branch: { rebaseFailure: { message: redactedMessage, firstAt, lastAt: nowIso } } }, (current) => current !== null && (branchRef === undefined || current.branch.name === branchRef));
105
107
  }
106
108
  catch (err) {
107
109
  // Includes BranchMetadataCorruptError from the load above (a save() against
@@ -328,7 +330,7 @@ function recordConflicts(recorded, found) {
328
330
  * clone's pre-rebase tip is exactly what the rebase destroys.
329
331
  */
330
332
  async function carryForwardRewrittenHistory(ctx, args) {
331
- const { branchPath, branchDir, branchRef, publishedSha, preRebaseHead } = args;
333
+ const { branchPath, branchDir, branchRef, publishedSha, preRebaseHead, gatewayPolicy } = args;
332
334
  if (publishedSha !== null) {
333
335
  if (publishedSha === preRebaseHead) {
334
336
  // ARMING GUARD: remote.git holds EXACTLY what this clone just rebased
@@ -345,14 +347,19 @@ async function carryForwardRewrittenHistory(ctx, args) {
345
347
  // delete that fixup from remote.git and then GitHub. Keying the lease to
346
348
  // the pre-rebase tip turns that case into the visible divergence it is.
347
349
  //
348
- // Order matters: mark, then push, then queue. A crash after any step
350
+ // Order matters: mark, then push, then queue, and a mark that declines
351
+ // (no branch.json names this ref) publishes nothing. A crash after any step
349
352
  // leaves the marker set with the work unfinished, which
350
353
  // reconcilePendingRewrite() completes on a later cycle. Pushing first
351
354
  // would leave remote.git rewritten, GitHub stale and nothing recorded --
352
355
  // unrecoverable, and diagnosed as a false "another deployment" collision.
353
- await markHistoryRewritten(ctx, branchPath, branchDir, publishedSha);
356
+ if (!(await markHistoryRewritten(ctx, branchPath, branchRef, publishedSha))) {
357
+ workerLogWarn(` ${branchDir}: rebased, but its branch.json is gone or names another branch than ` +
358
+ `${branchRef}, so the rewrite is not published`);
359
+ return;
360
+ }
354
361
  if (await forcePublishToLocalRemote(ctx, branchPath, branchRef, publishedSha)) {
355
- await enqueueGitHubPush(ctx, branchRef);
362
+ await enqueueGitHubPush(ctx, branchRef, gatewayPolicy);
356
363
  }
357
364
  }
358
365
  else {
@@ -360,7 +367,7 @@ async function carryForwardRewrittenHistory(ctx, args) {
360
367
  `clone never had (a direct push to the branch?). Left untouched -- reconcile it ` +
361
368
  `before submitting again.`;
362
369
  workerLogWarn(` ${branchDir}: ${divergence}`);
363
- await recordRebaseFailure(ctx, branchPath, branchDir, divergence);
370
+ await recordRebaseFailure(ctx, branchPath, branchDir, divergence, branchRef);
364
371
  }
365
372
  }
366
373
  }
@@ -376,7 +383,7 @@ async function carryForwardRewrittenHistory(ctx, args) {
376
383
  * that branch's cross-host content-write lock and runs a series of git
377
384
  * subprocesses against a shared filesystem.
378
385
  */
379
- export async function runRebaseCycle(ctx) {
386
+ export async function runRebaseCycle(ctx, gatewayPolicy) {
380
387
  const rebased = [];
381
388
  const skippedDirty = [];
382
389
  const skippedLocked = [];
@@ -433,7 +440,7 @@ export async function runRebaseCycle(ctx) {
433
440
  workerLogWarn(` Skipping ${branchDir}: settings branch, never a content workspace`);
434
441
  continue;
435
442
  }
436
- const outcome = await holdAndRebaseOneBranch(ctx, branchDir, branchPath);
443
+ const outcome = await holdAndRebaseOneBranch(ctx, branchDir, branchPath, gatewayPolicy);
437
444
  if (outcome.kind === 'rebased')
438
445
  rebased.push(branchDir);
439
446
  else if (outcome.kind === 'skippedDirty')
@@ -450,6 +457,50 @@ export async function runRebaseCycle(ctx) {
450
457
  }
451
458
  return { rebased, skippedDirty, skippedLocked, failed };
452
459
  }
460
+ /**
461
+ * The ref a branch clone's HEAD names, or null for a detached or unreadable one. Read from the
462
+ * file rather than through git, so it runs before `assertSharedRepoConfig` vets the clone.
463
+ */
464
+ async function readCloneRef(branchPath) {
465
+ try {
466
+ const head = (await fs.readFile(path.join(branchPath, '.git', 'HEAD'), 'utf-8')).trim();
467
+ return head.startsWith('ref: refs/heads/') ? head.slice('ref: refs/heads/'.length) : null;
468
+ }
469
+ catch {
470
+ return null;
471
+ }
472
+ }
473
+ /**
474
+ * Restore `name` to the clone's own ref when a worker that predates the namespace renamed a
475
+ * namespace branch to its directory (`canopycms-prod-x` for `canopycms/prod/x`), which no request
476
+ * resolves. Only when the ref is in `namespace` and the directory is that ref sanitized, so the
477
+ * ref is the name the branch was created with. Runs for every status, so a branch in review heals
478
+ * too. Resolves the metadata the rest of the cycle reads; a branch left alone, or a write that
479
+ * fails, keeps `metaFile`.
480
+ */
481
+ async function restoreRenamedBranch(ctx, branchDir, branchPath, metaFile, namespace) {
482
+ // A name already in the namespace was never renamed; one that differs from the ref shares its
483
+ // key with another branch, which this does not decide.
484
+ if (metaFile === null || namespace === undefined || metaFile.branch.name.startsWith(namespace)) {
485
+ return metaFile;
486
+ }
487
+ const staleName = metaFile.branch.name;
488
+ const ref = await readCloneRef(branchPath);
489
+ if (ref === null || !ref.startsWith(namespace) || sanitizeBranchName(ref) !== branchDir) {
490
+ return metaFile;
491
+ }
492
+ try {
493
+ const restored = await getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath).update((existing) => (existing?.branch.name === staleName ? { branch: { name: ref } } : null), { keepUpdatedAt: true });
494
+ if (restored === null)
495
+ return BranchMetadataFileManager.loadOnly(branchPath);
496
+ workerLog(` ${branchDir}: restored its name ${escapeForLog(ref)} from ${escapeForLog(staleName)}`);
497
+ return restored;
498
+ }
499
+ catch (err) {
500
+ workerLogWarn(` ${branchDir}: could not restore its name ${escapeForLog(ref)}: ${getErrorMessage(err)}`);
501
+ return metaFile;
502
+ }
503
+ }
453
504
  /**
454
505
  * {@link rebaseOneBranch} under the branch's provisioning lock, so no git step races a clone of
455
506
  * this directory. A branch whose lock is held elsewhere is `skippedLocked` and retried next cycle;
@@ -457,7 +508,7 @@ export async function runRebaseCycle(ctx) {
457
508
  * content-write lock inside it; see `provisioned-workspace.ts` for why that order cannot deadlock.
458
509
  * Never throws, like rebaseOneBranch.
459
510
  */
460
- async function holdAndRebaseOneBranch(ctx, branchDir, branchPath) {
511
+ async function holdAndRebaseOneBranch(ctx, branchDir, branchPath, gatewayPolicy) {
461
512
  let hold;
462
513
  try {
463
514
  hold = await holdProvisionedWorkspace(ctx.contentBranchesPath, branchDir);
@@ -477,7 +528,7 @@ async function holdAndRebaseOneBranch(ctx, branchDir, branchPath) {
477
528
  if (hold.kind === 'not-provisioned')
478
529
  return { kind: 'none' };
479
530
  try {
480
- return await rebaseOneBranch(ctx, branchDir, branchPath, hold.isCompromised);
531
+ return await rebaseOneBranch(ctx, branchDir, branchPath, hold.isCompromised, gatewayPolicy);
481
532
  }
482
533
  finally {
483
534
  await releaseProvisionedWorkspace(hold.release, branchDir);
@@ -495,12 +546,13 @@ async function holdAndRebaseOneBranch(ctx, branchDir, branchPath) {
495
546
  * returned as `{ kind: 'failed' }`, so one unreadable branch cannot halt the
496
547
  * rest of the cycle ([SYNC-M2]).
497
548
  */
498
- async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCompromised) {
549
+ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCompromised, gatewayPolicy) {
499
550
  // Read only by the catch below. See the `rebased` rider on
500
551
  // BranchRebaseOutcome.
501
552
  let didRebase = false;
502
553
  try {
503
- const metaFile = await BranchMetadataFileManager.loadOnly(branchPath);
554
+ // Restored first: everything below this cycle compares `name`.
555
+ const metaFile = await restoreRenamedBranch(ctx, branchDir, branchPath, await BranchMetadataFileManager.loadOnly(branchPath), gatewayPolicy?.namespace);
504
556
  const branchStatus = metaFile?.branch.status;
505
557
  // Skip branches that must not be mutated:
506
558
  // - submitted/approved: in review, so never rewrite history under an active
@@ -683,6 +735,7 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
683
735
  branchRef,
684
736
  headSha: (await branchGit.revparse(['HEAD'])).trim(),
685
737
  marker: metaFile.branch.historyRewrittenFrom,
738
+ gatewayPolicy,
686
739
  });
687
740
  }
688
741
  await fetchFromRemoteGit(branchGit, ctx.remoteGitPath, ctx.baseBranch);
@@ -714,7 +767,8 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
714
767
  if (currentMeta?.branch.rebaseFailure === undefined) {
715
768
  return { kind: 'none' };
716
769
  }
717
- await meta.save({ branch: { name: branchDir, rebaseFailure: undefined } });
770
+ const storedName = currentMeta.branch.name;
771
+ await meta.saveIf({ branch: { rebaseFailure: undefined } }, (existing) => existing?.branch.name === storedName);
718
772
  return { kind: 'none' };
719
773
  }
720
774
  workerLog(`Rebasing ${branchDir} (${behindCount} commits behind)...`);
@@ -790,15 +844,17 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
790
844
  workerLog(hadConflicts
791
845
  ? ` Rebased ${branchDir} (kept branch version for ${conflictIdsDeduped.length} conflicting file(s))`
792
846
  : ` Rebased ${branchDir} successfully`);
793
- await meta.update((existing) => ({
794
- branch: {
795
- name: branchDir,
796
- ...recordConflicts(existing?.branch.conflictFiles, conflictIdsDeduped),
797
- // The cycle completed, so clear any prior failure record regardless
798
- // of the conflict outcome.
799
- rebaseFailure: undefined,
800
- },
801
- }));
847
+ // Recorded only on a branch.json that still names the branch this cycle read.
848
+ await meta.update((existing) => existing !== null && existing.branch.name === metaFile?.branch.name
849
+ ? {
850
+ branch: {
851
+ ...recordConflicts(existing.branch.conflictFiles, conflictIdsDeduped),
852
+ // The cycle completed, so clear any prior failure record regardless
853
+ // of the conflict outcome.
854
+ rebaseFailure: undefined,
855
+ },
856
+ }
857
+ : null);
802
858
  didRebase = true;
803
859
  await carryForwardRewrittenHistory(ctx, {
804
860
  branchPath,
@@ -806,6 +862,7 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
806
862
  branchRef,
807
863
  publishedSha,
808
864
  preRebaseHead,
865
+ gatewayPolicy,
809
866
  });
810
867
  return { kind: 'rebased' };
811
868
  }
@@ -62,7 +62,10 @@ export declare function orphanRecoveryMaxAgeMs(ctx: Pick<TaskRunnerContext, 'tas
62
62
  export declare function processTaskQueue(ctx: TaskRunnerContext): Promise<void>;
63
63
  export declare function executeTask(ctx: TaskRunnerContext, task: Task, signal: AbortSignal): Promise<Record<string, unknown>>;
64
64
  /**
65
- * Update branch metadata after successful task completion.
65
+ * Update branch metadata after successful task completion. Like every outcome write here, it
66
+ * updates only a branch.json whose `name` is the task's ref: a workspace without one is not a
67
+ * branch whose name could be recorded, and one naming another ref is that branch's
68
+ * (`noMetadataToRecord`).
66
69
  */
67
70
  export declare function updateBranchMetadata(ctx: TaskRunnerContext, task: Task, result: Record<string, unknown>): Promise<void>;
68
71
  export declare function pushBranchToGitHub(ctx: TaskRunnerContext, branch: string, signal?: AbortSignal): Promise<void>;