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
@@ -19,8 +19,9 @@ import { simpleGit, } from 'simple-git';
19
19
  import { invalidateContentIndexesForRoot } from './content-index-registry.js';
20
20
  import { invalidateBranchContentCaches } from './content-index-generation.js';
21
21
  import { createDebugLogger } from './utils/debug.js';
22
- import { getErrorMessage, isNotFoundError } from './utils/error.js';
23
- import { isMissingRemoteRefFailure, isNetworkRemoteUrl, resolveBaseBranch } from './utils/git.js';
22
+ import { getErrorMessage, isNodeError, isNotFoundError, redactCredentials } from './utils/error.js';
23
+ import { isMissingRemoteRefFailure, isNetworkRemoteUrl, resolveBaseBranch, stageAllExceptCanopyState, } from './utils/git.js';
24
+ import { canopyLogWarn } from './utils/logger.js';
24
25
  import { acquireProvisioningLock } from './utils/provisioning-lock.js';
25
26
  const log = createDebugLogger({ prefix: 'GitManager' });
26
27
  /**
@@ -88,6 +89,134 @@ export function gitNetworkChildEnv() {
88
89
  }
89
90
  return { ...env, ...FORCE_C_LOCALE };
90
91
  }
92
+ /**
93
+ * `-c` on every git process a GitManager starts, so gc and auto-maintenance
94
+ * never run inside a Lambda, which can be frozen mid-run with a half-written
95
+ * pack or a `gc.pid` left on EFS. A provisioning clone also persists them
96
+ * into the workspace's own config. `remote.git`'s `receive-pack` never sees
97
+ * them; {@link REMOTE_GIT_CONFIG} covers that side.
98
+ */
99
+ const NO_AUTO_GC_CONFIG = ['gc.auto=0', 'maintenance.auto=false'];
100
+ /**
101
+ * Settings written into every bare `remote.git` CanopyCMS creates: the worker's (prod) and the
102
+ * simulated remote (dev). A push runs `receive-pack` with remote.git's own config and none of the
103
+ * pusher's: git unsets `GIT_CONFIG_PARAMETERS` before spawning it for a local-path push. Unset, a
104
+ * push starts auto-housekeeping there (detached, or inside a Lambda that is frozen mid-run) that
105
+ * repacks while another process clones, and pushes under the default `unpackLimit` (100) leave
106
+ * loose objects that every local clone copies one NFS round trip at a time.
107
+ *
108
+ * Never an `extensions.*` key: once a repo is at `repositoryformatversion` 1, a git that does not
109
+ * know the key refuses it, and Lambda runs git 2.39.
110
+ * @internal Exported for tests.
111
+ */
112
+ export const REMOTE_GIT_CONFIG = [
113
+ ['gc.auto', '0'],
114
+ ['receive.autogc', 'false'],
115
+ ['maintenance.auto', 'false'],
116
+ ['transfer.unpackLimit', '1'],
117
+ ];
118
+ /** Write each of {@link REMOTE_GIT_CONFIG} into the bare repo `gitDir` only where it differs. */
119
+ export async function ensureRemoteGitConfig(gitDir) {
120
+ const git = simpleGit().env(gitChildEnv({}));
121
+ const current = new Map();
122
+ const listed = await git.raw(['--git-dir', gitDir, 'config', '--local', '--list']);
123
+ for (const line of listed.split('\n')) {
124
+ const eq = line.indexOf('=');
125
+ if (eq > 0)
126
+ current.set(line.slice(0, eq).toLowerCase(), line.slice(eq + 1));
127
+ }
128
+ for (const [key, value] of REMOTE_GIT_CONFIG) {
129
+ if (current.get(key.toLowerCase()) === value)
130
+ continue;
131
+ await git.raw(['--git-dir', gitDir, 'config', '--local', key, value]);
132
+ }
133
+ }
134
+ /** Above either count, {@link repackBareRemoteIfNeeded} repacks. */
135
+ const REMOTE_GIT_MAX_LOOSE_OBJECTS = 50;
136
+ const REMOTE_GIT_MAX_PACKS = 6;
137
+ async function countObjects(git, gitDir) {
138
+ const output = await git.raw(['--git-dir', gitDir, 'count-objects', '-v']);
139
+ const fields = new Map(output.split('\n').map((line) => {
140
+ const [name, value = ''] = line.split(': ');
141
+ return [name, value.trim()];
142
+ }));
143
+ const field = (name) => {
144
+ const value = fields.get(name);
145
+ if (!value || !/^\d+$/.test(value)) {
146
+ throw new Error(`count-objects -v printed no '${name}' count: ${output.trim()}`);
147
+ }
148
+ return Number(value);
149
+ };
150
+ return { loose: field('count'), packs: field('packs') };
151
+ }
152
+ /**
153
+ * Repack the bare repo `gitDir` once it holds more than {@link REMOTE_GIT_MAX_LOOSE_OBJECTS}
154
+ * loose objects or {@link REMOTE_GIT_MAX_PACKS} packs, then pack its refs. Under
155
+ * {@link REMOTE_GIT_CONFIG} every push adds a pack, and nothing else ever compacts them.
156
+ *
157
+ * Safe beside concurrent pushes and clones, which is why it is `--cruft` with no expiry:
158
+ * unreachable objects move into a cruft pack rather than being dropped, so a push that read an
159
+ * old object before the repack still finds it, and a repack only deletes packs it listed when it
160
+ * began. A clone that hardlinked a pack keeps that inode when the repack unlinks the remote's
161
+ * name for it, and packs are never modified in place. `repack -d` also removes the loose objects
162
+ * it packed and their emptied fan-out directories.
163
+ */
164
+ export async function repackBareRemoteIfNeeded(gitDir) {
165
+ const git = simpleGit().env(gitChildEnv({}));
166
+ const before = await countObjects(git, gitDir);
167
+ if (before.loose <= REMOTE_GIT_MAX_LOOSE_OBJECTS && before.packs <= REMOTE_GIT_MAX_PACKS) {
168
+ return { repacked: false, before };
169
+ }
170
+ const startedAt = Date.now();
171
+ await git.raw(['--git-dir', gitDir, 'repack', '-a', '-d', '--cruft', '-q']);
172
+ await git.raw(['--git-dir', gitDir, 'pack-refs', '--all']);
173
+ const after = await countObjects(git, gitDir);
174
+ return { repacked: true, before, after, ms: Date.now() - startedAt };
175
+ }
176
+ /**
177
+ * simple-git reads a git that exited by signal (exit code null, often with no stderr) as success,
178
+ * so a clone or checkout the OOM killer stopped would pass for complete. Given as `errors` to a
179
+ * GitManager's own instance and to `cloneRepo`'s; its static helpers and the bare-remote functions
180
+ * do not pass it yet (.claude/future-tasks/simple-git-signal-exit-reads-as-success.md).
181
+ */
182
+ function failOnSignalExit(error, result) {
183
+ if (error)
184
+ return error;
185
+ if (result.exitCode === null) {
186
+ return new Error('git was killed by a signal before it finished');
187
+ }
188
+ return undefined;
189
+ }
190
+ /** EFS cost is per-file latency, which parallel file creation overlaps. git >= 2.32. */
191
+ const PARALLEL_CHECKOUT_ARGS = [
192
+ '-c',
193
+ 'checkout.workers=8',
194
+ '-c',
195
+ 'checkout.thresholdForParallelism=1',
196
+ ];
197
+ async function dirState(dir) {
198
+ try {
199
+ return (await fs.readdir(dir)).length === 0 ? 'empty' : 'occupied';
200
+ }
201
+ catch (err) {
202
+ if (isNodeError(err) && err.code === 'ENOENT')
203
+ return 'absent';
204
+ throw err;
205
+ }
206
+ }
207
+ /**
208
+ * Config every workspace clone writes into its own `.git/config`: the managed
209
+ * marker that ensureRemote's guard checks, and a fallback author for internal
210
+ * commits such as the orphan settings init (ensureAuthor() sets the real one
211
+ * before user-facing commits).
212
+ */
213
+ export function managedWorkspaceConfig(gitBotAuthorName, gitBotAuthorEmail) {
214
+ return {
215
+ 'canopycms.managed': 'true',
216
+ 'user.name': gitBotAuthorName,
217
+ 'user.email': gitBotAuthorEmail,
218
+ };
219
+ }
91
220
  // In-memory lock to prevent concurrent remote.git initialization
92
221
  const remoteInitLocks = new Map();
93
222
  /**
@@ -166,13 +295,90 @@ export class GitRemoteRefMissingError extends Error {
166
295
  this.name = 'GitRemoteRefMissingError';
167
296
  }
168
297
  }
298
+ /**
299
+ * No git remote is configured and the one the strategy auto-detects does not
300
+ * exist yet: in prod, the EC2 worker creates `{workspaceRoot}/remote.git` on
301
+ * its first boot, so until then every request that needs a workspace fails
302
+ * here. Usually transient, unlike a missing remote in a mode with nothing to wait for.
303
+ * The HTTP layer maps it to a 503 (`http/worker-not-ready.ts`).
304
+ */
305
+ export class RemoteNotReadyError extends Error {
306
+ constructor(expectedRemotePath) {
307
+ super(`CanopyCMS: no git remote is available yet. defaultRemoteUrl (or CANOPYCMS_REMOTE_URL) is ` +
308
+ `not set and the CMS worker has not created ${expectedRemotePath}; the worker may still be starting.`);
309
+ this.expectedRemotePath = expectedRemotePath;
310
+ this.name = 'RemoteNotReadyError';
311
+ }
312
+ }
313
+ /**
314
+ * Settings provisioning could not read the remote's settings branch, so it cannot tell a remote
315
+ * that has none from one it failed to reach. It stops instead of starting an empty settings
316
+ * branch: settings that silently vanish drop restrictive path rules, so content they hid
317
+ * becomes readable under `defaultPathAccess: { read: 'allow' }`.
318
+ */
319
+ class SettingsRemoteUnreadableError extends Error {
320
+ constructor(branch, remote,
321
+ /** The underlying git failure; see {@link GitRemoteRefMissingError.gitError}. */
322
+ gitError) {
323
+ super(`CanopyCMS: could not read settings branch '${branch}' from remote '${remote}', so ` +
324
+ `the settings workspace was not provisioned: ${getErrorMessage(gitError)}`);
325
+ this.branch = branch;
326
+ this.remote = remote;
327
+ this.gitError = gitError;
328
+ this.name = 'SettingsRemoteUnreadableError';
329
+ }
330
+ }
331
+ /**
332
+ * The workspace's settings branch shares no history with the remote's and holds work of its
333
+ * own, so neither copy can be chosen automatically, and a settings save could never pull.
334
+ */
335
+ class SettingsBranchDivergedError extends Error {
336
+ constructor(branch, workspacePath,
337
+ /** What the workspace holds beyond an empty initial commit. */
338
+ localWork) {
339
+ super(`CanopyCMS: settings branch '${branch}' in ${workspacePath} shares no history with the ` +
340
+ `remote's '${branch}', and holds ${localWork}. Settings saves cannot pull until one copy ` +
341
+ `is chosen. To keep the remote's settings: copy anything you need out of ` +
342
+ `${workspacePath}, move it aside, and restart; the next start checks out the remote's ` +
343
+ `'${branch}', and copied changes can then be re-applied in the editor.`);
344
+ this.branch = branch;
345
+ this.workspacePath = workspacePath;
346
+ this.localWork = localWork;
347
+ this.name = 'SettingsBranchDivergedError';
348
+ }
349
+ }
350
+ /** The only files a settings branch ever commits (explicit paths at its root). */
351
+ const SETTINGS_BRANCH_FILES = new Set(['permissions.json', 'groups.json']);
352
+ /**
353
+ * The remote's settings branch holds content rather than settings. Adopting it would load no
354
+ * groups or path rules, and settings saves would then commit onto content history.
355
+ */
356
+ class SettingsBranchHasContentHistoryError extends Error {
357
+ constructor(branch, remote,
358
+ /** What shows it is content. */
359
+ evidence) {
360
+ super(`CanopyCMS: the settings branch '${branch}' on remote '${remote}' holds content, not ` +
361
+ `settings (${evidence}), so it was not checked out. Restore '${branch}' on the remote ` +
362
+ `from a copy of the settings (it may hold only permissions.json and groups.json), ` +
363
+ `then restart.`);
364
+ this.branch = branch;
365
+ this.remote = remote;
366
+ this.evidence = evidence;
367
+ this.name = 'SettingsBranchHasContentHistoryError';
368
+ }
369
+ }
169
370
  export class GitManager {
170
371
  constructor(options, gitOptions) {
171
372
  this.repoPath = path.resolve(options.repoPath);
172
373
  this.baseBranch = options.baseBranch ?? 'main';
173
374
  this.remote = options.remote ?? 'origin';
174
375
  this.skipIndexMarker = options.skipIndexMarker ?? false;
175
- this.git = simpleGit({ baseDir: this.repoPath, ...gitOptions });
376
+ this.git = simpleGit({
377
+ baseDir: this.repoPath,
378
+ errors: failOnSignalExit,
379
+ ...gitOptions,
380
+ config: [...NO_AUTO_GC_CONFIG, ...(gitOptions?.config ?? [])],
381
+ });
176
382
  // `this.git` is for LOCAL working-tree ops: in the intended prod topology
177
383
  // `origin` resolves to a local path (an auto-detected/initialized
178
384
  // `remote.git`), so its env is gitChildEnv's allowlist, which drops
@@ -187,14 +393,47 @@ export class GitManager {
187
393
  // operate on the host repo above.
188
394
  this.git.env(gitChildEnv({ GIT_CEILING_DIRECTORIES: path.dirname(this.repoPath) }));
189
395
  }
190
- static async cloneRepo(remoteUrl, targetPath, baseBranch = 'main') {
396
+ /**
397
+ * Single-branch clone of `baseBranch`, retried once: a clone can lose a race
398
+ * with the worker's repack of `remote.git` deleting a pack it had just
399
+ * listed. Before the retry it clears only what it created itself, so a
400
+ * target that held files beforehand is never touched.
401
+ */
402
+ static async cloneRepo(remoteUrl, targetPath, baseBranch = 'main', options = {}) {
191
403
  log.debug('git', 'Cloning repository', {
192
404
  remoteUrl,
193
405
  targetPath,
194
406
  baseBranch,
195
407
  });
196
- const git = simpleGit();
197
- await git.clone(remoteUrl, targetPath, ['--branch', baseBranch, '--single-branch']);
408
+ const args = ['--single-branch', '--branch', baseBranch];
409
+ if (options.noCheckout)
410
+ args.push('--no-checkout');
411
+ if (options.remoteName)
412
+ args.push('--origin', options.remoteName);
413
+ for (const [key, value] of Object.entries(options.config ?? {})) {
414
+ args.push('-c', `${key}=${value}`);
415
+ }
416
+ for (const setting of NO_AUTO_GC_CONFIG)
417
+ args.push('-c', setting);
418
+ const git = simpleGit({ config: [...NO_AUTO_GC_CONFIG], errors: failOnSignalExit });
419
+ git.env({
420
+ ...(isNetworkRemoteUrl(remoteUrl) ? gitNetworkChildEnv() : gitChildEnv({})),
421
+ GIT_CEILING_DIRECTORIES: path.dirname(targetPath),
422
+ });
423
+ const before = await dirState(targetPath);
424
+ try {
425
+ await git.clone(remoteUrl, targetPath, args);
426
+ }
427
+ catch (err) {
428
+ if (before === 'occupied')
429
+ throw err;
430
+ canopyLogWarn(`[canopy] clone into ${targetPath} failed, retrying once: ` +
431
+ redactCredentials(getErrorMessage(err)));
432
+ await fs.rm(targetPath, { recursive: true, force: true });
433
+ if (before === 'empty')
434
+ await fs.mkdir(targetPath);
435
+ await git.clone(remoteUrl, targetPath, args);
436
+ }
198
437
  log.debug('git', 'Clone complete');
199
438
  }
200
439
  /**
@@ -243,9 +482,24 @@ export class GitManager {
243
482
  if (!isNotFoundError(err))
244
483
  throw err;
245
484
  }
485
+ if (remoteExists) {
486
+ // The worker's per-cycle repack never runs in dev; once per call keeps pushes from
487
+ // piling up packs. Best-effort: a remote it cannot compact still serves.
488
+ try {
489
+ const repack = await repackBareRemoteIfNeeded(options.remotePath);
490
+ if (repack.repacked)
491
+ log.debug('git', 'Repacked local simulated remote', repack);
492
+ }
493
+ catch (err) {
494
+ log.debug('git', 'Could not repack local simulated remote', {
495
+ error: getErrorMessage(err),
496
+ });
497
+ }
498
+ }
246
499
  if (remoteExists &&
247
500
  (await GitManager.bareRemoteHasBranch(options.remotePath, options.baseBranch))) {
248
501
  log.debug('git', 'Remote already has base branch, skipping');
502
+ await ensureRemoteGitConfig(options.remotePath);
249
503
  return;
250
504
  }
251
505
  // Find the actual git root directory — the subdirectory snapshot path
@@ -303,6 +557,7 @@ export class GitManager {
303
557
  options.remotePath,
304
558
  ]);
305
559
  }
560
+ await ensureRemoteGitConfig(options.remotePath);
306
561
  // Push baseBranch to remote (not current HEAD)
307
562
  await GitManager.pushBranchToLocalRemote({
308
563
  sourceGit,
@@ -407,7 +662,7 @@ export class GitManager {
407
662
  * deleteBranchHandler: a head left in `remote.git` forever makes the
408
663
  * create -> publish -> squash-merge -> delete -> reuse-the-name cycle reject
409
664
  * the reused branch's first publish non-fast-forward against the stale head
410
- * (`GitManager.push()` pushes `branch:branch`, and a squash-merged old tip is
665
+ * (`GitManager.push()` pushes the branch to the same name, and a squash-merged old tip is
411
666
  * not an ancestor of the new branch), and a retried submit then skips the
412
667
  * local push on a clean tree and enqueues the worker push of the STALE head,
413
668
  * resurrecting the deleted branch's content on GitHub as an apparent success.
@@ -562,6 +817,12 @@ export class GitManager {
562
817
  `intentionally runs git against a network remote (e.g. a single-VM deployment), set ` +
563
818
  `config.allowNetworkRemoteInProd: true to acknowledge this.`);
564
819
  }
820
+ /** The not-ready error for a mode whose remote is auto-detected, or `undefined` for other modes. */
821
+ static async remoteNotReadyError(mode) {
822
+ const { operatingStrategy } = await import('./operating-mode/index.js');
823
+ const { autoDetectRemotePath } = operatingStrategy(mode).getRemoteUrlConfig();
824
+ return autoDetectRemotePath ? new RemoteNotReadyError(autoDetectRemotePath) : undefined;
825
+ }
565
826
  /**
566
827
  * Resolves the remote URL for git operations following the priority:
567
828
  * 1. Explicit remoteUrl parameter
@@ -627,6 +888,33 @@ export class GitManager {
627
888
  }
628
889
  return undefined;
629
890
  }
891
+ /**
892
+ * {@link resolveRemoteUrl} for a workspace about to be cloned, which needs a
893
+ * remote: none resolving is the worker-not-ready error in a mode whose
894
+ * remote the worker creates, and a configuration error otherwise.
895
+ */
896
+ static async resolveCloneRemoteUrl(options) {
897
+ const remoteUrl = await GitManager.resolveRemoteUrl(options);
898
+ if (remoteUrl)
899
+ return remoteUrl;
900
+ const notReady = await GitManager.remoteNotReadyError(options.mode);
901
+ if (notReady)
902
+ throw notReady;
903
+ throw new Error('CanopyCMS: defaultRemoteUrl (or CANOPYCMS_REMOTE_URL) is required to initialize workspace');
904
+ }
905
+ /**
906
+ * {@link cloneRepo} for a workspace, failing with one message that names the
907
+ * workspace, the remote and the base branch, which git's own error mixes.
908
+ */
909
+ static async cloneWorkspace(remoteUrl, workspacePath, baseBranch, options) {
910
+ try {
911
+ await GitManager.cloneRepo(remoteUrl, workspacePath, baseBranch, options);
912
+ }
913
+ catch (err) {
914
+ throw new Error(`Failed to clone branch workspace at ${workspacePath} ` +
915
+ `from ${remoteUrl} (base branch '${baseBranch}'): ${getErrorMessage(err)}`);
916
+ }
917
+ }
630
918
  /**
631
919
  * Check whether a git repository is already initialized at `workspacePath`.
632
920
  *
@@ -685,9 +973,12 @@ export class GitManager {
685
973
  throw cleanupErr;
686
974
  }
687
975
  }
976
+ const step = (name, run) => options.provisionLog ? options.provisionLog.step(name, run) : run();
977
+ const identity = managedWorkspaceConfig(options.gitBotAuthorName, options.gitBotAuthorEmail);
688
978
  let justCloned = false;
979
+ let resolvedRemoteUrl;
689
980
  if (!repoExists) {
690
- const remoteUrl = await GitManager.resolveRemoteUrl({
981
+ const remoteUrl = await GitManager.resolveCloneRemoteUrl({
691
982
  mode: options.mode,
692
983
  remoteUrl: options.remoteUrl,
693
984
  defaultRemoteUrl: options.defaultRemoteUrl,
@@ -695,28 +986,15 @@ export class GitManager {
695
986
  sourceRoot: options.sourceRoot,
696
987
  allowNetworkRemoteInProd: options.allowNetworkRemoteInProd,
697
988
  });
698
- if (!remoteUrl) {
699
- throw new Error('CanopyCMS: defaultRemoteUrl (or CANOPYCMS_REMOTE_URL) is required to initialize workspace');
700
- }
701
- try {
702
- await GitManager.cloneRepo(remoteUrl, options.workspacePath, baseBranch);
703
- }
704
- catch (err) {
705
- // The raw git error ("Cloning into <workspace>… branch <base> not found")
706
- // mixes the workspace name and the base branch — spell both out.
707
- throw new Error(`Failed to clone branch workspace at ${options.workspacePath} ` +
708
- `from ${remoteUrl} (base branch '${baseBranch}'): ${getErrorMessage(err)}`);
709
- }
989
+ // A content clone skips its own checkout because checkoutFreshClone runs
990
+ // the one checkout this workspace needs.
991
+ await step('clone', () => GitManager.cloneWorkspace(remoteUrl, options.workspacePath, baseBranch, {
992
+ remoteName,
993
+ noCheckout: options.branchType === 'content',
994
+ config: identity,
995
+ }));
710
996
  justCloned = true;
711
- // Mark as managed immediately after clone so ensureRemote's guard works,
712
- // and set a fallback author identity: GIT_CEILING_DIRECTORIES blocks
713
- // global gitconfig, and internal commits (e.g. orphan branch init) need
714
- // one. ensureAuthor() sets the real bot author before user-facing commits.
715
- const freshGit = simpleGit({ baseDir: options.workspacePath });
716
- freshGit.env(gitChildEnv({ GIT_CEILING_DIRECTORIES: path.dirname(options.workspacePath) }));
717
- await freshGit.addConfig('canopycms.managed', 'true');
718
- await freshGit.addConfig('user.name', options.gitBotAuthorName);
719
- await freshGit.addConfig('user.email', options.gitBotAuthorEmail);
997
+ resolvedRemoteUrl = remoteUrl;
720
998
  }
721
999
  // Settings (orphan) workspaces never host ContentStores, so they skip the
722
1000
  // on-disk content-index generation marker.
@@ -726,46 +1004,55 @@ export class GitManager {
726
1004
  remote: remoteName,
727
1005
  skipIndexMarker: options.branchType === 'orphan',
728
1006
  });
729
- // The managed marker and fallback identity must be set before ensureRemote
730
- // (which checks the marker) and before createOrphanSettingsBranch (which
731
- // commits and needs an author). Idempotent — the clone above may have set them.
732
- await git.git.addConfig('canopycms.managed', 'true');
733
- await git.git.addConfig('user.name', options.gitBotAuthorName);
734
- await git.git.addConfig('user.email', options.gitBotAuthorEmail);
735
- log.debug('git', 'Marked workspace as CanopyCMS-managed', {
736
- workspacePath: options.workspacePath,
737
- });
738
- // Configure the remote only if we didn't just clone (clone sets up 'origin')
1007
+ // A reused workspace gets the same marker and identity the clone writes,
1008
+ // set before ensureRemote checks the marker; a healthy one writes nothing.
739
1009
  if (!justCloned) {
740
- const remoteUrl = await GitManager.resolveRemoteUrl({
741
- mode: options.mode,
742
- remoteUrl: options.remoteUrl,
743
- defaultRemoteUrl: options.defaultRemoteUrl,
744
- baseBranch,
745
- sourceRoot: options.sourceRoot,
746
- allowNetworkRemoteInProd: options.allowNetworkRemoteInProd,
1010
+ await step('config', async () => {
1011
+ await git.ensureLocalConfig(identity);
1012
+ const remoteUrl = await GitManager.resolveRemoteUrl({
1013
+ mode: options.mode,
1014
+ remoteUrl: options.remoteUrl,
1015
+ defaultRemoteUrl: options.defaultRemoteUrl,
1016
+ baseBranch,
1017
+ sourceRoot: options.sourceRoot,
1018
+ allowNetworkRemoteInProd: options.allowNetworkRemoteInProd,
1019
+ });
1020
+ if (remoteUrl) {
1021
+ await git.ensureRemote(remoteUrl);
1022
+ }
1023
+ resolvedRemoteUrl = remoteUrl;
747
1024
  });
748
- if (remoteUrl) {
749
- await git.ensureRemote(remoteUrl);
750
- }
751
1025
  }
752
1026
  if (options.branchType === 'orphan') {
753
- await git.createOrphanSettingsBranch(options.branchName, {});
1027
+ try {
1028
+ await step('checkout', () => git.createOrphanSettingsBranch(options.branchName, {}));
1029
+ }
1030
+ catch (err) {
1031
+ // A remote that is unreadable because the worker has not (re)created
1032
+ // it yet is the transient not-ready case, not a broken workspace.
1033
+ if (err instanceof SettingsRemoteUnreadableError && !resolvedRemoteUrl) {
1034
+ throw (await GitManager.remoteNotReadyError(options.mode)) ?? err;
1035
+ }
1036
+ throw err;
1037
+ }
754
1038
  // Settings mutations hold an OCC lockfile (<file>.lock, see
755
1039
  // authorization/settings-file-store.ts) inside this git-committed
756
1040
  // workspace. Commits here stage explicit paths, but a crash-orphaned lock
757
1041
  // dir must never be committable by a future broad stage either. Runs on
758
1042
  // every init, so existing clones pick it up.
759
- await git.ensureGitExclude('*.lock');
1043
+ await step('exclude', () => git.ensureGitExclude('*.lock'));
760
1044
  }
761
1045
  else {
762
- await git.checkoutBranch(options.branchName);
1046
+ await step('checkout', () => justCloned
1047
+ ? git.checkoutFreshClone(options.branchName)
1048
+ : git.checkoutBranch(options.branchName));
763
1049
  // Excludes runtime metadata (.canopy-meta/) from git tracking on content
764
1050
  // branches. Settings workspaces don't need it: they stage explicit file
765
1051
  // paths at the workspace root and skip the index marker entirely
766
1052
  // (skipIndexMarker), so nothing under .canopy-meta/ is ever staged.
767
- if (options.gitExcludePattern) {
768
- await git.ensureGitExclude(options.gitExcludePattern);
1053
+ const pattern = options.gitExcludePattern;
1054
+ if (pattern) {
1055
+ await step('exclude', () => git.ensureGitExclude(pattern));
769
1056
  }
770
1057
  }
771
1058
  return git;
@@ -808,6 +1095,24 @@ export class GitManager {
808
1095
  await this.invalidateContentIndexes();
809
1096
  }
810
1097
  }
1098
+ /**
1099
+ * The one checkout of a `--no-checkout` clone, which has the base branch
1100
+ * and its remote-tracking ref and nothing else, so neither the branch
1101
+ * listing nor the fetch that {@link checkoutBranch} starts with is needed.
1102
+ * The missing index makes git populate the whole tree, even for the base
1103
+ * branch HEAD already names.
1104
+ */
1105
+ async checkoutFreshClone(branch) {
1106
+ const target = branch === this.baseBranch
1107
+ ? ['checkout', branch]
1108
+ : ['checkout', '-b', branch, `${this.remote}/${this.baseBranch}`];
1109
+ try {
1110
+ await this.git.raw([...PARALLEL_CHECKOUT_ARGS, ...target]);
1111
+ }
1112
+ finally {
1113
+ await this.invalidateContentIndexes();
1114
+ }
1115
+ }
811
1116
  async checkoutBranchInner(branch) {
812
1117
  const branches = await this.git.branch();
813
1118
  if (branches.all.includes(branch)) {
@@ -893,7 +1198,8 @@ export class GitManager {
893
1198
  const branches = await this.git.branch();
894
1199
  const currentBranch = branches.current;
895
1200
  try {
896
- await this.git.fetch(this.remote, currentBranch);
1201
+ // The full ref: a bare name resolves to a same-named tag first.
1202
+ await this.git.fetch(this.remote, `refs/heads/${currentBranch}`);
897
1203
  }
898
1204
  catch (err) {
899
1205
  // The only benign failure here: the branch has never been pushed, so the
@@ -966,17 +1272,31 @@ export class GitManager {
966
1272
  throw err;
967
1273
  }
968
1274
  }
1275
+ /** `--sparse` stages a named path outside a sparse clone's cone (branch-sparse.ts). */
969
1276
  async add(files) {
970
1277
  const fileArray = Array.isArray(files) ? files : [files];
971
- await this.git.add(fileArray);
1278
+ await this.git.raw(['add', '--sparse', '--', ...fileArray]);
1279
+ }
1280
+ /**
1281
+ * Restrict the working tree to `dirs` in cone mode, which always keeps the root-level files.
1282
+ * Runs before a `--no-checkout` clone's one checkout, which invalidates the content caches.
1283
+ */
1284
+ async setSparseCone(dirs) {
1285
+ await this.git.raw(['sparse-checkout', 'set', '--cone', '--', ...dirs]);
1286
+ }
1287
+ /** Stage every working-tree change except canopycms's own state. See {@link stageAllExceptCanopyState}. */
1288
+ async addAllExceptCanopyState() {
1289
+ await stageAllExceptCanopyState(this.git);
972
1290
  }
973
1291
  async commit(message) {
974
1292
  await this.git.commit(message);
975
1293
  }
976
1294
  async push(branch) {
977
- const target = branch ?? (await this.git.revparse(['--abbrev-ref', 'HEAD']));
978
- // Explicit refspec (local:remote) so push works for branches not yet in the
979
- // remote (e.g. orphan settings branches). Built via raw() rather than the
1295
+ const target = branch ?? (await this.currentBranchName());
1296
+ // Explicit full-ref refspec (local:remote) so push works for branches not
1297
+ // yet in the remote (e.g. orphan settings branches), and a same-named tag
1298
+ // cannot redirect it: a short name for a new remote branch would create
1299
+ // `refs/heads/heads/<name>`. Built via raw() rather than the
980
1300
  // push() wrapper so `--end-of-options` sits immediately before the
981
1301
  // positional remote/refspec, guarding against a refspec starting with '-'
982
1302
  // being parsed as a git option (e.g. --receive-pack=...). Real flags must
@@ -986,9 +1306,20 @@ export class GitManager {
986
1306
  '--set-upstream',
987
1307
  '--end-of-options',
988
1308
  this.remote,
989
- `${target}:${target}`,
1309
+ `refs/heads/${target}:refs/heads/${target}`,
990
1310
  ]);
991
1311
  }
1312
+ /**
1313
+ * The checked-out branch's plain name. `rev-parse --abbrev-ref HEAD` prints
1314
+ * `heads/<name>` when a same-named tag exists, which is not a branch name.
1315
+ */
1316
+ async currentBranchName() {
1317
+ const branches = await this.git.branch();
1318
+ if (branches.detached || !branches.current) {
1319
+ throw new Error(`CanopyCMS: no branch is checked out (detached HEAD) in ${this.repoPath}`);
1320
+ }
1321
+ return branches.current;
1322
+ }
992
1323
  /**
993
1324
  * Whether the local branch has commits the remote mirror doesn't -- i.e.
994
1325
  * whether push() would move the remote ref forward.
@@ -1010,12 +1341,14 @@ export class GitManager {
1010
1341
  // `--end-of-options` before every caller-influenced ref name, as in push()
1011
1342
  // above: names are sanitized upstream, but this file's rule is that a
1012
1343
  // positional is guarded where it is passed, not where it was validated.
1013
- const target = branch ?? (await this.git.revparse(['--abbrev-ref', '--end-of-options', 'HEAD']));
1014
- const localSha = (await this.git.revparse(['--end-of-options', target])).trim();
1344
+ // Each rev-parse needs `--verify`: without it, rev-parse echoes
1345
+ // `--end-of-options` as its own output line ahead of the result.
1346
+ const target = branch ?? (await this.git.revparse(['--verify', '--abbrev-ref', '--end-of-options', 'HEAD']));
1347
+ const localSha = (await this.git.revparse(['--verify', '--end-of-options', target])).trim();
1015
1348
  let fetchedTip;
1016
1349
  try {
1017
1350
  await this.git.raw(['fetch', '--end-of-options', this.remote, target]);
1018
- fetchedTip = (await this.git.revparse(['--end-of-options', 'FETCH_HEAD'])).trim();
1351
+ fetchedTip = (await this.git.revparse(['--verify', '--end-of-options', 'FETCH_HEAD'])).trim();
1019
1352
  }
1020
1353
  catch {
1021
1354
  // No ref on the remote yet -- the branch has never been pushed.
@@ -1028,6 +1361,27 @@ export class GitManager {
1028
1361
  const aheadCount = (await this.git.raw(['rev-list', '--count', `${fetchedTip}..${localSha}`])).trim();
1029
1362
  return aheadCount !== '0';
1030
1363
  }
1364
+ /**
1365
+ * Repo-relative paths that differ between HEAD and its merge base with the
1366
+ * base branch: everything the branch changes, across all of its commits.
1367
+ * Diffs against the just-fetched base tip pinned to a SHA — the pullBaseInner
1368
+ * constraint.
1369
+ */
1370
+ async listChangedPathsSinceBase() {
1371
+ await this.git.raw(['fetch', '--end-of-options', this.remote, this.baseBranch]);
1372
+ // `--verify`: without it rev-parse echoes `--end-of-options` back as output.
1373
+ const baseTip = (await this.git.revparse(['--verify', '--end-of-options', 'FETCH_HEAD'])).trim();
1374
+ // -z: NUL-separated and unquoted, so no path needs unescaping.
1375
+ const output = await this.git.raw([
1376
+ 'diff',
1377
+ '--name-only',
1378
+ '-z',
1379
+ '--no-renames',
1380
+ `${baseTip}...HEAD`,
1381
+ '--',
1382
+ ]);
1383
+ return output.split('\0').filter((p) => p.length > 0);
1384
+ }
1031
1385
  async ensureAuthor(author) {
1032
1386
  const config = (await this.git.listConfig());
1033
1387
  // Verify this is a CanopyCMS-managed workspace before setting author
@@ -1046,6 +1400,14 @@ export class GitManager {
1046
1400
  await this.git.addConfig('user.email', author.email);
1047
1401
  }
1048
1402
  }
1403
+ /** Writes each key into this repo's own config only where it differs. */
1404
+ async ensureLocalConfig(values) {
1405
+ const local = (await this.git.listConfig('local'));
1406
+ for (const [key, value] of Object.entries(values)) {
1407
+ if (local.all[key] !== value)
1408
+ await this.git.addConfig(key, value);
1409
+ }
1410
+ }
1049
1411
  async ensureRemote(remoteUrl) {
1050
1412
  // Safety: verify this is a managed workspace before modifying remotes.
1051
1413
  // Prevents accidentally overwriting the host repo's origin if git
@@ -1089,10 +1451,24 @@ export class GitManager {
1089
1451
  await ensureGitExcludePattern(this.repoPath, pattern);
1090
1452
  }
1091
1453
  /**
1092
- * Create an orphan branch (no shared history) for settings, so
1093
- * deployment-specific settings never pollute content history. It holds only
1094
- * settings files committed by explicit path (permissions.json, groups.json at
1095
- * the workspace root).
1454
+ * Put the workspace on the settings branch: an orphan (no shared history),
1455
+ * so deployment-specific settings never pollute content history. It holds
1456
+ * only settings files committed by explicit path (permissions.json,
1457
+ * groups.json at the workspace root).
1458
+ *
1459
+ * The remote is the settings branch's durable copy, and the workspace clone
1460
+ * is `--single-branch` at the base branch, so the remote is asked first:
1461
+ * - no local branch: check out the remote's, or create an empty orphan only
1462
+ * when the remote has none. An unreadable remote throws
1463
+ * {@link SettingsRemoteUnreadableError} rather than guess "none".
1464
+ * - local branch present: a history unrelated to the remote's is repaired
1465
+ * onto the remote's when the local branch is still its empty initial
1466
+ * commit with a clean working tree, and otherwise throws
1467
+ * {@link SettingsBranchDivergedError}; local commits are never discarded.
1468
+ * An unreadable remote throws only for that empty branch, since one that
1469
+ * holds settings can serve them and the next save surfaces the remote error.
1470
+ * Either way a remote branch holding content is never adopted:
1471
+ * {@link SettingsBranchHasContentHistoryError}.
1096
1472
  */
1097
1473
  async createOrphanSettingsBranch(branchName, initialFiles) {
1098
1474
  try {
@@ -1104,26 +1480,49 @@ export class GitManager {
1104
1480
  }
1105
1481
  }
1106
1482
  async createOrphanSettingsBranchInner(branchName, initialFiles) {
1107
- log.debug('git', 'Creating orphan settings branch', { branchName });
1483
+ log.debug('git', 'Ensuring settings branch', { branchName });
1108
1484
  const branches = await this.git.branch();
1109
- if (branches.all.includes(branchName)) {
1110
- log.debug('git', 'Orphan branch already exists', { branchName });
1485
+ const localExists = branches.all.includes(branchName);
1486
+ if (localExists) {
1111
1487
  // No separator here — see checkoutBranch() above for why plain
1112
1488
  // `git checkout <branch>` can't safely take one. branchName is always an
1113
1489
  // internal/config-derived settings-branch name, never user input.
1114
1490
  await this.git.checkout(branchName);
1491
+ }
1492
+ let remoteTip;
1493
+ try {
1494
+ remoteTip = await this.remoteBranchTip(branchName);
1495
+ }
1496
+ catch (err) {
1497
+ if (localExists && !(await this.isEmptyInitialBranch(branchName))) {
1498
+ log.debug('git', 'Remote unreadable; serving the local settings branch', { branchName });
1499
+ return;
1500
+ }
1501
+ throw new SettingsRemoteUnreadableError(branchName, this.remote, err);
1502
+ }
1503
+ if (localExists) {
1504
+ await this.reconcileLocalSettingsBranch(branchName, remoteTip);
1505
+ return;
1506
+ }
1507
+ if (remoteTip) {
1508
+ const fetchedTip = await this.fetchBranchTip(branchName);
1509
+ await this.assertNotContentHistory(branchName, fetchedTip);
1510
+ // `-b` consumes branchName as its literal value; see checkoutBranchInner.
1511
+ await this.git.raw(['checkout', '-b', branchName, fetchedTip]);
1512
+ log.debug('git', 'Checked out the remote settings branch', { branchName });
1115
1513
  return;
1116
1514
  }
1117
1515
  // branchName is consumed as --orphan's literal argument value (like -b/-B
1118
1516
  // above), so it can't be reinterpreted as a flag; git's own ref-name
1119
1517
  // validation additionally rejects a leading-hyphen value here.
1120
1518
  await this.git.raw(['checkout', '--orphan', branchName]);
1121
- // Remove all files from index (orphan checkout keeps working tree)
1122
- try {
1123
- await this.git.raw(['rm', '-rf', '.']);
1124
- }
1125
- catch {
1126
- // Ignore errors (might fail if index is already empty)
1519
+ // The orphan starts from the base branch's index. `--sparse` also removes entries outside a
1520
+ // sparse cone, which a plain `rm` leaves in the index, and so in the first commit.
1521
+ await this.git.raw(['rm', '-r', '-f', '-q', '--sparse', '--ignore-unmatch', '--', '.']);
1522
+ const leftover = (await this.git.raw(['ls-files'])).trim();
1523
+ if (leftover) {
1524
+ throw new Error(`Settings branch '${branchName}' was not created: the base branch's files ` +
1525
+ `(${leftover.split('\n').slice(0, 3).join(', ')}) stayed in its index`);
1127
1526
  }
1128
1527
  for (const [filePath, content] of Object.entries(initialFiles)) {
1129
1528
  const absolutePath = path.join(this.repoPath, filePath);
@@ -1134,4 +1533,123 @@ export class GitManager {
1134
1533
  await this.git.commit('Initialize settings branch', ['--allow-empty']);
1135
1534
  log.debug('git', 'Orphan settings branch created', { branchName });
1136
1535
  }
1536
+ /**
1537
+ * Compares the checked-out local settings branch with the remote's tip.
1538
+ * Related histories are left for the next settings pull to reconcile; only
1539
+ * an unrelated one is acted on here.
1540
+ */
1541
+ async reconcileLocalSettingsBranch(branchName, remoteTip) {
1542
+ if (!remoteTip)
1543
+ return;
1544
+ const localTip = (await this.git.revparse([`refs/heads/${branchName}`])).trim();
1545
+ if (localTip === remoteTip)
1546
+ return;
1547
+ const fetchedTip = await this.fetchBranchTip(branchName);
1548
+ const [localRoots, remoteRoots] = await Promise.all([
1549
+ this.rootCommits(localTip),
1550
+ this.rootCommits(fetchedTip),
1551
+ ]);
1552
+ if (localRoots.some((root) => remoteRoots.includes(root)))
1553
+ return;
1554
+ const localWork = [];
1555
+ if (!(await this.isEmptyInitialBranch(branchName))) {
1556
+ localWork.push('commits beyond its empty initial commit');
1557
+ }
1558
+ const uncommitted = await this.getUncommittedFiles();
1559
+ if (uncommitted.length > 0) {
1560
+ localWork.push(`uncommitted changes to ${uncommitted.join(', ')}`);
1561
+ }
1562
+ if (localWork.length > 0) {
1563
+ throw new SettingsBranchDivergedError(branchName, this.repoPath, localWork.join(' and '));
1564
+ }
1565
+ await this.assertNotContentHistory(branchName, fetchedTip);
1566
+ // `checkout -B` resets the current branch onto the fetched tip, and
1567
+ // refuses rather than overwrite a file written since the check above.
1568
+ await this.git.raw(['checkout', '-B', branchName, fetchedTip]);
1569
+ log.debug('git', 'Replaced an empty settings branch with the remote one', { branchName });
1570
+ }
1571
+ /**
1572
+ * The remote's tip for `branch`, or `undefined` when the remote has no such
1573
+ * branch. A remote that cannot be read throws: `ls-remote` exits 0 with no
1574
+ * output only for a reachable remote without the ref.
1575
+ */
1576
+ async remoteBranchTip(branch) {
1577
+ const ref = `refs/heads/${branch}`;
1578
+ const output = await this.git.raw(['ls-remote', '--heads', this.remote, ref]);
1579
+ for (const line of output.split('\n')) {
1580
+ const [sha, name] = line.trim().split('\t');
1581
+ if (sha && name === ref)
1582
+ return sha;
1583
+ }
1584
+ return undefined;
1585
+ }
1586
+ /**
1587
+ * Fetch `branch` from the remote and return the fetched commit, pinned as
1588
+ * pullBaseInner explains. The full ref: a bare name resolves to a
1589
+ * same-named tag first.
1590
+ */
1591
+ async fetchBranchTip(branch) {
1592
+ await this.git.fetch(this.remote, `refs/heads/${branch}`);
1593
+ return (await this.git.revparse(['FETCH_HEAD'])).trim();
1594
+ }
1595
+ /**
1596
+ * Refuse to adopt a remote settings branch that holds content: its tip has a file other than
1597
+ * the settings files, or its roots include the base branch's (a settings branch is an orphan).
1598
+ * Neither alone suffices: a base can have a root of its own, and a content tree can be pruned.
1599
+ * The base comes from the remote when the clone lacks it (it was cloned at an earlier base).
1600
+ */
1601
+ async assertNotContentHistory(branchName, tip) {
1602
+ const tree = await this.git.raw(['ls-tree', '-r', '--name-only', tip]);
1603
+ const strays = tree.split('\n').filter((file) => file && !SETTINGS_BRANCH_FILES.has(file));
1604
+ if (strays.length > 0) {
1605
+ const shown = strays.slice(0, 3).join(', ') + (strays.length > 3 ? ', …' : '');
1606
+ throw new SettingsBranchHasContentHistoryError(branchName, this.remote, `it holds ${shown}`);
1607
+ }
1608
+ const [tipRoots, baseRoots] = await Promise.all([
1609
+ this.rootCommits(tip),
1610
+ this.baseBranchRev(branchName).then((rev) => this.rootCommits(rev)),
1611
+ ]);
1612
+ if (tipRoots.some((root) => baseRoots.includes(root))) {
1613
+ throw new SettingsBranchHasContentHistoryError(branchName, this.remote, `it shares history with the base branch '${this.baseBranch}'`);
1614
+ }
1615
+ }
1616
+ async baseBranchRev(settingsBranch) {
1617
+ for (const ref of [
1618
+ `refs/heads/${this.baseBranch}`,
1619
+ `refs/remotes/${this.remote}/${this.baseBranch}`,
1620
+ ]) {
1621
+ // `--quiet` makes a missing ref a silent non-zero exit, which simple-git resolves
1622
+ // with empty output rather than rejecting.
1623
+ const sha = await this.git
1624
+ .raw(['rev-parse', '--verify', '--quiet', `${ref}^{commit}`])
1625
+ .catch(() => '');
1626
+ if (sha.trim())
1627
+ return sha.trim();
1628
+ }
1629
+ try {
1630
+ return await this.fetchBranchTip(this.baseBranch);
1631
+ }
1632
+ catch (err) {
1633
+ throw new Error(`CanopyCMS: could not read base branch '${this.baseBranch}' from remote ` +
1634
+ `'${this.remote}', so settings branch '${settingsBranch}' was not checked out: ` +
1635
+ `${getErrorMessage(err)}`);
1636
+ }
1637
+ }
1638
+ async rootCommits(rev) {
1639
+ const output = await this.git.raw(['rev-list', '--max-parents=0', rev]);
1640
+ return output.split('\n').filter(Boolean);
1641
+ }
1642
+ /**
1643
+ * Whether `branch` is a single parentless commit with an empty tree, as
1644
+ * orphan creation leaves it. Full refs throughout: a bare name resolves to a
1645
+ * same-named tag first, and clones fetch tags.
1646
+ */
1647
+ async isEmptyInitialBranch(branch) {
1648
+ const ref = `refs/heads/${branch}`;
1649
+ const commits = await this.git.raw(['rev-list', '--max-count=2', ref]);
1650
+ if (commits.split('\n').filter(Boolean).length !== 1)
1651
+ return false;
1652
+ const tree = await this.git.raw(['ls-tree', ref]);
1653
+ return tree.trim() === '';
1654
+ }
1137
1655
  }