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
@@ -113,6 +113,10 @@ export interface GitManagerOptions {
113
113
  skipIndexMarker?: boolean;
114
114
  }
115
115
  export type GitStatus = Pick<StatusResult, 'files' | 'ahead' | 'behind' | 'current' | 'tracking'>;
116
+ /** The remote has no branch of the base branch's name, so there is nothing to fork from. */
117
+ export declare class BaseBranchMissingError extends Error {
118
+ constructor(remote: string, baseBranch: string);
119
+ }
116
120
  /** @internal Exported for tests. */
117
121
  export declare class GitConflictError extends Error {
118
122
  readonly conflictedFiles: string[];
@@ -264,6 +268,13 @@ export declare class GitManager {
264
268
  static bareRemoteHasBranch(remotePath: string, branch: string, options?: {
265
269
  namespaces?: 'both' | 'tracking';
266
270
  }): Promise<boolean>;
271
+ /**
272
+ * The branches inside `branch` (`<branch>/…`) that a bare mirror holds as local heads or in
273
+ * GitHub's tracking namespace, which git cannot hold beside `branch` itself. `for-each-ref`
274
+ * matches a pattern up to a slash, so the pattern `<branch>` lists them. Throws, as
275
+ * {@link bareRemoteHasBranch} does, when the mirror is unreadable.
276
+ */
277
+ static bareRemoteBranchesInside(remotePath: string, branch: string): Promise<string[]>;
267
278
  /**
268
279
  * Delete `refs/heads/<branch>` from a bare local mirror, if present. A
269
280
  * no-op (not an error) when the ref doesn't exist.
@@ -489,6 +500,66 @@ export declare class GitManager {
489
500
  private fetchBaseTip;
490
501
  /** Repo-relative paths of every file in `rev`'s tree, under `under` when given. */
491
502
  private listTreeFiles;
503
+ /**
504
+ * Every file in `rev`'s tree below repo-relative directory `under`, with its size in bytes,
505
+ * so a caller can refuse a file too large to read before reading it.
506
+ */
507
+ listFilesWithSizeAt(rev: string, under: string): Promise<Array<{
508
+ path: string;
509
+ size: number;
510
+ }>>;
511
+ /**
512
+ * The commit HEAD forked from the base branch at, or `undefined` when they share no history.
513
+ * Takes the base tip from the remote by `ls-remote` rather than `<remote>/<base>`: the worker
514
+ * fetches by path, which moves no remote-tracking ref, so that ref is left behind by every
515
+ * rebase. A tip this clone lacks is fetched as objects only (`--refmap=`, `--no-tags`,
516
+ * `--no-write-fetch-head`, git 2.29+): no ref and no FETCH_HEAD moves, so a concurrent fetch
517
+ * pinning FETCH_HEAD is never repointed. Throws {@link BaseBranchMissingError} when the
518
+ * remote has no base branch, and any other error when it can't be read.
519
+ */
520
+ mergeBaseWithBase(): Promise<string | undefined>;
521
+ /** Whether this clone holds commit `sha`. */
522
+ private hasCommit;
523
+ /**
524
+ * Repo-relative paths whose working-tree content differs from commit `rev`, with `T` (a type
525
+ * change) reported as `M`. A sparse clone leaves a path outside its cone unchecked out, marked
526
+ * skip-worktree, and git reads that path's index entry in place of the missing file: it is never
527
+ * reported deleted, and differs from `rev` only as the index does.
528
+ *
529
+ * `diff-index`, not `git diff`: the porcelain refreshes the index and writes it back under
530
+ * `index.lock`, which would fail a concurrent writer's own git step. `diff-index` writes
531
+ * nothing, so it reports a file whose stat changed but whose content did not; those are
532
+ * hashed here and dropped when they match `rev`.
533
+ */
534
+ diffNameStatusAgainst(rev: string): Promise<Array<{
535
+ status: 'A' | 'M' | 'D';
536
+ path: string;
537
+ }>>;
538
+ /**
539
+ * The paths whose working-tree file hashes to `sha`, as git hashes it: a symlink's blob is its
540
+ * target, a file's goes through `hash-object --stdin-paths`, so its attributes' filters apply.
541
+ * Callers pass only paths whose mode is unchanged, since diff-index reports the working tree's
542
+ * own mode. A path that is gone or can't be hashed matches nothing, so it stays reported as
543
+ * changed. When git refuses a path (exit 128), having printed
544
+ * the SHAs before it, the next run resumes past that path, for at most {@link MAX_HASH_RUNS}
545
+ * runs. A run that ends any other way (killed, timed out, a signal) stops the hashing, and the
546
+ * paths it didn't reach stay changed.
547
+ */
548
+ private matchingBlobs;
549
+ /**
550
+ * `hash-object --stdin-paths` over `paths`: one SHA per path, in order, up to where it ended.
551
+ * `refused` is git's own exit 128 on a path it can't hash; `stopped` is any other ending.
552
+ */
553
+ private hashPaths;
554
+ /** What a git run outside simple-git needs to run as `this.git` does. */
555
+ private processOptions;
556
+ /** Repo-relative paths of the working tree's untracked, non-ignored files. */
557
+ listUntrackedFiles(): Promise<string[]>;
558
+ /**
559
+ * The text of repo-relative `filePath` at commit `rev`, or `undefined` when `rev` has no such
560
+ * path. Any other failure, such as an unknown `rev`, throws.
561
+ */
562
+ showFile(rev: string, filePath: string): Promise<string | undefined>;
492
563
  ensureAuthor(author: {
493
564
  name: string;
494
565
  email: string;
@@ -13,6 +13,7 @@
13
13
  *
14
14
  * Module map: ./AGENTS.md. Locking rules: ../../../docs/concurrency.md.
15
15
  */
16
+ import { createHash } from 'node:crypto';
16
17
  import fs from 'node:fs/promises';
17
18
  import path from 'node:path';
18
19
  import { fileURLToPath } from 'node:url';
@@ -25,6 +26,7 @@ import { isMissingRemoteRefFailure, isNetworkRemoteUrl, resolveBaseBranch, stage
25
26
  import { BaseBranchUnresolvedError } from './utils/base-branch.js';
26
27
  import { canopyLogWarn } from './utils/logger.js';
27
28
  import { acquireProvisioningLock } from './utils/provisioning-lock.js';
29
+ import { GitProcessError, runGit, spawnGit } from './worker/git-process.js';
28
30
  const log = createDebugLogger({ prefix: 'GitManager' });
29
31
  /**
30
32
  * Child environment for spawned git processes: a deterministic ALLOWLIST of
@@ -99,6 +101,25 @@ export function gitNetworkChildEnv() {
99
101
  * them; {@link REMOTE_GIT_CONFIG} covers that side.
100
102
  */
101
103
  const NO_AUTO_GC_CONFIG = ['gc.auto=0', 'maintenance.auto=false'];
104
+ /** How long `hash-object --stdin-paths` may go without output before it is killed. */
105
+ const HASH_INACTIVITY_TIMEOUT_MS = 60_000;
106
+ /**
107
+ * `hash-object` runs per diff: each refused path costs a run, so 49 are resumed past and a 50th
108
+ * leaves the paths after it unhashed.
109
+ */
110
+ const MAX_HASH_RUNS = 50;
111
+ /** How long `ls-remote` may go without output before it is killed. */
112
+ const LS_REMOTE_INACTIVITY_TIMEOUT_MS = 60_000;
113
+ /**
114
+ * The blob SHA git gives a symlink pointing at `target`, in the object format of `likeSha` (a
115
+ * 40-character SHA-1 or a 64-character SHA-256).
116
+ */
117
+ const symlinkBlobSha = (target, likeSha) => {
118
+ const body = Buffer.from(target, 'utf8');
119
+ return createHash(likeSha.length === 64 ? 'sha256' : 'sha1')
120
+ .update(Buffer.concat([Buffer.from(`blob ${body.length}\0`), body]))
121
+ .digest('hex');
122
+ };
102
123
  /**
103
124
  * Settings written into every bare `remote.git` CanopyCMS creates: the worker's (prod) and the
104
125
  * simulated remote (dev). A push runs `receive-pack` with remote.git's own config and none of the
@@ -269,6 +290,13 @@ export async function ensureGitExcludePattern(repoPath, pattern) {
269
290
  await fs.writeFile(excludePath, newContent, 'utf-8');
270
291
  log.debug('git', 'Added pattern to .git/info/exclude', { pattern });
271
292
  }
293
+ /** The remote has no branch of the base branch's name, so there is nothing to fork from. */
294
+ export class BaseBranchMissingError extends Error {
295
+ constructor(remote, baseBranch) {
296
+ super(`CanopyCMS: remote '${remote}' has no branch '${baseBranch}'`);
297
+ this.name = 'BaseBranchMissingError';
298
+ }
299
+ }
272
300
  /** @internal Exported for tests. */
273
301
  export class GitConflictError extends Error {
274
302
  constructor(conflictedFiles) {
@@ -666,6 +694,37 @@ export class GitManager {
666
694
  return (refs.includes(`refs/heads/${branch}`) ||
667
695
  refs.includes(`${GITHUB_TRACKING_REF_PREFIX}${branch}`));
668
696
  }
697
+ /**
698
+ * The branches inside `branch` (`<branch>/…`) that a bare mirror holds as local heads or in
699
+ * GitHub's tracking namespace, which git cannot hold beside `branch` itself. `for-each-ref`
700
+ * matches a pattern up to a slash, so the pattern `<branch>` lists them. Throws, as
701
+ * {@link bareRemoteHasBranch} does, when the mirror is unreadable.
702
+ */
703
+ static async bareRemoteBranchesInside(remotePath, branch) {
704
+ let output;
705
+ try {
706
+ output = await simpleGit().raw([
707
+ '--git-dir',
708
+ remotePath,
709
+ 'for-each-ref',
710
+ '--format=%(refname)',
711
+ '--end-of-options',
712
+ `refs/heads/${branch}`,
713
+ `${GITHUB_TRACKING_REF_PREFIX}${branch}`,
714
+ ]);
715
+ }
716
+ catch (err) {
717
+ throw new Error(`Cannot inspect remote mirror at ${remotePath}: ${getErrorMessage(err)}`);
718
+ }
719
+ const inside = new Set();
720
+ for (const ref of output.split('\n').map((line) => line.trim())) {
721
+ for (const prefix of ['refs/heads/', GITHUB_TRACKING_REF_PREFIX]) {
722
+ if (ref.startsWith(`${prefix}${branch}/`))
723
+ inside.add(ref.slice(prefix.length));
724
+ }
725
+ }
726
+ return [...inside].sort();
727
+ }
669
728
  /**
670
729
  * Delete `refs/heads/<branch>` from a bare local mirror, if present. A
671
730
  * no-op (not an error) when the ref doesn't exist.
@@ -1498,6 +1557,212 @@ export class GitManager {
1498
1557
  ]);
1499
1558
  return output.split('\0').filter((p) => p.length > 0);
1500
1559
  }
1560
+ /**
1561
+ * Every file in `rev`'s tree below repo-relative directory `under`, with its size in bytes,
1562
+ * so a caller can refuse a file too large to read before reading it.
1563
+ */
1564
+ async listFilesWithSizeAt(rev, under) {
1565
+ const dir = `${under.replace(/\/+$/, '')}/`;
1566
+ const output = await this.git.raw([
1567
+ 'ls-tree',
1568
+ '-r',
1569
+ '-z',
1570
+ '-l',
1571
+ '--full-tree',
1572
+ '--end-of-options',
1573
+ rev,
1574
+ dir,
1575
+ ]);
1576
+ // -z -l: "<mode> <type> <object> <padded size>\t<path>" per record, unquoted.
1577
+ const files = [];
1578
+ for (const record of output.split('\0')) {
1579
+ const tab = record.indexOf('\t');
1580
+ if (tab === -1)
1581
+ continue;
1582
+ const [, type, , size] = record.slice(0, tab).split(/ +/);
1583
+ const filePath = record.slice(tab + 1);
1584
+ if (type === 'blob' && filePath.startsWith(dir)) {
1585
+ files.push({ path: filePath, size: Number(size) });
1586
+ }
1587
+ }
1588
+ return files;
1589
+ }
1590
+ /**
1591
+ * The commit HEAD forked from the base branch at, or `undefined` when they share no history.
1592
+ * Takes the base tip from the remote by `ls-remote` rather than `<remote>/<base>`: the worker
1593
+ * fetches by path, which moves no remote-tracking ref, so that ref is left behind by every
1594
+ * rebase. A tip this clone lacks is fetched as objects only (`--refmap=`, `--no-tags`,
1595
+ * `--no-write-fetch-head`, git 2.29+): no ref and no FETCH_HEAD moves, so a concurrent fetch
1596
+ * pinning FETCH_HEAD is never repointed. Throws {@link BaseBranchMissingError} when the
1597
+ * remote has no base branch, and any other error when it can't be read.
1598
+ */
1599
+ async mergeBaseWithBase() {
1600
+ const tip = await this.remoteBranchTip(this.baseBranch);
1601
+ if (!tip)
1602
+ throw new BaseBranchMissingError(this.remote, this.baseBranch);
1603
+ if (!(await this.hasCommit(tip))) {
1604
+ await this.git.raw([
1605
+ 'fetch',
1606
+ '--refmap=',
1607
+ '--no-tags',
1608
+ '--no-write-fetch-head',
1609
+ '--end-of-options',
1610
+ this.remote,
1611
+ `refs/heads/${this.baseBranch}`,
1612
+ ]);
1613
+ if (!(await this.hasCommit(tip))) {
1614
+ throw new Error(`CanopyCMS: could not fetch ${this.baseBranch}'s tip ${tip}`);
1615
+ }
1616
+ }
1617
+ // With no shared commit merge-base exits 1 silently, which simple-git resolves as ''.
1618
+ const forkPoint = (await this.git.raw(['merge-base', '--end-of-options', 'HEAD', tip])).trim();
1619
+ return forkPoint || undefined;
1620
+ }
1621
+ /** Whether this clone holds commit `sha`. */
1622
+ async hasCommit(sha) {
1623
+ // `--quiet` makes a missing commit a silent non-zero exit, which simple-git resolves as ''.
1624
+ return ((await this.git.raw(['rev-parse', '--verify', '--quiet', `${sha}^{commit}`])).trim() !== '');
1625
+ }
1626
+ /**
1627
+ * Repo-relative paths whose working-tree content differs from commit `rev`, with `T` (a type
1628
+ * change) reported as `M`. A sparse clone leaves a path outside its cone unchecked out, marked
1629
+ * skip-worktree, and git reads that path's index entry in place of the missing file: it is never
1630
+ * reported deleted, and differs from `rev` only as the index does.
1631
+ *
1632
+ * `diff-index`, not `git diff`: the porcelain refreshes the index and writes it back under
1633
+ * `index.lock`, which would fail a concurrent writer's own git step. `diff-index` writes
1634
+ * nothing, so it reports a file whose stat changed but whose content did not; those are
1635
+ * hashed here and dropped when they match `rev`.
1636
+ */
1637
+ async diffNameStatusAgainst(rev) {
1638
+ const output = await this.git.raw([
1639
+ 'diff-index',
1640
+ '--raw',
1641
+ '-z',
1642
+ '--no-renames',
1643
+ '--end-of-options',
1644
+ rev,
1645
+ '--',
1646
+ ]);
1647
+ // -z raw output alternates ":<srcmode> <dstmode> <srcsha> <dstsha> <status>" and a path.
1648
+ const tokens = output.split('\0');
1649
+ const changes = [];
1650
+ for (let i = 0; i + 1 < tokens.length; i += 2) {
1651
+ const [srcMode, dstMode, srcSha, dstSha, code] = tokens[i].slice(1).split(' ');
1652
+ const path = tokens[i + 1];
1653
+ const letter = code?.charAt(0);
1654
+ if (letter === 'A' || letter === 'D')
1655
+ changes.push({ status: letter, path });
1656
+ else if (letter === 'M' || letter === 'T') {
1657
+ // A mode change (a chmod, a file turned symlink) is a change whatever the bytes.
1658
+ const unhashed = /^0+$/.test(dstSha) && srcMode === dstMode ? srcSha : undefined;
1659
+ changes.push({ status: 'M', path, unhashed });
1660
+ }
1661
+ }
1662
+ const unchanged = await this.matchingBlobs(changes.flatMap((c) => (c.unhashed ? [{ path: c.path, sha: c.unhashed }] : [])));
1663
+ return changes
1664
+ .filter((c) => !unchanged.has(c.path))
1665
+ .map(({ status, path }) => ({ status, path }));
1666
+ }
1667
+ /**
1668
+ * The paths whose working-tree file hashes to `sha`, as git hashes it: a symlink's blob is its
1669
+ * target, a file's goes through `hash-object --stdin-paths`, so its attributes' filters apply.
1670
+ * Callers pass only paths whose mode is unchanged, since diff-index reports the working tree's
1671
+ * own mode. A path that is gone or can't be hashed matches nothing, so it stays reported as
1672
+ * changed. When git refuses a path (exit 128), having printed
1673
+ * the SHAs before it, the next run resumes past that path, for at most {@link MAX_HASH_RUNS}
1674
+ * runs. A run that ends any other way (killed, timed out, a signal) stops the hashing, and the
1675
+ * paths it didn't reach stay changed.
1676
+ */
1677
+ async matchingBlobs(files) {
1678
+ const matching = new Set();
1679
+ const regular = [];
1680
+ for (const file of files) {
1681
+ // --stdin-paths reads one path per line.
1682
+ if (file.path.includes('\n'))
1683
+ continue;
1684
+ const stat = await fs.lstat(path.join(this.repoPath, file.path)).catch(() => undefined);
1685
+ if (!stat)
1686
+ continue;
1687
+ if (stat.isSymbolicLink()) {
1688
+ const target = await fs.readlink(path.join(this.repoPath, file.path)).catch(() => undefined);
1689
+ if (target !== undefined && symlinkBlobSha(target, file.sha) === file.sha) {
1690
+ matching.add(file.path);
1691
+ }
1692
+ }
1693
+ else if (stat.isFile())
1694
+ regular.push(file);
1695
+ }
1696
+ for (let start = 0, runs = 0; start < regular.length && runs < MAX_HASH_RUNS; runs++) {
1697
+ const batch = regular.slice(start);
1698
+ const { shas, outcome } = await this.hashPaths(batch.map((f) => f.path));
1699
+ shas.forEach((sha, i) => {
1700
+ if (batch[i] && sha === batch[i].sha)
1701
+ matching.add(batch[i].path);
1702
+ });
1703
+ if (outcome !== 'refused')
1704
+ break;
1705
+ start += shas.length + 1;
1706
+ }
1707
+ return matching;
1708
+ }
1709
+ /**
1710
+ * `hash-object --stdin-paths` over `paths`: one SHA per path, in order, up to where it ended.
1711
+ * `refused` is git's own exit 128 on a path it can't hash; `stopped` is any other ending.
1712
+ */
1713
+ async hashPaths(paths) {
1714
+ const chunks = [];
1715
+ let proc;
1716
+ try {
1717
+ proc = spawnGit(['hash-object', '--stdin-paths'], {
1718
+ ...this.processOptions(),
1719
+ stdin: `${paths.join('\n')}\n`,
1720
+ inactivityTimeoutMs: HASH_INACTIVITY_TIMEOUT_MS,
1721
+ });
1722
+ }
1723
+ catch {
1724
+ return { shas: [], outcome: 'stopped' };
1725
+ }
1726
+ const read = (async () => {
1727
+ for await (const chunk of proc.stdout)
1728
+ chunks.push(chunk);
1729
+ })().catch(() => { });
1730
+ const outcome = await proc.exited.then(() => 'complete', (err) => err instanceof GitProcessError && err.exitCode === 128
1731
+ ? 'refused'
1732
+ : 'stopped');
1733
+ await read;
1734
+ return { shas: Buffer.concat(chunks).toString('utf8').split('\n').filter(Boolean), outcome };
1735
+ }
1736
+ /** What a git run outside simple-git needs to run as `this.git` does. */
1737
+ processOptions() {
1738
+ return {
1739
+ cwd: this.repoPath,
1740
+ env: gitChildEnv({ GIT_CEILING_DIRECTORIES: path.dirname(this.repoPath) }),
1741
+ config: NO_AUTO_GC_CONFIG,
1742
+ };
1743
+ }
1744
+ /** Repo-relative paths of the working tree's untracked, non-ignored files. */
1745
+ async listUntrackedFiles() {
1746
+ // -z: NUL-separated and unquoted, so no path needs unescaping.
1747
+ const output = await this.git.raw(['ls-files', '--others', '--exclude-standard', '-z']);
1748
+ return output.split('\0').filter((p) => p.length > 0);
1749
+ }
1750
+ /**
1751
+ * The text of repo-relative `filePath` at commit `rev`, or `undefined` when `rev` has no such
1752
+ * path. Any other failure, such as an unknown `rev`, throws.
1753
+ */
1754
+ async showFile(rev, filePath) {
1755
+ try {
1756
+ return await this.git.raw(['show', '--end-of-options', `${rev}:${filePath}`]);
1757
+ }
1758
+ catch (err) {
1759
+ // LC_ALL=C (gitChildEnv), so git's own wording is stable to match.
1760
+ if (/fatal: path '.*' (does not exist in|exists on disk, but not in) '/.test(getErrorMessage(err))) {
1761
+ return undefined;
1762
+ }
1763
+ throw err;
1764
+ }
1765
+ }
1501
1766
  async ensureAuthor(author) {
1502
1767
  const config = (await this.git.listConfig());
1503
1768
  // Verify this is a CanopyCMS-managed workspace before setting author
@@ -1694,7 +1959,11 @@ export class GitManager {
1694
1959
  */
1695
1960
  async remoteBranchTip(branch) {
1696
1961
  const ref = `refs/heads/${branch}`;
1697
- const output = await this.git.raw(['ls-remote', '--heads', this.remote, ref]);
1962
+ // runGit, not simple-git, which resolves a silent non-zero exit as '' (no such branch).
1963
+ const output = await runGit(['ls-remote', '--heads', '--end-of-options', this.remote, ref], {
1964
+ ...this.processOptions(),
1965
+ inactivityTimeoutMs: LS_REMOTE_INACTIVITY_TIMEOUT_MS,
1966
+ });
1698
1967
  for (const line of output.split('\n')) {
1699
1968
  const [sha, name] = line.trim().split('\t');
1700
1969
  if (sha && name === ref)
@@ -6,13 +6,15 @@ import { assertAuthPluginAllowedForMode } from '../auth/plugin.js';
6
6
  import { createCanopyServices } from '../services.js';
7
7
  import { recordConfiguredSparseCone } from '../branch-sparse.js';
8
8
  import { recordServedSchemaRegistry } from '../schema-registry-record.js';
9
- import { loadBranchContext, BranchWorkspaceManager } from '../branch-workspace.js';
9
+ import { BranchWorkspaceManager } from '../branch-workspace.js';
10
+ import { resolveBranchParam } from '../branch-param.js';
10
11
  import { BranchMetadataCorruptError } from '../branch-metadata.js';
11
12
  import { BRANCH_METADATA_CORRUPT_MESSAGE } from '../branch-metadata-error.js';
12
13
  import { resolveCanopyUser } from '../resolve-canopy-user.js';
13
14
  import { authResultToCanopyUser } from '../user.js';
14
15
  import { isAdmin } from '../authorization/index.js';
15
16
  import { isSettingsBranchName } from '../paths/branch-name.js';
17
+ import { exactBranchName } from '../paths/validation.js';
16
18
  import { clientOperatingStrategy, operatingStrategy } from '../operating-mode/index.js';
17
19
  import { getErrorMessage, redactCredentials, sanitizeErrorMessage } from '../utils/error.js';
18
20
  // canopyLogError, not console.error: this is shared code and not guaranteed to
@@ -36,10 +38,7 @@ const buildContext = async (options) => {
36
38
  const settingsBranch = operatingStrategy(operatingMode).getSettingsBranchName(services.config);
37
39
  const getBranchContext = options.getBranchContext ??
38
40
  (async (branch, opts) => {
39
- const existing = await loadBranchContext({
40
- branchName: branch,
41
- mode: operatingMode,
42
- });
41
+ const existing = await resolveBranchParam(branch, services.config);
43
42
  if (existing) {
44
43
  if (opts?.loadSchema) {
45
44
  const contentRootName = services.config.contentRoot || 'content';
@@ -52,12 +51,14 @@ const buildContext = async (options) => {
52
51
  // refreshActiveBranch() update takes effect immediately.
53
52
  const baseBranch = baseBranchOf(services.config);
54
53
  const activeBranch = services.config.defaultActiveBranch ?? baseBranch;
54
+ // The exact form names its full name's branch, and the editor sends a name holding a `/` so.
55
+ const named = exactBranchName(branch) ?? branch;
55
56
  const shouldAutoCreate = clientOperatingStrategy(operatingMode).supportsBranching() &&
56
- (branch === baseBranch || branch === activeBranch);
57
+ (named === baseBranch || named === activeBranch);
57
58
  if (shouldAutoCreate) {
58
59
  const manager = new BranchWorkspaceManager(services.config);
59
60
  const context = await manager.openOrCreateBranch({
60
- branchName: branch,
61
+ branchName: named,
61
62
  mode: operatingMode,
62
63
  createdBy: SYSTEM_USER_ID,
63
64
  });
@@ -77,8 +78,9 @@ const buildContext = async (options) => {
77
78
  // content branch would let any request clone a content-history copy that a
78
79
  // submit pushes under the settings name, which settings provisioning then refuses,
79
80
  // leaving settings unavailable.
80
- // Not found, like any branch the caller cannot resolve, even a workspace left on disk.
81
- getBranchContext: (branch, opts) => isSettingsBranchName(branch, settingsBranch)
81
+ // Not found, like any branch the caller cannot resolve, even a workspace left on disk. The
82
+ // exact form (`exactBranchSegment`) names the same branch, so it is refused by that name.
83
+ getBranchContext: (branch, opts) => isSettingsBranchName(exactBranchName(branch) ?? branch, settingsBranch)
82
84
  ? Promise.resolve(null)
83
85
  : timeRequestPhase('branchContext', () => getBranchContext(branch, opts)),
84
86
  authPlugin: options.authPlugin,
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Shared branch-namespace test fixture.
3
+ *
4
+ * The rules deciding whether a branch-namespace template is usable exist TWICE by necessity: at
5
+ * runtime (`branch-namespace.ts`'s `resolveBranchNamespaceTemplate`) and at synth time
6
+ * (`canopycms-cdk`'s `constructs/cms-service.ts`, which does not import core). A rule tightened
7
+ * at runtime but not at synth synths clean and then crash-loops the gateway at boot, so
8
+ * canopycms-cdk's suite requires the construct's verdict to match core's for every template here.
9
+ * Add a case when you change either copy of the rule.
10
+ *
11
+ * Dependency-free (plain arrays, no vitest import) so the CDK suite can import it across the
12
+ * package boundary. Every template is resolved with the deployment name `prod`.
13
+ */
14
+ /** Templates both copies must ACCEPT, each with the namespace it resolves to for `prod`. */
15
+ export declare const VALID_BRANCH_NAMESPACE_TEMPLATES: readonly [readonly ["canopycms/{deployment}/", "canopycms/prod/"], readonly ["cms/", "cms/"], readonly ["{deployment}/", "prod/"], readonly ["edits/{deployment}/drafts/", "edits/prod/drafts/"], readonly ["{deployment}-{deployment}/", "prod-prod/"], readonly ["team.a/{deployment}/", "team.a/prod/"], readonly ["Heads2/", "Heads2/"], readonly ["x.locked/", "x.locked/"]];
16
+ /** Templates both copies must REJECT. */
17
+ export declare const INVALID_BRANCH_NAMESPACE_TEMPLATES: readonly [readonly ["a namespace that sanitizes to the start of the settings prefix", "canopycms/"], readonly ["one that sanitizes to it with a dash", "canopycms-/"], readonly ["one that sanitizes further into it", "canopycms/settings/"], readonly ["no trailing slash", "canopycms/{deployment}"], readonly ["the empty string", ""], readonly ["an unknown placeholder", "canopycms/{env}/"], readonly ["an unclosed placeholder", "canopycms/{deployment/"], readonly ["a stray close brace", "canopycms/}/"], readonly ["a space", "canopycms edits/"], readonly ["a percent sign", "canopycms%2f/"], readonly ["a double quote", "canopycms\"/"], readonly ["dot-dot", "canopycms/../"], readonly ["an empty path component", "canopycms//{deployment}/"], readonly ["a leading slash", "/canopycms/"], readonly ["a leading dash", "-canopycms/"], readonly ["a leading dot", ".canopycms/"], readonly ["a dot-led component", "canopycms/.hidden/"], readonly ["a .lock component", "canopycms.lock/{deployment}/"], readonly ["a reflog selector", "canopycms@{1}/"], readonly ["a tilde", "canopycms~1/"], readonly ["a caret", "canopycms^/"], readonly ["a colon", "canopycms:/"], readonly ["a question mark", "canopycms?/"], readonly ["an asterisk", "canopycms*/"], readonly ["an open bracket", "canopycms[/"], readonly ["a backslash", "canopycms\\/"], readonly ["non-ASCII", "café/"], readonly ["a newline", "canopycms\n/"], readonly ["a leading refs/", "refs/heads/"], readonly ["a leading heads/", "heads/{deployment}/"], readonly ["a leading tags/", "tags/"], readonly ["a leading remotes/ in another case", "Remotes/"], readonly ["a name over the length limit", `${string}/`], readonly ["the settings branch prefix", "canopycms-settings-/"], readonly ["the settings branch prefix, after a slash", "canopycms/settings-/"], readonly ["the settings branch prefix, after sanitizing", "canopycms-settings!/"], readonly ["the settings branch prefix, through a placeholder", "canopycms-settings-{deployment}/"]];
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Shared branch-namespace test fixture.
3
+ *
4
+ * The rules deciding whether a branch-namespace template is usable exist TWICE by necessity: at
5
+ * runtime (`branch-namespace.ts`'s `resolveBranchNamespaceTemplate`) and at synth time
6
+ * (`canopycms-cdk`'s `constructs/cms-service.ts`, which does not import core). A rule tightened
7
+ * at runtime but not at synth synths clean and then crash-loops the gateway at boot, so
8
+ * canopycms-cdk's suite requires the construct's verdict to match core's for every template here.
9
+ * Add a case when you change either copy of the rule.
10
+ *
11
+ * Dependency-free (plain arrays, no vitest import) so the CDK suite can import it across the
12
+ * package boundary. Every template is resolved with the deployment name `prod`.
13
+ */
14
+ /** Templates both copies must ACCEPT, each with the namespace it resolves to for `prod`. */
15
+ export const VALID_BRANCH_NAMESPACE_TEMPLATES = [
16
+ ['canopycms/{deployment}/', 'canopycms/prod/'],
17
+ ['cms/', 'cms/'],
18
+ ['{deployment}/', 'prod/'],
19
+ ['edits/{deployment}/drafts/', 'edits/prod/drafts/'],
20
+ ['{deployment}-{deployment}/', 'prod-prod/'],
21
+ ['team.a/{deployment}/', 'team.a/prod/'],
22
+ ['Heads2/', 'Heads2/'],
23
+ ['x.locked/', 'x.locked/'],
24
+ ];
25
+ /** Templates both copies must REJECT. */
26
+ export const INVALID_BRANCH_NAMESPACE_TEMPLATES = [
27
+ // Their branches' directories start `canopycms-`, so `.../settings-x` would be a settings name.
28
+ ['a namespace that sanitizes to the start of the settings prefix', 'canopycms/'],
29
+ ['one that sanitizes to it with a dash', 'canopycms-/'],
30
+ ['one that sanitizes further into it', 'canopycms/settings/'],
31
+ ['no trailing slash', 'canopycms/{deployment}'],
32
+ ['the empty string', ''],
33
+ ['an unknown placeholder', 'canopycms/{env}/'],
34
+ ['an unclosed placeholder', 'canopycms/{deployment/'],
35
+ ['a stray close brace', 'canopycms/}/'],
36
+ ['a space', 'canopycms edits/'],
37
+ ['a percent sign', 'canopycms%2f/'],
38
+ ['a double quote', 'canopycms"/'],
39
+ ['dot-dot', 'canopycms/../'],
40
+ ['an empty path component', 'canopycms//{deployment}/'],
41
+ ['a leading slash', '/canopycms/'],
42
+ ['a leading dash', '-canopycms/'],
43
+ ['a leading dot', '.canopycms/'],
44
+ ['a dot-led component', 'canopycms/.hidden/'],
45
+ ['a .lock component', 'canopycms.lock/{deployment}/'],
46
+ ['a reflog selector', 'canopycms@{1}/'],
47
+ ['a tilde', 'canopycms~1/'],
48
+ ['a caret', 'canopycms^/'],
49
+ ['a colon', 'canopycms:/'],
50
+ ['a question mark', 'canopycms?/'],
51
+ ['an asterisk', 'canopycms*/'],
52
+ ['an open bracket', 'canopycms[/'],
53
+ ['a backslash', 'canopycms\\/'],
54
+ ['non-ASCII', 'café/'],
55
+ ['a newline', 'canopycms\n/'],
56
+ ['a leading refs/', 'refs/heads/'],
57
+ ['a leading heads/', 'heads/{deployment}/'],
58
+ ['a leading tags/', 'tags/'],
59
+ ['a leading remotes/ in another case', 'Remotes/'],
60
+ ['a name over the length limit', `${'a'.repeat(250)}/`],
61
+ ['the settings branch prefix', 'canopycms-settings-/'],
62
+ ['the settings branch prefix, after a slash', 'canopycms/settings-/'],
63
+ ['the settings branch prefix, after sanitizing', 'canopycms-settings!/'],
64
+ ['the settings branch prefix, through a placeholder', 'canopycms-settings-{deployment}/'],
65
+ ];
@@ -0,0 +1,34 @@
1
+ /** The template used when none is configured. */
2
+ export declare const DEFAULT_BRANCH_NAMESPACE_TEMPLATE = "canopycms/{deployment}/";
3
+ /**
4
+ * The environment variable that carries the template, or the namespace it resolved to.
5
+ * @internal Exported for tests.
6
+ */
7
+ export declare const BRANCH_NAMESPACE_ENV = "CANOPYCMS_BRANCH_NAMESPACE";
8
+ /**
9
+ * Why `namespace` cannot be a branch namespace, or null. It ends with `/`: a deployment name holds
10
+ * no `/`, so `canopycms/prod/` never starts `canopycms/prod-eu/`, as `canopycms-prod-` would start
11
+ * `canopycms-prod-eu-`. A branch in it must be a name git and the gateway take (checked with a
12
+ * branch appended), and none may be a settings branch, by name or by workspace directory.
13
+ */
14
+ export declare function branchNamespaceProblem(namespace: string): string | null;
15
+ /**
16
+ * The branch names git cannot hold beside any branch in `namespace`: each path it runs through
17
+ * (`canopycms` and `canopycms/prod` for `canopycms/prod/`), since no ref lives under a branch.
18
+ */
19
+ export declare function namespaceAncestors(namespace: string): string[];
20
+ /**
21
+ * The namespace `template` names for `deploymentName`, or why it cannot be one. Throws, naming
22
+ * `source`, for a template with a placeholder other than `{deployment}` or a namespace
23
+ * {@link branchNamespaceProblem} refuses.
24
+ */
25
+ export declare function resolveBranchNamespaceTemplate(template: string, deploymentName: string, source: string): string;
26
+ /**
27
+ * The branch namespace a process resolves: from `env`'s `CANOPYCMS_BRANCH_NAMESPACE` (trimmed,
28
+ * empty ignored), then `config.branchNamespace`, then the default, with `{deployment}` filled by
29
+ * `resolveDeploymentName`. Throws for an invalid template or deployment name.
30
+ */
31
+ export declare function resolveBranchNamespace(config: {
32
+ branchNamespace?: string;
33
+ deploymentName?: string;
34
+ }, modeDefault: string, env?: Readonly<Record<string, string | undefined>>): string;