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.
- package/CHANGELOG.md +187 -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-workspace.js +22 -15
- package/dist/cli/cli.js +1060 -172
- package/dist/cli/generate-ai-content.js +732 -101
- 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.js +23 -16
- package/dist/context.js +8 -7
- 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/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 +40 -25
- 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
package/dist/git-manager.d.ts
CHANGED
|
@@ -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;
|
package/dist/git-manager.js
CHANGED
|
@@ -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
|
-
|
|
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)
|
package/dist/http/handler.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
(
|
|
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:
|
|
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
|
-
|
|
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;
|