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.
- package/CHANGELOG.md +197 -47
- package/dist/api/admin-branch-health.js +47 -20
- package/dist/api/admin.d.ts +13 -1
- package/dist/api/admin.js +14 -24
- package/dist/api/branch-changes.d.ts +87 -0
- package/dist/api/branch-changes.js +269 -0
- package/dist/api/branch-entry-changes.d.ts +83 -0
- package/dist/api/branch-entry-changes.js +262 -0
- package/dist/api/branch-merge.js +2 -2
- package/dist/api/branch-review.js +35 -4
- package/dist/api/branch-status.js +14 -5
- package/dist/api/branch-withdraw.js +7 -0
- package/dist/api/branch.d.ts +13 -1
- package/dist/api/branch.js +171 -97
- package/dist/api/client.d.ts +15 -0
- package/dist/api/client.js +13 -1
- package/dist/api/content.d.ts +18 -18
- package/dist/api/content.js +1 -1
- package/dist/api/github-sync.js +7 -3
- package/dist/api/groups.d.ts +4 -4
- package/dist/api/pr-comment-sync.d.ts +9 -8
- package/dist/api/pr-comment-sync.js +23 -13
- package/dist/api/routes.js +4 -0
- package/dist/api/types.d.ts +3 -1
- package/dist/authorization/groups/schema.d.ts +4 -4
- package/dist/authorization/protected-branch.d.ts +3 -3
- package/dist/authorization/protected-branch.js +3 -3
- package/dist/branch-metadata-file.d.ts +84 -0
- package/dist/branch-metadata-file.js +9 -0
- package/dist/branch-metadata.d.ts +4 -4
- package/dist/branch-metadata.js +4 -5
- package/dist/branch-param.d.ts +24 -0
- package/dist/branch-param.js +87 -0
- package/dist/branch-provisioning.d.ts +6 -3
- package/dist/branch-provisioning.js +12 -10
- package/dist/branch-registry.d.ts +6 -0
- package/dist/branch-registry.js +12 -3
- package/dist/branch-schema-cache.d.ts +22 -2
- package/dist/branch-schema-cache.js +88 -12
- package/dist/branch-workspace.js +22 -15
- package/dist/cli/cli.js +1122 -181
- package/dist/cli/generate-ai-content.js +791 -109
- package/dist/cli/init.js +126 -0
- package/dist/comment-store.d.ts +3 -0
- package/dist/comment-store.js +4 -1
- package/dist/config/schemas/config.d.ts +3 -0
- package/dist/config/schemas/config.js +2 -0
- package/dist/config/types.d.ts +11 -0
- package/dist/content-id-index.d.ts +5 -0
- package/dist/content-id-index.js +1 -1
- package/dist/content-listing.d.ts +53 -0
- package/dist/content-listing.js +123 -27
- package/dist/content-reader.d.ts +24 -10
- package/dist/content-reader.js +66 -22
- package/dist/context.js +51 -48
- package/dist/editor/BranchManager.d.ts +7 -3
- package/dist/editor/BranchManager.js +51 -6
- package/dist/editor/Editor.js +166 -34
- package/dist/editor/EntryNavigator.d.ts +34 -10
- package/dist/editor/EntryNavigator.js +185 -29
- package/dist/editor/admin/SystemHealthPanel.js +36 -1
- package/dist/editor/admin/useSystemHealth.js +1 -0
- package/dist/editor/branch-names.d.ts +43 -0
- package/dist/editor/branch-names.js +75 -0
- package/dist/editor/components/BranchPill.d.ts +2 -2
- package/dist/editor/components/BranchPill.js +2 -2
- package/dist/editor/components/EditorHeader.d.ts +3 -1
- package/dist/editor/components/EditorHeader.js +2 -2
- package/dist/editor/components/EntryChangesDrawer.d.ts +51 -0
- package/dist/editor/components/EntryChangesDrawer.js +363 -0
- package/dist/editor/components/EntryChangesSkeleton.d.ts +9 -0
- package/dist/editor/components/EntryChangesSkeleton.js +7 -0
- package/dist/editor/components/EntryHeader.d.ts +9 -1
- package/dist/editor/components/EntryHeader.js +25 -3
- package/dist/editor/components/LazyEntryChangesDrawer.d.ts +16 -0
- package/dist/editor/components/LazyEntryChangesDrawer.js +45 -0
- package/dist/editor/components/ReviewDialogs.js +3 -3
- package/dist/editor/context/ApiClientContext.d.ts +9 -0
- package/dist/editor/context/ApiClientContext.js +26 -2
- package/dist/editor/context/index.d.ts +1 -1
- package/dist/editor/context/index.js +1 -1
- package/dist/editor/copy.d.ts +1 -0
- package/dist/editor/copy.js +1 -0
- package/dist/editor/editor-utils.d.ts +2 -0
- package/dist/editor/editor-utils.js +15 -0
- package/dist/editor/entry-diff.d.ts +143 -0
- package/dist/editor/entry-diff.js +593 -0
- package/dist/editor/hooks/create-branch-request.d.ts +11 -1
- package/dist/editor/hooks/create-branch-request.js +16 -6
- package/dist/editor/hooks/useBranchActions.d.ts +19 -2
- package/dist/editor/hooks/useBranchActions.js +29 -20
- package/dist/editor/hooks/useBranchChangesData.d.ts +57 -0
- package/dist/editor/hooks/useBranchChangesData.js +134 -0
- package/dist/editor/hooks/useBranchManager.d.ts +16 -0
- package/dist/editor/hooks/useBranchManager.js +142 -54
- package/dist/editor/hooks/useDraftManager.js +6 -2
- package/dist/editor/hooks/useEntryManager.d.ts +18 -2
- package/dist/editor/hooks/useEntryManager.js +36 -10
- package/dist/editor/utils/editor-errors.d.ts +5 -2
- package/dist/git-manager.d.ts +71 -0
- package/dist/git-manager.js +270 -1
- package/dist/http/handler.js +11 -9
- package/dist/operating-mode/branch-namespace-fixtures.d.ts +17 -0
- package/dist/operating-mode/branch-namespace-fixtures.js +65 -0
- package/dist/operating-mode/branch-namespace.d.ts +34 -0
- package/dist/operating-mode/branch-namespace.js +121 -0
- package/dist/operating-mode/client-unsafe-strategy.js +7 -0
- package/dist/operating-mode/types.d.ts +8 -0
- package/dist/paths/branch-name.d.ts +11 -19
- package/dist/paths/branch-name.js +17 -27
- package/dist/paths/index.d.ts +2 -2
- package/dist/paths/index.js +2 -2
- package/dist/paths/resolve.d.ts +0 -1
- package/dist/paths/resolve.js +0 -1
- package/dist/paths/validation.d.ts +40 -1
- package/dist/paths/validation.js +83 -4
- package/dist/pr-comments.d.ts +17 -9
- package/dist/pr-comments.js +53 -15
- package/dist/server.d.ts +6 -1
- package/dist/server.js +6 -1
- package/dist/services.js +2 -1
- package/dist/submission-attribution.d.ts +22 -0
- package/dist/submission-attribution.js +62 -6
- package/dist/task-queue/worker-status.d.ts +6 -1
- package/dist/task-queue/worker-status.js +23 -2
- package/dist/types.d.ts +59 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker/branch-policy.d.ts +111 -16
- package/dist/worker/branch-policy.js +222 -17
- package/dist/worker/cms-worker.d.ts +24 -2
- package/dist/worker/cms-worker.js +35 -24
- package/dist/worker/git-sync.d.ts +3 -3
- package/dist/worker/git-sync.js +42 -1
- package/dist/worker/github-gateway-http.js +2 -2
- package/dist/worker/github-gateway-wire.d.ts +42 -27
- package/dist/worker/github-gateway-wire.js +14 -3
- package/dist/worker/github-gateway.d.ts +17 -6
- package/dist/worker/github-gateway.js +62 -6
- package/dist/worker/github-mirror.d.ts +7 -4
- package/dist/worker/github-mirror.js +27 -16
- package/dist/worker/history-rewrite.d.ts +23 -10
- package/dist/worker/history-rewrite.js +43 -20
- package/dist/worker/legacy-branch-fixtures.d.ts +21 -0
- package/dist/worker/legacy-branch-fixtures.js +71 -0
- package/dist/worker/rebase.d.ts +2 -1
- package/dist/worker/rebase.js +93 -36
- package/dist/worker/task-runner.d.ts +4 -1
- package/dist/worker/task-runner.js +77 -48
- package/dist/worker/unpublishable-branches.d.ts +34 -0
- package/dist/worker/unpublishable-branches.js +182 -0
- 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
|
-
*
|
|
380
|
-
*
|
|
381
|
-
*
|
|
382
|
-
*
|
|
383
|
-
*
|
|
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
|
|
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(
|
|
393
|
+
.stat(gitDir)
|
|
390
394
|
.then(() => true)
|
|
391
395
|
.catch(() => false);
|
|
392
|
-
if (!hasGitDir)
|
|
393
|
-
return
|
|
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
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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
|
|
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) {
|
package/dist/api/admin.d.ts
CHANGED
|
@@ -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
|
|
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 {
|
|
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
|
+
};
|