canopycms 0.7.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/CHANGELOG.md +187 -47
  2. package/dist/api/admin-branch-health.js +47 -20
  3. package/dist/api/admin.d.ts +13 -1
  4. package/dist/api/admin.js +14 -24
  5. package/dist/api/branch-changes.d.ts +87 -0
  6. package/dist/api/branch-changes.js +269 -0
  7. package/dist/api/branch-entry-changes.d.ts +83 -0
  8. package/dist/api/branch-entry-changes.js +262 -0
  9. package/dist/api/branch-merge.js +2 -2
  10. package/dist/api/branch-review.js +35 -4
  11. package/dist/api/branch-status.js +14 -5
  12. package/dist/api/branch-withdraw.js +7 -0
  13. package/dist/api/branch.d.ts +13 -1
  14. package/dist/api/branch.js +171 -97
  15. package/dist/api/client.d.ts +15 -0
  16. package/dist/api/client.js +13 -1
  17. package/dist/api/content.d.ts +18 -18
  18. package/dist/api/content.js +1 -1
  19. package/dist/api/github-sync.js +7 -3
  20. package/dist/api/groups.d.ts +4 -4
  21. package/dist/api/pr-comment-sync.d.ts +9 -8
  22. package/dist/api/pr-comment-sync.js +23 -13
  23. package/dist/api/routes.js +4 -0
  24. package/dist/api/types.d.ts +3 -1
  25. package/dist/authorization/groups/schema.d.ts +4 -4
  26. package/dist/authorization/protected-branch.d.ts +3 -3
  27. package/dist/authorization/protected-branch.js +3 -3
  28. package/dist/branch-metadata-file.d.ts +84 -0
  29. package/dist/branch-metadata-file.js +9 -0
  30. package/dist/branch-metadata.d.ts +4 -4
  31. package/dist/branch-metadata.js +4 -5
  32. package/dist/branch-param.d.ts +24 -0
  33. package/dist/branch-param.js +87 -0
  34. package/dist/branch-provisioning.d.ts +6 -3
  35. package/dist/branch-provisioning.js +12 -10
  36. package/dist/branch-registry.d.ts +6 -0
  37. package/dist/branch-registry.js +12 -3
  38. package/dist/branch-workspace.js +22 -15
  39. package/dist/cli/cli.js +1060 -172
  40. package/dist/cli/generate-ai-content.js +732 -101
  41. package/dist/cli/init.js +126 -0
  42. package/dist/comment-store.d.ts +3 -0
  43. package/dist/comment-store.js +4 -1
  44. package/dist/config/schemas/config.d.ts +3 -0
  45. package/dist/config/schemas/config.js +2 -0
  46. package/dist/config/types.d.ts +11 -0
  47. package/dist/content-id-index.d.ts +5 -0
  48. package/dist/content-id-index.js +1 -1
  49. package/dist/content-listing.d.ts +53 -0
  50. package/dist/content-listing.js +123 -27
  51. package/dist/content-reader.js +23 -16
  52. package/dist/context.js +8 -7
  53. package/dist/editor/BranchManager.d.ts +7 -3
  54. package/dist/editor/BranchManager.js +51 -6
  55. package/dist/editor/Editor.js +166 -34
  56. package/dist/editor/EntryNavigator.d.ts +34 -10
  57. package/dist/editor/EntryNavigator.js +185 -29
  58. package/dist/editor/admin/SystemHealthPanel.js +36 -1
  59. package/dist/editor/admin/useSystemHealth.js +1 -0
  60. package/dist/editor/branch-names.d.ts +43 -0
  61. package/dist/editor/branch-names.js +75 -0
  62. package/dist/editor/components/BranchPill.d.ts +2 -2
  63. package/dist/editor/components/BranchPill.js +2 -2
  64. package/dist/editor/components/EditorHeader.d.ts +3 -1
  65. package/dist/editor/components/EditorHeader.js +2 -2
  66. package/dist/editor/components/EntryChangesDrawer.d.ts +51 -0
  67. package/dist/editor/components/EntryChangesDrawer.js +363 -0
  68. package/dist/editor/components/EntryChangesSkeleton.d.ts +9 -0
  69. package/dist/editor/components/EntryChangesSkeleton.js +7 -0
  70. package/dist/editor/components/EntryHeader.d.ts +9 -1
  71. package/dist/editor/components/EntryHeader.js +25 -3
  72. package/dist/editor/components/LazyEntryChangesDrawer.d.ts +16 -0
  73. package/dist/editor/components/LazyEntryChangesDrawer.js +45 -0
  74. package/dist/editor/components/ReviewDialogs.js +3 -3
  75. package/dist/editor/context/ApiClientContext.d.ts +9 -0
  76. package/dist/editor/context/ApiClientContext.js +26 -2
  77. package/dist/editor/context/index.d.ts +1 -1
  78. package/dist/editor/context/index.js +1 -1
  79. package/dist/editor/copy.d.ts +1 -0
  80. package/dist/editor/copy.js +1 -0
  81. package/dist/editor/editor-utils.d.ts +2 -0
  82. package/dist/editor/editor-utils.js +15 -0
  83. package/dist/editor/entry-diff.d.ts +143 -0
  84. package/dist/editor/entry-diff.js +593 -0
  85. package/dist/editor/hooks/create-branch-request.d.ts +11 -1
  86. package/dist/editor/hooks/create-branch-request.js +16 -6
  87. package/dist/editor/hooks/useBranchActions.d.ts +19 -2
  88. package/dist/editor/hooks/useBranchActions.js +29 -20
  89. package/dist/editor/hooks/useBranchChangesData.d.ts +57 -0
  90. package/dist/editor/hooks/useBranchChangesData.js +134 -0
  91. package/dist/editor/hooks/useBranchManager.d.ts +16 -0
  92. package/dist/editor/hooks/useBranchManager.js +142 -54
  93. package/dist/editor/hooks/useDraftManager.js +6 -2
  94. package/dist/editor/hooks/useEntryManager.d.ts +18 -2
  95. package/dist/editor/hooks/useEntryManager.js +36 -10
  96. package/dist/editor/utils/editor-errors.d.ts +5 -2
  97. package/dist/git-manager.d.ts +71 -0
  98. package/dist/git-manager.js +270 -1
  99. package/dist/http/handler.js +11 -9
  100. package/dist/operating-mode/branch-namespace-fixtures.d.ts +17 -0
  101. package/dist/operating-mode/branch-namespace-fixtures.js +65 -0
  102. package/dist/operating-mode/branch-namespace.d.ts +34 -0
  103. package/dist/operating-mode/branch-namespace.js +121 -0
  104. package/dist/operating-mode/client-unsafe-strategy.js +7 -0
  105. package/dist/operating-mode/types.d.ts +8 -0
  106. package/dist/paths/branch-name.d.ts +11 -19
  107. package/dist/paths/branch-name.js +17 -27
  108. package/dist/paths/index.d.ts +2 -2
  109. package/dist/paths/index.js +2 -2
  110. package/dist/paths/resolve.d.ts +0 -1
  111. package/dist/paths/resolve.js +0 -1
  112. package/dist/paths/validation.d.ts +40 -1
  113. package/dist/paths/validation.js +83 -4
  114. package/dist/pr-comments.d.ts +17 -9
  115. package/dist/pr-comments.js +53 -15
  116. package/dist/services.js +2 -1
  117. package/dist/submission-attribution.d.ts +22 -0
  118. package/dist/submission-attribution.js +62 -6
  119. package/dist/task-queue/worker-status.d.ts +6 -1
  120. package/dist/task-queue/worker-status.js +23 -2
  121. package/dist/types.d.ts +59 -0
  122. package/dist/version.d.ts +1 -1
  123. package/dist/version.js +1 -1
  124. package/dist/worker/branch-policy.d.ts +111 -16
  125. package/dist/worker/branch-policy.js +222 -17
  126. package/dist/worker/cms-worker.d.ts +24 -2
  127. package/dist/worker/cms-worker.js +35 -24
  128. package/dist/worker/git-sync.d.ts +3 -3
  129. package/dist/worker/git-sync.js +42 -1
  130. package/dist/worker/github-gateway-http.js +2 -2
  131. package/dist/worker/github-gateway-wire.d.ts +40 -25
  132. package/dist/worker/github-gateway-wire.js +14 -3
  133. package/dist/worker/github-gateway.d.ts +17 -6
  134. package/dist/worker/github-gateway.js +62 -6
  135. package/dist/worker/github-mirror.d.ts +7 -4
  136. package/dist/worker/github-mirror.js +27 -16
  137. package/dist/worker/history-rewrite.d.ts +23 -10
  138. package/dist/worker/history-rewrite.js +43 -20
  139. package/dist/worker/legacy-branch-fixtures.d.ts +21 -0
  140. package/dist/worker/legacy-branch-fixtures.js +71 -0
  141. package/dist/worker/rebase.d.ts +2 -1
  142. package/dist/worker/rebase.js +93 -36
  143. package/dist/worker/task-runner.d.ts +4 -1
  144. package/dist/worker/task-runner.js +77 -48
  145. package/dist/worker/unpublishable-branches.d.ts +34 -0
  146. package/dist/worker/unpublishable-branches.js +182 -0
  147. package/package.json +2 -1
@@ -346,6 +346,114 @@ var init_normalize = __esm({
346
346
  }
347
347
  });
348
348
 
349
+ // dist/paths/branch-name.js
350
+ function sanitizeBranchName(branchName) {
351
+ const replaced = branchName.replace(/[^a-zA-Z0-9._-]/g, "-");
352
+ const squashed = replaced.replace(/-+/g, "-");
353
+ const trimmedDots = squashed.replace(/^\.+/, "").replace(/(?<!\.)\.+$/, "");
354
+ return trimmedDots || "branch";
355
+ }
356
+ function branchShortName(name, namespace) {
357
+ if (namespace === null || !name.startsWith(namespace) || name.length === namespace.length) {
358
+ return name;
359
+ }
360
+ const rest = name.slice(namespace.length);
361
+ return rest.includes("/") ? name : rest;
362
+ }
363
+ function isSettingsBranchName(branchName, settingsBranchName) {
364
+ const sanitized = sanitizeBranchName(branchName);
365
+ return sanitized.startsWith(RESERVED_SETTINGS_BRANCH_PREFIX) || settingsBranchName !== void 0 && sanitized === sanitizeBranchName(settingsBranchName);
366
+ }
367
+ var RESERVED_SETTINGS_BRANCH_PREFIX;
368
+ var init_branch_name = __esm({
369
+ "dist/paths/branch-name.js"() {
370
+ "use strict";
371
+ RESERVED_SETTINGS_BRANCH_PREFIX = "canopycms-settings-";
372
+ }
373
+ });
374
+
375
+ // dist/paths/validation.js
376
+ function isValidContentId(id) {
377
+ return CONTENT_ID_PATTERN.test(id);
378
+ }
379
+ function parseBranchRef(name) {
380
+ if (!name || typeof name !== "string") {
381
+ return { ok: false, error: "Branch name is required" };
382
+ }
383
+ if (name.length > MAX_BRANCH_NAME_LENGTH) {
384
+ return { ok: false, error: `Branch name too long (max ${MAX_BRANCH_NAME_LENGTH} characters)` };
385
+ }
386
+ if (name.includes("..")) {
387
+ return { ok: false, error: 'Branch name cannot contain ".."' };
388
+ }
389
+ if (name.startsWith("/") || name.endsWith("/") || name.includes("//")) {
390
+ return { ok: false, error: "Invalid branch name format (invalid slashes)" };
391
+ }
392
+ if (name.includes(" ")) {
393
+ return { ok: false, error: "Branch name cannot contain spaces" };
394
+ }
395
+ if (name.startsWith("-")) {
396
+ return { ok: false, error: 'Branch name cannot start with "-"' };
397
+ }
398
+ if (name.startsWith(".") || name.endsWith(".")) {
399
+ return { ok: false, error: "Branch name cannot start or end with a dot" };
400
+ }
401
+ if (name.includes("@{")) {
402
+ return { ok: false, error: 'Branch name cannot contain "@{"' };
403
+ }
404
+ if (name === "HEAD" || name === "@") {
405
+ return { ok: false, error: 'Branch name cannot be the reserved ref "HEAD" or "@"' };
406
+ }
407
+ if (/[~^:?*[\\\x00-\x1f\x7f]/.test(name)) {
408
+ return { ok: false, error: "Branch name contains invalid characters" };
409
+ }
410
+ if (name.endsWith(".lock")) {
411
+ return { ok: false, error: 'Branch name cannot end with ".lock"' };
412
+ }
413
+ return { ok: true, name };
414
+ }
415
+ function parseSlug(slug) {
416
+ if (!slug) {
417
+ return {
418
+ ok: false,
419
+ error: "Slug is required"
420
+ };
421
+ }
422
+ if (slug.length > 64) {
423
+ return { ok: false, error: "Slug too long (max 64 characters)" };
424
+ }
425
+ if (slug.includes("/") || slug.includes("\\")) {
426
+ return {
427
+ ok: false,
428
+ error: "Slug cannot contain path separators"
429
+ };
430
+ }
431
+ if (slug === "." || slug === "..") {
432
+ return {
433
+ ok: false,
434
+ error: "Slug cannot be a traversal sequence"
435
+ };
436
+ }
437
+ const normalized = slug.toLowerCase();
438
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(normalized)) {
439
+ return {
440
+ ok: false,
441
+ error: "Slug must start with a letter or number and contain only lowercase letters, numbers, and hyphens"
442
+ };
443
+ }
444
+ return { ok: true, slug: normalized };
445
+ }
446
+ var BASE58_PATTERN, CONTENT_ID_PATTERN, PHYSICAL_SEGMENT_PATTERN, MAX_BRANCH_NAME_LENGTH;
447
+ var init_validation = __esm({
448
+ "dist/paths/validation.js"() {
449
+ "use strict";
450
+ BASE58_PATTERN = "[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]";
451
+ CONTENT_ID_PATTERN = new RegExp(`^${BASE58_PATTERN}{12}$`);
452
+ PHYSICAL_SEGMENT_PATTERN = new RegExp(`\\.${BASE58_PATTERN}{12}(?:\\.[a-z]+)?$`);
453
+ MAX_BRANCH_NAME_LENGTH = 250;
454
+ }
455
+ });
456
+
349
457
  // dist/paths/types.js
350
458
  var ROOT_COLLECTION_ID;
351
459
  var init_types = __esm({
@@ -946,6 +1054,8 @@ var init_config = __esm({
946
1054
  // (operating-mode/deployment-name.ts) implements — config would always "win" over
947
1055
  // modeDefault, masking dev's real default of 'local'. Do not remove `.optional()`.
948
1056
  deploymentName: deploymentNameSchema.optional(),
1057
+ // Checked where it is resolved (operating-mode/branch-namespace.ts), with the deployment name.
1058
+ branchNamespace: z6.string().optional(),
949
1059
  contentRoot: contentRootSchema.default("content"),
950
1060
  sourceRoot: sourceRootSchema.optional(),
951
1061
  basePath: basePathSchema,
@@ -1000,7 +1110,7 @@ var init_mode_env = __esm({
1000
1110
 
1001
1111
  // dist/config/validation.js
1002
1112
  var forEachReferenceField;
1003
- var init_validation = __esm({
1113
+ var init_validation2 = __esm({
1004
1114
  "dist/config/validation.js"() {
1005
1115
  "use strict";
1006
1116
  forEachReferenceField = (fields, visit3) => {
@@ -1134,6 +1244,80 @@ var init_deployment_name = __esm({
1134
1244
  }
1135
1245
  });
1136
1246
 
1247
+ // dist/operating-mode/branch-namespace.js
1248
+ function warnOnce(message) {
1249
+ if (warned4)
1250
+ return;
1251
+ warned4 = true;
1252
+ canopyLogWarn(message);
1253
+ }
1254
+ function branchNamespaceProblem(namespace) {
1255
+ if (!namespace.endsWith("/"))
1256
+ return 'it must end with "/"';
1257
+ const sample = `${namespace}x`;
1258
+ if (!/^[\x21-\x7e]+$/.test(namespace))
1259
+ return "it is not printable ASCII without spaces";
1260
+ if (/["%{}]/.test(namespace))
1261
+ return 'it contains ", %, { or }';
1262
+ if (/^(?:refs|heads|tags|remotes)\//i.test(namespace)) {
1263
+ return "it starts with 'refs/', 'heads/', 'tags/' or 'remotes/'";
1264
+ }
1265
+ const parsed = parseBranchRef(sample);
1266
+ if (!parsed.ok)
1267
+ return `a branch in it would be refused (${parsed.error})`;
1268
+ if (sample.split("/").some((part) => part.startsWith(".") || part.endsWith(".lock"))) {
1269
+ return "a part of it starts with '.' or ends with '.lock'";
1270
+ }
1271
+ const directoryPrefix = sanitizeBranchName(namespace);
1272
+ if (RESERVED_SETTINGS_BRANCH_PREFIX.startsWith(directoryPrefix) || sanitizeBranchName(sample).startsWith(RESERVED_SETTINGS_BRANCH_PREFIX)) {
1273
+ return `some branches in it would be ${RESERVED_SETTINGS_BRANCH_PREFIX}* settings branches`;
1274
+ }
1275
+ return null;
1276
+ }
1277
+ function resolveBranchNamespaceTemplate(template, deploymentName, source) {
1278
+ const unknown = template.replaceAll(DEPLOYMENT_PLACEHOLDER, "").match(/\{[^}]*\}?/);
1279
+ if (unknown) {
1280
+ throw new Error(`CanopyCMS: invalid branch namespace ${JSON.stringify(template)} (from ${source}): ${JSON.stringify(unknown[0])} is not a placeholder; the only one is ${DEPLOYMENT_PLACEHOLDER}`);
1281
+ }
1282
+ const namespace = template.replaceAll(DEPLOYMENT_PLACEHOLDER, deploymentName);
1283
+ const problem = branchNamespaceProblem(namespace);
1284
+ if (problem !== null) {
1285
+ throw new Error(`CanopyCMS: invalid branch namespace ${JSON.stringify(namespace)} (from ${source}): ${problem}`);
1286
+ }
1287
+ return namespace;
1288
+ }
1289
+ function resolveBranchNamespace(config, modeDefault, env = process.env) {
1290
+ const fromEnv = env[BRANCH_NAMESPACE_ENV]?.trim();
1291
+ const [template, source] = fromEnv ? [fromEnv, BRANCH_NAMESPACE_ENV] : config.branchNamespace !== void 0 ? [config.branchNamespace, "config.branchNamespace"] : [DEFAULT_BRANCH_NAMESPACE_TEMPLATE, "the default"];
1292
+ const deploymentName = resolveDeploymentName({ deploymentName: config.deploymentName }, modeDefault);
1293
+ const namespace = resolveBranchNamespaceTemplate(template, deploymentName, source);
1294
+ if (fromEnv && config.branchNamespace !== void 0) {
1295
+ let fromConfig = null;
1296
+ try {
1297
+ fromConfig = resolveBranchNamespaceTemplate(config.branchNamespace, deploymentName, "config.branchNamespace");
1298
+ } catch {
1299
+ }
1300
+ if (fromConfig !== namespace) {
1301
+ warnOnce(`CanopyCMS: ${BRANCH_NAMESPACE_ENV} (${JSON.stringify(fromEnv)}) and config.branchNamespace (${JSON.stringify(config.branchNamespace)}) do not resolve to the same valid namespace; using the environment variable, which infrastructure stamps per stack.`);
1302
+ }
1303
+ }
1304
+ return namespace;
1305
+ }
1306
+ var warned4, DEPLOYMENT_PLACEHOLDER, DEFAULT_BRANCH_NAMESPACE_TEMPLATE, BRANCH_NAMESPACE_ENV;
1307
+ var init_branch_namespace = __esm({
1308
+ "dist/operating-mode/branch-namespace.js"() {
1309
+ "use strict";
1310
+ init_branch_name();
1311
+ init_validation();
1312
+ init_logger();
1313
+ init_deployment_name();
1314
+ warned4 = false;
1315
+ DEPLOYMENT_PLACEHOLDER = "{deployment}";
1316
+ DEFAULT_BRANCH_NAMESPACE_TEMPLATE = `canopycms/${DEPLOYMENT_PLACEHOLDER}/`;
1317
+ BRANCH_NAMESPACE_ENV = "CANOPYCMS_BRANCH_NAMESPACE";
1318
+ }
1319
+ });
1320
+
1137
1321
  // dist/config/index.js
1138
1322
  var init_config2 = __esm({
1139
1323
  "dist/config/index.js"() {
@@ -1173,6 +1357,7 @@ var init_client_unsafe_strategy = __esm({
1173
1357
  "dist/operating-mode/client-unsafe-strategy.js"() {
1174
1358
  "use strict";
1175
1359
  init_client_safe_strategy();
1360
+ init_branch_namespace();
1176
1361
  init_deployment_name();
1177
1362
  init_config2();
1178
1363
  ProdStrategy = class extends ProdClientSafeStrategy {
@@ -1213,6 +1398,9 @@ var init_client_unsafe_strategy = __esm({
1213
1398
  return config.settingsBranch;
1214
1399
  return `canopycms-settings-${resolveDeploymentName(config, "prod")}`;
1215
1400
  }
1401
+ getBranchNamespace(config) {
1402
+ return resolveBranchNamespace(config, "prod");
1403
+ }
1216
1404
  getSettingsRoot(sourceRoot) {
1217
1405
  return path9.join(this.getWorkspaceRoot(sourceRoot), "settings");
1218
1406
  }
@@ -1262,6 +1450,9 @@ var init_client_unsafe_strategy = __esm({
1262
1450
  return config.settingsBranch;
1263
1451
  return `canopycms-settings-${resolveDeploymentName(config, "local")}`;
1264
1452
  }
1453
+ getBranchNamespace(config) {
1454
+ return resolveBranchNamespace(config, "local");
1455
+ }
1265
1456
  getSettingsRoot(sourceRoot) {
1266
1457
  return path9.join(this.getWorkspaceRoot(sourceRoot), "settings");
1267
1458
  }
@@ -1326,11 +1517,27 @@ __export(worker_status_exports, {
1326
1517
  WORKER_STATUS_FILE: () => WORKER_STATUS_FILE,
1327
1518
  readCarriedOverStatus: () => readCarriedOverStatus,
1328
1519
  readWorkerStartupFailure: () => readWorkerStartupFailure,
1520
+ readWorkerStatus: () => readWorkerStatus,
1329
1521
  readWorkerStatusStartedAt: () => readWorkerStatusStartedAt,
1330
1522
  writeWorkerStatus: () => writeWorkerStatus
1331
1523
  });
1332
1524
  import fs15 from "node:fs/promises";
1333
1525
  import path19 from "node:path";
1526
+ async function readWorkerStatus(taskDir) {
1527
+ let content;
1528
+ try {
1529
+ content = await fs15.readFile(path19.join(taskDir, WORKER_STATUS_FILE), "utf-8");
1530
+ } catch (err) {
1531
+ if (isNotFoundError(err))
1532
+ return { workerStatus: null };
1533
+ return { workerStatus: null, statusReadError: getErrorMessage(err) };
1534
+ }
1535
+ try {
1536
+ return { workerStatus: JSON.parse(content) };
1537
+ } catch (err) {
1538
+ return { workerStatus: null, statusReadError: getErrorMessage(err) };
1539
+ }
1540
+ }
1334
1541
  async function readCarriedOverStatus(taskDir) {
1335
1542
  let previous;
1336
1543
  try {
@@ -3667,48 +3874,8 @@ import fs3 from "node:fs/promises";
3667
3874
  import path4 from "node:path";
3668
3875
 
3669
3876
  // dist/id.js
3877
+ init_validation();
3670
3878
  import { generate } from "short-uuid";
3671
-
3672
- // dist/paths/validation.js
3673
- var BASE58_PATTERN = "[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]";
3674
- var CONTENT_ID_PATTERN = new RegExp(`^${BASE58_PATTERN}{12}$`);
3675
- var PHYSICAL_SEGMENT_PATTERN = new RegExp(`\\.${BASE58_PATTERN}{12}(?:\\.[a-z]+)?$`);
3676
- function isValidContentId(id) {
3677
- return CONTENT_ID_PATTERN.test(id);
3678
- }
3679
- function parseSlug(slug) {
3680
- if (!slug) {
3681
- return {
3682
- ok: false,
3683
- error: "Slug is required"
3684
- };
3685
- }
3686
- if (slug.length > 64) {
3687
- return { ok: false, error: "Slug too long (max 64 characters)" };
3688
- }
3689
- if (slug.includes("/") || slug.includes("\\")) {
3690
- return {
3691
- ok: false,
3692
- error: "Slug cannot contain path separators"
3693
- };
3694
- }
3695
- if (slug === "." || slug === "..") {
3696
- return {
3697
- ok: false,
3698
- error: "Slug cannot be a traversal sequence"
3699
- };
3700
- }
3701
- const normalized = slug.toLowerCase();
3702
- if (!/^[a-z0-9][a-z0-9-]*$/.test(normalized)) {
3703
- return {
3704
- ok: false,
3705
- error: "Slug must start with a letter or number and contain only lowercase letters, numbers, and hyphens"
3706
- };
3707
- }
3708
- return { ok: true, slug: normalized };
3709
- }
3710
-
3711
- // dist/id.js
3712
3879
  function generateId() {
3713
3880
  const full = generate();
3714
3881
  return full.substring(0, 12);
@@ -4538,7 +4705,7 @@ function registryFingerprint(registry2) {
4538
4705
  init_flatten();
4539
4706
 
4540
4707
  // dist/validation/entry-type-reference-validator.js
4541
- init_validation();
4708
+ init_validation2();
4542
4709
  var editDistance = (a, b) => {
4543
4710
  let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
4544
4711
  for (let i = 1; i <= a.length; i++) {
@@ -4997,24 +5164,13 @@ function validateAndNormalizePath(root, target) {
4997
5164
  };
4998
5165
  }
4999
5166
 
5167
+ // dist/paths/index.js
5168
+ init_validation();
5169
+
5000
5170
  // dist/paths/branch.js
5001
5171
  init_operating_mode();
5002
5172
  import path10 from "node:path";
5003
-
5004
- // dist/paths/branch-name.js
5005
- function sanitizeBranchName(branchName) {
5006
- const replaced = branchName.replace(/[^a-zA-Z0-9._-]/g, "-");
5007
- const squashed = replaced.replace(/-+/g, "-");
5008
- const trimmedDots = squashed.replace(/^\.+/, "").replace(/(?<!\.)\.+$/, "");
5009
- return trimmedDots || "branch";
5010
- }
5011
- var RESERVED_SETTINGS_BRANCH_PREFIX = "canopycms-settings-";
5012
- function isSettingsBranchName(branchName, settingsBranchName) {
5013
- const sanitized = sanitizeBranchName(branchName);
5014
- return sanitized.startsWith(RESERVED_SETTINGS_BRANCH_PREFIX) || settingsBranchName !== void 0 && sanitized === sanitizeBranchName(settingsBranchName);
5015
- }
5016
-
5017
- // dist/paths/branch.js
5173
+ init_branch_name();
5018
5174
  var BranchPathError = class extends Error {
5019
5175
  };
5020
5176
  var resolveContentBranchesRoot = (mode, override) => {
@@ -5043,6 +5199,9 @@ function resolveBranchPath(options) {
5043
5199
  return { branchRoot, baseRoot: normalizedBase, branchName: safeBranch };
5044
5200
  }
5045
5201
 
5202
+ // dist/paths/index.js
5203
+ init_branch_name();
5204
+
5046
5205
  // dist/content-store.js
5047
5206
  async function withLocks(keys, fn) {
5048
5207
  const sorted = Array.from(new Set(keys)).sort();
@@ -7640,7 +7799,13 @@ var branchMetadataShape = {
7640
7799
  historyRewrittenFrom: z7.string().optional(),
7641
7800
  syncFailureReason: z7.string().optional(),
7642
7801
  editors: z7.array(z7.string()).optional(),
7643
- uncommittedEditors: z7.array(z7.string()).optional()
7802
+ uncommittedEditors: z7.array(z7.string()).optional(),
7803
+ // Dropped when malformed: it only decorates the PR body, so it never fails a read.
7804
+ review: z7.object({
7805
+ state: z7.enum(["approved", "changes-requested"]),
7806
+ by: z7.object({ userId: z7.string(), name: z7.string().optional() }),
7807
+ at: z7.string()
7808
+ }).optional().catch(void 0)
7644
7809
  };
7645
7810
  var branchMetadataSchema = z7.object(branchMetadataShape).passthrough();
7646
7811
  var branchMetadataFileSchema = z7.object({
@@ -7781,14 +7946,23 @@ var BranchRegistry = class {
7781
7946
  * genuinely-absent branch costs at most one extra scan per window.
7782
7947
  */
7783
7948
  async get(name) {
7784
- const branches = await this.list();
7785
- const found = branches.find((b) => b.branch.name === name);
7949
+ return this.find((b) => b.branch.name === name);
7950
+ }
7951
+ /**
7952
+ * Returns the branch whose workspace directory is `key` (`sanitizeBranchName` of its ref),
7953
+ * whatever its name, with {@link get}'s backstop.
7954
+ */
7955
+ async getByKey(key) {
7956
+ return this.find((b) => path15.basename(b.branchRoot) === key);
7957
+ }
7958
+ async find(matches) {
7959
+ const found = (await this.list()).find(matches);
7786
7960
  if (found)
7787
7961
  return found;
7788
7962
  if (!this.shouldForceRefresh())
7789
7963
  return void 0;
7790
7964
  const refreshed = await this.regenerate();
7791
- return refreshed.find((b) => b.branch.name === name);
7965
+ return refreshed.find(matches);
7792
7966
  }
7793
7967
  shouldForceRefresh() {
7794
7968
  const now = Date.now();
@@ -8257,18 +8431,228 @@ var loadBranchContext = async (options) => {
8257
8431
  };
8258
8432
  };
8259
8433
 
8434
+ // dist/branch-param.js
8435
+ init_operating_mode();
8436
+ init_branch_name();
8437
+ function cmsBranchNamespace(config) {
8438
+ try {
8439
+ return operatingStrategy(config.mode).getBranchNamespace(config);
8440
+ } catch {
8441
+ return null;
8442
+ }
8443
+ }
8444
+ function namesBaseOrActive(param, config) {
8445
+ let base;
8446
+ try {
8447
+ base = baseBranchOf(config);
8448
+ } catch (err) {
8449
+ if (err instanceof BaseBranchUnresolvedError)
8450
+ return false;
8451
+ throw err;
8452
+ }
8453
+ return param === base || param === (config.defaultActiveBranch ?? base);
8454
+ }
8455
+ function shortNameOf(config, name) {
8456
+ const short = branchShortName(name, cmsBranchNamespace(config));
8457
+ return short !== name && namesBaseOrActive(short, config) ? name : short;
8458
+ }
8459
+
8260
8460
  // dist/branch-provisioning.js
8261
- import { createHash as createHash2, randomBytes } from "node:crypto";
8461
+ import { createHash as createHash3, randomBytes } from "node:crypto";
8262
8462
  import fs17 from "node:fs/promises";
8263
8463
  import path21 from "node:path";
8264
8464
 
8265
8465
  // dist/git-manager.js
8466
+ import { createHash as createHash2 } from "node:crypto";
8266
8467
  import fs16 from "node:fs/promises";
8267
8468
  import path20 from "node:path";
8268
8469
  import { fileURLToPath } from "node:url";
8269
8470
  import { simpleGit as simpleGit2 } from "simple-git";
8270
8471
  init_error();
8271
8472
  init_logger();
8473
+
8474
+ // dist/worker/git-process.js
8475
+ init_error();
8476
+ import { spawn } from "node:child_process";
8477
+ import { Readable, Transform, Writable } from "node:stream";
8478
+ import { pipeline } from "node:stream/promises";
8479
+ var GitProcessError = class extends Error {
8480
+ constructor(message, exitCode, stderr) {
8481
+ super(message);
8482
+ this.exitCode = exitCode;
8483
+ this.stderr = stderr;
8484
+ this.name = "GitProcessError";
8485
+ }
8486
+ };
8487
+ var STDERR_CAP_BYTES = 64 * 1024;
8488
+ var KILL_GRACE_MS = 2e3;
8489
+ function spawnGit(args, options) {
8490
+ const child = spawn("git", [...(options.config ?? []).flatMap((c) => ["-c", c]), ...args], {
8491
+ cwd: options.cwd,
8492
+ // An app's own ProcessEnv augmentation (Next.js requires NODE_ENV) says nothing about git's.
8493
+ env: options.env,
8494
+ stdio: ["pipe", "pipe", "pipe"],
8495
+ // Its own process group, so a stop reaches what git spawned too (bundle create's
8496
+ // pack-objects), which would otherwise outlive it holding the pipes open.
8497
+ detached: process.platform !== "win32"
8498
+ });
8499
+ const { stdout } = child;
8500
+ if (stdout === null)
8501
+ throw new Error("git was spawned without a stdout pipe");
8502
+ const command = args.find((arg) => !arg.startsWith("-")) ?? "git";
8503
+ let stderr = "";
8504
+ let stderrBytes = 0;
8505
+ let killedFor = null;
8506
+ let closed = false;
8507
+ let exitedCleanly = false;
8508
+ let inactivity;
8509
+ const idle = () => {
8510
+ if (stdout.readableLength > 0 && !stdout.destroyed) {
8511
+ inactivity = setTimeout(idle, options.inactivityTimeoutMs);
8512
+ return;
8513
+ }
8514
+ stop(`git ${command} wrote nothing for ${options.inactivityTimeoutMs}ms`);
8515
+ };
8516
+ const touch = () => {
8517
+ options.onActivity?.();
8518
+ clearTimeout(inactivity);
8519
+ if (exitedCleanly)
8520
+ return;
8521
+ inactivity = setTimeout(idle, options.inactivityTimeoutMs);
8522
+ };
8523
+ const stop = (reason) => {
8524
+ if (closed || killedFor !== null)
8525
+ return;
8526
+ if (child.exitCode === 0 || child.exitCode === null && child.signalCode === null) {
8527
+ killedFor = reason;
8528
+ }
8529
+ terminate(child);
8530
+ stdout.destroy();
8531
+ };
8532
+ child.on("exit", (code) => {
8533
+ if (code !== 0)
8534
+ return;
8535
+ exitedCleanly = true;
8536
+ clearTimeout(inactivity);
8537
+ clearTimeout(deadline);
8538
+ });
8539
+ child.stderr?.on("data", (chunk) => {
8540
+ touch();
8541
+ if (stderrBytes < STDERR_CAP_BYTES) {
8542
+ stderr += chunk.subarray(0, STDERR_CAP_BYTES - stderrBytes).toString("utf8");
8543
+ }
8544
+ stderrBytes += chunk.length;
8545
+ });
8546
+ stdout.on("data", touch);
8547
+ const deadline = options.deadlineMs === void 0 ? void 0 : setTimeout(() => stop(`git ${command} ran longer than ${options.deadlineMs}ms`), options.deadlineMs);
8548
+ const onAbort = () => stop(`git ${command} was aborted`);
8549
+ if (options.signal?.aborted)
8550
+ onAbort();
8551
+ options.signal?.addEventListener("abort", onAbort, { once: true });
8552
+ const input = feedStdin(child, options.stdin, touch, (err) => stop(`git ${command}'s input failed: ${getErrorMessage(err)}`));
8553
+ input.catch(() => void 0);
8554
+ touch();
8555
+ const exited = new Promise((resolve2, reject) => {
8556
+ child.on("error", (err) => {
8557
+ if (child.pid === void 0) {
8558
+ clearTimeout(inactivity);
8559
+ clearTimeout(deadline);
8560
+ options.signal?.removeEventListener("abort", onAbort);
8561
+ reject(new GitProcessError(`git ${command} could not run: ${err.message}`, null, ""));
8562
+ }
8563
+ });
8564
+ child.on("close", (code, signal) => {
8565
+ closed = true;
8566
+ clearTimeout(inactivity);
8567
+ clearTimeout(deadline);
8568
+ options.signal?.removeEventListener("abort", onAbort);
8569
+ const written = stderrBytes > STDERR_CAP_BYTES ? `${stderr}
8570
+ [stderr truncated]` : stderr;
8571
+ if (killedFor === null && code === 0) {
8572
+ input.then(resolve2, (err) => reject(new GitProcessError(`git ${command}'s input failed: ${getErrorMessage(err)}`, 0, written)));
8573
+ return;
8574
+ }
8575
+ const why = killedFor ?? (written.trim() || (code === null ? `git ${command} was killed by ${signal ?? "a signal"}` : `git ${command} exited with code ${code}`));
8576
+ reject(new GitProcessError(why, code, written));
8577
+ });
8578
+ });
8579
+ exited.catch(() => void 0);
8580
+ return { stdout, exited, kill: stop };
8581
+ }
8582
+ async function runGit(args, options) {
8583
+ const max = options.maxStdoutBytes ?? 64 * 1024 * 1024;
8584
+ const proc = spawnGit(args, options);
8585
+ const chunks = [];
8586
+ let total = 0;
8587
+ const collect = new Writable({
8588
+ write(chunk, _encoding, callback) {
8589
+ total += chunk.length;
8590
+ if (total > max) {
8591
+ proc.kill(`git printed more than ${max} bytes`);
8592
+ callback();
8593
+ return;
8594
+ }
8595
+ chunks.push(chunk);
8596
+ callback();
8597
+ }
8598
+ });
8599
+ try {
8600
+ await Promise.all([pipeline(proc.stdout, collect).catch(() => void 0), proc.exited]);
8601
+ } catch (err) {
8602
+ if (options.stdoutInFailure && err instanceof GitProcessError && chunks.length > 0) {
8603
+ throw new GitProcessError(`${Buffer.concat(chunks).toString("utf8")}${err.message}`, err.exitCode, err.stderr);
8604
+ }
8605
+ throw err;
8606
+ }
8607
+ return Buffer.concat(chunks).toString("utf8");
8608
+ }
8609
+ function terminate(child) {
8610
+ signalGroup(child, "SIGTERM");
8611
+ const timer = setTimeout(() => signalGroup(child, "SIGKILL"), KILL_GRACE_MS);
8612
+ timer.unref();
8613
+ child.once("close", () => clearTimeout(timer));
8614
+ }
8615
+ function signalGroup(child, signal) {
8616
+ if (child.pid !== void 0 && process.platform !== "win32") {
8617
+ try {
8618
+ process.kill(-child.pid, signal);
8619
+ return;
8620
+ } catch {
8621
+ }
8622
+ }
8623
+ child.kill(signal);
8624
+ }
8625
+ function feedStdin(child, source, touch, onSourceError) {
8626
+ const sink2 = child.stdin;
8627
+ if (sink2 === null)
8628
+ return Promise.resolve();
8629
+ sink2.on("error", () => void 0);
8630
+ if (source === void 0) {
8631
+ sink2.end();
8632
+ return Promise.resolve();
8633
+ }
8634
+ const readable = typeof source === "string" ? Readable.from([Buffer.from(source)]) : source;
8635
+ const counted = new Transform({
8636
+ transform(chunk, _encoding, callback) {
8637
+ touch();
8638
+ callback(null, chunk);
8639
+ }
8640
+ });
8641
+ return new Promise((resolve2, reject) => {
8642
+ readable.once("error", (err) => {
8643
+ onSourceError(err);
8644
+ reject(err);
8645
+ });
8646
+ sink2.once("close", () => {
8647
+ if (!readable.readableEnded)
8648
+ readable.destroy();
8649
+ resolve2();
8650
+ });
8651
+ readable.pipe(counted).pipe(sink2);
8652
+ });
8653
+ }
8654
+
8655
+ // dist/git-manager.js
8272
8656
  var log9 = createDebugLogger({ prefix: "GitManager" });
8273
8657
  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_]*)$/;
8274
8658
  var FORCE_C_LOCALE = { LC_ALL: "C", LANG: "C" };
@@ -8292,6 +8676,13 @@ function gitNetworkChildEnv() {
8292
8676
  return { ...env, ...FORCE_C_LOCALE };
8293
8677
  }
8294
8678
  var NO_AUTO_GC_CONFIG = ["gc.auto=0", "maintenance.auto=false"];
8679
+ var HASH_INACTIVITY_TIMEOUT_MS = 6e4;
8680
+ var MAX_HASH_RUNS = 50;
8681
+ var LS_REMOTE_INACTIVITY_TIMEOUT_MS = 6e4;
8682
+ var symlinkBlobSha = (target, likeSha) => {
8683
+ const body = Buffer.from(target, "utf8");
8684
+ return createHash2(likeSha.length === 64 ? "sha256" : "sha1").update(Buffer.concat([Buffer.from(`blob ${body.length}\0`), body])).digest("hex");
8685
+ };
8295
8686
  var REMOTE_GIT_CONFIG = [
8296
8687
  ["gc.auto", "0"],
8297
8688
  ["receive.autogc", "false"],
@@ -8392,6 +8783,12 @@ async function ensureGitExcludePattern(repoPath, pattern) {
8392
8783
  await fs16.writeFile(excludePath, newContent, "utf-8");
8393
8784
  log9.debug("git", "Added pattern to .git/info/exclude", { pattern });
8394
8785
  }
8786
+ var BaseBranchMissingError = class extends Error {
8787
+ constructor(remote, baseBranch) {
8788
+ super(`CanopyCMS: remote '${remote}' has no branch '${baseBranch}'`);
8789
+ this.name = "BaseBranchMissingError";
8790
+ }
8791
+ };
8395
8792
  var GitConflictError = class extends Error {
8396
8793
  constructor(conflictedFiles) {
8397
8794
  super(`Git conflict in ${conflictedFiles.length} file(s): ${conflictedFiles.join(", ")}`);
@@ -8667,6 +9064,36 @@ var GitManager = class _GitManager {
8667
9064
  }
8668
9065
  return refs.includes(`refs/heads/${branch}`) || refs.includes(`${GITHUB_TRACKING_REF_PREFIX}${branch}`);
8669
9066
  }
9067
+ /**
9068
+ * The branches inside `branch` (`<branch>/…`) that a bare mirror holds as local heads or in
9069
+ * GitHub's tracking namespace, which git cannot hold beside `branch` itself. `for-each-ref`
9070
+ * matches a pattern up to a slash, so the pattern `<branch>` lists them. Throws, as
9071
+ * {@link bareRemoteHasBranch} does, when the mirror is unreadable.
9072
+ */
9073
+ static async bareRemoteBranchesInside(remotePath, branch) {
9074
+ let output;
9075
+ try {
9076
+ output = await simpleGit2().raw([
9077
+ "--git-dir",
9078
+ remotePath,
9079
+ "for-each-ref",
9080
+ "--format=%(refname)",
9081
+ "--end-of-options",
9082
+ `refs/heads/${branch}`,
9083
+ `${GITHUB_TRACKING_REF_PREFIX}${branch}`
9084
+ ]);
9085
+ } catch (err) {
9086
+ throw new Error(`Cannot inspect remote mirror at ${remotePath}: ${getErrorMessage(err)}`);
9087
+ }
9088
+ const inside = /* @__PURE__ */ new Set();
9089
+ for (const ref of output.split("\n").map((line) => line.trim())) {
9090
+ for (const prefix of ["refs/heads/", GITHUB_TRACKING_REF_PREFIX]) {
9091
+ if (ref.startsWith(`${prefix}${branch}/`))
9092
+ inside.add(ref.slice(prefix.length));
9093
+ }
9094
+ }
9095
+ return [...inside].sort();
9096
+ }
8670
9097
  /**
8671
9098
  * Delete `refs/heads/<branch>` from a bare local mirror, if present. A
8672
9099
  * no-op (not an error) when the ref doesn't exist.
@@ -9365,6 +9792,199 @@ var GitManager = class _GitManager {
9365
9792
  ]);
9366
9793
  return output.split("\0").filter((p) => p.length > 0);
9367
9794
  }
9795
+ /**
9796
+ * Every file in `rev`'s tree below repo-relative directory `under`, with its size in bytes,
9797
+ * so a caller can refuse a file too large to read before reading it.
9798
+ */
9799
+ async listFilesWithSizeAt(rev, under) {
9800
+ const dir = `${under.replace(/\/+$/, "")}/`;
9801
+ const output = await this.git.raw([
9802
+ "ls-tree",
9803
+ "-r",
9804
+ "-z",
9805
+ "-l",
9806
+ "--full-tree",
9807
+ "--end-of-options",
9808
+ rev,
9809
+ dir
9810
+ ]);
9811
+ const files = [];
9812
+ for (const record of output.split("\0")) {
9813
+ const tab = record.indexOf(" ");
9814
+ if (tab === -1)
9815
+ continue;
9816
+ const [, type, , size] = record.slice(0, tab).split(/ +/);
9817
+ const filePath = record.slice(tab + 1);
9818
+ if (type === "blob" && filePath.startsWith(dir)) {
9819
+ files.push({ path: filePath, size: Number(size) });
9820
+ }
9821
+ }
9822
+ return files;
9823
+ }
9824
+ /**
9825
+ * The commit HEAD forked from the base branch at, or `undefined` when they share no history.
9826
+ * Takes the base tip from the remote by `ls-remote` rather than `<remote>/<base>`: the worker
9827
+ * fetches by path, which moves no remote-tracking ref, so that ref is left behind by every
9828
+ * rebase. A tip this clone lacks is fetched as objects only (`--refmap=`, `--no-tags`,
9829
+ * `--no-write-fetch-head`, git 2.29+): no ref and no FETCH_HEAD moves, so a concurrent fetch
9830
+ * pinning FETCH_HEAD is never repointed. Throws {@link BaseBranchMissingError} when the
9831
+ * remote has no base branch, and any other error when it can't be read.
9832
+ */
9833
+ async mergeBaseWithBase() {
9834
+ const tip = await this.remoteBranchTip(this.baseBranch);
9835
+ if (!tip)
9836
+ throw new BaseBranchMissingError(this.remote, this.baseBranch);
9837
+ if (!await this.hasCommit(tip)) {
9838
+ await this.git.raw([
9839
+ "fetch",
9840
+ "--refmap=",
9841
+ "--no-tags",
9842
+ "--no-write-fetch-head",
9843
+ "--end-of-options",
9844
+ this.remote,
9845
+ `refs/heads/${this.baseBranch}`
9846
+ ]);
9847
+ if (!await this.hasCommit(tip)) {
9848
+ throw new Error(`CanopyCMS: could not fetch ${this.baseBranch}'s tip ${tip}`);
9849
+ }
9850
+ }
9851
+ const forkPoint = (await this.git.raw(["merge-base", "--end-of-options", "HEAD", tip])).trim();
9852
+ return forkPoint || void 0;
9853
+ }
9854
+ /** Whether this clone holds commit `sha`. */
9855
+ async hasCommit(sha) {
9856
+ return (await this.git.raw(["rev-parse", "--verify", "--quiet", `${sha}^{commit}`])).trim() !== "";
9857
+ }
9858
+ /**
9859
+ * Repo-relative paths whose working-tree content differs from commit `rev`, with `T` (a type
9860
+ * change) reported as `M`. A sparse clone leaves a path outside its cone unchecked out, marked
9861
+ * skip-worktree, and git reads that path's index entry in place of the missing file: it is never
9862
+ * reported deleted, and differs from `rev` only as the index does.
9863
+ *
9864
+ * `diff-index`, not `git diff`: the porcelain refreshes the index and writes it back under
9865
+ * `index.lock`, which would fail a concurrent writer's own git step. `diff-index` writes
9866
+ * nothing, so it reports a file whose stat changed but whose content did not; those are
9867
+ * hashed here and dropped when they match `rev`.
9868
+ */
9869
+ async diffNameStatusAgainst(rev) {
9870
+ const output = await this.git.raw([
9871
+ "diff-index",
9872
+ "--raw",
9873
+ "-z",
9874
+ "--no-renames",
9875
+ "--end-of-options",
9876
+ rev,
9877
+ "--"
9878
+ ]);
9879
+ const tokens = output.split("\0");
9880
+ const changes = [];
9881
+ for (let i = 0; i + 1 < tokens.length; i += 2) {
9882
+ const [srcMode, dstMode, srcSha, dstSha, code] = tokens[i].slice(1).split(" ");
9883
+ const path27 = tokens[i + 1];
9884
+ const letter = code?.charAt(0);
9885
+ if (letter === "A" || letter === "D")
9886
+ changes.push({ status: letter, path: path27 });
9887
+ else if (letter === "M" || letter === "T") {
9888
+ const unhashed = /^0+$/.test(dstSha) && srcMode === dstMode ? srcSha : void 0;
9889
+ changes.push({ status: "M", path: path27, unhashed });
9890
+ }
9891
+ }
9892
+ const unchanged = await this.matchingBlobs(changes.flatMap((c) => c.unhashed ? [{ path: c.path, sha: c.unhashed }] : []));
9893
+ return changes.filter((c) => !unchanged.has(c.path)).map(({ status, path: path27 }) => ({ status, path: path27 }));
9894
+ }
9895
+ /**
9896
+ * The paths whose working-tree file hashes to `sha`, as git hashes it: a symlink's blob is its
9897
+ * target, a file's goes through `hash-object --stdin-paths`, so its attributes' filters apply.
9898
+ * Callers pass only paths whose mode is unchanged, since diff-index reports the working tree's
9899
+ * own mode. A path that is gone or can't be hashed matches nothing, so it stays reported as
9900
+ * changed. When git refuses a path (exit 128), having printed
9901
+ * the SHAs before it, the next run resumes past that path, for at most {@link MAX_HASH_RUNS}
9902
+ * runs. A run that ends any other way (killed, timed out, a signal) stops the hashing, and the
9903
+ * paths it didn't reach stay changed.
9904
+ */
9905
+ async matchingBlobs(files) {
9906
+ const matching = /* @__PURE__ */ new Set();
9907
+ const regular = [];
9908
+ for (const file of files) {
9909
+ if (file.path.includes("\n"))
9910
+ continue;
9911
+ const stat = await fs16.lstat(path20.join(this.repoPath, file.path)).catch(() => void 0);
9912
+ if (!stat)
9913
+ continue;
9914
+ if (stat.isSymbolicLink()) {
9915
+ const target = await fs16.readlink(path20.join(this.repoPath, file.path)).catch(() => void 0);
9916
+ if (target !== void 0 && symlinkBlobSha(target, file.sha) === file.sha) {
9917
+ matching.add(file.path);
9918
+ }
9919
+ } else if (stat.isFile())
9920
+ regular.push(file);
9921
+ }
9922
+ for (let start = 0, runs = 0; start < regular.length && runs < MAX_HASH_RUNS; runs++) {
9923
+ const batch = regular.slice(start);
9924
+ const { shas, outcome } = await this.hashPaths(batch.map((f) => f.path));
9925
+ shas.forEach((sha, i) => {
9926
+ if (batch[i] && sha === batch[i].sha)
9927
+ matching.add(batch[i].path);
9928
+ });
9929
+ if (outcome !== "refused")
9930
+ break;
9931
+ start += shas.length + 1;
9932
+ }
9933
+ return matching;
9934
+ }
9935
+ /**
9936
+ * `hash-object --stdin-paths` over `paths`: one SHA per path, in order, up to where it ended.
9937
+ * `refused` is git's own exit 128 on a path it can't hash; `stopped` is any other ending.
9938
+ */
9939
+ async hashPaths(paths) {
9940
+ const chunks = [];
9941
+ let proc;
9942
+ try {
9943
+ proc = spawnGit(["hash-object", "--stdin-paths"], {
9944
+ ...this.processOptions(),
9945
+ stdin: `${paths.join("\n")}
9946
+ `,
9947
+ inactivityTimeoutMs: HASH_INACTIVITY_TIMEOUT_MS
9948
+ });
9949
+ } catch {
9950
+ return { shas: [], outcome: "stopped" };
9951
+ }
9952
+ const read = (async () => {
9953
+ for await (const chunk of proc.stdout)
9954
+ chunks.push(chunk);
9955
+ })().catch(() => {
9956
+ });
9957
+ const outcome = await proc.exited.then(() => "complete", (err) => err instanceof GitProcessError && err.exitCode === 128 ? "refused" : "stopped");
9958
+ await read;
9959
+ return { shas: Buffer.concat(chunks).toString("utf8").split("\n").filter(Boolean), outcome };
9960
+ }
9961
+ /** What a git run outside simple-git needs to run as `this.git` does. */
9962
+ processOptions() {
9963
+ return {
9964
+ cwd: this.repoPath,
9965
+ env: gitChildEnv({ GIT_CEILING_DIRECTORIES: path20.dirname(this.repoPath) }),
9966
+ config: NO_AUTO_GC_CONFIG
9967
+ };
9968
+ }
9969
+ /** Repo-relative paths of the working tree's untracked, non-ignored files. */
9970
+ async listUntrackedFiles() {
9971
+ const output = await this.git.raw(["ls-files", "--others", "--exclude-standard", "-z"]);
9972
+ return output.split("\0").filter((p) => p.length > 0);
9973
+ }
9974
+ /**
9975
+ * The text of repo-relative `filePath` at commit `rev`, or `undefined` when `rev` has no such
9976
+ * path. Any other failure, such as an unknown `rev`, throws.
9977
+ */
9978
+ async showFile(rev, filePath) {
9979
+ try {
9980
+ return await this.git.raw(["show", "--end-of-options", `${rev}:${filePath}`]);
9981
+ } catch (err) {
9982
+ if (/fatal: path '.*' (does not exist in|exists on disk, but not in) '/.test(getErrorMessage(err))) {
9983
+ return void 0;
9984
+ }
9985
+ throw err;
9986
+ }
9987
+ }
9368
9988
  async ensureAuthor(author) {
9369
9989
  const config = await this.git.listConfig();
9370
9990
  const isManaged = config.all["canopycms.managed"] === "true";
@@ -9535,7 +10155,10 @@ var GitManager = class _GitManager {
9535
10155
  */
9536
10156
  async remoteBranchTip(branch) {
9537
10157
  const ref = `refs/heads/${branch}`;
9538
- const output = await this.git.raw(["ls-remote", "--heads", this.remote, ref]);
10158
+ const output = await runGit(["ls-remote", "--heads", "--end-of-options", this.remote, ref], {
10159
+ ...this.processOptions(),
10160
+ inactivityTimeoutMs: LS_REMOTE_INACTIVITY_TIMEOUT_MS
10161
+ });
9539
10162
  for (const line of output.split("\n")) {
9540
10163
  const [sha, name] = line.trim().split(" ");
9541
10164
  if (sha && name === ref)
@@ -9616,7 +10239,7 @@ var REPAIR_MAX_AGE_MS = 20 * 6e4;
9616
10239
  var DELETING_MAX_AGE_MS = 10 * 6e4;
9617
10240
  var SETTINGS_TRASH_MAX_AGE_MS = 30 * 24 * 60 * 6e4;
9618
10241
  var BranchProvisioningBusyError = class extends Error {
9619
- constructor(dirName, message = `Branch '${dirName}' is still being set up. Try again in a minute.`) {
10242
+ constructor(dirName, shortName = dirName, message = `Branch '${shortName}' is still being set up. Try again in a minute.`) {
9620
10243
  super(message);
9621
10244
  this.dirName = dirName;
9622
10245
  this.name = "BranchProvisioningBusyError";
@@ -9633,7 +10256,7 @@ function formatDirStamp(date) {
9633
10256
  return date.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
9634
10257
  }
9635
10258
  function shortDirName(dirName) {
9636
- const digest = createHash2("sha1").update(dirName).digest("hex").slice(0, 10);
10259
+ const digest = createHash3("sha1").update(dirName).digest("hex").slice(0, 10);
9637
10260
  return `${dirName.slice(0, 40)}-${digest}`;
9638
10261
  }
9639
10262
  function stampedName(prefix, dirName, now) {
@@ -9825,12 +10448,13 @@ async function stageBranchWorkspace(options) {
9825
10448
  throw err;
9826
10449
  }
9827
10450
  }
9828
- async function lockBranchName(baseRoot, dirName) {
10451
+ async function lockBranchName(baseRoot, dirName, shortName = dirName) {
9829
10452
  try {
9830
10453
  return await acquireProvisioningLockWithin(baseRoot, branchProvisioningLockName(dirName), PUBLISH_LOCK_WAIT_MS);
9831
10454
  } catch (err) {
9832
- if (isNodeError(err) && err.code === "ELOCKED")
9833
- throw new BranchProvisioningBusyError(dirName);
10455
+ if (isNodeError(err) && err.code === "ELOCKED") {
10456
+ throw new BranchProvisioningBusyError(dirName, shortName);
10457
+ }
9834
10458
  throw err;
9835
10459
  }
9836
10460
  }
@@ -9844,9 +10468,9 @@ async function inspectBlocking(finalPath) {
9844
10468
  }
9845
10469
  var RENAME_BLOCKED = /* @__PURE__ */ new Set(["EEXIST", "ENOTEMPTY", "ENOTDIR"]);
9846
10470
  async function publishStaging(options) {
9847
- const { baseRoot, dirName, stagingPath } = options;
10471
+ const { baseRoot, dirName, stagingPath, shortName = dirName } = options;
9848
10472
  const finalPath = path21.join(baseRoot, dirName);
9849
- const release = await lockBranchName(baseRoot, dirName);
10473
+ const release = await lockBranchName(baseRoot, dirName, shortName);
9850
10474
  try {
9851
10475
  for (let attempt = 0; attempt < 2; attempt++) {
9852
10476
  try {
@@ -9868,7 +10492,7 @@ async function publishStaging(options) {
9868
10492
  if (blocking === "live")
9869
10493
  return "exists";
9870
10494
  if (blocking === "protected")
9871
- throw new BranchProvisioningBusyError(dirName);
10495
+ throw new BranchProvisioningBusyError(dirName, shortName);
9872
10496
  const result = await quarantineResidueAt(baseRoot, dirName, {
9873
10497
  minQuietMs: CREATE_RESIDUE_MIN_QUIET_MS,
9874
10498
  expectedRemoteUrl: options.expectedRemoteUrl
@@ -9883,9 +10507,9 @@ async function publishStaging(options) {
9883
10507
  }
9884
10508
  if (result.reason === "foreign")
9885
10509
  throw new BranchDirOccupiedError(dirName);
9886
- throw new BranchProvisioningBusyError(dirName);
10510
+ throw new BranchProvisioningBusyError(dirName, shortName);
9887
10511
  }
9888
- throw new BranchProvisioningBusyError(dirName);
10512
+ throw new BranchProvisioningBusyError(dirName, shortName);
9889
10513
  } finally {
9890
10514
  await release().catch((err) => {
9891
10515
  canopyLogWarn(`[canopy] Failed to release the provisioning lock for ${dirName}:`, err);
@@ -10017,12 +10641,16 @@ var BranchWorkspaceManager = class {
10017
10641
  if (isSettingsBranchName(options.branchName, operatingStrategy(options.mode).getSettingsBranchName(this.config))) {
10018
10642
  throw new BranchPathError("Settings branches are not content branches");
10019
10643
  }
10020
- const { branchRoot, baseRoot, branchName: safeName } = resolveBranchPath(options);
10644
+ const named = parseBranchRef(options.branchName);
10645
+ if (!named.ok)
10646
+ throw new BranchPathError(named.error);
10647
+ const { branchRoot, baseRoot } = resolveBranchPath(options);
10021
10648
  const paths = {
10022
10649
  branchRoot,
10023
10650
  baseRoot,
10024
10651
  dirName: path23.basename(branchRoot),
10025
- safeName
10652
+ ref: options.branchName,
10653
+ shortName: shortNameOf({ ...this.config, mode: options.mode }, options.branchName)
10026
10654
  };
10027
10655
  const existing = await readBranchMetadataFile(branchRoot);
10028
10656
  if (existing) {
@@ -10040,7 +10668,7 @@ var BranchWorkspaceManager = class {
10040
10668
  }
10041
10669
  }
10042
10670
  async provisionUnshared(options, paths) {
10043
- const { branchRoot, baseRoot, dirName } = paths;
10671
+ const { branchRoot, baseRoot, dirName, shortName } = paths;
10044
10672
  await fs19.mkdir(baseRoot, { recursive: true });
10045
10673
  const baseBranch = await GitManager.resolveWorkspaceBaseBranch({
10046
10674
  ...this.config,
@@ -10058,7 +10686,7 @@ var BranchWorkspaceManager = class {
10058
10686
  });
10059
10687
  const metadata = buildInitialBranchMetadata({
10060
10688
  branch: {
10061
- name: paths.safeName,
10689
+ name: paths.ref,
10062
10690
  title: options.title,
10063
10691
  description: options.description,
10064
10692
  access: options.access,
@@ -10070,14 +10698,14 @@ var BranchWorkspaceManager = class {
10070
10698
  if (state.kind === "live" || state.kind === "corrupt")
10071
10699
  return this.existing(paths);
10072
10700
  if (state.kind === "protected")
10073
- throw new BranchProvisioningBusyError(dirName);
10701
+ throw new BranchProvisioningBusyError(dirName, shortName);
10074
10702
  if (state.kind === "foreign") {
10075
10703
  if (options.mode !== "dev")
10076
10704
  throw new BranchDirOccupiedError(dirName);
10077
10705
  return this.adoptInPlace(options, paths, baseBranch, metadata);
10078
10706
  }
10079
10707
  if (state.kind === "residue" && await this.recentlyActive(paths)) {
10080
- throw new BranchProvisioningBusyError(dirName);
10708
+ throw new BranchProvisioningBusyError(dirName, shortName);
10081
10709
  }
10082
10710
  const sparseCone = sparseConeFor(this.config.contentRoot);
10083
10711
  await recordSparseCone(baseRoot, sparseCone).catch((err) => {
@@ -10102,11 +10730,11 @@ var BranchWorkspaceManager = class {
10102
10730
  return outcome;
10103
10731
  }
10104
10732
  async stageAndPublish(paths, run) {
10105
- const { branchRoot, baseRoot, dirName } = paths;
10733
+ const { branchRoot, baseRoot, dirName, shortName } = paths;
10106
10734
  const staged = await stageBranchWorkspace({
10107
10735
  baseRoot,
10108
10736
  dirName,
10109
- branchName: paths.safeName,
10737
+ branchName: paths.ref,
10110
10738
  baseBranch: run.baseBranch,
10111
10739
  remoteUrl: run.remoteUrl,
10112
10740
  clone: {
@@ -10126,6 +10754,7 @@ var BranchWorkspaceManager = class {
10126
10754
  published = await run.provisionLog.step("publish", () => publishStaging({
10127
10755
  baseRoot,
10128
10756
  dirName,
10757
+ shortName,
10129
10758
  stagingPath: staged.stagingPath,
10130
10759
  writeId: staged.meta.writeId,
10131
10760
  expectedRemoteUrl: run.remoteUrl,
@@ -10145,16 +10774,16 @@ var BranchWorkspaceManager = class {
10145
10774
  * is, given branch.json under the branch's lock.
10146
10775
  */
10147
10776
  async adoptInPlace(options, paths, baseBranch, metadata) {
10148
- const { branchRoot, baseRoot, dirName } = paths;
10777
+ const { branchRoot, baseRoot, dirName, shortName } = paths;
10149
10778
  const provisionLog = new ProvisionLog(dirName);
10150
10779
  let created = false;
10151
10780
  try {
10152
- const release = await provisionLog.step("lock", () => lockBranchName(baseRoot, dirName));
10781
+ const release = await provisionLog.step("lock", () => lockBranchName(baseRoot, dirName, shortName));
10153
10782
  try {
10154
10783
  if (!await readBranchMetadataFile(branchRoot)) {
10155
10784
  await GitManager.initializeWorkspace({
10156
10785
  workspacePath: branchRoot,
10157
- branchName: paths.safeName,
10786
+ branchName: paths.ref,
10158
10787
  mode: options.mode,
10159
10788
  baseBranch,
10160
10789
  sourceRoot: this.config.sourceRoot,
@@ -10189,7 +10818,7 @@ var BranchWorkspaceManager = class {
10189
10818
  return this.existing(paths);
10190
10819
  const meta = await readBranchMetadataFile(branchRoot);
10191
10820
  if (!meta)
10192
- throw new BranchProvisioningBusyError(dirName);
10821
+ throw new BranchProvisioningBusyError(dirName, shortName);
10193
10822
  return { kind: "created", context: { branch: meta.branch, branchRoot, baseRoot } };
10194
10823
  }
10195
10824
  /** Whether residue at the branch path may still belong to a live process. */
@@ -10199,10 +10828,10 @@ var BranchWorkspaceManager = class {
10199
10828
  return true;
10200
10829
  return await quietAgeMs(branchRoot) < CREATE_RESIDUE_MIN_QUIET_MS;
10201
10830
  }
10202
- async existing({ branchRoot, baseRoot, dirName }) {
10831
+ async existing({ branchRoot, baseRoot, dirName, shortName }) {
10203
10832
  const meta = await readBranchMetadataFile(branchRoot);
10204
10833
  if (!meta)
10205
- throw new BranchProvisioningBusyError(dirName);
10834
+ throw new BranchProvisioningBusyError(dirName, shortName);
10206
10835
  return { kind: "exists", context: { branch: meta.branch, branchRoot, baseRoot } };
10207
10836
  }
10208
10837
  };
@@ -10286,21 +10915,23 @@ var normalizePath = (root, target) => {
10286
10915
  }
10287
10916
  return result.normalizedPath;
10288
10917
  };
10918
+ var parseEntryParts = (raw, format) => {
10919
+ if (format === "json")
10920
+ return { data: asRecord(JSON.parse(raw)) };
10921
+ if (format === "yaml")
10922
+ return { data: asRecord(yamlParse2(raw)) };
10923
+ const parsed = matter3(raw);
10924
+ return { data: { ...parsed.data ?? {} }, body: parsed.content };
10925
+ };
10926
+ var parseEntryData = (raw, format, bodyFieldName = "body") => {
10927
+ const { data, body } = parseEntryParts(raw, format);
10928
+ if (body)
10929
+ data[bodyFieldName] = body;
10930
+ return data;
10931
+ };
10289
10932
  var readEntryData = async (filePath, format, bodyFieldName = "body") => {
10290
10933
  try {
10291
- const raw = await fs20.readFile(filePath, "utf8");
10292
- if (format === "json") {
10293
- return asRecord(JSON.parse(raw));
10294
- }
10295
- if (format === "yaml") {
10296
- return asRecord(yamlParse2(raw));
10297
- }
10298
- const parsed = matter3(raw);
10299
- const data = { ...parsed.data ?? {} };
10300
- if (parsed.content) {
10301
- data[bodyFieldName] = parsed.content;
10302
- }
10303
- return data;
10934
+ return parseEntryData(await fs20.readFile(filePath, "utf8"), format, bodyFieldName);
10304
10935
  } catch (err) {
10305
10936
  if (isNotFoundError(err))
10306
10937
  return {};