canopycms 0.0.67 → 0.0.68-int.101

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 (281) hide show
  1. package/README.md +24 -17
  2. package/dist/ai/handler.js +8 -0
  3. package/dist/api/admin-branch-health.js +17 -20
  4. package/dist/api/admin.d.ts +30 -9
  5. package/dist/api/admin.js +32 -2
  6. package/dist/api/assets.js +84 -76
  7. package/dist/api/branch-create-window.d.ts +12 -0
  8. package/dist/api/branch-create-window.js +13 -0
  9. package/dist/api/branch-review.d.ts +3 -3
  10. package/dist/api/branch-review.js +14 -5
  11. package/dist/api/branch-status.d.ts +5 -5
  12. package/dist/api/branch-status.js +26 -6
  13. package/dist/api/branch-withdraw.d.ts +2 -2
  14. package/dist/api/branch-withdraw.js +7 -2
  15. package/dist/api/branch.d.ts +15 -2
  16. package/dist/api/branch.js +118 -92
  17. package/dist/api/client.d.ts +18 -6
  18. package/dist/api/client.js +79 -12
  19. package/dist/api/content.d.ts +6 -5
  20. package/dist/api/content.js +44 -41
  21. package/dist/api/entries.js +16 -15
  22. package/dist/api/github-sync.d.ts +19 -1
  23. package/dist/api/github-sync.js +57 -10
  24. package/dist/api/index.d.ts +1 -1
  25. package/dist/api/reference-options.js +1 -1
  26. package/dist/api/resolve-references.js +14 -40
  27. package/dist/api/settings-helpers.d.ts +1 -1
  28. package/dist/api/settings-helpers.js +1 -4
  29. package/dist/api/user.d.ts +3 -0
  30. package/dist/api/user.js +3 -0
  31. package/dist/assets/asset-url.d.ts +22 -16
  32. package/dist/assets/asset-url.js +42 -25
  33. package/dist/assets/factory.d.ts +5 -0
  34. package/dist/assets/factory.js +5 -0
  35. package/dist/assets/finalize.js +3 -0
  36. package/dist/assets/index.d.ts +1 -1
  37. package/dist/assets/materialize.d.ts +153 -0
  38. package/dist/assets/materialize.js +402 -0
  39. package/dist/assets/store-local.d.ts +19 -5
  40. package/dist/assets/store-local.js +69 -10
  41. package/dist/assets/store-s3.d.ts +38 -7
  42. package/dist/assets/store-s3.js +128 -22
  43. package/dist/assets/transform-directives.d.ts +52 -13
  44. package/dist/assets/transform-directives.js +87 -27
  45. package/dist/assets/transform.d.ts +3 -3
  46. package/dist/assets/transform.js +13 -8
  47. package/dist/assets/types.d.ts +31 -4
  48. package/dist/auth/file-based-auth-cache.js +7 -8
  49. package/dist/authorization/content.d.ts +5 -4
  50. package/dist/authorization/content.js +5 -5
  51. package/dist/authorization/path.d.ts +11 -6
  52. package/dist/authorization/path.js +11 -6
  53. package/dist/authorization/types.d.ts +2 -2
  54. package/dist/branch-health.d.ts +7 -1
  55. package/dist/branch-health.js +8 -3
  56. package/dist/branch-metadata.d.ts +5 -5
  57. package/dist/branch-metadata.js +44 -36
  58. package/dist/branch-provisioning.d.ts +167 -0
  59. package/dist/branch-provisioning.js +501 -0
  60. package/dist/branch-schema-cache.d.ts +5 -3
  61. package/dist/branch-schema-cache.js +29 -11
  62. package/dist/branch-sparse.d.ts +23 -0
  63. package/dist/branch-sparse.js +118 -0
  64. package/dist/branch-workspace.d.ts +42 -3
  65. package/dist/branch-workspace.js +240 -94
  66. package/dist/build/asset-refs.d.ts +93 -0
  67. package/dist/build/asset-refs.js +251 -0
  68. package/dist/build-identity.d.ts +3 -0
  69. package/dist/build-identity.js +13 -0
  70. package/dist/cli/asset-refs.d.ts +52 -0
  71. package/dist/cli/asset-refs.js +194 -0
  72. package/dist/cli/cli.d.ts +15 -1
  73. package/dist/cli/cli.js +5928 -2195
  74. package/dist/cli/configured-asset-store.d.ts +12 -0
  75. package/dist/cli/configured-asset-store.js +46 -0
  76. package/dist/cli/generate-ai-content.js +2185 -587
  77. package/dist/cli/github-app-manifest.d.ts +4 -3
  78. package/dist/cli/github-app-manifest.js +4 -3
  79. package/dist/cli/init.js +30 -27
  80. package/dist/cli/migrate.js +12 -8
  81. package/dist/cli/sync.js +83 -37
  82. package/dist/cli/template-files/Dockerfile.cms.template +8 -0
  83. package/dist/cli/template-files/cdk-app.ts.template +4 -0
  84. package/dist/cli/template-files/cms-stack.ts.template +23 -12
  85. package/dist/cli/template-files/deploy-cms.yml.template +2 -0
  86. package/dist/client.d.ts +1 -1
  87. package/dist/client.js +1 -1
  88. package/dist/config/helpers.js +1 -2
  89. package/dist/config/schemas/config.d.ts +19 -32
  90. package/dist/config/schemas/config.js +9 -11
  91. package/dist/config/schemas/field.js +1 -0
  92. package/dist/config/schemas/media.d.ts +4 -13
  93. package/dist/config/schemas/media.js +5 -8
  94. package/dist/config/schemas/url.d.ts +4 -20
  95. package/dist/config/schemas/url.js +6 -22
  96. package/dist/config/types.d.ts +30 -20
  97. package/dist/config/validation.d.ts +6 -0
  98. package/dist/config/validation.js +37 -0
  99. package/dist/content-listing.d.ts +12 -12
  100. package/dist/content-listing.js +10 -6
  101. package/dist/content-reader.d.ts +11 -2
  102. package/dist/content-reader.js +22 -14
  103. package/dist/content-store.d.ts +37 -9
  104. package/dist/content-store.js +70 -43
  105. package/dist/content-tree.d.ts +1 -1
  106. package/dist/content-tree.js +2 -2
  107. package/dist/context.d.ts +27 -9
  108. package/dist/context.js +154 -70
  109. package/dist/editor/BranchManager.d.ts +7 -1
  110. package/dist/editor/BranchManager.js +29 -14
  111. package/dist/editor/CanopyEditor.d.ts +1 -1
  112. package/dist/editor/CanopyEditor.js +2 -6
  113. package/dist/editor/Editor.d.ts +4 -3
  114. package/dist/editor/Editor.js +32 -22
  115. package/dist/editor/FormRenderer.js +34 -18
  116. package/dist/editor/PreviewFrame.d.ts +24 -0
  117. package/dist/editor/PreviewFrame.js +140 -0
  118. package/dist/editor/admin/SystemHealthPanel.js +47 -7
  119. package/dist/editor/components/BranchesDrawer.d.ts +9 -0
  120. package/dist/editor/components/BranchesDrawer.js +4 -0
  121. package/dist/editor/components/NoEditPermissionNotice.d.ts +10 -0
  122. package/dist/editor/components/NoEditPermissionNotice.js +17 -0
  123. package/dist/editor/context/AssetContext.d.ts +18 -35
  124. package/dist/editor/context/AssetContext.js +20 -29
  125. package/dist/editor/context/index.d.ts +1 -1
  126. package/dist/editor/context/index.js +1 -1
  127. package/dist/editor/editor-config.d.ts +1 -2
  128. package/dist/editor/editor-config.js +0 -23
  129. package/dist/editor/editor-utils.d.ts +24 -21
  130. package/dist/editor/editor-utils.js +75 -62
  131. package/dist/editor/fields/BlockField.d.ts +1 -0
  132. package/dist/editor/fields/BlockField.js +6 -4
  133. package/dist/editor/fields/CodeField.d.ts +1 -0
  134. package/dist/editor/fields/CodeField.js +2 -2
  135. package/dist/editor/fields/DateTimeField.d.ts +1 -0
  136. package/dist/editor/fields/DateTimeField.js +2 -2
  137. package/dist/editor/fields/FieldDescription.d.ts +16 -0
  138. package/dist/editor/fields/FieldDescription.js +11 -0
  139. package/dist/editor/fields/ImageField.d.ts +1 -0
  140. package/dist/editor/fields/ImageField.js +3 -2
  141. package/dist/editor/fields/InlineGroupField.js +4 -1
  142. package/dist/editor/fields/MarkdownField.d.ts +1 -0
  143. package/dist/editor/fields/MarkdownField.js +115 -20
  144. package/dist/editor/fields/NumberField.d.ts +1 -0
  145. package/dist/editor/fields/NumberField.js +2 -2
  146. package/dist/editor/fields/NumberListField.d.ts +1 -0
  147. package/dist/editor/fields/NumberListField.js +2 -2
  148. package/dist/editor/fields/ObjectField.d.ts +1 -0
  149. package/dist/editor/fields/ObjectField.js +5 -2
  150. package/dist/editor/fields/ReferenceField.d.ts +1 -0
  151. package/dist/editor/fields/ReferenceField.js +5 -4
  152. package/dist/editor/fields/SelectField.d.ts +1 -0
  153. package/dist/editor/fields/SelectField.js +2 -2
  154. package/dist/editor/fields/StringListField.d.ts +1 -0
  155. package/dist/editor/fields/StringListField.js +2 -2
  156. package/dist/editor/fields/TextField.d.ts +1 -0
  157. package/dist/editor/fields/TextField.js +2 -2
  158. package/dist/editor/fields/ToggleField.d.ts +1 -0
  159. package/dist/editor/fields/ToggleField.js +7 -4
  160. package/dist/editor/fields/entry-link/InsertEntryLink.d.ts +1 -1
  161. package/dist/editor/fields/entry-link/InsertEntryLink.js +3 -2
  162. package/dist/editor/fields/mdx-jsx-support.d.ts +1 -0
  163. package/dist/editor/fields/mdx-jsx-support.js +137 -0
  164. package/dist/editor/hooks/create-branch-request.d.ts +20 -0
  165. package/dist/editor/hooks/create-branch-request.js +52 -0
  166. package/dist/editor/hooks/useBranchActions.d.ts +13 -2
  167. package/dist/editor/hooks/useBranchActions.js +48 -17
  168. package/dist/editor/hooks/useBranchManager.d.ts +21 -0
  169. package/dist/editor/hooks/useBranchManager.js +204 -37
  170. package/dist/editor/hooks/useBranchesData.d.ts +6 -0
  171. package/dist/editor/hooks/useBranchesData.js +9 -3
  172. package/dist/editor/hooks/useCommentSystem.js +25 -6
  173. package/dist/editor/hooks/useDraftManager.d.ts +16 -1
  174. package/dist/editor/hooks/useDraftManager.js +193 -36
  175. package/dist/editor/hooks/useEntriesData.js +3 -3
  176. package/dist/editor/hooks/useEntryManager.d.ts +2 -1
  177. package/dist/editor/hooks/useEntryManager.js +34 -19
  178. package/dist/editor/media/AssetCard.d.ts +1 -1
  179. package/dist/editor/media/crop-math.d.ts +3 -4
  180. package/dist/editor/media/crop-math.js +11 -22
  181. package/dist/editor/media/editor-image-src.d.ts +7 -0
  182. package/dist/editor/media/editor-image-src.js +12 -0
  183. package/dist/editor/preview-asset-base.d.ts +8 -0
  184. package/dist/editor/preview-asset-base.js +13 -0
  185. package/dist/editor/preview-bridge.d.ts +9 -21
  186. package/dist/editor/preview-bridge.js +19 -139
  187. package/dist/editor/preview-path.d.ts +10 -0
  188. package/dist/editor/preview-path.js +20 -0
  189. package/dist/editor/theme.js +2 -2
  190. package/dist/entry-schema-registry.d.ts +4 -4
  191. package/dist/entry-schema-registry.js +10 -6
  192. package/dist/entry-schema.d.ts +27 -4
  193. package/dist/entry-schema.js +20 -3
  194. package/dist/git-manager.d.ts +173 -6
  195. package/dist/git-manager.js +594 -76
  196. package/dist/github-service.d.ts +15 -0
  197. package/dist/github-service.js +19 -1
  198. package/dist/http/handler.js +37 -13
  199. package/dist/http/index.d.ts +2 -0
  200. package/dist/http/index.js +2 -0
  201. package/dist/http/router.d.ts +2 -0
  202. package/dist/http/router.js +2 -1
  203. package/dist/http/types.d.ts +2 -0
  204. package/dist/http/worker-not-ready.d.ts +10 -0
  205. package/dist/http/worker-not-ready.js +24 -0
  206. package/dist/operating-mode/client-unsafe-strategy.js +0 -6
  207. package/dist/operating-mode/types.d.ts +1 -4
  208. package/dist/paths/branch-name.d.ts +5 -0
  209. package/dist/paths/branch-name.js +9 -0
  210. package/dist/paths/branch.d.ts +6 -2
  211. package/dist/paths/branch.js +16 -10
  212. package/dist/paths/index.d.ts +3 -3
  213. package/dist/paths/index.js +3 -3
  214. package/dist/paths/normalize.d.ts +7 -0
  215. package/dist/paths/normalize.js +9 -0
  216. package/dist/paths/validation.js +0 -2
  217. package/dist/preview.d.ts +8 -0
  218. package/dist/preview.js +7 -0
  219. package/dist/reference-resolver.d.ts +5 -21
  220. package/dist/reference-resolver.js +9 -44
  221. package/dist/resolve-canopy-user.js +2 -1
  222. package/dist/schema/schema-store.d.ts +54 -31
  223. package/dist/schema/schema-store.js +77 -31
  224. package/dist/server.d.ts +64 -9
  225. package/dist/server.js +48 -8
  226. package/dist/services.d.ts +18 -6
  227. package/dist/services.js +85 -70
  228. package/dist/settings-workspace.d.ts +23 -3
  229. package/dist/settings-workspace.js +197 -45
  230. package/dist/static/seo.d.ts +2 -14
  231. package/dist/static/seo.js +2 -25
  232. package/dist/submission-attribution.d.ts +73 -0
  233. package/dist/submission-attribution.js +221 -0
  234. package/dist/sync-core.d.ts +12 -1
  235. package/dist/sync-core.js +31 -13
  236. package/dist/task-queue/worker-status.d.ts +8 -0
  237. package/dist/task-queue/worker-status.js +17 -0
  238. package/dist/types.d.ts +31 -2
  239. package/dist/utils/content-serialize.d.ts +5 -2
  240. package/dist/utils/content-serialize.js +98 -24
  241. package/dist/utils/content-write-lock.d.ts +18 -7
  242. package/dist/utils/content-write-lock.js +18 -9
  243. package/dist/utils/debug.d.ts +8 -0
  244. package/dist/utils/debug.js +10 -2
  245. package/dist/utils/git.d.ts +32 -0
  246. package/dist/utils/git.js +42 -0
  247. package/dist/utils/occ-json-write.js +10 -2
  248. package/dist/utils/provision-log.d.ts +25 -0
  249. package/dist/utils/provision-log.js +49 -0
  250. package/dist/utils/provisioning-lock.d.ts +35 -5
  251. package/dist/utils/provisioning-lock.js +73 -12
  252. package/dist/utils/request-timing.d.ts +25 -0
  253. package/dist/utils/request-timing.js +101 -0
  254. package/dist/utils/sanitize-href.d.ts +8 -22
  255. package/dist/utils/sanitize-href.js +11 -28
  256. package/dist/utils/url-prefix.d.ts +30 -0
  257. package/dist/utils/url-prefix.js +61 -0
  258. package/dist/utils/yaml-source-splice.d.ts +58 -0
  259. package/dist/utils/yaml-source-splice.js +515 -0
  260. package/dist/validation/entry-validator.js +9 -3
  261. package/dist/version.d.ts +1 -0
  262. package/dist/version.js +3 -0
  263. package/dist/worker/canopy-state.d.ts +45 -0
  264. package/dist/worker/canopy-state.js +75 -0
  265. package/dist/worker/cms-worker.d.ts +8 -3
  266. package/dist/worker/cms-worker.js +38 -2
  267. package/dist/worker/git-sync.d.ts +30 -12
  268. package/dist/worker/git-sync.js +253 -58
  269. package/dist/worker/history-rewrite.d.ts +1 -1
  270. package/dist/worker/history-rewrite.js +1 -1
  271. package/dist/worker/provisioned-workspace.d.ts +35 -0
  272. package/dist/worker/provisioned-workspace.js +50 -0
  273. package/dist/worker/rebase.d.ts +1 -1
  274. package/dist/worker/rebase.js +95 -23
  275. package/dist/worker/remote-git-maintenance.d.ts +3 -0
  276. package/dist/worker/remote-git-maintenance.js +11 -0
  277. package/dist/worker/sparse-cone.d.ts +20 -0
  278. package/dist/worker/sparse-cone.js +132 -0
  279. package/dist/worker/task-runner.js +99 -14
  280. package/dist/worker/worker-context.d.ts +6 -1
  281. package/package.json +8 -2
@@ -15,11 +15,11 @@ export type AuthCacheRefresher = () => Promise<void>;
15
15
  * interface for the two shapes and why an App is optional.
16
16
  */
17
17
  export interface CmsWorkerConfig extends GitHubAuthConfig {
18
- /** Path to workspace root on EFS (e.g., /mnt/efs/workspace) */
18
+ /** Path to workspace root on EFS (e.g., /mnt/efs) */
19
19
  workspacePath: string;
20
- /** GitHub owner (e.g., 'safeinsights') */
20
+ /** GitHub owner (e.g., 'acme') */
21
21
  githubOwner: string;
22
- /** GitHub repo name (e.g., 'docs-site') */
22
+ /** GitHub repo name (e.g., 'site') */
23
23
  githubRepo: string;
24
24
  /** Called periodically to update the auth metadata cache on EFS. */
25
25
  refreshAuthCache?: AuthCacheRefresher;
@@ -203,6 +203,11 @@ export declare class CmsWorker {
203
203
  * survives, so a failed scrub is never indistinguishable from a clean one.
204
204
  */
205
205
  private scrubPersistedRemote;
206
+ /**
207
+ * Best-effort: without these settings remote.git still works, and a boot that
208
+ * cannot write its own config has a louder problem to report elsewhere.
209
+ */
210
+ private applyRemoteGitConfig;
206
211
  /**
207
212
  * Ensure the remote.git bare repo exists, cloning it from GitHub on first
208
213
  * run.
@@ -5,10 +5,12 @@ import lockfile from 'proper-lockfile';
5
5
  import { recoverOrphanedTasks, cmsTaskQueueLogger } from '../task-queue/cms-task-queue.js';
6
6
  import { createCanopyOctokit } from '../github-service.js';
7
7
  import { isTransientAuthFailure, resolveWorkerGitHubAuth, } from './github-auth.js';
8
+ import { ensureRemoteGitConfig } from '../git-manager.js';
8
9
  import { sanitizeBranchName, RESERVED_SETTINGS_BRANCH_PREFIX } from '../paths/branch-name.js';
9
10
  import { resolveDeploymentName } from '../operating-mode/deployment-name.js';
10
11
  import { getErrorMessage, isNodeError, redactCredentials } from '../utils/error.js';
11
- import { writeWorkerStatus } from '../task-queue/worker-status.js';
12
+ import { readLastFatalError, writeWorkerStatus } from '../task-queue/worker-status.js';
13
+ import { CANOPYCMS_VERSION } from '../version.js';
12
14
  import { workerLog, workerLogWarn, workerLogError } from './log.js';
13
15
  import { executeTask, orphanRecoveryMaxAgeMs, processTaskQueue, pushBranchToGitHub, updateBranchMetadata, } from './task-runner.js';
14
16
  import { pollMergeState, runRebaseCycle } from './rebase.js';
@@ -67,7 +69,12 @@ export class CmsWorker {
67
69
  ensureStatusReport() {
68
70
  if (!this.statusReport) {
69
71
  const now = new Date().toISOString();
70
- this.statusReport = { version: 1, startedAt: now, updatedAt: now };
72
+ this.statusReport = {
73
+ version: 1,
74
+ workerVersion: CANOPYCMS_VERSION,
75
+ startedAt: now,
76
+ updatedAt: now,
77
+ };
71
78
  }
72
79
  return this.statusReport;
73
80
  }
@@ -144,6 +151,21 @@ export class CmsWorker {
144
151
  workerLog('CMS Worker starting...');
145
152
  this.ensureStatusReport();
146
153
  await this.acquireLock();
154
+ // Replace the previous holder's status file now: the first sync can take
155
+ // minutes, and until then System health would report the old worker's
156
+ // version. Best-effort, like the startup-failure write below. The previous
157
+ // `lastFatalError` rides along in this snapshot only, so a crash loop keeps
158
+ // its alert between restarts while the first successful sync still clears it.
159
+ try {
160
+ const lastFatalError = await readLastFatalError(this.taskDir);
161
+ await writeWorkerStatus(this.taskDir, {
162
+ ...this.ensureStatusReport(),
163
+ ...(lastFatalError ? { lastFatalError } : {}),
164
+ });
165
+ }
166
+ catch (err) {
167
+ workerLogError('Failed to write worker status after acquiring the lock:', getErrorMessage(err));
168
+ }
147
169
  // Everything below runs while holding the cross-host worker lock. A failure
148
170
  // here (most notably the empty-remote guard inside ensureRemoteGit) means
149
171
  // the process is about to exit and systemd (Restart=always) will retry —
@@ -411,6 +433,18 @@ export class CmsWorker {
411
433
  `shared storage. Refusing to continue.`);
412
434
  }
413
435
  }
436
+ /**
437
+ * Best-effort: without these settings remote.git still works, and a boot that
438
+ * cannot write its own config has a louder problem to report elsewhere.
439
+ */
440
+ async applyRemoteGitConfig(gitDir) {
441
+ try {
442
+ await ensureRemoteGitConfig(gitDir);
443
+ }
444
+ catch (err) {
445
+ workerLogWarn(`Could not apply remote.git config in ${gitDir}: ${getErrorMessage(err)}`);
446
+ }
447
+ }
414
448
  /**
415
449
  * Ensure the remote.git bare repo exists, cloning it from GitHub on first
416
450
  * run.
@@ -450,6 +484,7 @@ export class CmsWorker {
450
484
  // deletion is the operator's call.
451
485
  throw new Error(`remote.git at ${this.remoteGitPath} has no branch '${this.baseBranch}' (likely cloned while the GitHub repo was empty). Delete ${this.remoteGitPath} and restart the worker to re-clone.`);
452
486
  }
487
+ await this.applyRemoteGitConfig(this.remoteGitPath);
453
488
  return; // Already exists and has the base branch
454
489
  }
455
490
  workerLog('Initializing remote.git from GitHub...');
@@ -468,6 +503,7 @@ export class CmsWorker {
468
503
  // swallowing) if the scrub does not take.
469
504
  await this.scrubPersistedRemote(stagingPath);
470
505
  await this.verifyBaseBranchExists(stagingPath);
506
+ await this.applyRemoteGitConfig(stagingPath);
471
507
  }
472
508
  catch (err) {
473
509
  workerLogError(`remote.git clone failed: ${redactCredentials(getErrorMessage(err))}`);
@@ -1,4 +1,5 @@
1
1
  import { simpleGit } from 'simple-git';
2
+ import type { BaseRefreshReport } from '../types.js';
2
3
  import { type RebaseContext } from './rebase.js';
3
4
  import type { WorkerContext } from './worker-context.js';
4
5
  /**
@@ -6,7 +7,10 @@ import type { WorkerContext } from './worker-context.js';
6
7
  * slower of the worker's two poll loops (default 5 minutes, against the task
7
8
  * queue's 5 seconds).
8
9
  *
9
- * One cycle, in order: fetch every GitHub branch into the tracking namespace,
10
+ * One cycle, in order: repair what killed provisioning and deletes left under
11
+ * the branches root (`repairBranchDirResidue`), move sparse clones to a changed
12
+ * content root's cone (sparse-cone.ts), repack `remote.git` when it needs it
13
+ * (remote-git-maintenance.ts), fetch every GitHub branch into the tracking namespace,
10
14
  * bring `refs/heads/*` toward it non-destructively (`reconcileTrackedBranches`),
11
15
  * push this deployment's own settings branch, fast-forward the base branch's
12
16
  * workspace, rebase every branch that is behind it (rebase.ts), then sweep old
@@ -14,7 +18,7 @@ import type { WorkerContext } from './worker-context.js';
14
18
  *
15
19
  * One ordering is load-bearing and nothing enforces it: `runRebaseCycle` MUST
16
20
  * follow `reconcileTrackedBranches`. Branch clones fetch the base tip from
17
- * `remote.git` (`origin`), and `reconcileTrackedBranches` is what advances
21
+ * `remote.git`, and `reconcileTrackedBranches` is what advances
18
22
  * `remote.git`'s `refs/heads/*` toward what the fetch above put in the tracking
19
23
  * namespace; reorder them and every branch rebases onto the PREVIOUS cycle's
20
24
  * base tip -- not corrupting, but silently a cycle behind.
@@ -39,27 +43,41 @@ export type GitSyncContext = Pick<WorkerContext, 'baseBranch' | 'sanitizedBaseBr
39
43
  */
40
44
  export declare function pushSettingsBranches(ctx: GitSyncContext, git: ReturnType<typeof simpleGit>, trackedNames: ReadonlySet<string>): Promise<void>;
41
45
  export declare function syncGit(ctx: GitSyncContext): Promise<void>;
46
+ /**
47
+ * Repair what killed provisioning and deletes left under the branches root: quarantine residue
48
+ * at a branch's name (branch-provisioning.ts's `classifyFinalDir`) once it has been quiet for
49
+ * {@link ORPHAN_YOUTH_THRESHOLD_MS}, then sweep stale `.prov-*`, `.deleting-*` and `.repair-*`
50
+ * siblings and settings leftovers. Each directory is best-effort, like [SYNC-M2]. The provisioning
51
+ * lock is taken zero-retry, so a live provisioner or admin action wins and the directory is
52
+ * revisited next cycle.
53
+ * @internal Exported for tests; `syncGit` runs it.
54
+ */
55
+ export declare function repairBranchDirResidue(ctx: Pick<GitSyncContext, 'contentBranchesPath' | 'remoteGitPath'>, now?: number): Promise<void>;
42
56
  /**
43
57
  * [C1] Remove `.trash-*` branch directories (created by the admin purge action,
44
- * api/admin-branch-health.ts) whose name-embedded stamp is older than
45
- * {@link TRASH_RETENTION_MS}. A name that doesn't match `.trash-{dirName}-
46
- * {STAMP}`, or whose stamp fails to parse, is left alone and logged once per
47
- * cycle: purge is the only writer of this naming scheme, so an unparseable name
48
- * is unexpected and worth a human looking rather than a silent skip.
58
+ * api/admin-branch-health.ts, and by residue quarantine, branch-provisioning.ts)
59
+ * whose name-embedded stamp is older than {@link TRASH_RETENTION_MS}. A name
60
+ * whose trailing stamp fails to parse is left alone and logged once per cycle:
61
+ * both writers end every name with one, so it is worth a human looking.
49
62
  */
50
63
  export declare function cleanupTrashedBranchDirs(ctx: Pick<GitSyncContext, 'contentBranchesPath'>): Promise<number>;
51
64
  /**
52
65
  * Fast-forward the base branch's own working-tree clone
53
- * (content-branches/<baseBranch>) to match origin/<baseBranch>, every sync
66
+ * (content-branches/<baseBranch>) to match remote.git's <baseBranch>, every sync
54
67
  * cycle, so the drift window is bounded by gitSyncInterval.
55
68
  *
56
69
  * A dedicated, explicit and LOUD step rather than a side effect of the rebase
57
70
  * loop, whose skip paths (a dirty tree, a missing .git) are silent: a wedged
58
71
  * base clone otherwise leaves no diagnosable signal, and an editor forking a
59
- * new branch "from base" silently gets a stale snapshot.
72
+ * new branch "from base" silently gets a stale snapshot. The returned outcome
73
+ * goes to worker-status.json for the same reason.
60
74
  *
61
- * ff-only on purpose: this clone must stay a linear mirror of
62
- * origin/<baseBranch>, so a merge that isn't a fast-forward (diverged local
75
+ * ff-only on purpose: this clone must stay a linear mirror of remote.git's
76
+ * <baseBranch>, so a merge that isn't a fast-forward (diverged local
63
77
  * history) is left untouched rather than force-resolved.
78
+ *
79
+ * Holds the provisioning lock and then, like the rebase loop, the [SYNC-C1]
80
+ * content-write lock, both try-only: the base branch is writable in dev, and
81
+ * a save racing the merge's working-tree update can be overwritten by it.
64
82
  */
65
- export declare function refreshBaseBranchWorkspace(ctx: GitSyncContext): Promise<void>;
83
+ export declare function refreshBaseBranchWorkspace(ctx: GitSyncContext): Promise<BaseRefreshReport>;
@@ -2,39 +2,31 @@ import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { simpleGit } from 'simple-git';
4
4
  import { BranchMetadataFileManager, getBranchMetadataFileManager } from '../branch-metadata.js';
5
+ import { BRANCH_META_DIR, BRANCH_META_FILE } from '../branch-metadata-file.js';
6
+ import { ORPHAN_YOUTH_THRESHOLD_MS } from '../branch-health.js';
7
+ import { classifyFinalDir, parseDirStamp, quarantineResidueAt, sweepProvisioningLeftovers, } from '../branch-provisioning.js';
5
8
  import { invalidateBranchContentCaches } from '../content-index-generation.js';
6
- import { GITHUB_TRACKING_REF_PREFIX, gitNetworkChildEnv } from '../git-manager.js';
9
+ import { GITHUB_TRACKING_REF_PREFIX, ensureGitExcludePattern, gitNetworkChildEnv, } from '../git-manager.js';
7
10
  import { RESERVED_SETTINGS_BRANCH_PREFIX } from '../paths/branch-name.js';
11
+ import { tryAcquireContentWriteLock } from '../utils/content-write-lock.js';
12
+ import { branchProvisioningLockName, tryAcquireProvisioningLock } from '../utils/provisioning-lock.js';
8
13
  import { getErrorMessage, isNodeError, redactCredentials } from '../utils/error.js';
9
- import { isNonFastForwardRejection } from '../utils/git.js';
14
+ import { CANOPY_META_DIR, isCanopyInternalPath, isNonFastForwardRejection } from '../utils/git.js';
15
+ import { MAX_REPORTED_PATHS, TRACKED_CANOPY_STATE_FIX, isUntracked, listTrackedCanopyState, restoreRetiredSchemaCache, splitByUpstreamTracking, untrackInIndex, } from './canopy-state.js';
10
16
  import { hasPendingHistoryRewrite } from './history-rewrite.js';
11
17
  import { runRebaseCycle } from './rebase.js';
12
18
  import { cleanupOldTasks } from '../task-queue/cms-task-queue.js';
13
19
  import { writeWorkerStatus } from '../task-queue/worker-status.js';
14
20
  import { workerLog, workerLogError, workerLogWarn } from './log.js';
21
+ import { holdProvisionedWorkspace, releaseProvisionedWorkspace } from './provisioned-workspace.js';
22
+ import { maintainRemoteGit } from './remote-git-maintenance.js';
23
+ import { reapplySparseCones } from './sparse-cone.js';
15
24
  /**
16
25
  * [C1] Retention window for `.trash-*` branch directories left behind by the
17
26
  * admin purge action (api/admin-branch-health.ts). Matches
18
27
  * cleanupOldTasks's default task retention for consistency.
19
28
  */
20
29
  const TRASH_RETENTION_MS = 30 * 24 * 60 * 60_000;
21
- /** Matches `.trash-{dirName}-{STAMP}` names, capturing the trailing stamp. */
22
- const TRASH_DIR_STAMP_RE = /-(\d{8}T\d{6}Z)$/;
23
- /**
24
- * Parse a purge-generated `YYYYMMDDTHHMMSSZ` stamp into a Date, or null if
25
- * malformed. Age comes ONLY from this name-embedded stamp, never the dir's
26
- * own mtime -- `fs.rename` preserves the original directory's mtime, so an
27
- * mtime-based retention check would delete a months-stale orphan's trash on
28
- * the very first cleanup pass after purge.
29
- */
30
- function parseTrashStamp(stamp) {
31
- const match = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/.exec(stamp);
32
- if (!match)
33
- return null;
34
- const [, year, month, day, hour, minute, second] = match;
35
- const date = new Date(`${year}-${month}-${day}T${hour}:${minute}:${second}.000Z`);
36
- return Number.isNaN(date.getTime()) ? null : date;
37
- }
38
30
  /**
39
31
  * Push THIS deployment's own settings branch (`ensureSettingsBranch()`) from
40
32
  * remote.git to GitHub. Non-fatal: a no-op push for an up-to-date branch just
@@ -288,6 +280,27 @@ export async function syncGit(ctx) {
288
280
  // mask the real error on a failed one. Failures rethrow, so scheduleLoop's
289
281
  // per-cycle catch stays the loud path.
290
282
  try {
283
+ // Best-effort and ahead of the GitHub fetch, so a GitHub outage never
284
+ // stalls upkeep and a failure here never costs the cycle. start() runs a
285
+ // cycle at once, so residue repair also runs on boot.
286
+ try {
287
+ await repairBranchDirResidue(ctx);
288
+ }
289
+ catch (err) {
290
+ workerLogWarn(`Branch directory residue repair failed: ${getErrorMessage(err)}`);
291
+ }
292
+ try {
293
+ await reapplySparseCones(ctx);
294
+ }
295
+ catch (err) {
296
+ workerLogWarn(`Sparse-checkout cone update failed: ${getErrorMessage(err)}`);
297
+ }
298
+ try {
299
+ await maintainRemoteGit(ctx.remoteGitPath);
300
+ }
301
+ catch (err) {
302
+ workerLogWarn(`remote.git maintenance failed: ${getErrorMessage(err)}`);
303
+ }
291
304
  // Direct URL (no named remote), into the GITHUB_TRACKING_REF_PREFIX
292
305
  // remote-tracking namespace rather than refs/heads/* -- see that constant's
293
306
  // doc comment for the destructive-fetch bug this avoids. Raw git, because
@@ -306,7 +319,7 @@ export async function syncGit(ctx) {
306
319
  // correctness dependency now that the fetch can't clobber refs/heads/*
307
320
  // -- this could run before or after them just as safely.
308
321
  await pushSettingsBranches(ctx, git, trackedNames);
309
- await refreshBaseBranchWorkspace(ctx);
322
+ const baseRefresh = await refreshBaseBranchWorkspace(ctx);
310
323
  const rebaseSummary = await runRebaseCycle(ctx);
311
324
  await cleanupOldTasks(ctx.taskDir, undefined, ctx.log);
312
325
  // [C1] Sweep branch directories the admin purge action trashed more than
@@ -323,9 +336,14 @@ export async function syncGit(ctx) {
323
336
  report.lastGitSync = {
324
337
  durationMs: Date.now() - cycleStartedAt,
325
338
  rebased: rebaseSummary.rebased,
326
- skippedDirty: rebaseSummary.skippedDirty,
327
- skippedLocked: rebaseSummary.skippedLocked,
339
+ skippedDirty: baseRefresh.outcome === 'skipped-dirty'
340
+ ? [ctx.sanitizedBaseBranch, ...rebaseSummary.skippedDirty]
341
+ : rebaseSummary.skippedDirty,
342
+ skippedLocked: baseRefresh.outcome === 'skipped-locked'
343
+ ? [ctx.sanitizedBaseBranch, ...rebaseSummary.skippedLocked]
344
+ : rebaseSummary.skippedLocked,
328
345
  failed: rebaseSummary.failed,
346
+ baseRefresh,
329
347
  tracked: trackedSummary,
330
348
  };
331
349
  await writeWorkerStatus(ctx.taskDir, report).catch((writeErr) => workerLogError('Failed to write worker status:', getErrorMessage(writeErr)));
@@ -343,13 +361,87 @@ export async function syncGit(ctx) {
343
361
  throw err;
344
362
  }
345
363
  }
364
+ /** Foreign repositories already reported by this process, so each is logged once. */
365
+ const reportedForeignDirs = new Set();
366
+ /**
367
+ * Repair what killed provisioning and deletes left under the branches root: quarantine residue
368
+ * at a branch's name (branch-provisioning.ts's `classifyFinalDir`) once it has been quiet for
369
+ * {@link ORPHAN_YOUTH_THRESHOLD_MS}, then sweep stale `.prov-*`, `.deleting-*` and `.repair-*`
370
+ * siblings and settings leftovers. Each directory is best-effort, like [SYNC-M2]. The provisioning
371
+ * lock is taken zero-retry, so a live provisioner or admin action wins and the directory is
372
+ * revisited next cycle.
373
+ * @internal Exported for tests; `syncGit` runs it.
374
+ */
375
+ export async function repairBranchDirResidue(ctx, now = Date.now()) {
376
+ let entries;
377
+ try {
378
+ entries = await fs.readdir(ctx.contentBranchesPath, { withFileTypes: true });
379
+ }
380
+ catch (err) {
381
+ if (isNodeError(err) && err.code === 'ENOENT')
382
+ return;
383
+ throw err;
384
+ }
385
+ for (const entry of entries) {
386
+ if (!entry.isDirectory() || entry.name.startsWith('.'))
387
+ continue;
388
+ try {
389
+ await repairOneBranchDir(ctx, entry.name, now);
390
+ }
391
+ catch (err) {
392
+ workerLogWarn(` Residue check of ${entry.name} failed: ${getErrorMessage(err)}`);
393
+ }
394
+ }
395
+ const leftovers = await sweepProvisioningLeftovers(ctx.contentBranchesPath, path.dirname(ctx.contentBranchesPath), now);
396
+ for (const { name, action, detail } of leftovers) {
397
+ const line = ` Leftover ${name}: ${action}${detail ? ` (${detail})` : ''}`;
398
+ if (action === 'removed')
399
+ workerLog(line);
400
+ else if (action === 'failed')
401
+ workerLogWarn(line);
402
+ else
403
+ workerLogError(line);
404
+ }
405
+ }
406
+ async function repairOneBranchDir(ctx, dirName, now) {
407
+ const dirPath = path.join(ctx.contentBranchesPath, dirName);
408
+ // One stat for the common case: a live (or corrupt) branch is never residue.
409
+ const hasMeta = await fs.access(path.join(dirPath, BRANCH_META_DIR, BRANCH_META_FILE)).then(() => true, () => false);
410
+ if (hasMeta)
411
+ return;
412
+ const state = await classifyFinalDir(dirPath, ctx.remoteGitPath);
413
+ if (state.kind === 'foreign' && !reportedForeignDirs.has(dirPath)) {
414
+ reportedForeignDirs.add(dirPath);
415
+ workerLogWarn(` ${dirName} holds a git repository this deployment did not create; left for an admin`);
416
+ }
417
+ if (state.kind !== 'residue')
418
+ return;
419
+ let release;
420
+ try {
421
+ release = await tryAcquireProvisioningLock(ctx.contentBranchesPath, branchProvisioningLockName(dirName));
422
+ }
423
+ catch (err) {
424
+ if (isNodeError(err) && err.code === 'ELOCKED')
425
+ return;
426
+ throw err;
427
+ }
428
+ try {
429
+ await quarantineResidueAt(ctx.contentBranchesPath, dirName, {
430
+ minQuietMs: ORPHAN_YOUTH_THRESHOLD_MS,
431
+ expectedRemoteUrl: ctx.remoteGitPath,
432
+ now,
433
+ });
434
+ }
435
+ finally {
436
+ await releaseProvisionedWorkspace(release, dirName);
437
+ }
438
+ }
346
439
  /**
347
440
  * [C1] Remove `.trash-*` branch directories (created by the admin purge action,
348
- * api/admin-branch-health.ts) whose name-embedded stamp is older than
349
- * {@link TRASH_RETENTION_MS}. A name that doesn't match `.trash-{dirName}-
350
- * {STAMP}`, or whose stamp fails to parse, is left alone and logged once per
351
- * cycle: purge is the only writer of this naming scheme, so an unparseable name
352
- * is unexpected and worth a human looking rather than a silent skip.
441
+ * api/admin-branch-health.ts, and by residue quarantine, branch-provisioning.ts)
442
+ * whose name-embedded stamp is older than {@link TRASH_RETENTION_MS}. A name
443
+ * whose trailing stamp fails to parse is left alone and logged once per cycle:
444
+ * both writers end every name with one, so it is worth a human looking.
353
445
  */
354
446
  export async function cleanupTrashedBranchDirs(ctx) {
355
447
  let entries;
@@ -367,8 +459,7 @@ export async function cleanupTrashedBranchDirs(ctx) {
367
459
  for (const name of entries) {
368
460
  if (!name.startsWith('.trash-'))
369
461
  continue;
370
- const stampMatch = TRASH_DIR_STAMP_RE.exec(name);
371
- const stampDate = stampMatch ? parseTrashStamp(stampMatch[1]) : null;
462
+ const stampDate = parseDirStamp(name);
372
463
  if (!stampDate) {
373
464
  if (!loggedUnparseable) {
374
465
  workerLog(`CanopyCMS: Skipping trash dir with unparseable stamp: ${name}`);
@@ -393,37 +484,76 @@ export async function cleanupTrashedBranchDirs(ctx) {
393
484
  }
394
485
  return removed;
395
486
  }
487
+ /**
488
+ * Warned (base path + tracked file list) pairs, so a repo that tracks
489
+ * `.canopy-meta/` is called out once per worker process rather than every cycle;
490
+ * worker-status.json carries it every cycle regardless.
491
+ */
492
+ const warnedTrackedCanopyState = new Set();
396
493
  /**
397
494
  * Fast-forward the base branch's own working-tree clone
398
- * (content-branches/<baseBranch>) to match origin/<baseBranch>, every sync
495
+ * (content-branches/<baseBranch>) to match remote.git's <baseBranch>, every sync
399
496
  * cycle, so the drift window is bounded by gitSyncInterval.
400
497
  *
401
498
  * A dedicated, explicit and LOUD step rather than a side effect of the rebase
402
499
  * loop, whose skip paths (a dirty tree, a missing .git) are silent: a wedged
403
500
  * base clone otherwise leaves no diagnosable signal, and an editor forking a
404
- * new branch "from base" silently gets a stale snapshot.
501
+ * new branch "from base" silently gets a stale snapshot. The returned outcome
502
+ * goes to worker-status.json for the same reason.
405
503
  *
406
- * ff-only on purpose: this clone must stay a linear mirror of
407
- * origin/<baseBranch>, so a merge that isn't a fast-forward (diverged local
504
+ * ff-only on purpose: this clone must stay a linear mirror of remote.git's
505
+ * <baseBranch>, so a merge that isn't a fast-forward (diverged local
408
506
  * history) is left untouched rather than force-resolved.
507
+ *
508
+ * Holds the provisioning lock and then, like the rebase loop, the [SYNC-C1]
509
+ * content-write lock, both try-only: the base branch is writable in dev, and
510
+ * a save racing the merge's working-tree update can be overwritten by it.
409
511
  */
410
512
  export async function refreshBaseBranchWorkspace(ctx) {
411
513
  // Sanitized name for the workspace directory (a base branch containing
412
514
  // e.g. '/' would otherwise stat a wrong nested path here forever).
413
515
  const basePath = path.join(ctx.contentBranchesPath, ctx.sanitizedBaseBranch);
414
- const gitDir = path.join(basePath, '.git');
516
+ let trackedCanopyMeta;
517
+ let releaseProvisioning;
518
+ let releaseContentLock;
415
519
  try {
416
- let gitDirStat;
417
- try {
418
- gitDirStat = await fs.stat(gitDir);
419
- }
420
- catch {
421
- gitDirStat = null;
520
+ // Held to the end, so no git step below races a clone of this directory.
521
+ const hold = await holdProvisionedWorkspace(ctx.contentBranchesPath, ctx.sanitizedBaseBranch);
522
+ if (hold.kind === 'locked') {
523
+ workerLog(`Base branch workspace (${ctx.baseBranch}): provisioning lock held elsewhere, skipping refresh`);
524
+ return { outcome: 'skipped-locked' };
422
525
  }
423
- if (!gitDirStat || !gitDirStat.isDirectory()) {
526
+ if (hold.kind === 'not-provisioned') {
424
527
  workerLog(`Base branch workspace (${ctx.baseBranch}): not yet provisioned, skipping refresh`);
425
- return;
528
+ return { outcome: 'skipped-not-provisioned' };
426
529
  }
530
+ releaseProvisioning = hold.release;
531
+ let contentLockCompromised = false;
532
+ try {
533
+ releaseContentLock = await tryAcquireContentWriteLock(basePath, (lockErr) => {
534
+ contentLockCompromised = true;
535
+ workerLogWarn(`Base branch workspace (${ctx.baseBranch}): content-write lock compromised mid-refresh: ${getErrorMessage(lockErr)}`);
536
+ });
537
+ }
538
+ catch (lockErr) {
539
+ if (isNodeError(lockErr) && lockErr.code === 'ELOCKED') {
540
+ workerLog(`Base branch workspace (${ctx.baseBranch}): content write in progress, skipping refresh`);
541
+ return { outcome: 'skipped-locked' };
542
+ }
543
+ throw lockErr;
544
+ }
545
+ // Checked before each destructive step: a lost provisioning lock means
546
+ // another process may be cloning into this directory, a lost content lock
547
+ // that an editor save may be landing in it.
548
+ const lockLost = () => {
549
+ if (!hold.isCompromised() && !contentLockCompromised)
550
+ return null;
551
+ const lost = hold.isCompromised() ? 'provisioning' : 'content-write';
552
+ workerLogWarn(`Base branch workspace (${ctx.baseBranch}): ${lost} lock lost mid-refresh, stopping`);
553
+ return { outcome: 'skipped-locked', trackedCanopyMeta };
554
+ };
555
+ // Idempotent, and applied every cycle so any clone lacking it gets it.
556
+ await ensureGitExcludePattern(basePath, `${CANOPY_META_DIR}/`);
427
557
  const baseGit = simpleGit({
428
558
  baseDir: basePath,
429
559
  // Keep git non-interactive during the merge so it never blocks on an
@@ -437,37 +567,80 @@ export async function refreshBaseBranchWorkspace(ctx) {
437
567
  // Inactivity-based, so a slow-but-flowing transfer is unaffected.
438
568
  timeout: { block: ctx.taskTimeoutMs },
439
569
  });
570
+ const trackedState = await listTrackedCanopyState(baseGit);
571
+ if (trackedState.length > 0) {
572
+ trackedCanopyMeta = trackedState.slice(0, MAX_REPORTED_PATHS);
573
+ const warnKey = `${basePath}\0${trackedState.join('\0')}`;
574
+ if (!warnedTrackedCanopyState.has(warnKey)) {
575
+ warnedTrackedCanopyState.add(warnKey);
576
+ workerLogWarn(`Base branch (${ctx.baseBranch}) tracks canopycms state that must not be committed: ` +
577
+ `${trackedState.join(', ')}. To fix, ${TRACKED_CANOPY_STATE_FIX}.`);
578
+ }
579
+ }
580
+ let status = await baseGit.status();
581
+ const lostBeforeRestore = lockLost();
582
+ if (lostBeforeRestore)
583
+ return lostBeforeRestore;
584
+ if (await restoreRetiredSchemaCache(baseGit, status)) {
585
+ workerLog(`Base branch workspace (${ctx.baseBranch}): restored the retired in-tree schema cache`);
586
+ status = await baseGit.status();
587
+ }
440
588
  // Nothing makes this clone read-only, and a direct edit here wedges every
441
589
  // editor's view of the base branch until an operator intervenes, so a dirty
442
- // tree is loud, not a quiet skip. Only TRACKED changes block the refresh: a
443
- // stray untracked file must not wedge the fast-forward forever, and if one
444
- // would collide with incoming content the --ff-only merge below refuses on
445
- // its own and that failure is already logged loudly.
446
- const status = await baseGit.status();
447
- const trackedDirty = status.files.filter((f) => f.index !== '?' || f.working_dir !== '?');
590
+ // tree is loud, not a quiet skip. Only TRACKED content changes block the
591
+ // refresh: canopycms's own state never does, and neither does a stray
592
+ // untracked file. A modified tracked file or an untracked one that would
593
+ // collide with incoming content makes the --ff-only merge below refuse and
594
+ // report `failed`; an IGNORED file, which every .canopy-meta file is, git
595
+ // overwrites instead.
596
+ const trackedDirty = status.files
597
+ .filter((f) => !isUntracked(f) && !isCanopyInternalPath(f.path))
598
+ .map((f) => f.path);
448
599
  if (trackedDirty.length > 0) {
449
- workerLogError(`Base branch workspace (${ctx.baseBranch}) has uncommitted changes -- skipping refresh. Dirty files: ${trackedDirty.map((f) => f.path).join(', ')}`);
450
- return;
600
+ workerLogError(`Base branch workspace (${ctx.baseBranch}) has uncommitted changes -- skipping refresh. Dirty files: ${trackedDirty.join(', ')}`);
601
+ return {
602
+ outcome: 'skipped-dirty',
603
+ dirtyFiles: trackedDirty.slice(0, MAX_REPORTED_PATHS),
604
+ message: `${trackedDirty.length} uncommitted tracked file(s) in the base branch workspace`,
605
+ trackedCanopyMeta,
606
+ };
451
607
  }
452
608
  // Raw (unsanitized) name from here on: these are git ref operations
453
- // against origin/<baseBranch>, not filesystem paths, so they must use
609
+ // against remote.git's <baseBranch>, not filesystem paths, so they must use
454
610
  // the same name GitHub knows the branch by.
455
- await baseGit.fetch('origin', ctx.baseBranch);
611
+ await baseGit.fetch(ctx.remoteGitPath, ctx.baseBranch);
456
612
  // rev-list, not status.behind, which needs an upstream tracking branch that
457
- // is not guaranteed here. Against the just-fetched tip rather than
458
- // origin/<base>: workspaces are cloned --single-branch (git-manager.ts), so
459
- // for any other base branch origin/<base> never exists and rev-list dies
460
- // with "ambiguous argument". Pin FETCH_HEAD to a SHA immediately -- it is
461
- // one shared mutable file per repo, silently repointed by any other fetch.
613
+ // is not guaranteed here. Against the just-fetched tip: a fetch by path
614
+ // updates no remote-tracking ref. Pin FETCH_HEAD to a SHA immediately -- it
615
+ // is one shared mutable file per repo, silently repointed by any other fetch.
462
616
  const fetchedTip = (await baseGit.revparse(['FETCH_HEAD'])).trim();
463
617
  const behindCount = parseInt((await baseGit.raw(['rev-list', '--count', `HEAD..${fetchedTip}`])).trim(), 10);
464
618
  if (behindCount > 0) {
619
+ const lostBeforeMerge = lockLost();
620
+ if (lostBeforeMerge)
621
+ return lostBeforeMerge;
622
+ // Untrack, in the index only, any state the tip has stopped tracking:
623
+ // the merge would otherwise refuse to overwrite a modified copy, or
624
+ // delete a clean one from disk. Safe here and not in the rebase loop,
625
+ // because this clone has no commits of its own to replay. State still
626
+ // tracked upstream is left for the merge, which refuses where upstream
627
+ // changed a locally modified copy.
628
+ const { droppedUpstream } = await splitByUpstreamTracking(baseGit, trackedState, fetchedTip);
629
+ if (droppedUpstream.length > 0) {
630
+ await untrackInIndex(baseGit, droppedUpstream);
631
+ workerLog(`Base branch workspace (${ctx.baseBranch}): stopped tracking ${droppedUpstream.join(', ')}, as upstream has`);
632
+ }
465
633
  try {
466
634
  await baseGit.merge(['--ff-only', fetchedTip]);
467
635
  }
468
636
  catch (err) {
469
637
  workerLogError(`Base branch workspace (${ctx.baseBranch}) failed to fast-forward (diverged local history?): ${getErrorMessage(err)}`);
470
- return;
638
+ return {
639
+ outcome: 'failed',
640
+ // [REDACT] Served to the browser by the admin panel.
641
+ message: redactCredentials(`failed to fast-forward: ${getErrorMessage(err)}`),
642
+ trackedCanopyMeta,
643
+ };
471
644
  }
472
645
  await invalidateBranchContentCaches(basePath);
473
646
  }
@@ -491,8 +664,30 @@ export async function refreshBaseBranchWorkspace(ctx) {
491
664
  workerLog(behindCount > 0
492
665
  ? `Base branch workspace (${ctx.baseBranch}): fast-forwarded ${behindCount} commit(s)`
493
666
  : `Base branch workspace (${ctx.baseBranch}): up to date`);
667
+ if (trackedCanopyMeta && behindCount > 0) {
668
+ const stillTracked = await listTrackedCanopyState(baseGit);
669
+ trackedCanopyMeta =
670
+ stillTracked.length > 0 ? stillTracked.slice(0, MAX_REPORTED_PATHS) : undefined;
671
+ }
672
+ return { outcome: behindCount > 0 ? 'refreshed' : 'up-to-date', trackedCanopyMeta };
494
673
  }
495
674
  catch (err) {
496
675
  workerLogError(`Base branch workspace (${ctx.baseBranch}) refresh failed: ${getErrorMessage(err)}`);
676
+ // [REDACT] Served to the browser by the admin panel.
677
+ return {
678
+ outcome: 'failed',
679
+ message: redactCredentials(getErrorMessage(err)),
680
+ trackedCanopyMeta,
681
+ };
682
+ }
683
+ finally {
684
+ if (releaseContentLock) {
685
+ await releaseContentLock().catch((err) => {
686
+ workerLogWarn(`Base branch workspace (${ctx.baseBranch}): failed to release content-write lock: ${getErrorMessage(err)}`);
687
+ });
688
+ }
689
+ if (releaseProvisioning) {
690
+ await releaseProvisionedWorkspace(releaseProvisioning, ctx.sanitizedBaseBranch);
691
+ }
497
692
  }
498
693
  }
@@ -72,7 +72,7 @@ export declare function clearHistoryRewrittenMarker(ctx: Pick<HistoryRewriteCont
72
72
  * Returns whether the push landed. A refused lease means a concurrent Lambda
73
73
  * push moved the ref; logged and retried by the self-heal pass, never thrown.
74
74
  */
75
- export declare function forcePublishToLocalRemote(ctx: Pick<HistoryRewriteContext, 'taskTimeoutMs'>, branchPath: string, branchRef: string, expectedSha: string): Promise<boolean>;
75
+ export declare function forcePublishToLocalRemote(ctx: Pick<HistoryRewriteContext, 'remoteGitPath' | 'taskTimeoutMs'>, branchPath: string, branchRef: string, expectedSha: string): Promise<boolean>;
76
76
  /**
77
77
  * Queue the GitHub hop for a branch whose rewritten history now sits in
78
78
  * `remote.git`, so an open PR's head follows the rebase within a cycle instead
@@ -108,7 +108,7 @@ export async function forcePublishToLocalRemote(ctx, branchPath, branchRef, expe
108
108
  // Real flags must precede --end-of-options; everything after it is
109
109
  // positional (see GitManager.push() for the same guard).
110
110
  '--end-of-options',
111
- 'origin',
111
+ ctx.remoteGitPath,
112
112
  `${branchRef}:${branchRef}`,
113
113
  ]);
114
114
  workerLog(` Published rebased ${branchRef} into remote.git`);