canopycms 0.7.1 → 0.8.1

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 (152) hide show
  1. package/CHANGELOG.md +197 -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-schema-cache.d.ts +22 -2
  39. package/dist/branch-schema-cache.js +88 -12
  40. package/dist/branch-workspace.js +22 -15
  41. package/dist/cli/cli.js +1122 -181
  42. package/dist/cli/generate-ai-content.js +791 -109
  43. package/dist/cli/init.js +126 -0
  44. package/dist/comment-store.d.ts +3 -0
  45. package/dist/comment-store.js +4 -1
  46. package/dist/config/schemas/config.d.ts +3 -0
  47. package/dist/config/schemas/config.js +2 -0
  48. package/dist/config/types.d.ts +11 -0
  49. package/dist/content-id-index.d.ts +5 -0
  50. package/dist/content-id-index.js +1 -1
  51. package/dist/content-listing.d.ts +53 -0
  52. package/dist/content-listing.js +123 -27
  53. package/dist/content-reader.d.ts +24 -10
  54. package/dist/content-reader.js +66 -22
  55. package/dist/context.js +51 -48
  56. package/dist/editor/BranchManager.d.ts +7 -3
  57. package/dist/editor/BranchManager.js +51 -6
  58. package/dist/editor/Editor.js +166 -34
  59. package/dist/editor/EntryNavigator.d.ts +34 -10
  60. package/dist/editor/EntryNavigator.js +185 -29
  61. package/dist/editor/admin/SystemHealthPanel.js +36 -1
  62. package/dist/editor/admin/useSystemHealth.js +1 -0
  63. package/dist/editor/branch-names.d.ts +43 -0
  64. package/dist/editor/branch-names.js +75 -0
  65. package/dist/editor/components/BranchPill.d.ts +2 -2
  66. package/dist/editor/components/BranchPill.js +2 -2
  67. package/dist/editor/components/EditorHeader.d.ts +3 -1
  68. package/dist/editor/components/EditorHeader.js +2 -2
  69. package/dist/editor/components/EntryChangesDrawer.d.ts +51 -0
  70. package/dist/editor/components/EntryChangesDrawer.js +363 -0
  71. package/dist/editor/components/EntryChangesSkeleton.d.ts +9 -0
  72. package/dist/editor/components/EntryChangesSkeleton.js +7 -0
  73. package/dist/editor/components/EntryHeader.d.ts +9 -1
  74. package/dist/editor/components/EntryHeader.js +25 -3
  75. package/dist/editor/components/LazyEntryChangesDrawer.d.ts +16 -0
  76. package/dist/editor/components/LazyEntryChangesDrawer.js +45 -0
  77. package/dist/editor/components/ReviewDialogs.js +3 -3
  78. package/dist/editor/context/ApiClientContext.d.ts +9 -0
  79. package/dist/editor/context/ApiClientContext.js +26 -2
  80. package/dist/editor/context/index.d.ts +1 -1
  81. package/dist/editor/context/index.js +1 -1
  82. package/dist/editor/copy.d.ts +1 -0
  83. package/dist/editor/copy.js +1 -0
  84. package/dist/editor/editor-utils.d.ts +2 -0
  85. package/dist/editor/editor-utils.js +15 -0
  86. package/dist/editor/entry-diff.d.ts +143 -0
  87. package/dist/editor/entry-diff.js +593 -0
  88. package/dist/editor/hooks/create-branch-request.d.ts +11 -1
  89. package/dist/editor/hooks/create-branch-request.js +16 -6
  90. package/dist/editor/hooks/useBranchActions.d.ts +19 -2
  91. package/dist/editor/hooks/useBranchActions.js +29 -20
  92. package/dist/editor/hooks/useBranchChangesData.d.ts +57 -0
  93. package/dist/editor/hooks/useBranchChangesData.js +134 -0
  94. package/dist/editor/hooks/useBranchManager.d.ts +16 -0
  95. package/dist/editor/hooks/useBranchManager.js +142 -54
  96. package/dist/editor/hooks/useDraftManager.js +6 -2
  97. package/dist/editor/hooks/useEntryManager.d.ts +18 -2
  98. package/dist/editor/hooks/useEntryManager.js +36 -10
  99. package/dist/editor/utils/editor-errors.d.ts +5 -2
  100. package/dist/git-manager.d.ts +71 -0
  101. package/dist/git-manager.js +270 -1
  102. package/dist/http/handler.js +11 -9
  103. package/dist/operating-mode/branch-namespace-fixtures.d.ts +17 -0
  104. package/dist/operating-mode/branch-namespace-fixtures.js +65 -0
  105. package/dist/operating-mode/branch-namespace.d.ts +34 -0
  106. package/dist/operating-mode/branch-namespace.js +121 -0
  107. package/dist/operating-mode/client-unsafe-strategy.js +7 -0
  108. package/dist/operating-mode/types.d.ts +8 -0
  109. package/dist/paths/branch-name.d.ts +11 -19
  110. package/dist/paths/branch-name.js +17 -27
  111. package/dist/paths/index.d.ts +2 -2
  112. package/dist/paths/index.js +2 -2
  113. package/dist/paths/resolve.d.ts +0 -1
  114. package/dist/paths/resolve.js +0 -1
  115. package/dist/paths/validation.d.ts +40 -1
  116. package/dist/paths/validation.js +83 -4
  117. package/dist/pr-comments.d.ts +17 -9
  118. package/dist/pr-comments.js +53 -15
  119. package/dist/server.d.ts +6 -1
  120. package/dist/server.js +6 -1
  121. package/dist/services.js +2 -1
  122. package/dist/submission-attribution.d.ts +22 -0
  123. package/dist/submission-attribution.js +62 -6
  124. package/dist/task-queue/worker-status.d.ts +6 -1
  125. package/dist/task-queue/worker-status.js +23 -2
  126. package/dist/types.d.ts +59 -0
  127. package/dist/version.d.ts +1 -1
  128. package/dist/version.js +1 -1
  129. package/dist/worker/branch-policy.d.ts +111 -16
  130. package/dist/worker/branch-policy.js +222 -17
  131. package/dist/worker/cms-worker.d.ts +24 -2
  132. package/dist/worker/cms-worker.js +35 -24
  133. package/dist/worker/git-sync.d.ts +3 -3
  134. package/dist/worker/git-sync.js +42 -1
  135. package/dist/worker/github-gateway-http.js +2 -2
  136. package/dist/worker/github-gateway-wire.d.ts +42 -27
  137. package/dist/worker/github-gateway-wire.js +14 -3
  138. package/dist/worker/github-gateway.d.ts +17 -6
  139. package/dist/worker/github-gateway.js +62 -6
  140. package/dist/worker/github-mirror.d.ts +7 -4
  141. package/dist/worker/github-mirror.js +27 -16
  142. package/dist/worker/history-rewrite.d.ts +23 -10
  143. package/dist/worker/history-rewrite.js +43 -20
  144. package/dist/worker/legacy-branch-fixtures.d.ts +21 -0
  145. package/dist/worker/legacy-branch-fixtures.js +71 -0
  146. package/dist/worker/rebase.d.ts +2 -1
  147. package/dist/worker/rebase.js +93 -36
  148. package/dist/worker/task-runner.d.ts +4 -1
  149. package/dist/worker/task-runner.js +77 -48
  150. package/dist/worker/unpublishable-branches.d.ts +34 -0
  151. package/dist/worker/unpublishable-branches.js +182 -0
  152. package/package.json +2 -1
@@ -19,13 +19,14 @@ import { BranchMetadataFileManager, getBranchMetadataFileManager } from '../bran
19
19
  import { BRANCH_META_DIR, BRANCH_META_FILE } from '../branch-metadata-file.js';
20
20
  import { ORPHAN_YOUTH_THRESHOLD_MS, scanBranchHealth, } from '../branch-health.js';
21
21
  import { formatDirStamp } from '../branch-provisioning.js';
22
+ import { gitChildEnv } from '../git-manager.js';
22
23
  import { ContentIdIndex } from '../content-id-index.js';
23
24
  import { invalidateContentIndexesDurable } from '../content-index-generation.js';
24
25
  import { getDefaultBranchBase, sanitizeBranchName } from '../paths/index.js';
25
26
  import { withOccFileLock } from '../utils/occ-json-write.js';
26
27
  import { branchProvisioningLockName, tryAcquireProvisioningLock } from '../utils/provisioning-lock.js';
27
28
  import { withContentWriteLock, ContentWriteLockBusyError, DEFAULT_CONTENT_WRITE_LOCK_WAIT_MS, } from '../utils/content-write-lock.js';
28
- import { getErrorMessage, isNodeError, isNotFoundError } from '../utils/error.js';
29
+ import { getErrorMessage, isNodeError, isNotFoundError, sanitizeErrorMessage } from '../utils/error.js';
29
30
  import { defineEndpoint } from './route-builder.js';
30
31
  import { baseBranchOf } from '../utils/base-branch.js';
31
32
  /** [H1] A fresh (< 5 min old) init lock blocks purge -- provisioning may be running. */
@@ -297,6 +298,10 @@ const repairBranchDirHandler = async (_gc, ctx, req, params) => {
297
298
  ? { ok: false, status: 409, error: 'Metadata is healthy' }
298
299
  : { ok: false, status: 409, error: 'No metadata file -- use purge for orphans' };
299
300
  }
301
+ const checkedOut = await checkedOutBranch(dirPath, params.dirName);
302
+ if (!checkedOut.ok)
303
+ return { ok: false, status: 409, error: checkedOut.error };
304
+ const branchName = checkedOut.branch;
300
305
  // Zero-retry provisioning lock, mirroring purge's: fails fast (409) on
301
306
  // genuine live contention instead of hanging the request.
302
307
  // Held until the `finally` below, AFTER save() completes.
@@ -339,9 +344,6 @@ const repairBranchDirHandler = async (_gc, ctx, req, params) => {
339
344
  error: `Could not lock branch metadata: ${getErrorMessage(err)}`,
340
345
  };
341
346
  }
342
- // Prefer the clone's actual checked-out branch over the
343
- // (possibly sanitized) directory name -- see resolveRepairedBranchName.
344
- const branchName = await resolveRepairedBranchName(dirPath, params.dirName);
345
347
  // Lock released above (we're outside the withOccFileLock callback now) --
346
348
  // save() takes its own hold on the same lock internally. Its defaults
347
349
  // path fabricates the rest of BranchMetadata and invalidates the
@@ -375,30 +377,55 @@ const repairBranchDirHandler = async (_gc, ctx, req, params) => {
375
377
  }
376
378
  }
377
379
  };
380
+ const LOCAL_BRANCH_PREFIX = 'refs/heads/';
378
381
  /**
379
- * Best-effort read of the branch actually checked out in the
380
- * clone at `dirPath`, falling back to `dirName` on any failure (no `.git`,
381
- * detached HEAD, corrupt repo, etc.). Workspace directory names are
382
- * sanitized (slashes stripped, see paths/branch.ts's `sanitizeBranchName`),
383
- * so a branch named `feature/foo` lives in a directory named `feature-foo`
384
- * -- writing `dirName` as `branch.name` would make later push/PR tasks
385
- * target the wrong ref.
382
+ * The branch checked out in the clone at `dirPath`, which repair records as `branch.name`, or why
383
+ * there is none. Nothing else names the branch: the directory is its key, which a ref's `/`s do
384
+ * not survive, and a guessed name would send later pushes and PRs to the wrong ref. The branch
385
+ * counts only when its key is `dirName`, the directory's own name; a clone on any other branch is
386
+ * not this directory's workspace. Git reads only this clone's `.git`, never a repository above it.
386
387
  */
387
- async function resolveRepairedBranchName(dirPath, dirName) {
388
+ async function checkedOutBranch(dirPath, dirName) {
389
+ const cannot = 'Cannot repair this branch: ';
390
+ const then = ' Check out its branch in the clone, then repair again, or purge the directory.';
391
+ const gitDir = path.join(dirPath, '.git');
388
392
  const hasGitDir = await fs
389
- .stat(path.join(dirPath, '.git'))
393
+ .stat(gitDir)
390
394
  .then(() => true)
391
395
  .catch(() => false);
392
- if (!hasGitDir)
393
- return dirName;
396
+ if (!hasGitDir) {
397
+ return {
398
+ ok: false,
399
+ error: `${cannot}the directory holds no git clone, so the branch it belongs to is unknown.${then}`,
400
+ };
401
+ }
402
+ let ref;
394
403
  try {
395
- const current = await simpleGit({ baseDir: dirPath }).revparse(['--abbrev-ref', 'HEAD']);
396
- const trimmed = current.trim();
397
- return trimmed || dirName;
404
+ // `-q` makes a detached HEAD a quiet failure, which simple-git resolves as empty output.
405
+ ref = (await simpleGit({ baseDir: dirPath })
406
+ .env(gitChildEnv({ GIT_CEILING_DIRECTORIES: path.dirname(dirPath) }))
407
+ .raw(['--git-dir', gitDir, '--work-tree', dirPath, 'symbolic-ref', '-q', 'HEAD'])).trim();
398
408
  }
399
- catch {
400
- return dirName;
409
+ catch (err) {
410
+ return {
411
+ ok: false,
412
+ error: `${cannot}git could not read the clone's checked-out branch (${sanitizeErrorMessage(getErrorMessage(err))}).${then}`,
413
+ };
414
+ }
415
+ const branch = ref.startsWith(LOCAL_BRANCH_PREFIX) ? ref.slice(LOCAL_BRANCH_PREFIX.length) : '';
416
+ if (!branch) {
417
+ return {
418
+ ok: false,
419
+ error: `${cannot}its clone has no branch checked out (a detached HEAD), so the branch it belongs to is unknown.${then}`,
420
+ };
421
+ }
422
+ if (sanitizeBranchName(branch) !== dirName) {
423
+ return {
424
+ ok: false,
425
+ error: `${cannot}its clone has "${branch}" checked out, which is not the branch this directory holds.${then}`,
426
+ };
401
427
  }
428
+ return { ok: true, branch };
402
429
  }
403
430
  /** Classify branch.json's current state for repair's precondition checks. */
404
431
  async function checkStillCorrupt(dirPath) {
@@ -9,7 +9,7 @@ import { z } from 'zod';
9
9
  import type { ApiResponse } from './types.js';
10
10
  import type { Task, QueueStats, CorruptTaskFile } from '../task-queue/cms-task-queue.js';
11
11
  import type { BuildIdentity, WorkerStatusReport } from '../types.js';
12
- import type { OperatingMode } from '../operating-mode/index.js';
12
+ import { type OperatingMode } from '../operating-mode/index.js';
13
13
  import type { SchemaIssue } from '../schema/types.js';
14
14
  export type { BranchHealthResponse, PurgeBranchDirResponse, RepairBranchDirResponse, RepairContentDuplicatesResponse, } from './admin-branch-health.js';
15
15
  type WorkerLivenessState = 'alive' | 'stale' | 'absent';
@@ -40,6 +40,18 @@ export interface AdminStatusData {
40
40
  settingsWorkspaceError?: string;
41
41
  /** What the base branch's schema leaves out under this process's code; absent when nothing. */
42
42
  schemaIssues?: SchemaIssue[];
43
+ /**
44
+ * The branch namespace this process resolves for the branches it creates, or why it cannot.
45
+ * System health compares it with the GitHub gateway's (`workerStatus.gatewayBranchNamespace`).
46
+ * Optional on the wire, so an editor reading an older API shows nothing rather than failing.
47
+ */
48
+ branchNamespace?: {
49
+ ok: true;
50
+ namespace: string;
51
+ } | {
52
+ ok: false;
53
+ error: string;
54
+ };
43
55
  /** Build of the API process answering this request. */
44
56
  build: BuildIdentity;
45
57
  /** Whether `media` is configured; without it every upload returns 501. */
package/dist/api/admin.js CHANGED
@@ -10,7 +10,8 @@ import path from 'node:path';
10
10
  import { z } from 'zod';
11
11
  import { getQueueStats, listTasks, listCorruptTaskFiles, requeueFailedTask, } from '../task-queue/cms-task-queue.js';
12
12
  import { getTaskQueueDir } from '../task-queue/task-queue-config.js';
13
- import { WORKER_STATUS_FILE } from '../task-queue/worker-status.js';
13
+ import { readWorkerStatus } from '../task-queue/worker-status.js';
14
+ import { operatingStrategy } from '../operating-mode/index.js';
14
15
  import { defineEndpoint } from './route-builder.js';
15
16
  import { getErrorMessage, isNotFoundError, redactCredentials } from '../utils/error.js';
16
17
  import { getBuildIdentity } from '../build-identity.js';
@@ -52,29 +53,6 @@ async function classifyWorkerLiveness(taskDir) {
52
53
  lockAgeMs,
53
54
  };
54
55
  }
55
- /** Read + parse worker-status.json (written by the worker daemon, PR-W1). */
56
- async function readWorkerStatus(taskDir) {
57
- const statusPath = path.join(taskDir, WORKER_STATUS_FILE);
58
- let content;
59
- try {
60
- content = await fs.readFile(statusPath, 'utf-8');
61
- }
62
- catch (err) {
63
- if (isNotFoundError(err))
64
- return { workerStatus: null };
65
- return { workerStatus: null, statusReadError: getErrorMessage(err) };
66
- }
67
- try {
68
- // Trust boundary: this file is written only by the CMS worker daemon
69
- // (PR-W1), never derived from request input — cast rather than
70
- // runtime-validate every field.
71
- const parsed = JSON.parse(content);
72
- return { workerStatus: parsed };
73
- }
74
- catch (err) {
75
- return { workerStatus: null, statusReadError: getErrorMessage(err) };
76
- }
77
- }
78
56
  async function readSettingsWorkspaceError(ctx) {
79
57
  try {
80
58
  await ctx.services.getSettingsBranchRoot();
@@ -190,6 +168,7 @@ const getAdminStatusHandler = async (_gc, ctx, _req) => {
190
168
  ...(statusReadError ? { statusReadError } : {}),
191
169
  ...(settingsWorkspaceError ? { settingsWorkspaceError } : {}),
192
170
  ...(schemaIssues.length > 0 ? { schemaIssues } : {}),
171
+ branchNamespace: resolveOwnBranchNamespace(ctx),
193
172
  build: getBuildIdentity(),
194
173
  assetStore: { configured: !!ctx.assetStore },
195
174
  imageProcessing,
@@ -200,6 +179,16 @@ const getAdminStatusHandler = async (_gc, ctx, _req) => {
200
179
  return { ok: false, status: 500, error: getErrorMessage(err) };
201
180
  }
202
181
  };
182
+ /** This process's branch namespace, as an answer rather than a throw. */
183
+ function resolveOwnBranchNamespace(ctx) {
184
+ try {
185
+ const { config } = ctx.services;
186
+ return { ok: true, namespace: operatingStrategy(config.mode).getBranchNamespace(config) };
187
+ }
188
+ catch (err) {
189
+ return { ok: false, error: getErrorMessage(err) };
190
+ }
191
+ }
203
192
  const listAdminTasksHandler = async (_gc, ctx, _req, params) => {
204
193
  const taskDir = getTaskQueueDir(ctx.services.config);
205
194
  const limit = params.limit ?? DEFAULT_ADMIN_TASKS_LIMIT;
@@ -306,6 +295,7 @@ const getAdminStatus = defineEndpoint({
306
295
  build: { canopycmsVersion: '0.0.0' },
307
296
  assetStore: { configured: false },
308
297
  imageProcessing: { available: true },
298
+ branchNamespace: { ok: true, namespace: 'canopycms/prod/' },
309
299
  },
310
300
  guards: ['admin'],
311
301
  handler: getAdminStatusHandler,
@@ -0,0 +1,87 @@
1
+ import type { z } from 'zod';
2
+ import type { ApiContext, ApiResponse } from './types.js';
3
+ /** An entry whose saved state on the branch differs from the base branch. */
4
+ export interface BranchChangeEntry {
5
+ status: 'added' | 'modified' | 'deleted' | 'moved';
6
+ logicalPath: string;
7
+ /** A moved entry's path at the base. */
8
+ previousLogicalPath?: string;
9
+ contentId: string;
10
+ collectionPath: string;
11
+ entryType: string;
12
+ /** A deleted entry's title at the base. */
13
+ title?: string;
14
+ }
15
+ export interface BranchChangesData {
16
+ base: string;
17
+ mergeBaseSha?: string;
18
+ entries: BranchChangeEntry[];
19
+ /** Collections whose settings (`.collection.json`) differ. */
20
+ collections: Array<{
21
+ status: 'added' | 'modified' | 'deleted';
22
+ logicalPath: string;
23
+ }>;
24
+ /** Something changed that this user may not read. Never a count, which would leak its size. */
25
+ hiddenChanges: boolean;
26
+ /** The branch and its base share no commit, so there is no fork point to compare against. */
27
+ baseUnavailable?: boolean;
28
+ /** The remote no longer has the base branch, so nothing can be compared until it is back. */
29
+ baseMissing?: boolean;
30
+ /** Changed files that are neither entries nor collection settings. Admins only. */
31
+ otherFiles?: string[];
32
+ /** `otherFiles` holds only the first {@link MAX_OTHER_FILES}. Admins only. */
33
+ otherFilesTruncated?: boolean;
34
+ }
35
+ export type BranchChangesResponse = ApiResponse<BranchChangesData>;
36
+ type BranchGit = ReturnType<ApiContext['services']['createGitManagerFor']>;
37
+ /** Why a read of a branch against its base can't be trusted: see {@link readWhileSteady}. */
38
+ export type UnsteadyRead = 'syncing' | 'moved';
39
+ export type SteadyRead<T> = {
40
+ steady: true;
41
+ value: T;
42
+ } | {
43
+ steady: false;
44
+ reason: UnsteadyRead;
45
+ };
46
+ /**
47
+ * `read`'s result, taken lock-free, or why it can't be trusted: `syncing` while a rebase is
48
+ * under way before or after it, since a half-rebased tree reports the base's own changes as
49
+ * the branch's; `moved` when HEAD moved during it, as a submit's commit does, leaving a mix of
50
+ * two trees.
51
+ */
52
+ export declare const readWhileSteady: <T>(branchRoot: string, git: BranchGit, read: () => Promise<T>) => Promise<SteadyRead<T>>;
53
+ /** The 409 for a read {@link readWhileSteady} refused, nothing lost. */
54
+ export declare const unsteadyReadResponse: (reason: UnsteadyRead, base: string) => ApiResponse<never>;
55
+ /** The 503 for a git failure, logged with the branch and base it compared. */
56
+ export declare const branchReadFailedResponse: (branch: string, base: string, err: unknown) => ApiResponse<never>;
57
+ /**
58
+ * What this branch changes against its base: its working tree, saved but uncommitted edits
59
+ * included, against the commit it forked from.
60
+ * GET /:branch/changes
61
+ * @internal Exported for tests.
62
+ */
63
+ export declare const listBranchChanges: import("./route-builder.js").RouteDefinition<z.ZodObject<{
64
+ branch: z.ZodType<import("../paths/index.js").BranchName, z.ZodTypeDef, import("../paths/index.js").BranchName>;
65
+ }, "strip", z.ZodTypeAny, {
66
+ branch: string & {
67
+ readonly __brand: "BranchName";
68
+ };
69
+ }, {
70
+ branch: string & {
71
+ readonly __brand: "BranchName";
72
+ };
73
+ }>, undefined, BranchChangesResponse>;
74
+ export declare const BRANCH_CHANGES_ROUTES: {
75
+ readonly list: import("./route-builder.js").RouteDefinition<z.ZodObject<{
76
+ branch: z.ZodType<import("../paths/index.js").BranchName, z.ZodTypeDef, import("../paths/index.js").BranchName>;
77
+ }, "strip", z.ZodTypeAny, {
78
+ branch: string & {
79
+ readonly __brand: "BranchName";
80
+ };
81
+ }, {
82
+ branch: string & {
83
+ readonly __brand: "BranchName";
84
+ };
85
+ }>, undefined, BranchChangesResponse>;
86
+ };
87
+ export {};
@@ -0,0 +1,269 @@
1
+ import { entryRefFromRepoPath, entryRefFromRepoPathShape, parseEntryData, } from '../content-listing.js';
2
+ import { getFormatExtension } from '../utils/format.js';
3
+ import { defineEndpoint } from './route-builder.js';
4
+ import { BaseBranchMissingError } from '../git-manager.js';
5
+ import { branchParamSchema } from './validators.js';
6
+ import { getBranchProtection } from '../authorization/protected-branch.js';
7
+ import { isAdmin } from '../authorization/helpers.js';
8
+ import { baseBranchOf } from '../utils/base-branch.js';
9
+ import { isCanopyInternalPath, isRebaseInProgress } from '../utils/git.js';
10
+ import { resolveEntryTitle } from '../utils/title-field.js';
11
+ import { getErrorMessage, redactCredentials } from '../utils/error.js';
12
+ import { canopyLogWarn } from '../utils/logger.js';
13
+ /** Deleted entries whose titles are read from the base, one `git show` each. */
14
+ const MAX_DELETED_TITLES = 200;
15
+ /** Other files listed to an admin; the rest are counted only as `otherFilesTruncated`. */
16
+ const MAX_OTHER_FILES = 200;
17
+ const SYNCING_ERROR = (base) => `This branch is being synced with ${base}. Try again in a moment.`;
18
+ const CHANGED_MID_READ_ERROR = 'This branch changed while its changes were being read. Try again in a moment.';
19
+ const READ_FAILED_ERROR = "Couldn't read this branch's changes right now. Try again in a moment.";
20
+ /**
21
+ * `read`'s result, taken lock-free, or why it can't be trusted: `syncing` while a rebase is
22
+ * under way before or after it, since a half-rebased tree reports the base's own changes as
23
+ * the branch's; `moved` when HEAD moved during it, as a submit's commit does, leaving a mix of
24
+ * two trees.
25
+ */
26
+ export const readWhileSteady = async (branchRoot, git, read) => {
27
+ if (await isRebaseInProgress(branchRoot))
28
+ return { steady: false, reason: 'syncing' };
29
+ const head = await git.headSha();
30
+ const value = await read();
31
+ if (await isRebaseInProgress(branchRoot))
32
+ return { steady: false, reason: 'syncing' };
33
+ if ((await git.headSha()) !== head)
34
+ return { steady: false, reason: 'moved' };
35
+ return { steady: true, value };
36
+ };
37
+ /** The 409 for a read {@link readWhileSteady} refused, nothing lost. */
38
+ export const unsteadyReadResponse = (reason, base) => ({
39
+ ok: false,
40
+ status: 409,
41
+ error: reason === 'syncing' ? SYNCING_ERROR(base) : CHANGED_MID_READ_ERROR,
42
+ });
43
+ /** The 503 for a git failure, logged with the branch and base it compared. */
44
+ export const branchReadFailedResponse = (branch, base, err) => {
45
+ canopyLogWarn(`CanopyCMS: could not read what ${branch} changes against ${base}:`, redactCredentials(getErrorMessage(err)));
46
+ return { ok: false, status: 503, error: READ_FAILED_ERROR };
47
+ };
48
+ const FORMATS = ['mdx', 'md', 'json', 'yaml'];
49
+ const COLLECTION_STATUS = { A: 'added', M: 'modified', D: 'deleted' };
50
+ /**
51
+ * Every changed file's status against `mergeBase`: tracked changes plus untracked files as
52
+ * added. A path both deleted from the index and present untracked is a modified file.
53
+ */
54
+ const changedFiles = async (git, mergeBase) => {
55
+ const [tracked, untracked] = await Promise.all([
56
+ git.diffNameStatusAgainst(mergeBase),
57
+ git.listUntrackedFiles(),
58
+ ]);
59
+ const files = new Map();
60
+ for (const { status, path } of tracked)
61
+ files.set(path, status);
62
+ for (const path of untracked)
63
+ files.set(path, files.get(path) === 'D' ? 'M' : 'A');
64
+ for (const path of files.keys())
65
+ if (isCanopyInternalPath(path))
66
+ files.delete(path);
67
+ return files;
68
+ };
69
+ const branchChangesHandler = async (gc, ctx, req, _params) => {
70
+ const { branchContext } = gc;
71
+ const { config } = ctx.services;
72
+ const base = branchContext.branch.baseBranch ?? baseBranchOf(config);
73
+ const empty = { base, entries: [], collections: [], hiddenChanges: false };
74
+ if (getBranchProtection(config, branchContext.branch.name, branchContext.branch.baseBranch)
75
+ .isProtected) {
76
+ return { ok: true, status: 200, data: empty };
77
+ }
78
+ const git = ctx.services.createGitManagerFor(branchContext.branchRoot, {
79
+ baseBranch: branchContext.branch.baseBranch,
80
+ });
81
+ let read;
82
+ try {
83
+ read = await readWhileSteady(branchContext.branchRoot, git, async () => {
84
+ const mergeBase = await git.mergeBaseWithBase();
85
+ return mergeBase ? readChanges(ctx, req, branchContext, git, mergeBase) : null;
86
+ });
87
+ }
88
+ catch (err) {
89
+ if (err instanceof BaseBranchMissingError) {
90
+ return { ok: true, status: 200, data: { ...empty, baseMissing: true } };
91
+ }
92
+ return branchReadFailedResponse(branchContext.branch.name, base, err);
93
+ }
94
+ if (!read.steady)
95
+ return unsteadyReadResponse(read.reason, base);
96
+ if (!read.value)
97
+ return { ok: true, status: 200, data: { ...empty, baseUnavailable: true } };
98
+ return { ok: true, status: 200, data: { base, ...read.value } };
99
+ };
100
+ /** The branch's changes against `mergeBase`, filtered to what `req.user` may read. */
101
+ const readChanges = async (ctx, req, branchContext, git, mergeBase) => {
102
+ const files = await changedFiles(git, mergeBase);
103
+ const flatSchema = branchContext.flatSchema;
104
+ const contentRoot = ctx.services.config.contentRoot || 'content';
105
+ const checkAccess = await ctx.services.createContentAccessChecker(branchContext, branchContext.branchRoot, req.user);
106
+ const readable = (logicalPath) => checkAccess(logicalPath, 'read').allowed;
107
+ const admin = isAdmin(req.user.groups);
108
+ const underContentRoot = (path) => path.startsWith(`${contentRoot}/`);
109
+ let hiddenChanges = false;
110
+ const entries = [];
111
+ const collections = [];
112
+ const otherFiles = [];
113
+ const added = new Map();
114
+ const deleted = new Map();
115
+ const modified = [];
116
+ const push = (map, ref) => map.set(ref.contentId, [...(map.get(ref.contentId) ?? []), ref]);
117
+ for (const [path, status] of files) {
118
+ let ref = entryRefFromRepoPath(path, flatSchema, contentRoot);
119
+ // A deleted file the schema no longer places, as under a renamed or deleted collection, is
120
+ // read from its path's shape, so it still reads as deleted or as the old side of a move.
121
+ if (ref.kind === 'other' && status === 'D') {
122
+ ref = entryRefFromRepoPathShape(path, contentRoot) ?? ref;
123
+ }
124
+ if (ref.kind === 'other') {
125
+ otherFiles.push(ref.physicalPath);
126
+ // Under the content root it may be content this user can't see placed; never nothing.
127
+ if (!admin && underContentRoot(path))
128
+ hiddenChanges = true;
129
+ }
130
+ else if (ref.kind === 'collection-meta') {
131
+ // `/*`, as entries.ts probes a collection: a rule for its contents decides too.
132
+ if (readable(ref.collectionPath) && readable(`${ref.collectionPath}/*`)) {
133
+ collections.push({ status: COLLECTION_STATUS[status], logicalPath: ref.collectionPath });
134
+ }
135
+ else
136
+ hiddenChanges = true;
137
+ }
138
+ else if (status === 'A')
139
+ push(added, ref);
140
+ else if (status === 'D')
141
+ push(deleted, ref);
142
+ else
143
+ modified.push(ref);
144
+ }
145
+ const toEntry = (ref, status, previous) => ({
146
+ status,
147
+ logicalPath: ref.logicalPath,
148
+ ...(previous && { previousLogicalPath: previous.logicalPath }),
149
+ contentId: ref.contentId,
150
+ collectionPath: ref.collectionPath,
151
+ entryType: ref.entryType,
152
+ });
153
+ const addedShown = [];
154
+ const deletedShown = [];
155
+ const showAdded = (ref) => {
156
+ if (readable(ref.logicalPath))
157
+ addedShown.push(ref);
158
+ else
159
+ hiddenChanges = true;
160
+ };
161
+ const showDeleted = (ref) => {
162
+ if (readable(ref.logicalPath))
163
+ deletedShown.push(ref);
164
+ else
165
+ hiddenChanges = true;
166
+ };
167
+ for (const ref of modified) {
168
+ if (readable(ref.logicalPath))
169
+ entries.push(toEntry(ref, 'modified'));
170
+ else
171
+ hiddenChanges = true;
172
+ }
173
+ for (const [contentId, adds] of added) {
174
+ const dels = deleted.get(contentId) ?? [];
175
+ deleted.delete(contentId);
176
+ // A content id on more than one file on either side is a duplicate, not one entry moving,
177
+ // so its files are listed as they are.
178
+ if (adds.length !== 1 || dels.length !== 1) {
179
+ adds.forEach(showAdded);
180
+ dels.forEach(showDeleted);
181
+ continue;
182
+ }
183
+ const [ref] = adds;
184
+ const [from] = dels;
185
+ const toReadable = readable(ref.logicalPath);
186
+ const fromReadable = readable(from.logicalPath);
187
+ if (from.logicalPath === ref.logicalPath) {
188
+ // The same entry under another entry type.
189
+ if (toReadable)
190
+ entries.push(toEntry(ref, 'modified'));
191
+ else
192
+ hiddenChanges = true;
193
+ }
194
+ else if (toReadable && fromReadable) {
195
+ entries.push(toEntry(ref, 'moved', from));
196
+ }
197
+ else {
198
+ // A move with a side this user can't read shows only the side they can, so the other
199
+ // side's path never reaches them.
200
+ showAdded(ref);
201
+ showDeleted(from);
202
+ }
203
+ }
204
+ for (const dels of deleted.values())
205
+ dels.forEach(showDeleted);
206
+ for (const ref of addedShown)
207
+ entries.push(toEntry(ref, 'added'));
208
+ const titles = await Promise.all(deletedShown.map((ref, i) => i < MAX_DELETED_TITLES ? deletedTitle(git, mergeBase, ref, flatSchema) : undefined));
209
+ deletedShown.forEach((ref, i) => {
210
+ const title = titles[i];
211
+ entries.push({ ...toEntry(ref, 'deleted'), ...(title !== undefined && { title }) });
212
+ });
213
+ entries.sort((a, b) => a.logicalPath.localeCompare(b.logicalPath));
214
+ collections.sort((a, b) => a.logicalPath.localeCompare(b.logicalPath));
215
+ otherFiles.sort();
216
+ return {
217
+ mergeBaseSha: mergeBase,
218
+ entries,
219
+ collections,
220
+ hiddenChanges,
221
+ ...(admin && {
222
+ otherFiles: otherFiles.slice(0, MAX_OTHER_FILES),
223
+ otherFilesTruncated: otherFiles.length > MAX_OTHER_FILES,
224
+ }),
225
+ };
226
+ };
227
+ /** A deleted entry's title as the listing would have shown it, from its file at `rev`. */
228
+ const deletedTitle = async (git, rev, ref, flatSchema) => {
229
+ const collection = flatSchema.find((item) => item.type === 'collection' && item.logicalPath === ref.collectionPath);
230
+ const entryType = collection?.entries?.find((e) => e.name === ref.entryType);
231
+ // An entry the schema no longer places is parsed by its extension, with no field list.
232
+ const format = entryType?.format ?? FORMATS.find((f) => ref.physicalPath.endsWith(getFormatExtension(f)));
233
+ if (!format)
234
+ return undefined;
235
+ try {
236
+ const raw = await git.showFile(rev, ref.physicalPath);
237
+ if (raw === undefined)
238
+ return undefined;
239
+ return resolveEntryTitle(parseEntryData(raw, format), {
240
+ schema: entryType?.schema,
241
+ entryTypeLabel: entryType?.label,
242
+ slug: ref.logicalPath.slice(ref.logicalPath.lastIndexOf('/') + 1),
243
+ });
244
+ }
245
+ catch {
246
+ return undefined;
247
+ }
248
+ };
249
+ /**
250
+ * What this branch changes against its base: its working tree, saved but uncommitted edits
251
+ * included, against the commit it forked from.
252
+ * GET /:branch/changes
253
+ * @internal Exported for tests.
254
+ */
255
+ export const listBranchChanges = defineEndpoint({
256
+ namespace: 'changes',
257
+ name: 'list',
258
+ method: 'GET',
259
+ path: '/:branch/changes',
260
+ params: branchParamSchema,
261
+ responseType: 'BranchChangesResponse',
262
+ response: {},
263
+ defaultMockData: { base: 'main', entries: [], collections: [], hiddenChanges: false },
264
+ guards: ['branchAccessWithSchema'],
265
+ handler: branchChangesHandler,
266
+ });
267
+ export const BRANCH_CHANGES_ROUTES = {
268
+ list: listBranchChanges,
269
+ };
@@ -0,0 +1,83 @@
1
+ import { z } from 'zod';
2
+ import type { ApiResponse } from './types.js';
3
+ import type { ContentFormat } from '../config/index.js';
4
+ import type { LogicalPath } from '../paths/types.js';
5
+ /** One side of an entry's change: its file as the editor reads it, references unresolved. */
6
+ export interface EntryChangeSide {
7
+ format: ContentFormat;
8
+ entryType: string;
9
+ data: Record<string, unknown>;
10
+ /** An md/mdx entry's markdown content. */
11
+ body?: string;
12
+ /** The field `body` maps to on this side, as `ContentStore.read` names it. */
13
+ bodyFieldName?: string;
14
+ }
15
+ export interface BranchEntryChangeData {
16
+ /** The entry at the commit the branch forked from; null when it is new on the branch. */
17
+ before: EntryChangeSide | null;
18
+ /** The entry as saved on the branch; null when the branch deleted it. */
19
+ after: EntryChangeSide | null;
20
+ mergeBaseSha: string;
21
+ }
22
+ export type BranchEntryChangeResponse = ApiResponse<BranchEntryChangeData>;
23
+ /**
24
+ * One entry's saved state on the branch against the commit the branch forked from, for the
25
+ * editor's "Show changes". A moved entry takes `from`, its path at the base; `id` pins both
26
+ * sides to one content id.
27
+ * GET /:branch/changes/...entryPath
28
+ * @internal Exported for tests.
29
+ */
30
+ export declare const readBranchEntryChange: import("./route-builder.js").RouteDefinition<z.ZodObject<{
31
+ branch: z.ZodType<import("../paths/index.js").BranchName, z.ZodTypeDef, import("../paths/index.js").BranchName>;
32
+ entryPath: z.ZodType<LogicalPath, z.ZodTypeDef, LogicalPath>;
33
+ /** A moved entry's path at the base. */
34
+ from: z.ZodOptional<z.ZodType<LogicalPath, z.ZodTypeDef, LogicalPath>>;
35
+ /** The entry's content id: both sides must carry it, so a slug reused by another entry isn't it. */
36
+ id: z.ZodOptional<z.ZodType<import("../paths/index.js").ContentId, z.ZodTypeDef, import("../paths/index.js").ContentId>>;
37
+ }, "strip", z.ZodTypeAny, {
38
+ branch: string & {
39
+ readonly __brand: "BranchName";
40
+ };
41
+ entryPath: string & {
42
+ readonly __brand: "LogicalPath";
43
+ };
44
+ id?: import("../paths/index.js").ContentId | undefined;
45
+ from?: LogicalPath | undefined;
46
+ }, {
47
+ branch: string & {
48
+ readonly __brand: "BranchName";
49
+ };
50
+ entryPath: string & {
51
+ readonly __brand: "LogicalPath";
52
+ };
53
+ id?: import("../paths/index.js").ContentId | undefined;
54
+ from?: LogicalPath | undefined;
55
+ }>, undefined, BranchEntryChangeResponse>;
56
+ export declare const BRANCH_ENTRY_CHANGE_ROUTES: {
57
+ readonly entry: import("./route-builder.js").RouteDefinition<z.ZodObject<{
58
+ branch: z.ZodType<import("../paths/index.js").BranchName, z.ZodTypeDef, import("../paths/index.js").BranchName>;
59
+ entryPath: z.ZodType<LogicalPath, z.ZodTypeDef, LogicalPath>;
60
+ /** A moved entry's path at the base. */
61
+ from: z.ZodOptional<z.ZodType<LogicalPath, z.ZodTypeDef, LogicalPath>>;
62
+ /** The entry's content id: both sides must carry it, so a slug reused by another entry isn't it. */
63
+ id: z.ZodOptional<z.ZodType<import("../paths/index.js").ContentId, z.ZodTypeDef, import("../paths/index.js").ContentId>>;
64
+ }, "strip", z.ZodTypeAny, {
65
+ branch: string & {
66
+ readonly __brand: "BranchName";
67
+ };
68
+ entryPath: string & {
69
+ readonly __brand: "LogicalPath";
70
+ };
71
+ id?: import("../paths/index.js").ContentId | undefined;
72
+ from?: LogicalPath | undefined;
73
+ }, {
74
+ branch: string & {
75
+ readonly __brand: "BranchName";
76
+ };
77
+ entryPath: string & {
78
+ readonly __brand: "LogicalPath";
79
+ };
80
+ id?: import("../paths/index.js").ContentId | undefined;
81
+ from?: LogicalPath | undefined;
82
+ }>, undefined, BranchEntryChangeResponse>;
83
+ };