canopycms 0.0.67 → 0.0.68-int.94

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 (148) hide show
  1. package/README.md +3 -2
  2. package/dist/ai/handler.js +8 -0
  3. package/dist/api/admin-branch-health.js +12 -7
  4. package/dist/api/admin.d.ts +21 -9
  5. package/dist/api/admin.js +18 -2
  6. package/dist/api/branch-status.js +19 -4
  7. package/dist/api/branch.js +11 -11
  8. package/dist/api/client.d.ts +12 -0
  9. package/dist/api/client.js +34 -11
  10. package/dist/api/content.d.ts +6 -5
  11. package/dist/api/content.js +44 -41
  12. package/dist/api/entries.js +16 -15
  13. package/dist/api/github-sync.d.ts +10 -1
  14. package/dist/api/github-sync.js +20 -10
  15. package/dist/api/reference-options.js +1 -1
  16. package/dist/api/resolve-references.js +14 -40
  17. package/dist/api/settings-helpers.d.ts +1 -1
  18. package/dist/api/settings-helpers.js +1 -4
  19. package/dist/api/user.d.ts +3 -0
  20. package/dist/api/user.js +3 -0
  21. package/dist/assets/factory.d.ts +5 -0
  22. package/dist/assets/factory.js +5 -0
  23. package/dist/authorization/content.d.ts +5 -4
  24. package/dist/authorization/content.js +5 -5
  25. package/dist/authorization/path.d.ts +11 -6
  26. package/dist/authorization/path.js +11 -6
  27. package/dist/authorization/types.d.ts +2 -2
  28. package/dist/branch-health.js +3 -3
  29. package/dist/branch-schema-cache.d.ts +5 -3
  30. package/dist/branch-schema-cache.js +29 -11
  31. package/dist/branch-workspace.js +8 -3
  32. package/dist/build-identity.d.ts +3 -0
  33. package/dist/build-identity.js +13 -0
  34. package/dist/cli/cli.js +1070 -388
  35. package/dist/cli/generate-ai-content.js +537 -150
  36. package/dist/cli/github-app-manifest.d.ts +4 -3
  37. package/dist/cli/github-app-manifest.js +4 -3
  38. package/dist/cli/init.js +21 -11
  39. package/dist/cli/migrate.js +12 -8
  40. package/dist/cli/sync.js +83 -37
  41. package/dist/cli/template-files/Dockerfile.cms.template +8 -0
  42. package/dist/cli/template-files/cdk-app.ts.template +4 -0
  43. package/dist/cli/template-files/cms-stack.ts.template +6 -0
  44. package/dist/cli/template-files/deploy-cms.yml.template +2 -0
  45. package/dist/config/schemas/config.d.ts +11 -3
  46. package/dist/config/schemas/config.js +5 -1
  47. package/dist/config/schemas/field.js +1 -0
  48. package/dist/config/schemas/url.d.ts +2 -0
  49. package/dist/config/schemas/url.js +7 -0
  50. package/dist/config/types.d.ts +28 -4
  51. package/dist/config/validation.d.ts +6 -0
  52. package/dist/config/validation.js +37 -0
  53. package/dist/content-listing.d.ts +12 -12
  54. package/dist/content-listing.js +10 -6
  55. package/dist/content-reader.d.ts +11 -2
  56. package/dist/content-reader.js +22 -14
  57. package/dist/content-store.d.ts +37 -9
  58. package/dist/content-store.js +70 -43
  59. package/dist/content-tree.d.ts +1 -1
  60. package/dist/content-tree.js +2 -2
  61. package/dist/context.d.ts +27 -9
  62. package/dist/context.js +154 -70
  63. package/dist/editor/CanopyEditor.d.ts +1 -1
  64. package/dist/editor/CanopyEditor.js +1 -1
  65. package/dist/editor/Editor.d.ts +2 -0
  66. package/dist/editor/Editor.js +6 -5
  67. package/dist/editor/FormRenderer.js +20 -5
  68. package/dist/editor/admin/SystemHealthPanel.js +45 -7
  69. package/dist/editor/components/NoEditPermissionNotice.d.ts +10 -0
  70. package/dist/editor/components/NoEditPermissionNotice.js +17 -0
  71. package/dist/editor/editor-utils.d.ts +17 -15
  72. package/dist/editor/editor-utils.js +43 -36
  73. package/dist/editor/hooks/useBranchesData.js +3 -1
  74. package/dist/editor/hooks/useCommentSystem.js +5 -1
  75. package/dist/editor/hooks/useDraftManager.d.ts +1 -1
  76. package/dist/editor/hooks/useDraftManager.js +2 -1
  77. package/dist/editor/hooks/useEntriesData.js +2 -2
  78. package/dist/editor/hooks/useEntryManager.js +17 -11
  79. package/dist/editor/preview-bridge.js +4 -1
  80. package/dist/editor/preview-path.d.ts +10 -0
  81. package/dist/editor/preview-path.js +20 -0
  82. package/dist/entry-schema-registry.d.ts +4 -4
  83. package/dist/entry-schema-registry.js +10 -6
  84. package/dist/entry-schema.d.ts +27 -4
  85. package/dist/entry-schema.js +20 -3
  86. package/dist/git-manager.d.ts +79 -5
  87. package/dist/git-manager.js +308 -18
  88. package/dist/github-service.d.ts +8 -0
  89. package/dist/github-service.js +5 -1
  90. package/dist/http/handler.js +35 -13
  91. package/dist/http/index.d.ts +2 -0
  92. package/dist/http/index.js +2 -0
  93. package/dist/http/router.d.ts +2 -0
  94. package/dist/http/router.js +2 -1
  95. package/dist/http/worker-not-ready.d.ts +9 -0
  96. package/dist/http/worker-not-ready.js +17 -0
  97. package/dist/operating-mode/client-unsafe-strategy.js +0 -6
  98. package/dist/operating-mode/types.d.ts +0 -3
  99. package/dist/paths/branch-name.d.ts +5 -0
  100. package/dist/paths/branch-name.js +9 -0
  101. package/dist/paths/branch.d.ts +6 -1
  102. package/dist/paths/branch.js +15 -2
  103. package/dist/paths/index.d.ts +3 -3
  104. package/dist/paths/index.js +3 -3
  105. package/dist/paths/normalize.d.ts +7 -0
  106. package/dist/paths/normalize.js +9 -0
  107. package/dist/reference-resolver.d.ts +5 -21
  108. package/dist/reference-resolver.js +9 -44
  109. package/dist/resolve-canopy-user.js +2 -1
  110. package/dist/schema/schema-store.d.ts +48 -22
  111. package/dist/schema/schema-store.js +66 -22
  112. package/dist/services.d.ts +18 -6
  113. package/dist/services.js +85 -70
  114. package/dist/settings-workspace.js +42 -4
  115. package/dist/static/seo.d.ts +2 -14
  116. package/dist/static/seo.js +2 -25
  117. package/dist/submission-attribution.d.ts +73 -0
  118. package/dist/submission-attribution.js +221 -0
  119. package/dist/sync-core.d.ts +12 -1
  120. package/dist/sync-core.js +31 -13
  121. package/dist/task-queue/worker-status.d.ts +8 -0
  122. package/dist/task-queue/worker-status.js +17 -0
  123. package/dist/types.d.ts +31 -2
  124. package/dist/utils/content-write-lock.d.ts +18 -7
  125. package/dist/utils/content-write-lock.js +18 -9
  126. package/dist/utils/debug.d.ts +8 -0
  127. package/dist/utils/debug.js +10 -2
  128. package/dist/utils/git.d.ts +28 -0
  129. package/dist/utils/git.js +38 -0
  130. package/dist/utils/provisioning-lock.d.ts +15 -5
  131. package/dist/utils/provisioning-lock.js +31 -11
  132. package/dist/utils/request-timing.d.ts +25 -0
  133. package/dist/utils/request-timing.js +101 -0
  134. package/dist/utils/url-prefix.d.ts +30 -0
  135. package/dist/utils/url-prefix.js +61 -0
  136. package/dist/version.d.ts +1 -0
  137. package/dist/version.js +3 -0
  138. package/dist/worker/canopy-state.d.ts +45 -0
  139. package/dist/worker/canopy-state.js +75 -0
  140. package/dist/worker/cms-worker.js +23 -2
  141. package/dist/worker/git-sync.d.ts +8 -2
  142. package/dist/worker/git-sync.js +138 -24
  143. package/dist/worker/provisioned-workspace.d.ts +35 -0
  144. package/dist/worker/provisioned-workspace.js +50 -0
  145. package/dist/worker/rebase.d.ts +1 -1
  146. package/dist/worker/rebase.js +83 -12
  147. package/dist/worker/task-runner.js +39 -4
  148. package/package.json +1 -1
@@ -89,6 +89,9 @@ function createLogicalPath(...segments) {
89
89
  }
90
90
  return normalized;
91
91
  }
92
+ function entryLogicalPath(collectionLogicalPath, slug) {
93
+ return collectionLogicalPath ? `${collectionLogicalPath}/${slug}` : slug;
94
+ }
92
95
  function trimSlashes(path22) {
93
96
  let start = 0;
94
97
  let end = path22.length;
@@ -310,7 +313,8 @@ var init_field = __esm({
310
313
  });
311
314
  objectFieldSchema = fieldBaseSchema.extend({
312
315
  type: z2.literal("object"),
313
- fields: z2.array(z2.lazy(() => fieldHolder[0])).min(1)
316
+ fields: z2.array(z2.lazy(() => fieldHolder[0])).min(1),
317
+ itemTitleField: z2.string().min(1).optional()
314
318
  });
315
319
  inlineGroupFieldSchema = z2.object({
316
320
  type: z2.literal("group"),
@@ -375,7 +379,7 @@ var init_collection = __esm({
375
379
 
376
380
  // dist/config/schemas/url.js
377
381
  import { z as z4 } from "zod";
378
- var uploadTargetUrlSchema, assetMountUrlSchema;
382
+ var uploadTargetUrlSchema, assetMountUrlSchema, previewPrefixSchema;
379
383
  var init_url = __esm({
380
384
  "dist/config/schemas/url.js"() {
381
385
  "use strict";
@@ -386,6 +390,9 @@ var init_url = __esm({
386
390
  assetMountUrlSchema = z4.string().trim().refine((value) => isHttpUrlOrSameOriginPath(value, { allowProtocolRelative: true }), {
387
391
  message: 'must be an absolute http(s) URL, a protocol-relative "//host" URL, or a site-relative path beginning with a single "/", with no query or fragment'
388
392
  });
393
+ previewPrefixSchema = z4.string().trim().refine((value) => isHttpUrlOrSameOriginPath(value), {
394
+ message: 'editor.previewPrefix must be an absolute http(s) URL or a site-relative path beginning with a single "/", with no query or fragment'
395
+ });
389
396
  }
390
397
  });
391
398
 
@@ -443,6 +450,7 @@ var init_config = __esm({
443
450
  "use strict";
444
451
  init_collection();
445
452
  init_media();
453
+ init_url();
446
454
  defaultBranchAccessSchema = z6.enum(["allow", "deny"]).default("deny");
447
455
  defaultPathAccessLevelSchema = z6.enum(["allow", "deny"]);
448
456
  defaultPathAccessLevelsSchema = z6.object({
@@ -474,6 +482,7 @@ var init_config = __esm({
474
482
  subtitle: z6.string().optional(),
475
483
  theme: z6.unknown().optional(),
476
484
  previewBase: z6.record(z6.string()).optional(),
485
+ previewPrefix: previewPrefixSchema.optional(),
477
486
  // UI handler functions (runtime only, don't serialize)
478
487
  onAccountClick: z6.function().returns(z6.void()).optional(),
479
488
  onLogoutClick: z6.function().returns(z6.void()).optional(),
@@ -501,6 +510,9 @@ var init_config = __esm({
501
510
  defaultRemoteUrl: defaultRemoteUrlSchema.optional(),
502
511
  gitBotAuthorName: gitBotAuthorNameSchema,
503
512
  gitBotAuthorEmail: gitBotAuthorEmailSchema,
513
+ // Unset reads as on for Edited-by and off for Co-authored-by (services.ts submitBranch).
514
+ gitEditedByTrailers: z6.boolean().optional(),
515
+ gitCoAuthoredByTrailers: z6.boolean().optional(),
504
516
  githubTokenEnvVar: githubTokenEnvVarSchema.optional(),
505
517
  // Required by design (follow-up to SEC-C1): a prod deploy that omits `mode` must fail
506
518
  // validation loudly rather than silently running header-trusting dev auth semantics.
@@ -512,7 +524,6 @@ var init_config = __esm({
512
524
  // Default false/unset — the standard AWS Lambda+worker topology must leave this unset.
513
525
  allowNetworkRemoteInProd: z6.boolean().optional(),
514
526
  settingsBranch: z6.string().optional(),
515
- autoCreateSettingsPR: z6.boolean().optional(),
516
527
  // `.optional()`, NOT bare `deploymentNameSchema`: its own `.default('prod')` would make
517
528
  // `parse(undefined)` resolve to 'prod' instead of staying `undefined`, collapsing the
518
529
  // env > config > modeDefault precedence chain `resolveDeploymentName`
@@ -835,9 +846,6 @@ var init_client_unsafe_strategy = __esm({
835
846
  throw new Error("gitBotAuthorName and gitBotAuthorEmail are required in prod mode");
836
847
  }
837
848
  }
838
- shouldCreateSettingsPR(config) {
839
- return config.autoCreateSettingsPR ?? true;
840
- }
841
849
  };
842
850
  DevStrategy = class extends DevClientSafeStrategy {
843
851
  getWorkspaceRoot(sourceRoot) {
@@ -884,9 +892,6 @@ var init_client_unsafe_strategy = __esm({
884
892
  }
885
893
  validateConfig(_config) {
886
894
  }
887
- shouldCreateSettingsPR(_config) {
888
- return false;
889
- }
890
895
  };
891
896
  strategyCache = /* @__PURE__ */ new Map();
892
897
  }
@@ -1193,12 +1198,15 @@ import fs2 from "node:fs/promises";
1193
1198
  import path2 from "node:path";
1194
1199
  import lockfile from "proper-lockfile";
1195
1200
  init_logger();
1196
- function provisioningLockOptions(lockPath, retries, onCompromised) {
1201
+ var PROVISIONING_LOCK_STALE_MS = 9e4;
1202
+ function provisioningLockOptions(lockPath, retries, onCompromised, staleMs) {
1197
1203
  return {
1198
1204
  lockfilePath: lockPath,
1199
1205
  realpath: false,
1200
1206
  retries,
1201
- stale: 3e4,
1207
+ stale: staleMs,
1208
+ // Fixed, not stale/2: every holder refreshes at 15s whatever threshold it judges others by.
1209
+ update: 15e3,
1202
1210
  // proper-lockfile invokes this from inside its refresh timer, so ANY throw escaping here is
1203
1211
  // an uncaught exception that kills the process. Call sites are told not to throw (see
1204
1212
  // OnLockCompromised); this makes it structural rather than a convention, and also covers the
@@ -1234,16 +1242,19 @@ function releaseIgnoringAlreadyReleased(release, lockPath) {
1234
1242
  }
1235
1243
  };
1236
1244
  }
1245
+ function branchProvisioningLockName(dirName) {
1246
+ return `.${dirName}.init.lock`;
1247
+ }
1237
1248
  async function acquireProvisioningLock(lockTargetDir2, lockName, onCompromised) {
1238
1249
  await fs2.mkdir(lockTargetDir2, { recursive: true });
1239
1250
  const lockPath = path2.join(lockTargetDir2, lockName);
1240
- const release = await lockfile.lock(lockPath, provisioningLockOptions(lockPath, { retries: 600, factor: 1, minTimeout: 300, maxTimeout: 800, randomize: true }, onCompromised));
1251
+ const release = await lockfile.lock(lockPath, provisioningLockOptions(lockPath, { retries: 600, factor: 1, minTimeout: 300, maxTimeout: 800, randomize: true }, onCompromised, PROVISIONING_LOCK_STALE_MS));
1241
1252
  return releaseIgnoringAlreadyReleased(release, lockPath);
1242
1253
  }
1243
- async function tryAcquireProvisioningLock(lockTargetDir2, lockName, onCompromised) {
1254
+ async function tryAcquireProvisioningLock(lockTargetDir2, lockName, onCompromised, staleMs = PROVISIONING_LOCK_STALE_MS) {
1244
1255
  await fs2.mkdir(lockTargetDir2, { recursive: true });
1245
1256
  const lockPath = path2.join(lockTargetDir2, lockName);
1246
- const release = await lockfile.lock(lockPath, provisioningLockOptions(lockPath, 0, onCompromised));
1257
+ const release = await lockfile.lock(lockPath, provisioningLockOptions(lockPath, 0, onCompromised, staleMs));
1247
1258
  return releaseIgnoringAlreadyReleased(release, lockPath);
1248
1259
  }
1249
1260
 
@@ -1252,8 +1263,9 @@ var META_DIR = ".canopy-meta";
1252
1263
  var CONTENT_WRITE_LOCK_NAME = "content-write.lock";
1253
1264
  var DEFAULT_CONTENT_WRITE_LOCK_WAIT_MS = 2e3;
1254
1265
  var ContentWriteLockBusyError = class extends Error {
1255
- constructor(message = "This branch is busy (syncing with the base branch, or another save is in flight); the change was not saved. Try again in a moment.") {
1266
+ constructor(message = "This branch is busy (syncing with the base branch, or another save is in flight); the change was not saved. Try again in a moment.", outcome = "not-run") {
1256
1267
  super(message);
1268
+ this.outcome = outcome;
1257
1269
  this.name = "ContentWriteLockBusyError";
1258
1270
  }
1259
1271
  };
@@ -1263,8 +1275,9 @@ function lockTargetDir(branchRoot) {
1263
1275
  var sleep = (ms) => new Promise((resolve2) => {
1264
1276
  setTimeout(resolve2, ms);
1265
1277
  });
1278
+ var CONTENT_WRITE_LOCK_STALE_MS = 3e4;
1266
1279
  function tryAcquireContentWriteLock(branchRoot, onCompromised) {
1267
- return tryAcquireProvisioningLock(lockTargetDir(branchRoot), CONTENT_WRITE_LOCK_NAME, onCompromised);
1280
+ return tryAcquireProvisioningLock(lockTargetDir(branchRoot), CONTENT_WRITE_LOCK_NAME, onCompromised, CONTENT_WRITE_LOCK_STALE_MS);
1268
1281
  }
1269
1282
  async function acquireContentWriteLock(branchRoot, waitMs = DEFAULT_CONTENT_WRITE_LOCK_WAIT_MS, onCompromised) {
1270
1283
  const startedAt = Date.now();
@@ -1295,7 +1308,7 @@ async function withContentWriteLock(branchRoot, fn, waitMs = DEFAULT_CONTENT_WRI
1295
1308
  await release();
1296
1309
  }
1297
1310
  if (compromised) {
1298
- throw new ContentWriteLockBusyError("This branch was being synced while your change was written, so the change may or may not have been recorded. Reload the entry to see the current state before saving again.");
1311
+ throw new ContentWriteLockBusyError("This branch was being synced while your change was written, so the change may or may not have been recorded. Reload the entry to see the current state before saving again.", "unknown");
1299
1312
  }
1300
1313
  return result;
1301
1314
  }
@@ -1345,8 +1358,21 @@ function buildResolvedReference(data, meta, body) {
1345
1358
  resolved.slug = meta.slug;
1346
1359
  resolved.collection = meta.collection;
1347
1360
  resolved.urlPath = meta.urlPath;
1361
+ delete resolved[RESTRICTED_REFERENCE_MARKER];
1348
1362
  return resolved;
1349
1363
  }
1364
+ var RESTRICTED_REFERENCE_MARKER = "unavailable";
1365
+ function buildRestrictedReference(meta, title) {
1366
+ return {
1367
+ id: meta.id,
1368
+ slug: meta.slug,
1369
+ collection: meta.collection,
1370
+ urlPath: meta.urlPath,
1371
+ title,
1372
+ unavailable: true,
1373
+ reason: "restricted"
1374
+ };
1375
+ }
1350
1376
  var SEO_GROUP_FIELDS = [
1351
1377
  {
1352
1378
  name: DEFAULT_SEO_FIELD_NAMES.title,
@@ -1399,6 +1425,50 @@ var SEO_GROUP_FIELDS = [
1399
1425
  }
1400
1426
  ];
1401
1427
 
1428
+ // dist/utils/title-field.js
1429
+ function isGroupField(field) {
1430
+ return field.type === "group";
1431
+ }
1432
+ function extractTitleFromSchema(fields, data) {
1433
+ return findTitleValue(fields, data);
1434
+ }
1435
+ function findTitleValue(fields, data) {
1436
+ for (const field of fields) {
1437
+ if (isGroupField(field)) {
1438
+ const result = findTitleValue(field.fields, data);
1439
+ if (result !== void 0)
1440
+ return result;
1441
+ } else if ("isTitle" in field && field.isTitle) {
1442
+ const value = data[field.name];
1443
+ return typeof value === "string" ? value : void 0;
1444
+ } else if (field.type === "object" && "fields" in field && field.fields && !field.list) {
1445
+ const nested = data[field.name];
1446
+ if (nested != null && typeof nested === "object" && !Array.isArray(nested)) {
1447
+ const result = findTitleValue(field.fields, nested);
1448
+ if (result !== void 0)
1449
+ return result;
1450
+ }
1451
+ }
1452
+ }
1453
+ return void 0;
1454
+ }
1455
+ function humanizeSlug(slug) {
1456
+ return slug.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()).trim();
1457
+ }
1458
+ function resolveEntryTitle(data, options) {
1459
+ if (options?.schema) {
1460
+ const schemaTitle = extractTitleFromSchema(options.schema, data);
1461
+ if (schemaTitle)
1462
+ return schemaTitle;
1463
+ }
1464
+ const title = data.title ?? data.name;
1465
+ if (typeof title === "string")
1466
+ return title;
1467
+ if (options?.entryTypeLabel)
1468
+ return options.entryTypeLabel;
1469
+ return options?.slug ? humanizeSlug(options.slug) : "Untitled";
1470
+ }
1471
+
1402
1472
  // dist/utils/entry-url.js
1403
1473
  init_normalize();
1404
1474
  function isIndexSlug(slug) {
@@ -2048,6 +2118,10 @@ var DebugLogger = class {
2048
2118
  throw new Error(errorMsg);
2049
2119
  }
2050
2120
  }
2121
+ /**
2122
+ * Keyed by label alone, so two overlapping timers with one label overwrite each other;
2123
+ * anything that can run concurrently uses {@link timed}.
2124
+ */
2051
2125
  time(label) {
2052
2126
  this.timers.set(label, Date.now());
2053
2127
  }
@@ -2062,12 +2136,16 @@ var DebugLogger = class {
2062
2136
  this.debug(category, `${label} completed`, { durationMs: duration });
2063
2137
  return duration;
2064
2138
  }
2139
+ /**
2140
+ * Log `<label> completed {durationMs}` once `fn` settles. The start time is local to the
2141
+ * call, so concurrent spans with the same label on one logger never overwrite each other.
2142
+ */
2065
2143
  async timed(category, label, fn) {
2066
- this.time(label);
2144
+ const start = Date.now();
2067
2145
  try {
2068
2146
  return await fn();
2069
2147
  } finally {
2070
- this.timeEnd(category, label);
2148
+ this.debug(category, `${label} completed`, { durationMs: Date.now() - start });
2071
2149
  }
2072
2150
  }
2073
2151
  };
@@ -2393,9 +2471,80 @@ var STATIC_DEPLOY_USER = Object.freeze({
2393
2471
  name: "Static Deploy"
2394
2472
  });
2395
2473
 
2474
+ // dist/utils/request-timing.js
2475
+ import { AsyncLocalStorage } from "node:async_hooks";
2476
+ var storage = new AsyncLocalStorage();
2477
+ var log2 = createDebugLogger({ prefix: "CanopyCMS" });
2478
+ async function timeRequestPhase(phase, fn) {
2479
+ const parent = storage.getStore();
2480
+ if (!parent)
2481
+ return fn();
2482
+ const key = parent.path ? `${parent.path}>${phase}` : phase;
2483
+ let total = parent.phases.get(key);
2484
+ if (!total) {
2485
+ total = { count: 0, ms: 0 };
2486
+ parent.phases.set(key, total);
2487
+ }
2488
+ const start = performance.now();
2489
+ try {
2490
+ return await storage.run({ ...parent, path: key }, fn);
2491
+ } finally {
2492
+ total.count += 1;
2493
+ total.ms += performance.now() - start;
2494
+ }
2495
+ }
2496
+
2497
+ // dist/utils/git.js
2498
+ import { simpleGit } from "simple-git";
2499
+ var NETWORK_SCHEME_PATTERN = /^(https?|ssh|git):\/\//i;
2500
+ var TRANSPORT_HELPER_PATTERN = /^[a-z][a-z0-9+.-]*::/i;
2501
+ var LEADING_OPTION_PATTERN = /^-/;
2502
+ var WINDOWS_DRIVE_PATTERN = /^[a-zA-Z]:[\\/]/;
2503
+ var COLON_BEFORE_SLASH_PATTERN = /^[^/\\\s]+:/;
2504
+ function isNetworkRemoteUrl(url) {
2505
+ if (/^file:\/\//i.test(url))
2506
+ return false;
2507
+ if (NETWORK_SCHEME_PATTERN.test(url))
2508
+ return true;
2509
+ if (TRANSPORT_HELPER_PATTERN.test(url))
2510
+ return true;
2511
+ if (LEADING_OPTION_PATTERN.test(url))
2512
+ return true;
2513
+ if (WINDOWS_DRIVE_PATTERN.test(url))
2514
+ return false;
2515
+ return COLON_BEFORE_SLASH_PATTERN.test(url);
2516
+ }
2517
+ var MISSING_REMOTE_REF_REASONS = ["couldn't find remote ref", "Couldn't find remote ref"];
2518
+ function isMissingRemoteRefFailure(message) {
2519
+ return MISSING_REMOTE_REF_REASONS.some((reason) => message.includes(reason));
2520
+ }
2521
+ var CANOPY_META_DIR = ".canopy-meta";
2522
+ async function stageAllExceptCanopyState(git) {
2523
+ await git.add(["-A"]);
2524
+ await git.raw(["reset", "-q", "--", CANOPY_META_DIR]);
2525
+ }
2526
+ async function detectHeadBranch(repoRoot, fallback = "main") {
2527
+ try {
2528
+ const git = simpleGit({ baseDir: repoRoot });
2529
+ const head = (await git.revparse(["--abbrev-ref", "HEAD"])).trim();
2530
+ return head && head !== "HEAD" ? head : fallback;
2531
+ } catch {
2532
+ return fallback;
2533
+ }
2534
+ }
2535
+ async function resolveBaseBranch(options) {
2536
+ if (options.defaultBaseBranch)
2537
+ return options.defaultBaseBranch;
2538
+ if (options.mode === "dev") {
2539
+ return detectHeadBranch(options.detectFrom ?? process.cwd());
2540
+ }
2541
+ return "main";
2542
+ }
2543
+
2396
2544
  // dist/branch-schema-cache.js
2397
2545
  var SCHEMA_CACHE_VERSION = 3;
2398
2546
  var MTIME_CHECK_DEBOUNCE_MS = 1e3;
2547
+ var SCHEMA_CACHE_FILE = "schema-cache.json";
2399
2548
  var SCHEMA_GENERATION_RESOURCE = "schema";
2400
2549
  async function isStaleByMtime(dir, cachedAt) {
2401
2550
  let entries;
@@ -2418,6 +2567,11 @@ async function isStaleByMtime(dir, cachedAt) {
2418
2567
  }
2419
2568
  return false;
2420
2569
  }
2570
+ async function schemaCacheDir(branchRoot) {
2571
+ const gitDir = path8.join(branchRoot, ".git");
2572
+ const isClone = await fs7.stat(gitDir).then((stat) => stat.isDirectory(), () => false);
2573
+ return isClone ? path8.join(gitDir, "canopycms") : path8.join(branchRoot, CANOPY_META_DIR);
2574
+ }
2421
2575
  var BranchSchemaCache = class {
2422
2576
  constructor(mode = "prod") {
2423
2577
  this.lastMtimeCheck = /* @__PURE__ */ new Map();
@@ -2426,7 +2580,7 @@ var BranchSchemaCache = class {
2426
2580
  /**
2427
2581
  * Whether to skip the on-disk cache for this branchRoot.
2428
2582
  *
2429
- * Never write `.canopy-meta/` at the project root, whichever entrypoint
2583
+ * Never write a schema cache at the project root, whichever entrypoint
2430
2584
  * produced the cwd branchRoot. branchRoot equals process.cwd() only in the
2431
2585
  * synthetic contexts static deployments and build phases use; a real branch
2432
2586
  * root is always nested under the workspace.
@@ -2436,7 +2590,7 @@ var BranchSchemaCache = class {
2436
2590
  }
2437
2591
  /** Get schema for a branch, from the cache when fresh, else resolved fresh. */
2438
2592
  async getSchema(branchRoot, entrySchemaRegistry, contentRootName = "content") {
2439
- return this.loadFromCacheOrResolve(branchRoot, entrySchemaRegistry, contentRootName);
2593
+ return timeRequestPhase("schema", () => this.loadFromCacheOrResolve(branchRoot, entrySchemaRegistry, contentRootName));
2440
2594
  }
2441
2595
  /**
2442
2596
  * Resolve the schema from disk. Protected rather than a direct resolveSchema
@@ -2450,8 +2604,7 @@ var BranchSchemaCache = class {
2450
2604
  const contentRoot = path8.join(branchRoot, contentRootName);
2451
2605
  const skipDiskCache = this.skipDiskCache(branchRoot);
2452
2606
  if (!skipDiskCache) {
2453
- const cacheDir = path8.join(branchRoot, ".canopy-meta");
2454
- const cachePath = path8.join(cacheDir, "schema-cache.json");
2607
+ const cachePath = path8.join(await schemaCacheDir(branchRoot), SCHEMA_CACHE_FILE);
2455
2608
  let cacheData = null;
2456
2609
  try {
2457
2610
  const cacheContent = await fs7.readFile(cachePath, "utf-8");
@@ -2493,7 +2646,7 @@ var BranchSchemaCache = class {
2493
2646
  const { skipDiskCache } = options;
2494
2647
  const contentRoot = path8.join(branchRoot, contentRootName);
2495
2648
  const read = skipDiskCache ? null : await readResourceGeneration(branchRoot, SCHEMA_GENERATION_RESOURCE);
2496
- const result = await this.resolveFresh(contentRoot, entrySchemaRegistry);
2649
+ const result = await timeRequestPhase("resolve", () => this.resolveFresh(contentRoot, entrySchemaRegistry));
2497
2650
  if (!isValidSchema(result.schema)) {
2498
2651
  throw new Error(`No schema found in ${contentRoot}. Create .collection.json files with references to field schemas defined in your entry schema registry.`);
2499
2652
  }
@@ -2504,9 +2657,9 @@ var BranchSchemaCache = class {
2504
2657
  }
2505
2658
  const flatSchema = flattenSchema(result.schema, contentRootName);
2506
2659
  if (!skipDiskCache) {
2507
- const cacheDir = path8.join(branchRoot, ".canopy-meta");
2508
- const cachePath = path8.join(cacheDir, "schema-cache.json");
2509
- await fs7.unlink(path8.join(cacheDir, "schema-cache.stale")).catch(() => {
2660
+ const cacheDir = await schemaCacheDir(branchRoot);
2661
+ const cachePath = path8.join(cacheDir, SCHEMA_CACHE_FILE);
2662
+ await fs7.unlink(path8.join(branchRoot, CANOPY_META_DIR, "schema-cache.stale")).catch(() => {
2510
2663
  });
2511
2664
  if (read && read.ok) {
2512
2665
  const newCache = {
@@ -2644,6 +2797,11 @@ function sanitizeBranchName(branchName) {
2644
2797
  const trimmedDots = squashed.replace(/^\.+/, "").replace(/(?<!\.)\.+$/, "");
2645
2798
  return trimmedDots || "branch";
2646
2799
  }
2800
+ var RESERVED_SETTINGS_BRANCH_PREFIX = "canopycms-settings-";
2801
+ function isSettingsBranchName(branchName, settingsBranchName) {
2802
+ const sanitized = sanitizeBranchName(branchName);
2803
+ return sanitized.startsWith(RESERVED_SETTINGS_BRANCH_PREFIX) || settingsBranchName !== void 0 && sanitized === sanitizeBranchName(settingsBranchName);
2804
+ }
2647
2805
 
2648
2806
  // dist/paths/branch.js
2649
2807
  var BranchPathError = class extends Error {
@@ -2655,6 +2813,9 @@ function resolveBranchPath(options) {
2655
2813
  if (options.branchName.includes("..")) {
2656
2814
  throw new BranchPathError("Branch name cannot contain traversal segments");
2657
2815
  }
2816
+ if (isSettingsBranchName(options.branchName)) {
2817
+ throw new BranchPathError("Settings branches are not content branches");
2818
+ }
2658
2819
  const safeBranch = sanitizeBranchName(options.branchName);
2659
2820
  const strategy = operatingStrategy(options.mode);
2660
2821
  const baseRoot = resolveContentBranchesRoot(options.mode, options.basePathOverride);
@@ -3116,6 +3277,7 @@ var ContentStore = class {
3116
3277
  return {
3117
3278
  absolutePath: resolved,
3118
3279
  relativePath: path11.relative(this.root, resolved),
3280
+ logicalPath: entryLogicalPath(schemaItem.logicalPath, safeSlug),
3119
3281
  id,
3120
3282
  entryTypeName: finalEntryTypeName,
3121
3283
  existed
@@ -3145,6 +3307,10 @@ var ContentStore = class {
3145
3307
  const schemaItem = this.assertSchemaItem(schemaPath);
3146
3308
  return await this.buildPaths(schemaItem, slug);
3147
3309
  }
3310
+ /**
3311
+ * Read one entry. References resolve unless `resolveReferences: false`; a target that
3312
+ * `referenceAccess` denies resolves to a `RestrictedReference` rather than its data.
3313
+ */
3148
3314
  async read(collectionPath, slug = "", options = {}) {
3149
3315
  const schemaItem = this.assertSchemaItem(collectionPath);
3150
3316
  const { absolutePath, relativePath, entryTypeName: resolvedEntryTypeName } = await this.buildPaths(schemaItem, slug);
@@ -3206,7 +3372,7 @@ var ContentStore = class {
3206
3372
  };
3207
3373
  }
3208
3374
  if (options.resolveReferences !== false) {
3209
- doc.data = await this.resolveReferencesInData(doc.data, fields);
3375
+ doc.data = await this.resolveReferencesInData(doc.data, fields, void 0, options.referenceAccess);
3210
3376
  }
3211
3377
  doc.version = stat.mtimeMs;
3212
3378
  return doc;
@@ -3660,20 +3826,25 @@ var ContentStore = class {
3660
3826
  * a target referenced by many entries is read once; omit `cache` for exactly `read()`'s
3661
3827
  * unmemoized behavior.
3662
3828
  *
3663
- * **Path ACLs are not consulted for the targets.** Resolution goes through this store's own
3664
- * `read()`, below the per-entry permission check in content-reader.ts, so a resolved target
3665
- * may be an entry the caller could not `read()` directly -- pre-existing `read()` behavior,
3666
- * matched here on purpose so one rule covers both.
3829
+ * `access` applies to every target exactly as in `read()`; see {@link ReferenceTargetAccess}.
3667
3830
  */
3668
- async resolveReferences(data, fields, cache) {
3669
- return this.resolveReferencesInData(data, fields, cache);
3831
+ async resolveReferences(data, fields, cache, access) {
3832
+ return this.resolveReferencesInData(data, fields, cache, access);
3670
3833
  }
3671
- async resolveReferencesInData(data, fields, cache) {
3834
+ /**
3835
+ * Resolve one reference id the way a reference field resolves it, for the editor's
3836
+ * live-preview endpoint (api/resolve-references.ts), which has ids but no field to walk. Null
3837
+ * when the id names no entry. No body is embedded, since there is no field to ask for one.
3838
+ */
3839
+ async resolveReferenceTarget(id, access) {
3840
+ return this.resolveSingleReference(id, await this.idIndex(), false, void 0, access);
3841
+ }
3842
+ async resolveReferencesInData(data, fields, cache, access) {
3672
3843
  const resolved = { ...data };
3673
3844
  const idIndex = await this.idIndex();
3674
3845
  for (const field of fields) {
3675
3846
  if (field.type === "group") {
3676
- const groupResolved = await this.resolveReferencesInData(resolved, field.fields, cache);
3847
+ const groupResolved = await this.resolveReferencesInData(resolved, field.fields, cache, access);
3677
3848
  Object.assign(resolved, groupResolved);
3678
3849
  continue;
3679
3850
  }
@@ -3681,18 +3852,18 @@ var ContentStore = class {
3681
3852
  if (field.type === "reference") {
3682
3853
  const includeBody = field.includeBody === true;
3683
3854
  if (typeof value === "string" && value) {
3684
- resolved[field.name] = await this.resolveSingleReference(value, idIndex, includeBody, cache);
3855
+ resolved[field.name] = await this.resolveSingleReference(value, idIndex, includeBody, cache, access);
3685
3856
  } else if (field.list && Array.isArray(value)) {
3686
- resolved[field.name] = await Promise.all(value.map((id) => typeof id === "string" ? this.resolveSingleReference(id, idIndex, includeBody, cache) : null));
3857
+ resolved[field.name] = await Promise.all(value.map((id) => typeof id === "string" ? this.resolveSingleReference(id, idIndex, includeBody, cache, access) : null));
3687
3858
  }
3688
3859
  } else if (field.type === "object" && value) {
3689
3860
  const objectField = field;
3690
3861
  if (!objectField.fields)
3691
3862
  continue;
3692
3863
  if (objectField.list && Array.isArray(value)) {
3693
- resolved[field.name] = await Promise.all(value.map((item) => typeof item === "object" && item !== null ? this.resolveReferencesInData(item, objectField.fields, cache) : item));
3864
+ resolved[field.name] = await Promise.all(value.map((item) => typeof item === "object" && item !== null ? this.resolveReferencesInData(item, objectField.fields, cache, access) : item));
3694
3865
  } else if (typeof value === "object") {
3695
- resolved[field.name] = await this.resolveReferencesInData(value, objectField.fields, cache);
3866
+ resolved[field.name] = await this.resolveReferencesInData(value, objectField.fields, cache, access);
3696
3867
  }
3697
3868
  } else if (field.type === "block" && Array.isArray(value)) {
3698
3869
  const blockField = field;
@@ -3705,7 +3876,7 @@ var ContentStore = class {
3705
3876
  return block;
3706
3877
  return {
3707
3878
  ...b,
3708
- value: await this.resolveReferencesInData(b.value, template.fields, cache)
3879
+ value: await this.resolveReferencesInData(b.value, template.fields, cache, access)
3709
3880
  };
3710
3881
  }));
3711
3882
  }
@@ -3736,13 +3907,13 @@ var ContentStore = class {
3736
3907
  * `includeBody` is opt-in per field: an embedded body sits inside the memoized object and is
3737
3908
  * cloned once per referencing entry.
3738
3909
  */
3739
- resolveSingleReference(id, idIndex, includeBody, cache) {
3910
+ resolveSingleReference(id, idIndex, includeBody, cache, access) {
3740
3911
  if (!cache)
3741
- return this.resolveSingleReferenceUncached(id, idIndex, includeBody);
3912
+ return this.resolveSingleReferenceUncached(id, idIndex, includeBody, access);
3742
3913
  const key = includeBody ? `${id}:body` : id;
3743
3914
  let pending = cache.get(key);
3744
3915
  if (!pending) {
3745
- pending = this.resolveSingleReferenceUncached(id, idIndex, includeBody);
3916
+ pending = this.resolveSingleReferenceUncached(id, idIndex, includeBody, access);
3746
3917
  cache.set(key, pending);
3747
3918
  }
3748
3919
  return pending.then((resolved) => resolved === null ? null : structuredClone(resolved));
@@ -3760,15 +3931,27 @@ var ContentStore = class {
3760
3931
  * A {@link ReferenceResolveCache} sits ABOVE this, never inside it, so every distinct id
3761
3932
  * still runs the full refresh-and-retry; a cache only makes repeats of the SAME id share it.
3762
3933
  */
3763
- async resolveSingleReferenceUncached(id, idIndex, includeBody) {
3764
- const first = await this.resolveSingleReferenceOnce(id, idIndex, includeBody);
3934
+ async resolveSingleReferenceUncached(id, idIndex, includeBody, access) {
3935
+ const first = await this.resolveSingleReferenceOnce(id, idIndex, includeBody, access);
3765
3936
  if (first !== STALE_LOOKUP)
3766
3937
  return first;
3767
3938
  await this.refreshIndexForSuspiciousLookup();
3768
- const second = await this.resolveSingleReferenceOnce(id, await this.idIndex(), includeBody);
3939
+ const second = await this.resolveSingleReferenceOnce(id, await this.idIndex(), includeBody, access);
3769
3940
  return second === STALE_LOOKUP ? null : second;
3770
3941
  }
3771
- async resolveSingleReferenceOnce(id, idIndex, includeBody) {
3942
+ /**
3943
+ * The entry type an entry file was written as, by its filename's type token, falling back to
3944
+ * the collection's default the way `read()` does for a legacy untyped file.
3945
+ */
3946
+ entryTypeOf(collectionPath, relativePath) {
3947
+ const schemaItem = this.assertSchemaItem(collectionPath);
3948
+ if (schemaItem.type === "entry-type")
3949
+ return schemaItem;
3950
+ const entries = schemaItem.entries;
3951
+ const typeName = extractEntryTypeFromFilename(path11.basename(relativePath));
3952
+ return entries?.find((e) => e.name === typeName) ?? getDefaultEntryType(schemaItem.entries);
3953
+ }
3954
+ async resolveSingleReferenceOnce(id, idIndex, includeBody, access) {
3772
3955
  try {
3773
3956
  const location = idIndex.findById(id);
3774
3957
  if (!location)
@@ -3779,13 +3962,22 @@ var ContentStore = class {
3779
3962
  const doc = await this.read(location.collection, location.slug, {
3780
3963
  resolveReferences: false
3781
3964
  });
3782
- const bodyForEmbed = includeBody && "body" in doc ? { fieldName: doc.bodyFieldName, value: doc.body } : void 0;
3783
- return buildResolvedReference(doc.data, {
3965
+ const meta = {
3784
3966
  id,
3785
3967
  slug: location.slug,
3786
3968
  collection: location.collection,
3787
3969
  urlPath: computeEntryUrl(location.collection, location.slug, this.contentRootName)
3788
- }, bodyForEmbed);
3970
+ };
3971
+ if (access && !access(entryLogicalPath(location.collection, location.slug))) {
3972
+ const entryType = this.entryTypeOf(location.collection, location.relativePath);
3973
+ return buildRestrictedReference(meta, resolveEntryTitle(doc.data, {
3974
+ schema: entryType?.schema,
3975
+ entryTypeLabel: entryType?.label,
3976
+ slug: location.slug
3977
+ }));
3978
+ }
3979
+ const bodyForEmbed = includeBody && "body" in doc ? { fieldName: doc.bodyFieldName, value: doc.body } : void 0;
3980
+ return buildResolvedReference(doc.data, meta, bodyForEmbed);
3789
3981
  } catch (error) {
3790
3982
  if (isNodeError(error) && error.code === "ENOENT")
3791
3983
  return STALE_LOOKUP;
@@ -4353,7 +4545,7 @@ function yamlValue(value) {
4353
4545
  }
4354
4546
 
4355
4547
  // dist/entry-link-resolver.js
4356
- var log2 = createDebugLogger({ prefix: "EntryLinks" });
4548
+ var log3 = createDebugLogger({ prefix: "EntryLinks" });
4357
4549
  var BASE58_CHAR = "[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]";
4358
4550
  var ENTRY_LINK_PATTERN = new RegExp(`entry:(${BASE58_CHAR}{12})(#[^\\s)>"']*)?`, "g");
4359
4551
  function resolveEntryUrl(location, contentRoot) {
@@ -4367,7 +4559,7 @@ function resolveEntryLinksInText(text, idIndex, contentRoot, customResolver) {
4367
4559
  return part.text.replace(ENTRY_LINK_PATTERN, (_match, id, anchor) => {
4368
4560
  const location = idIndex.findById(id);
4369
4561
  if (!location || location.type !== "entry" || !location.collection || !location.slug) {
4370
- log2.warn("resolve", `Entry link target not found: entry:${id}`);
4562
+ log3.warn("resolve", `Entry link target not found: entry:${id}`);
4371
4563
  return anchor ?? "#";
4372
4564
  }
4373
4565
  let url;
@@ -4765,7 +4957,7 @@ async function readBranchMetadataFile(branchRoot) {
4765
4957
 
4766
4958
  // dist/branch-registry.js
4767
4959
  init_logger();
4768
- var log3 = createDebugLogger({ prefix: "BranchRegistry" });
4960
+ var log4 = createDebugLogger({ prefix: "BranchRegistry" });
4769
4961
  var REGISTRY_FILE = "branches.json";
4770
4962
  var REGISTRY_STALE_FILE = "branches.stale.json";
4771
4963
  var REGISTRY_TEMP_FILE = "branches.tmp.json";
@@ -4845,7 +5037,7 @@ var BranchRegistry = class {
4845
5037
  });
4846
5038
  await this.regenerate();
4847
5039
  } catch (err) {
4848
- log3.warn("invalidate", "Eager regeneration after invalidate() failed", {
5040
+ log4.warn("invalidate", "Eager regeneration after invalidate() failed", {
4849
5041
  error: getErrorMessage(err)
4850
5042
  });
4851
5043
  }
@@ -4936,7 +5128,7 @@ import fs14 from "node:fs/promises";
4936
5128
  import path15 from "node:path";
4937
5129
  import { randomUUID as randomUUID2 } from "node:crypto";
4938
5130
  import lockfile2 from "proper-lockfile";
4939
- var log4 = createDebugLogger({ prefix: "OccJsonWrite" });
5131
+ var log5 = createDebugLogger({ prefix: "OccJsonWrite" });
4940
5132
  var OccWriteConflictError = class extends Error {
4941
5133
  constructor(message = "Concurrent modification detected") {
4942
5134
  super(message);
@@ -5031,7 +5223,7 @@ async function withOccFileLock(filePath, fn) {
5031
5223
  // Our critical sections are short and idempotent-on-conflict (OCC
5032
5224
  // verify inside); log and let the section finish.
5033
5225
  onCompromised: (err) => {
5034
- log4.warn("lock", `Lock compromised mid-hold for ${filePath}`, {
5226
+ log5.warn("lock", `Lock compromised mid-hold for ${filePath}`, {
5035
5227
  error: getErrorMessage(err)
5036
5228
  });
5037
5229
  }
@@ -5053,7 +5245,7 @@ async function withOccFileLock(filePath, fn) {
5053
5245
  await release().catch((err) => {
5054
5246
  if (isNodeError(err) && err.code === "ENOENT")
5055
5247
  return;
5056
- log4.warn("lock", `Failed to release lock for ${filePath}`, { error: getErrorMessage(err) });
5248
+ log5.warn("lock", `Failed to release lock for ${filePath}`, { error: getErrorMessage(err) });
5057
5249
  });
5058
5250
  }
5059
5251
  }
@@ -5229,51 +5421,7 @@ init_operating_mode();
5229
5421
  import fs16 from "node:fs/promises";
5230
5422
  import path17 from "node:path";
5231
5423
  import { simpleGit as simpleGit2 } from "simple-git";
5232
-
5233
- // dist/utils/git.js
5234
- import { simpleGit } from "simple-git";
5235
- var NETWORK_SCHEME_PATTERN = /^(https?|ssh|git):\/\//i;
5236
- var TRANSPORT_HELPER_PATTERN = /^[a-z][a-z0-9+.-]*::/i;
5237
- var LEADING_OPTION_PATTERN = /^-/;
5238
- var WINDOWS_DRIVE_PATTERN = /^[a-zA-Z]:[\\/]/;
5239
- var COLON_BEFORE_SLASH_PATTERN = /^[^/\\\s]+:/;
5240
- function isNetworkRemoteUrl(url) {
5241
- if (/^file:\/\//i.test(url))
5242
- return false;
5243
- if (NETWORK_SCHEME_PATTERN.test(url))
5244
- return true;
5245
- if (TRANSPORT_HELPER_PATTERN.test(url))
5246
- return true;
5247
- if (LEADING_OPTION_PATTERN.test(url))
5248
- return true;
5249
- if (WINDOWS_DRIVE_PATTERN.test(url))
5250
- return false;
5251
- return COLON_BEFORE_SLASH_PATTERN.test(url);
5252
- }
5253
- var MISSING_REMOTE_REF_REASONS = ["couldn't find remote ref", "Couldn't find remote ref"];
5254
- function isMissingRemoteRefFailure(message) {
5255
- return MISSING_REMOTE_REF_REASONS.some((reason) => message.includes(reason));
5256
- }
5257
- async function detectHeadBranch(repoRoot, fallback = "main") {
5258
- try {
5259
- const git = simpleGit({ baseDir: repoRoot });
5260
- const head = (await git.revparse(["--abbrev-ref", "HEAD"])).trim();
5261
- return head && head !== "HEAD" ? head : fallback;
5262
- } catch {
5263
- return fallback;
5264
- }
5265
- }
5266
- async function resolveBaseBranch(options) {
5267
- if (options.defaultBaseBranch)
5268
- return options.defaultBaseBranch;
5269
- if (options.mode === "dev") {
5270
- return detectHeadBranch(options.detectFrom ?? process.cwd());
5271
- }
5272
- return "main";
5273
- }
5274
-
5275
- // dist/git-manager.js
5276
- var log5 = createDebugLogger({ prefix: "GitManager" });
5424
+ var log6 = createDebugLogger({ prefix: "GitManager" });
5277
5425
  var GIT_ENV_PASSTHROUGH = /^(PATH|HOME|USER|LANG|LC_[A-Z]+|TZ|TMPDIR|GIT_(AUTHOR|COMMITTER)_(NAME|EMAIL|DATE)|GIT_TERMINAL_PROMPT|GIT_TRACE[0-9A-Z_]*)$/;
5278
5426
  var FORCE_C_LOCALE = { LC_ALL: "C", LANG: "C" };
5279
5427
  function gitChildEnv(overrides) {
@@ -5298,13 +5446,13 @@ async function ensureGitExcludePattern(repoPath, pattern) {
5298
5446
  }
5299
5447
  const lines = content.split("\n");
5300
5448
  if (lines.some((line) => line.trim() === pattern)) {
5301
- log5.debug("git", "Pattern already in .git/info/exclude", { pattern });
5449
+ log6.debug("git", "Pattern already in .git/info/exclude", { pattern });
5302
5450
  return;
5303
5451
  }
5304
5452
  const needsLeadingNewline = content.length > 0 && !content.endsWith("\n");
5305
5453
  const newContent = content + (needsLeadingNewline ? "\n" : "") + pattern + "\n";
5306
5454
  await fs16.writeFile(excludePath, newContent, "utf-8");
5307
- log5.debug("git", "Added pattern to .git/info/exclude", { pattern });
5455
+ log6.debug("git", "Added pattern to .git/info/exclude", { pattern });
5308
5456
  }
5309
5457
  var GitConflictError = class extends Error {
5310
5458
  constructor(conflictedFiles) {
@@ -5322,6 +5470,41 @@ var GitRemoteRefMissingError = class extends Error {
5322
5470
  this.name = "GitRemoteRefMissingError";
5323
5471
  }
5324
5472
  };
5473
+ var RemoteNotReadyError = class extends Error {
5474
+ constructor(expectedRemotePath) {
5475
+ super(`CanopyCMS: no git remote is available yet. defaultRemoteUrl (or CANOPYCMS_REMOTE_URL) is not set and the CMS worker has not created ${expectedRemotePath}; the worker may still be starting.`);
5476
+ this.expectedRemotePath = expectedRemotePath;
5477
+ this.name = "RemoteNotReadyError";
5478
+ }
5479
+ };
5480
+ var SettingsRemoteUnreadableError = class extends Error {
5481
+ constructor(branch, remote, gitError) {
5482
+ super(`CanopyCMS: could not read settings branch '${branch}' from remote '${remote}', so the settings workspace was not provisioned: ${getErrorMessage(gitError)}`);
5483
+ this.branch = branch;
5484
+ this.remote = remote;
5485
+ this.gitError = gitError;
5486
+ this.name = "SettingsRemoteUnreadableError";
5487
+ }
5488
+ };
5489
+ var SettingsBranchDivergedError = class extends Error {
5490
+ constructor(branch, workspacePath, localWork) {
5491
+ super(`CanopyCMS: settings branch '${branch}' in ${workspacePath} shares no history with the remote's '${branch}', and holds ${localWork}. Settings saves cannot pull until one copy is chosen. To keep the remote's settings: copy anything you need out of ${workspacePath}, move it aside, and restart; the next start checks out the remote's '${branch}', and copied changes can then be re-applied in the editor.`);
5492
+ this.branch = branch;
5493
+ this.workspacePath = workspacePath;
5494
+ this.localWork = localWork;
5495
+ this.name = "SettingsBranchDivergedError";
5496
+ }
5497
+ };
5498
+ var SETTINGS_BRANCH_FILES = /* @__PURE__ */ new Set(["permissions.json", "groups.json"]);
5499
+ var SettingsBranchHasContentHistoryError = class extends Error {
5500
+ constructor(branch, remote, evidence) {
5501
+ super(`CanopyCMS: the settings branch '${branch}' on remote '${remote}' holds content, not settings (${evidence}), so it was not checked out. Restore '${branch}' on the remote from a copy of the settings (it may hold only permissions.json and groups.json), then restart.`);
5502
+ this.branch = branch;
5503
+ this.remote = remote;
5504
+ this.evidence = evidence;
5505
+ this.name = "SettingsBranchHasContentHistoryError";
5506
+ }
5507
+ };
5325
5508
  var GitManager = class _GitManager {
5326
5509
  constructor(options, gitOptions) {
5327
5510
  this.repoPath = path17.resolve(options.repoPath);
@@ -5332,14 +5515,14 @@ var GitManager = class _GitManager {
5332
5515
  this.git.env(gitChildEnv({ GIT_CEILING_DIRECTORIES: path17.dirname(this.repoPath) }));
5333
5516
  }
5334
5517
  static async cloneRepo(remoteUrl, targetPath, baseBranch = "main") {
5335
- log5.debug("git", "Cloning repository", {
5518
+ log6.debug("git", "Cloning repository", {
5336
5519
  remoteUrl,
5337
5520
  targetPath,
5338
5521
  baseBranch
5339
5522
  });
5340
5523
  const git = simpleGit2();
5341
5524
  await git.clone(remoteUrl, targetPath, ["--branch", baseBranch, "--single-branch"]);
5342
- log5.debug("git", "Clone complete");
5525
+ log6.debug("git", "Clone complete");
5343
5526
  }
5344
5527
  /**
5345
5528
  * Initializes a local bare git repository to simulate a remote for dev mode,
@@ -5353,15 +5536,15 @@ var GitManager = class _GitManager {
5353
5536
  static async ensureLocalSimulatedRemote(options) {
5354
5537
  const existingLock = remoteInitLocks.get(options.remotePath);
5355
5538
  if (existingLock) {
5356
- log5.debug("git", "Waiting for existing remote initialization", {
5539
+ log6.debug("git", "Waiting for existing remote initialization", {
5357
5540
  remotePath: options.remotePath
5358
5541
  });
5359
5542
  await existingLock;
5360
5543
  }
5361
- const lockPromise = log5.timed("git", "ensureLocalSimulatedRemote", async () => {
5544
+ const lockPromise = log6.timed("git", "ensureLocalSimulatedRemote", async () => {
5362
5545
  let releaseLock;
5363
5546
  try {
5364
- log5.debug("git", "Initializing local simulated remote", {
5547
+ log6.debug("git", "Initializing local simulated remote", {
5365
5548
  remotePath: options.remotePath,
5366
5549
  baseBranch: options.baseBranch
5367
5550
  });
@@ -5376,7 +5559,7 @@ var GitManager = class _GitManager {
5376
5559
  throw err;
5377
5560
  }
5378
5561
  if (remoteExists && await _GitManager.bareRemoteHasBranch(options.remotePath, options.baseBranch)) {
5379
- log5.debug("git", "Remote already has base branch, skipping");
5562
+ log6.debug("git", "Remote already has base branch, skipping");
5380
5563
  return;
5381
5564
  }
5382
5565
  let gitRoot = options.sourcePath;
@@ -5395,8 +5578,8 @@ var GitManager = class _GitManager {
5395
5578
  }
5396
5579
  let hasCommits = false;
5397
5580
  try {
5398
- const log8 = await sourceGit.log(["-1"]);
5399
- hasCommits = log8.total > 0;
5581
+ const log9 = await sourceGit.log(["-1"]);
5582
+ hasCommits = log9.total > 0;
5400
5583
  } catch {
5401
5584
  hasCommits = false;
5402
5585
  }
@@ -5408,12 +5591,12 @@ var GitManager = class _GitManager {
5408
5591
  throw new Error(`Cannot initialize local simulated remote: base branch '${options.baseBranch}' does not exist locally. Please checkout '${options.baseBranch}' first or provide an explicit remoteUrl.`);
5409
5592
  }
5410
5593
  if (remoteExists) {
5411
- log5.debug("git", "Existing remote is missing base branch \u2014 pushing it from source", {
5594
+ log6.debug("git", "Existing remote is missing base branch \u2014 pushing it from source", {
5412
5595
  remotePath: options.remotePath,
5413
5596
  baseBranch: options.baseBranch
5414
5597
  });
5415
5598
  } else {
5416
- log5.debug("git", "Creating bare remote repository");
5599
+ log6.debug("git", "Creating bare remote repository");
5417
5600
  await simpleGit2().raw([
5418
5601
  "init",
5419
5602
  "--bare",
@@ -5427,13 +5610,13 @@ var GitManager = class _GitManager {
5427
5610
  baseBranch: options.baseBranch,
5428
5611
  subdirectory: options.subdirectory
5429
5612
  });
5430
- log5.debug("git", "Remote initialization complete");
5613
+ log6.debug("git", "Remote initialization complete");
5431
5614
  } finally {
5432
5615
  if (releaseLock) {
5433
5616
  try {
5434
5617
  await releaseLock();
5435
5618
  } catch (err) {
5436
- log5.debug("git", "Failed to release remote-init lock", { err });
5619
+ log6.debug("git", "Failed to release remote-init lock", { err });
5437
5620
  }
5438
5621
  }
5439
5622
  remoteInitLocks.delete(options.remotePath);
@@ -5501,7 +5684,7 @@ var GitManager = class _GitManager {
5501
5684
  * deleteBranchHandler: a head left in `remote.git` forever makes the
5502
5685
  * create -> publish -> squash-merge -> delete -> reuse-the-name cycle reject
5503
5686
  * the reused branch's first publish non-fast-forward against the stale head
5504
- * (`GitManager.push()` pushes `branch:branch`, and a squash-merged old tip is
5687
+ * (`GitManager.push()` pushes the branch to the same name, and a squash-merged old tip is
5505
5688
  * not an ancestor of the new branch), and a retried submit then skips the
5506
5689
  * local push on a clean tree and enqueues the worker push of the STALE head,
5507
5690
  * resurrecting the deleted branch's content on GitHub as an apparent success.
@@ -5632,6 +5815,12 @@ var GitManager = class _GitManager {
5632
5815
  return;
5633
5816
  throw new Error(`CanopyCMS: refusing to use a network remote URL in prod mode (from ${source}: "${url}"). The standard AWS Lambda+EC2-worker topology runs the CMS Lambda with no internet access \u2014 the EC2 worker owns all network git I/O, and the Lambda is expected to reach the EFS-local bare repo ({workspace}/remote.git) via auto-detect instead. Pointing a network URL here would make the internet-less Lambda try to clone/fetch/push it directly and hang until timeout. If this prod host genuinely has internet access and intentionally runs git against a network remote (e.g. a single-VM deployment), set config.allowNetworkRemoteInProd: true to acknowledge this.`);
5634
5817
  }
5818
+ /** The not-ready error for a mode whose remote is auto-detected, or `undefined` for other modes. */
5819
+ static async remoteNotReadyError(mode) {
5820
+ const { operatingStrategy: operatingStrategy2 } = await Promise.resolve().then(() => (init_operating_mode(), operating_mode_exports));
5821
+ const { autoDetectRemotePath } = operatingStrategy2(mode).getRemoteUrlConfig();
5822
+ return autoDetectRemotePath ? new RemoteNotReadyError(autoDetectRemotePath) : void 0;
5823
+ }
5635
5824
  /**
5636
5825
  * Resolves the remote URL for git operations following the priority:
5637
5826
  * 1. Explicit remoteUrl parameter
@@ -5668,7 +5857,7 @@ var GitManager = class _GitManager {
5668
5857
  try {
5669
5858
  const stat = await fs16.stat(config.autoDetectRemotePath);
5670
5859
  if (stat.isDirectory()) {
5671
- log5.debug("git", "Auto-detected local remote", {
5860
+ log6.debug("git", "Auto-detected local remote", {
5672
5861
  path: config.autoDetectRemotePath
5673
5862
  });
5674
5863
  return config.autoDetectRemotePath;
@@ -5732,7 +5921,7 @@ var GitManager = class _GitManager {
5732
5921
  try {
5733
5922
  const stat = await fs16.stat(gitPath);
5734
5923
  if (stat.isDirectory()) {
5735
- log5.debug("git", "Removing corrupt .git directory", {
5924
+ log6.debug("git", "Removing corrupt .git directory", {
5736
5925
  workspacePath: options.workspacePath
5737
5926
  });
5738
5927
  await fs16.rm(gitPath, { recursive: true });
@@ -5743,6 +5932,7 @@ var GitManager = class _GitManager {
5743
5932
  }
5744
5933
  }
5745
5934
  let justCloned = false;
5935
+ let resolvedRemoteUrl;
5746
5936
  if (!repoExists) {
5747
5937
  const remoteUrl = await _GitManager.resolveRemoteUrl({
5748
5938
  mode: options.mode,
@@ -5753,6 +5943,9 @@ var GitManager = class _GitManager {
5753
5943
  allowNetworkRemoteInProd: options.allowNetworkRemoteInProd
5754
5944
  });
5755
5945
  if (!remoteUrl) {
5946
+ const notReady = await _GitManager.remoteNotReadyError(options.mode);
5947
+ if (notReady)
5948
+ throw notReady;
5756
5949
  throw new Error("CanopyCMS: defaultRemoteUrl (or CANOPYCMS_REMOTE_URL) is required to initialize workspace");
5757
5950
  }
5758
5951
  try {
@@ -5761,6 +5954,7 @@ var GitManager = class _GitManager {
5761
5954
  throw new Error(`Failed to clone branch workspace at ${options.workspacePath} from ${remoteUrl} (base branch '${baseBranch}'): ${getErrorMessage(err)}`);
5762
5955
  }
5763
5956
  justCloned = true;
5957
+ resolvedRemoteUrl = remoteUrl;
5764
5958
  const freshGit = simpleGit2({ baseDir: options.workspacePath });
5765
5959
  freshGit.env(gitChildEnv({ GIT_CEILING_DIRECTORIES: path17.dirname(options.workspacePath) }));
5766
5960
  await freshGit.addConfig("canopycms.managed", "true");
@@ -5776,7 +5970,7 @@ var GitManager = class _GitManager {
5776
5970
  await git.git.addConfig("canopycms.managed", "true");
5777
5971
  await git.git.addConfig("user.name", options.gitBotAuthorName);
5778
5972
  await git.git.addConfig("user.email", options.gitBotAuthorEmail);
5779
- log5.debug("git", "Marked workspace as CanopyCMS-managed", {
5973
+ log6.debug("git", "Marked workspace as CanopyCMS-managed", {
5780
5974
  workspacePath: options.workspacePath
5781
5975
  });
5782
5976
  if (!justCloned) {
@@ -5791,9 +5985,17 @@ var GitManager = class _GitManager {
5791
5985
  if (remoteUrl) {
5792
5986
  await git.ensureRemote(remoteUrl);
5793
5987
  }
5988
+ resolvedRemoteUrl = remoteUrl;
5794
5989
  }
5795
5990
  if (options.branchType === "orphan") {
5796
- await git.createOrphanSettingsBranch(options.branchName, {});
5991
+ try {
5992
+ await git.createOrphanSettingsBranch(options.branchName, {});
5993
+ } catch (err) {
5994
+ if (err instanceof SettingsRemoteUnreadableError && !resolvedRemoteUrl) {
5995
+ throw await _GitManager.remoteNotReadyError(options.mode) ?? err;
5996
+ }
5997
+ throw err;
5998
+ }
5797
5999
  await git.ensureGitExclude("*.lock");
5798
6000
  } else {
5799
6001
  await git.checkoutBranch(options.branchName);
@@ -5902,7 +6104,7 @@ var GitManager = class _GitManager {
5902
6104
  const branches = await this.git.branch();
5903
6105
  const currentBranch = branches.current;
5904
6106
  try {
5905
- await this.git.fetch(this.remote, currentBranch);
6107
+ await this.git.fetch(this.remote, `refs/heads/${currentBranch}`);
5906
6108
  } catch (err) {
5907
6109
  if (!isMissingRemoteRefFailure(getErrorMessage(err)))
5908
6110
  throw err;
@@ -5959,19 +6161,34 @@ var GitManager = class _GitManager {
5959
6161
  const fileArray = Array.isArray(files) ? files : [files];
5960
6162
  await this.git.add(fileArray);
5961
6163
  }
6164
+ /** Stage every working-tree change except canopycms's own state. See {@link stageAllExceptCanopyState}. */
6165
+ async addAllExceptCanopyState() {
6166
+ await stageAllExceptCanopyState(this.git);
6167
+ }
5962
6168
  async commit(message) {
5963
6169
  await this.git.commit(message);
5964
6170
  }
5965
6171
  async push(branch) {
5966
- const target = branch ?? await this.git.revparse(["--abbrev-ref", "HEAD"]);
6172
+ const target = branch ?? await this.currentBranchName();
5967
6173
  await this.git.raw([
5968
6174
  "push",
5969
6175
  "--set-upstream",
5970
6176
  "--end-of-options",
5971
6177
  this.remote,
5972
- `${target}:${target}`
6178
+ `refs/heads/${target}:refs/heads/${target}`
5973
6179
  ]);
5974
6180
  }
6181
+ /**
6182
+ * The checked-out branch's plain name. `rev-parse --abbrev-ref HEAD` prints
6183
+ * `heads/<name>` when a same-named tag exists, which is not a branch name.
6184
+ */
6185
+ async currentBranchName() {
6186
+ const branches = await this.git.branch();
6187
+ if (branches.detached || !branches.current) {
6188
+ throw new Error(`CanopyCMS: no branch is checked out (detached HEAD) in ${this.repoPath}`);
6189
+ }
6190
+ return branches.current;
6191
+ }
5975
6192
  /**
5976
6193
  * Whether the local branch has commits the remote mirror doesn't -- i.e.
5977
6194
  * whether push() would move the remote ref forward.
@@ -5990,12 +6207,12 @@ var GitManager = class _GitManager {
5990
6207
  * ("couldn't find remote ref"); that counts as "ahead", not an error.
5991
6208
  */
5992
6209
  async hasUnpushedCommits(branch) {
5993
- const target = branch ?? await this.git.revparse(["--abbrev-ref", "--end-of-options", "HEAD"]);
5994
- const localSha = (await this.git.revparse(["--end-of-options", target])).trim();
6210
+ const target = branch ?? await this.git.revparse(["--verify", "--abbrev-ref", "--end-of-options", "HEAD"]);
6211
+ const localSha = (await this.git.revparse(["--verify", "--end-of-options", target])).trim();
5995
6212
  let fetchedTip;
5996
6213
  try {
5997
6214
  await this.git.raw(["fetch", "--end-of-options", this.remote, target]);
5998
- fetchedTip = (await this.git.revparse(["--end-of-options", "FETCH_HEAD"])).trim();
6215
+ fetchedTip = (await this.git.revparse(["--verify", "--end-of-options", "FETCH_HEAD"])).trim();
5999
6216
  } catch {
6000
6217
  return true;
6001
6218
  }
@@ -6004,6 +6221,25 @@ var GitManager = class _GitManager {
6004
6221
  const aheadCount = (await this.git.raw(["rev-list", "--count", `${fetchedTip}..${localSha}`])).trim();
6005
6222
  return aheadCount !== "0";
6006
6223
  }
6224
+ /**
6225
+ * Repo-relative paths that differ between HEAD and its merge base with the
6226
+ * base branch: everything the branch changes, across all of its commits.
6227
+ * Diffs against the just-fetched base tip pinned to a SHA — the pullBaseInner
6228
+ * constraint.
6229
+ */
6230
+ async listChangedPathsSinceBase() {
6231
+ await this.git.raw(["fetch", "--end-of-options", this.remote, this.baseBranch]);
6232
+ const baseTip = (await this.git.revparse(["--verify", "--end-of-options", "FETCH_HEAD"])).trim();
6233
+ const output = await this.git.raw([
6234
+ "diff",
6235
+ "--name-only",
6236
+ "-z",
6237
+ "--no-renames",
6238
+ `${baseTip}...HEAD`,
6239
+ "--"
6240
+ ]);
6241
+ return output.split("\0").filter((p) => p.length > 0);
6242
+ }
6007
6243
  async ensureAuthor(author) {
6008
6244
  const config = await this.git.listConfig();
6009
6245
  const isManaged = config.all["canopycms.managed"] === "true";
@@ -6057,10 +6293,24 @@ var GitManager = class _GitManager {
6057
6293
  await ensureGitExcludePattern(this.repoPath, pattern);
6058
6294
  }
6059
6295
  /**
6060
- * Create an orphan branch (no shared history) for settings, so
6061
- * deployment-specific settings never pollute content history. It holds only
6062
- * settings files committed by explicit path (permissions.json, groups.json at
6063
- * the workspace root).
6296
+ * Put the workspace on the settings branch: an orphan (no shared history),
6297
+ * so deployment-specific settings never pollute content history. It holds
6298
+ * only settings files committed by explicit path (permissions.json,
6299
+ * groups.json at the workspace root).
6300
+ *
6301
+ * The remote is the settings branch's durable copy, and the workspace clone
6302
+ * is `--single-branch` at the base branch, so the remote is asked first:
6303
+ * - no local branch: check out the remote's, or create an empty orphan only
6304
+ * when the remote has none. An unreadable remote throws
6305
+ * {@link SettingsRemoteUnreadableError} rather than guess "none".
6306
+ * - local branch present: a history unrelated to the remote's is repaired
6307
+ * onto the remote's when the local branch is still its empty initial
6308
+ * commit with a clean working tree, and otherwise throws
6309
+ * {@link SettingsBranchDivergedError}; local commits are never discarded.
6310
+ * An unreadable remote throws only for that empty branch, since one that
6311
+ * holds settings can serve them and the next save surfaces the remote error.
6312
+ * Either way a remote branch holding content is never adopted:
6313
+ * {@link SettingsBranchHasContentHistoryError}.
6064
6314
  */
6065
6315
  async createOrphanSettingsBranch(branchName, initialFiles) {
6066
6316
  try {
@@ -6070,11 +6320,31 @@ var GitManager = class _GitManager {
6070
6320
  }
6071
6321
  }
6072
6322
  async createOrphanSettingsBranchInner(branchName, initialFiles) {
6073
- log5.debug("git", "Creating orphan settings branch", { branchName });
6323
+ log6.debug("git", "Ensuring settings branch", { branchName });
6074
6324
  const branches = await this.git.branch();
6075
- if (branches.all.includes(branchName)) {
6076
- log5.debug("git", "Orphan branch already exists", { branchName });
6325
+ const localExists = branches.all.includes(branchName);
6326
+ if (localExists) {
6077
6327
  await this.git.checkout(branchName);
6328
+ }
6329
+ let remoteTip;
6330
+ try {
6331
+ remoteTip = await this.remoteBranchTip(branchName);
6332
+ } catch (err) {
6333
+ if (localExists && !await this.isEmptyInitialBranch(branchName)) {
6334
+ log6.debug("git", "Remote unreadable; serving the local settings branch", { branchName });
6335
+ return;
6336
+ }
6337
+ throw new SettingsRemoteUnreadableError(branchName, this.remote, err);
6338
+ }
6339
+ if (localExists) {
6340
+ await this.reconcileLocalSettingsBranch(branchName, remoteTip);
6341
+ return;
6342
+ }
6343
+ if (remoteTip) {
6344
+ const fetchedTip = await this.fetchBranchTip(branchName);
6345
+ await this.assertNotContentHistory(branchName, fetchedTip);
6346
+ await this.git.raw(["checkout", "-b", branchName, fetchedTip]);
6347
+ log6.debug("git", "Checked out the remote settings branch", { branchName });
6078
6348
  return;
6079
6349
  }
6080
6350
  await this.git.raw(["checkout", "--orphan", branchName]);
@@ -6089,19 +6359,129 @@ var GitManager = class _GitManager {
6089
6359
  await this.git.add(filePath);
6090
6360
  }
6091
6361
  await this.git.commit("Initialize settings branch", ["--allow-empty"]);
6092
- log5.debug("git", "Orphan settings branch created", { branchName });
6362
+ log6.debug("git", "Orphan settings branch created", { branchName });
6363
+ }
6364
+ /**
6365
+ * Compares the checked-out local settings branch with the remote's tip.
6366
+ * Related histories are left for the next settings pull to reconcile; only
6367
+ * an unrelated one is acted on here.
6368
+ */
6369
+ async reconcileLocalSettingsBranch(branchName, remoteTip) {
6370
+ if (!remoteTip)
6371
+ return;
6372
+ const localTip = (await this.git.revparse([`refs/heads/${branchName}`])).trim();
6373
+ if (localTip === remoteTip)
6374
+ return;
6375
+ const fetchedTip = await this.fetchBranchTip(branchName);
6376
+ const [localRoots, remoteRoots] = await Promise.all([
6377
+ this.rootCommits(localTip),
6378
+ this.rootCommits(fetchedTip)
6379
+ ]);
6380
+ if (localRoots.some((root) => remoteRoots.includes(root)))
6381
+ return;
6382
+ const localWork = [];
6383
+ if (!await this.isEmptyInitialBranch(branchName)) {
6384
+ localWork.push("commits beyond its empty initial commit");
6385
+ }
6386
+ const uncommitted = await this.getUncommittedFiles();
6387
+ if (uncommitted.length > 0) {
6388
+ localWork.push(`uncommitted changes to ${uncommitted.join(", ")}`);
6389
+ }
6390
+ if (localWork.length > 0) {
6391
+ throw new SettingsBranchDivergedError(branchName, this.repoPath, localWork.join(" and "));
6392
+ }
6393
+ await this.assertNotContentHistory(branchName, fetchedTip);
6394
+ await this.git.raw(["checkout", "-B", branchName, fetchedTip]);
6395
+ log6.debug("git", "Replaced an empty settings branch with the remote one", { branchName });
6396
+ }
6397
+ /**
6398
+ * The remote's tip for `branch`, or `undefined` when the remote has no such
6399
+ * branch. A remote that cannot be read throws: `ls-remote` exits 0 with no
6400
+ * output only for a reachable remote without the ref.
6401
+ */
6402
+ async remoteBranchTip(branch) {
6403
+ const ref = `refs/heads/${branch}`;
6404
+ const output = await this.git.raw(["ls-remote", "--heads", this.remote, ref]);
6405
+ for (const line of output.split("\n")) {
6406
+ const [sha, name] = line.trim().split(" ");
6407
+ if (sha && name === ref)
6408
+ return sha;
6409
+ }
6410
+ return void 0;
6411
+ }
6412
+ /**
6413
+ * Fetch `branch` from the remote and return the fetched commit, pinned as
6414
+ * pullBaseInner explains. The full ref: a bare name resolves to a
6415
+ * same-named tag first.
6416
+ */
6417
+ async fetchBranchTip(branch) {
6418
+ await this.git.fetch(this.remote, `refs/heads/${branch}`);
6419
+ return (await this.git.revparse(["FETCH_HEAD"])).trim();
6420
+ }
6421
+ /**
6422
+ * Refuse to adopt a remote settings branch that holds content: its tip has a file other than
6423
+ * the settings files, or its roots include the base branch's (a settings branch is an orphan).
6424
+ * Neither alone suffices: a base can have a root of its own, and a content tree can be pruned.
6425
+ * The base comes from the remote when the clone lacks it (it was cloned at an earlier base).
6426
+ */
6427
+ async assertNotContentHistory(branchName, tip) {
6428
+ const tree = await this.git.raw(["ls-tree", "-r", "--name-only", tip]);
6429
+ const strays = tree.split("\n").filter((file) => file && !SETTINGS_BRANCH_FILES.has(file));
6430
+ if (strays.length > 0) {
6431
+ const shown = strays.slice(0, 3).join(", ") + (strays.length > 3 ? ", \u2026" : "");
6432
+ throw new SettingsBranchHasContentHistoryError(branchName, this.remote, `it holds ${shown}`);
6433
+ }
6434
+ const [tipRoots, baseRoots] = await Promise.all([
6435
+ this.rootCommits(tip),
6436
+ this.baseBranchRev(branchName).then((rev) => this.rootCommits(rev))
6437
+ ]);
6438
+ if (tipRoots.some((root) => baseRoots.includes(root))) {
6439
+ throw new SettingsBranchHasContentHistoryError(branchName, this.remote, `it shares history with the base branch '${this.baseBranch}'`);
6440
+ }
6441
+ }
6442
+ async baseBranchRev(settingsBranch) {
6443
+ for (const ref of [
6444
+ `refs/heads/${this.baseBranch}`,
6445
+ `refs/remotes/${this.remote}/${this.baseBranch}`
6446
+ ]) {
6447
+ const sha = await this.git.raw(["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]).catch(() => "");
6448
+ if (sha.trim())
6449
+ return sha.trim();
6450
+ }
6451
+ try {
6452
+ return await this.fetchBranchTip(this.baseBranch);
6453
+ } catch (err) {
6454
+ throw new Error(`CanopyCMS: could not read base branch '${this.baseBranch}' from remote '${this.remote}', so settings branch '${settingsBranch}' was not checked out: ${getErrorMessage(err)}`);
6455
+ }
6456
+ }
6457
+ async rootCommits(rev) {
6458
+ const output = await this.git.raw(["rev-list", "--max-parents=0", rev]);
6459
+ return output.split("\n").filter(Boolean);
6460
+ }
6461
+ /**
6462
+ * Whether `branch` is a single parentless commit with an empty tree, as
6463
+ * orphan creation leaves it. Full refs throughout: a bare name resolves to a
6464
+ * same-named tag first, and clones fetch tags.
6465
+ */
6466
+ async isEmptyInitialBranch(branch) {
6467
+ const ref = `refs/heads/${branch}`;
6468
+ const commits = await this.git.raw(["rev-list", "--max-count=2", ref]);
6469
+ if (commits.split("\n").filter(Boolean).length !== 1)
6470
+ return false;
6471
+ const tree = await this.git.raw(["ls-tree", ref]);
6472
+ return tree.trim() === "";
6093
6473
  }
6094
6474
  };
6095
6475
 
6096
6476
  // dist/branch-workspace.js
6097
- var log6 = createDebugLogger({ prefix: "BranchWorkspace" });
6477
+ var log7 = createDebugLogger({ prefix: "BranchWorkspace" });
6098
6478
  var workspaceInitLocks = /* @__PURE__ */ new Map();
6099
6479
  var BranchWorkspaceManager = class {
6100
6480
  constructor(config) {
6101
6481
  this.config = config;
6102
6482
  }
6103
6483
  async ensureGitWorkspace(options) {
6104
- return log6.timed("workspace", "ensureGitWorkspace", async () => {
6484
+ return log7.timed("workspace", "ensureGitWorkspace", async () => {
6105
6485
  const existingLock = workspaceInitLocks.get(options.branchRoot);
6106
6486
  if (existingLock) {
6107
6487
  await existingLock;
@@ -6110,11 +6490,11 @@ var BranchWorkspaceManager = class {
6110
6490
  const lockPromise = (async () => {
6111
6491
  let releaseLock;
6112
6492
  try {
6113
- log6.debug("workspace", "Ensuring git workspace", {
6493
+ log7.debug("workspace", "Ensuring git workspace", {
6114
6494
  branchName: options.branchName,
6115
6495
  mode: options.mode
6116
6496
  });
6117
- releaseLock = await acquireProvisioningLock(path18.dirname(options.branchRoot), `.${path18.basename(options.branchRoot)}.init.lock`);
6497
+ releaseLock = await acquireProvisioningLock(path18.dirname(options.branchRoot), branchProvisioningLockName(path18.basename(options.branchRoot)));
6118
6498
  await GitManager.initializeWorkspace({
6119
6499
  workspacePath: options.branchRoot,
6120
6500
  branchName: options.branchName,
@@ -6135,7 +6515,7 @@ var BranchWorkspaceManager = class {
6135
6515
  try {
6136
6516
  await releaseLock();
6137
6517
  } catch (err) {
6138
- log6.debug("workspace", "Failed to release workspace-init lock", { err });
6518
+ log7.debug("workspace", "Failed to release workspace-init lock", { err });
6139
6519
  }
6140
6520
  }
6141
6521
  workspaceInitLocks.delete(options.branchRoot);
@@ -6147,6 +6527,9 @@ var BranchWorkspaceManager = class {
6147
6527
  }
6148
6528
  async openOrCreateBranch(options) {
6149
6529
  const { branchName, mode, basePathOverride, title, description, access, createdBy, remoteUrl } = options;
6530
+ if (isSettingsBranchName(branchName, operatingStrategy(mode).getSettingsBranchName(this.config))) {
6531
+ throw new BranchPathError("Settings branches are not content branches");
6532
+ }
6150
6533
  const { branchRoot, baseRoot, branchName: safeName } = await ensureBranchRoot({
6151
6534
  mode,
6152
6535
  branchName,
@@ -6243,7 +6626,7 @@ import fs17 from "node:fs/promises";
6243
6626
  import path19 from "node:path";
6244
6627
  import matter3 from "gray-matter";
6245
6628
  import { parse as yamlParse2 } from "yaml";
6246
- var log7 = createDebugLogger({ prefix: "ContentListing" });
6629
+ var log8 = createDebugLogger({ prefix: "ContentListing" });
6247
6630
  var normalizePath = (root, target) => {
6248
6631
  const result = validateAndNormalizePath(root, target);
6249
6632
  if (!result.valid) {
@@ -6269,13 +6652,17 @@ var readEntryData = async (filePath, format, bodyFieldName = "body") => {
6269
6652
  } catch (err) {
6270
6653
  if (isNotFoundError(err))
6271
6654
  return {};
6272
- log7.warn("readEntryData", `Failed to read entry data from ${filePath}: ${getErrorMessage(err)}`);
6655
+ log8.warn("readEntryData", `Failed to read entry data from ${filePath}: ${getErrorMessage(err)}`);
6273
6656
  return {};
6274
6657
  }
6275
6658
  };
6276
- var createReferenceResolver = (branchRoot, flatSchema, contentRootName) => ({
6659
+ var createReferenceResolver = (branchRoot, flatSchema, contentRootName, visibility) => ({
6277
6660
  store: new ContentStore(branchRoot, flatSchema, { contentRootName }),
6278
- cache: createReferenceResolveCache()
6661
+ cache: createReferenceResolveCache(),
6662
+ // The predicate that hides an entry from the listing is the one that restricts it as a
6663
+ // reference target, so a target is never shown in a sibling's data that its own listing
6664
+ // row would have hidden.
6665
+ access: visibility?.shouldInclude
6279
6666
  });
6280
6667
  var resolveCollectionItemReferences = async (items, collection, resolver) => Promise.all(items.map(async (item) => {
6281
6668
  const fields = collection.entries?.find((e) => e.name === item.entryType)?.schema;
@@ -6283,7 +6670,7 @@ var resolveCollectionItemReferences = async (items, collection, resolver) => Pro
6283
6670
  return item;
6284
6671
  return {
6285
6672
  ...item,
6286
- data: await resolver.store.resolveReferences(item.data, fields, resolver.cache)
6673
+ data: await resolver.store.resolveReferences(item.data, fields, resolver.cache, resolver.access)
6287
6674
  };
6288
6675
  }));
6289
6676
  async function listEntries(branchRoot, flatSchema, contentRootName, options, visibility) {
@@ -6294,10 +6681,10 @@ async function listEntries(branchRoot, flatSchema, contentRootName, options, vis
6294
6681
  const collections = flatSchema.filter((item) => item.type === "collection" && item.entries !== void 0 && (item.logicalPath === rootPath || item.logicalPath.startsWith(`${rootPath}/`)));
6295
6682
  const shouldInclude = visibility?.shouldInclude;
6296
6683
  const skippedFiles = [];
6297
- const resolver = options?.resolveReferences ? createReferenceResolver(branchRoot, flatSchema, contentRootName) : null;
6684
+ const resolver = options?.resolveReferences ? createReferenceResolver(branchRoot, flatSchema, contentRootName, visibility) : null;
6298
6685
  const collectionResults = await Promise.all(collections.map(async (collection) => {
6299
6686
  const entries = await listCollectionEntries(branchRoot, collection, (file) => skippedFiles.push(file));
6300
- const visible = shouldInclude ? entries.filter((e) => shouldInclude(e.physicalPath)) : entries;
6687
+ const visible = shouldInclude ? entries.filter((e) => shouldInclude(e.logicalPath)) : entries;
6301
6688
  const resolved = resolver ? await resolveCollectionItemReferences(visible, collection, resolver) : visible;
6302
6689
  return resolved.map((entry) => ({ entry, collection }));
6303
6690
  }));
@@ -6387,7 +6774,7 @@ var listCollectionEntries = async (root, collection, onSkip) => {
6387
6774
  const relativePath = normalizePath(root, absolutePath);
6388
6775
  const parsed = parseTypedFilename(file.name, entryTypes);
6389
6776
  if (!parsed) {
6390
- log7.warn("listCollectionEntries", `Skipping file with unrecognized filename format: ${file.name} (expected {type}.{slug}.{id}.{ext} with a known entry type and valid 12-char Base58 ID)`);
6777
+ log8.warn("listCollectionEntries", `Skipping file with unrecognized filename format: ${file.name} (expected {type}.{slug}.{id}.{ext} with a known entry type and valid 12-char Base58 ID)`);
6391
6778
  if (looksLikeMalformedEntry(file.name, entryTypes)) {
6392
6779
  onSkip?.({ filename: file.name, collectionPath: collection.logicalPath });
6393
6780
  }
@@ -6402,7 +6789,7 @@ var listCollectionEntries = async (root, collection, onSkip) => {
6402
6789
  readEntryData(absolutePath, format, bodyField)
6403
6790
  ]);
6404
6791
  const item = {
6405
- logicalPath: `${collection.logicalPath}/${slug}`,
6792
+ logicalPath: entryLogicalPath(collection.logicalPath, slug),
6406
6793
  contentId,
6407
6794
  slug,
6408
6795
  collectionPath: collection.logicalPath,