canopycms 0.8.1 → 0.8.3
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 +120 -47
- package/dist/ai/resolve-branch.js +2 -1
- package/dist/api/admin-branch-health.d.ts +11 -0
- package/dist/api/admin-branch-health.js +36 -0
- package/dist/api/admin.d.ts +8 -1
- package/dist/api/branch-merge.js +1 -1
- package/dist/api/branch-review.js +20 -3
- package/dist/api/branch-status.js +30 -19
- package/dist/api/branch.d.ts +8 -3
- package/dist/api/branch.js +32 -3
- package/dist/api/client.d.ts +3 -1
- package/dist/api/client.js +4 -0
- package/dist/api/github-sync.d.ts +2 -0
- package/dist/api/github-sync.js +6 -1
- package/dist/api/pr-comment-sync.js +3 -1
- package/dist/authorization/branch.d.ts +10 -4
- package/dist/authorization/branch.js +24 -4
- package/dist/authorization/types.d.ts +1 -1
- package/dist/branch-metadata-file.d.ts +66 -0
- package/dist/branch-metadata-file.js +14 -0
- package/dist/branch-metadata.d.ts +4 -2
- package/dist/branch-metadata.js +10 -2
- package/dist/cli/cli.js +1913 -1606
- package/dist/cli/configured-asset-store.js +2 -8
- package/dist/cli/generate-ai-content.js +17 -4
- package/dist/cli/init.js +2 -2
- package/dist/cli/site-config.d.ts +9 -0
- package/dist/cli/site-config.js +17 -0
- package/dist/cli/sync.js +226 -74
- package/dist/editor/BranchManager.d.ts +8 -4
- package/dist/editor/BranchManager.js +43 -18
- package/dist/editor/Editor.js +3 -2
- package/dist/editor/admin/SystemHealthPanel.js +65 -31
- package/dist/editor/admin/UnpublishedAtMergeAlert.d.ts +16 -0
- package/dist/editor/admin/UnpublishedAtMergeAlert.js +50 -0
- package/dist/editor/admin/useSystemHealth.d.ts +2 -0
- package/dist/editor/admin/useSystemHealth.js +21 -0
- package/dist/editor/branch-lock.d.ts +2 -0
- package/dist/editor/branch-lock.js +5 -3
- package/dist/editor/branch-status.d.ts +25 -1
- package/dist/editor/branch-status.js +75 -0
- package/dist/editor/components/EditorHeader.d.ts +6 -2
- package/dist/editor/components/EditorHeader.js +51 -30
- package/dist/editor/components/EntryHeader.d.ts +3 -1
- package/dist/editor/components/EntryHeader.js +2 -1
- package/dist/editor/components/TooltipDetail.d.ts +14 -0
- package/dist/editor/components/TooltipDetail.js +18 -0
- package/dist/editor/copy.d.ts +7 -0
- package/dist/editor/copy.js +9 -0
- package/dist/editor/hooks/useBranchManager.d.ts +8 -3
- package/dist/editor/hooks/useBranchManager.js +48 -5
- package/dist/pr-comments.d.ts +2 -0
- package/dist/pr-comments.js +20 -14
- package/dist/resource-generation.js +2 -3
- package/dist/services.d.ts +3 -2
- package/dist/submission-attribution.d.ts +1 -1
- package/dist/submission-attribution.js +20 -2
- package/dist/system-users.d.ts +2 -0
- package/dist/system-users.js +6 -1
- package/dist/task-queue/task-queue.d.ts +5 -1
- package/dist/task-queue/task-queue.js +4 -2
- package/dist/types.d.ts +61 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker/branch-merge.d.ts +68 -0
- package/dist/worker/branch-merge.js +228 -0
- package/dist/worker/canopy-state.d.ts +2 -0
- package/dist/worker/canopy-state.js +1 -1
- package/dist/worker/cms-worker.d.ts +1 -0
- package/dist/worker/cms-worker.js +5 -3
- package/dist/worker/git-sync.js +15 -8
- package/dist/worker/github-commits.d.ts +106 -0
- package/dist/worker/github-commits.js +485 -0
- package/dist/worker/github-gateway-http.js +3 -2
- package/dist/worker/github-gateway-wire.d.ts +22 -13
- package/dist/worker/github-gateway-wire.js +4 -1
- package/dist/worker/github-gateway.d.ts +16 -5
- package/dist/worker/github-gateway.js +22 -5
- package/dist/worker/github-mirror.d.ts +8 -0
- package/dist/worker/github-mirror.js +12 -1
- package/dist/worker/github-transfer.d.ts +3 -1
- package/dist/worker/github-transfer.js +3 -2
- package/dist/worker/history-rewrite.d.ts +13 -1
- package/dist/worker/history-rewrite.js +27 -3
- package/dist/worker/rebase.d.ts +13 -7
- package/dist/worker/rebase.js +351 -95
- package/dist/worker/task-runner.d.ts +1 -1
- package/dist/worker/task-runner.js +172 -34
- package/dist/worker/worker-context.d.ts +2 -0
- package/package.json +1 -1
|
@@ -4,11 +4,11 @@
|
|
|
4
4
|
* site's own dependencies, which a release job given `--bucket` does not have.
|
|
5
5
|
*/
|
|
6
6
|
import path from 'node:path';
|
|
7
|
-
import { createJiti } from 'jiti';
|
|
8
7
|
import { z } from 'zod';
|
|
9
8
|
import { createAssetStore } from '../assets/factory.js';
|
|
10
9
|
import { mediaSchema } from '../config/schemas/media.js';
|
|
11
10
|
import { operatingStrategy } from '../operating-mode/index.js';
|
|
11
|
+
import { importSiteServerConfig } from './site-config.js';
|
|
12
12
|
const serverConfigSchema = z.object({
|
|
13
13
|
mode: z.enum(['prod', 'dev']),
|
|
14
14
|
media: mediaSchema.optional(),
|
|
@@ -19,13 +19,7 @@ const serverConfigSchema = z.object({
|
|
|
19
19
|
* local store, prod never does.
|
|
20
20
|
*/
|
|
21
21
|
export async function loadConfiguredAssetStore(projectDir) {
|
|
22
|
-
const configPath =
|
|
23
|
-
const jiti = createJiti(import.meta.url);
|
|
24
|
-
const mod = (await jiti.import(configPath));
|
|
25
|
-
const configExport = mod.default ?? mod.config ?? mod;
|
|
26
|
-
const server = typeof configExport === 'object' && configExport !== null && 'server' in configExport
|
|
27
|
-
? configExport.server
|
|
28
|
-
: configExport;
|
|
22
|
+
const { configPath, server } = await importSiteServerConfig(projectDir);
|
|
29
23
|
const parsed = serverConfigSchema.safeParse(server);
|
|
30
24
|
if (!parsed.success) {
|
|
31
25
|
throw new Error(`Invalid CanopyCMS config at ${configPath}: ${parsed.error.message}`);
|
|
@@ -4404,6 +4404,7 @@ init_error();
|
|
|
4404
4404
|
import fs5 from "node:fs/promises";
|
|
4405
4405
|
import path7 from "node:path";
|
|
4406
4406
|
import { randomUUID } from "node:crypto";
|
|
4407
|
+
init_logger();
|
|
4407
4408
|
var log4 = createDebugLogger({ prefix: "ResourceGeneration" });
|
|
4408
4409
|
var META_DIR2 = ".canopy-meta";
|
|
4409
4410
|
function resourceGenerationPath(root, resource) {
|
|
@@ -4415,9 +4416,7 @@ async function bumpResourceGeneration(root, resource, options) {
|
|
|
4415
4416
|
await atomicWriteFile(resourceGenerationPath(root, resource), token);
|
|
4416
4417
|
return token;
|
|
4417
4418
|
} catch (err) {
|
|
4418
|
-
|
|
4419
|
-
error: getErrorMessage(err)
|
|
4420
|
-
});
|
|
4419
|
+
canopyLogWarn(`CanopyCMS: failed to bump ${resource} generation for ${root}:`, getErrorMessage(err));
|
|
4421
4420
|
if (options?.mustSucceed)
|
|
4422
4421
|
throw err;
|
|
4423
4422
|
return null;
|
|
@@ -7848,9 +7847,20 @@ var branchMetadataShape = {
|
|
|
7848
7847
|
mergedAt: z7.string().optional(),
|
|
7849
7848
|
rebaseFailure: z7.object({ message: z7.string(), firstAt: z7.string(), lastAt: z7.string() }).optional(),
|
|
7850
7849
|
historyRewrittenFrom: z7.string().optional(),
|
|
7850
|
+
lastGitHubTip: z7.string().optional(),
|
|
7851
|
+
keptOverGitHub: z7.array(z7.string()).optional(),
|
|
7852
|
+
rewrittenOnGitHub: z7.string().optional(),
|
|
7853
|
+
submitUnfinished: z7.boolean().optional(),
|
|
7854
|
+
submitNotQueued: z7.boolean().optional(),
|
|
7855
|
+
unpublishedAtMerge: z7.boolean().optional(),
|
|
7851
7856
|
syncFailureReason: z7.string().optional(),
|
|
7852
7857
|
editors: z7.array(z7.string()).optional(),
|
|
7853
7858
|
uncommittedEditors: z7.array(z7.string()).optional(),
|
|
7859
|
+
// Dropped when malformed: it only decorates the branch card, so it never fails a read.
|
|
7860
|
+
pullRequestHold: z7.discriminatedUnion("kind", [
|
|
7861
|
+
z7.object({ kind: z7.literal("conflicts") }),
|
|
7862
|
+
z7.object({ kind: z7.literal("retargeted"), base: z7.string() })
|
|
7863
|
+
]).optional().catch(void 0),
|
|
7854
7864
|
// Dropped when malformed: it only decorates the PR body, so it never fails a read.
|
|
7855
7865
|
review: z7.object({
|
|
7856
7866
|
state: z7.enum(["approved", "changes-requested"]),
|
|
@@ -10927,6 +10937,9 @@ async function loadOrCreateBranchContext(options) {
|
|
|
10927
10937
|
});
|
|
10928
10938
|
}
|
|
10929
10939
|
|
|
10940
|
+
// dist/system-users.js
|
|
10941
|
+
var AI_USER_ID = "canopycms-ai";
|
|
10942
|
+
|
|
10930
10943
|
// dist/ai/resolve-branch.js
|
|
10931
10944
|
async function resolveBranchRoot(config) {
|
|
10932
10945
|
if (readsFromCheckout(config)) {
|
|
@@ -10945,7 +10958,7 @@ async function resolveBranchRoot(config) {
|
|
|
10945
10958
|
config: resolved,
|
|
10946
10959
|
branchName: activeBranch,
|
|
10947
10960
|
mode: config.mode,
|
|
10948
|
-
createdBy:
|
|
10961
|
+
createdBy: AI_USER_ID,
|
|
10949
10962
|
remoteUrl: config.defaultRemoteUrl
|
|
10950
10963
|
});
|
|
10951
10964
|
return context.branchRoot;
|
package/dist/cli/init.js
CHANGED
|
@@ -1207,7 +1207,7 @@ async function failTask(taskDir, taskId, error, logger = nullLogger) {
|
|
|
1207
1207
|
});
|
|
1208
1208
|
logger.debug("Failed task", { id: taskId, error });
|
|
1209
1209
|
}
|
|
1210
|
-
async function retryTask(taskDir, taskId, error, logger = nullLogger) {
|
|
1210
|
+
async function retryTask(taskDir, taskId, error, logger = nullLogger, options = {}) {
|
|
1211
1211
|
const processingPath = path6.join(taskDir, "processing", `${taskId}.json`);
|
|
1212
1212
|
const pendingDir = path6.join(taskDir, "pending");
|
|
1213
1213
|
const pendingPath = path6.join(pendingDir, `${taskId}.json`);
|
|
@@ -1228,7 +1228,7 @@ async function retryTask(taskDir, taskId, error, logger = nullLogger) {
|
|
|
1228
1228
|
throw err;
|
|
1229
1229
|
}
|
|
1230
1230
|
const retryCount = (task.retryCount ?? 0) + 1;
|
|
1231
|
-
const backoffMs = Math.min(5e3 * Math.pow(2, retryCount - 1), 6e4);
|
|
1231
|
+
const backoffMs = options.delayMs ?? Math.min(5e3 * Math.pow(2, retryCount - 1), 6e4);
|
|
1232
1232
|
task.status = "pending";
|
|
1233
1233
|
task.retryCount = retryCount;
|
|
1234
1234
|
task.retryAfter = new Date(Date.now() + backoffMs).toISOString();
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Imports a site's `canopycms.config.ts` for CLI commands that need it (`sync`, and
|
|
3
|
+
* `configured-asset-store.ts`). Importing runs the site's config and loads its own dependencies.
|
|
4
|
+
*/
|
|
5
|
+
/** The site's server config export (`server` of a split config, else the export itself). */
|
|
6
|
+
export declare function importSiteServerConfig(projectDir: string): Promise<{
|
|
7
|
+
configPath: string;
|
|
8
|
+
server: unknown;
|
|
9
|
+
}>;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Imports a site's `canopycms.config.ts` for CLI commands that need it (`sync`, and
|
|
3
|
+
* `configured-asset-store.ts`). Importing runs the site's config and loads its own dependencies.
|
|
4
|
+
*/
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { createJiti } from 'jiti';
|
|
7
|
+
/** The site's server config export (`server` of a split config, else the export itself). */
|
|
8
|
+
export async function importSiteServerConfig(projectDir) {
|
|
9
|
+
const configPath = path.join(projectDir, 'canopycms.config.ts');
|
|
10
|
+
const jiti = createJiti(import.meta.url);
|
|
11
|
+
const mod = (await jiti.import(configPath));
|
|
12
|
+
const configExport = mod.default ?? mod.config ?? mod;
|
|
13
|
+
const server = typeof configExport === 'object' && configExport !== null && 'server' in configExport
|
|
14
|
+
? configExport.server
|
|
15
|
+
: configExport;
|
|
16
|
+
return { configPath, server };
|
|
17
|
+
}
|
package/dist/cli/sync.js
CHANGED
|
@@ -16,13 +16,20 @@ import fs from 'node:fs/promises';
|
|
|
16
16
|
import path from 'node:path';
|
|
17
17
|
import { simpleGit } from 'simple-git';
|
|
18
18
|
import * as p from '@clack/prompts';
|
|
19
|
+
import { z } from 'zod';
|
|
20
|
+
import { resolveBranchParam } from '../branch-param.js';
|
|
21
|
+
import { readBranchMetadataFile } from '../branch-metadata-file.js';
|
|
22
|
+
import { namesNoWorkspace, resolveBranchPath } from '../paths/branch.js';
|
|
23
|
+
import { exactBranchName, exactBranchSegment, isExactBranchForm, parseBranchRef, } from '../paths/validation.js';
|
|
24
|
+
import { getErrorMessage } from '../utils/error.js';
|
|
19
25
|
import { filePathExists } from '../utils/fs.js';
|
|
20
|
-
import { isCanopyInternalPath, stageAllExceptCanopyState } from '../utils/git.js';
|
|
26
|
+
import { detectHeadBranch, isCanopyInternalPath, stageAllExceptCanopyState } from '../utils/git.js';
|
|
21
27
|
import { assertWithinDir, copyDir, listFilesRecursive, pushContentToWorkspace, safeReplaceDir, SYNC_BASE_TAG, } from '../sync-core.js';
|
|
22
28
|
import { invalidateBranchContentCaches } from '../content-index-generation.js';
|
|
23
29
|
import { ContentWriteLockBusyError, withContentWriteLock } from '../utils/content-write-lock.js';
|
|
24
30
|
import { ensureGitExcludePattern } from '../git-manager.js';
|
|
25
31
|
import { operatingStrategy } from '../operating-mode/index.js';
|
|
32
|
+
import { importSiteServerConfig } from './site-config.js';
|
|
26
33
|
/**
|
|
27
34
|
* Sync precondition failure (missing content dir, unknown branch, …).
|
|
28
35
|
* Thrown so the CLI exits non-zero — scripts/CI must not sail past a
|
|
@@ -78,12 +85,183 @@ async function detectCurrentBranch(projectDir) {
|
|
|
78
85
|
throw new Error('Detached HEAD — use --branch to specify a branch name');
|
|
79
86
|
return branch;
|
|
80
87
|
}
|
|
88
|
+
/** The site config fields that decide which workspace a branch name names. */
|
|
89
|
+
const branchConfigSchema = z.object({
|
|
90
|
+
branchNamespace: z.string().optional(),
|
|
91
|
+
deploymentName: z.string().optional(),
|
|
92
|
+
defaultBaseBranch: z.string().optional(),
|
|
93
|
+
defaultActiveBranch: z.string().optional(),
|
|
94
|
+
});
|
|
95
|
+
/** {@link branchConfigSchema}'s fields from the site config: none, with a warning, when unusable. */
|
|
96
|
+
async function readBranchConfigFields(projectDir) {
|
|
97
|
+
if (!(await filePathExists(path.join(projectDir, 'canopycms.config.ts'))))
|
|
98
|
+
return {};
|
|
99
|
+
let why;
|
|
100
|
+
try {
|
|
101
|
+
const parsed = branchConfigSchema.safeParse((await importSiteServerConfig(projectDir)).server);
|
|
102
|
+
if (parsed.success)
|
|
103
|
+
return parsed.data;
|
|
104
|
+
why = parsed.error.message;
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
why = getErrorMessage(err);
|
|
108
|
+
}
|
|
109
|
+
p.log.warn(`Could not use canopycms.config.ts (${why}); branch names resolve against the default branch settings.`);
|
|
110
|
+
return {};
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* The config {@link resolveBranchParam} resolves names against, built as the dev server builds
|
|
114
|
+
* it: dev mode, the site config's branch fields, and an unset base or active branch taken from the
|
|
115
|
+
* project's git HEAD (services.ts).
|
|
116
|
+
*/
|
|
117
|
+
async function loadBranchParamConfig(projectDir) {
|
|
118
|
+
const fields = await readBranchConfigFields(projectDir);
|
|
119
|
+
const head = await detectHeadBranch(projectDir, fields.defaultBaseBranch ?? 'main');
|
|
120
|
+
return {
|
|
121
|
+
...fields,
|
|
122
|
+
mode: 'dev',
|
|
123
|
+
defaultBaseBranch: fields.defaultBaseBranch ?? head,
|
|
124
|
+
defaultActiveBranch: fields.defaultActiveBranch ?? head,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Refuses a directory that is not the top of its own git repository. Git run there would walk up
|
|
129
|
+
* to the developer's own repository and commit and tag in it.
|
|
130
|
+
*/
|
|
131
|
+
async function assertOwnRepository(branchPath) {
|
|
132
|
+
const top = await simpleGit({ baseDir: branchPath })
|
|
133
|
+
.revparse(['--show-toplevel'])
|
|
134
|
+
.then((out) => out.trim())
|
|
135
|
+
.catch(() => '');
|
|
136
|
+
if (top && (await fs.realpath(top)) === (await fs.realpath(branchPath)))
|
|
137
|
+
return;
|
|
138
|
+
throw new SyncError(`"${path.basename(branchPath)}" under content-branches/ is not a branch workspace: git does not report it as the top of its own repository.`);
|
|
139
|
+
}
|
|
140
|
+
/** The branch a workspace's HEAD is on (an unborn one included), or null when it is detached. */
|
|
141
|
+
async function workspaceHead(branchPath) {
|
|
142
|
+
return simpleGit({ baseDir: branchPath })
|
|
143
|
+
.raw(['symbolic-ref', '--short', '-q', 'HEAD'])
|
|
144
|
+
.then((out) => out.trim() || null)
|
|
145
|
+
.catch(() => null);
|
|
146
|
+
}
|
|
147
|
+
/** The workspace at directory `key` under content-branches/, named by its branch.json or HEAD. */
|
|
148
|
+
async function keyWorkspace(key, projectDir) {
|
|
149
|
+
const branchPath = path.join(operatingStrategy('dev').getContentBranchesRoot(projectDir), key);
|
|
150
|
+
await assertOwnRepository(branchPath);
|
|
151
|
+
const meta = await readBranchMetadataFile(branchPath);
|
|
152
|
+
return { branchName: meta?.branch.name ?? (await workspaceHead(branchPath)) ?? key, branchPath };
|
|
153
|
+
}
|
|
154
|
+
/** The ref `name` gives: decoded from the exact form (`~fix~x`), else `name` itself. */
|
|
155
|
+
function refOf(name) {
|
|
156
|
+
if (!isExactBranchForm(name))
|
|
157
|
+
return name;
|
|
158
|
+
const ref = exactBranchName(name);
|
|
159
|
+
if (ref === null)
|
|
160
|
+
throw new SyncError(`"${name}" is not a valid branch name.`);
|
|
161
|
+
return ref;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* The workspace `name` names, as the dev server resolves it, or null:
|
|
165
|
+
* 1. what the runtime resolves it to (`resolveBranchParam`): the ref, a namespace branch's short
|
|
166
|
+
* name, or the exact form;
|
|
167
|
+
* 2. a workspace key, the directory itself;
|
|
168
|
+
* 3. the directory the ref keys to, when it holds a workspace sync built (no branch.json) on that
|
|
169
|
+
* ref. Two refs can sanitize to one key, so one whose branch.json or HEAD names another branch
|
|
170
|
+
* is refused.
|
|
171
|
+
* A `name` read from git HEAD (`byRef`) is a ref, never a short name or key: it resolves in its
|
|
172
|
+
* exact form and skips step 2, so a local branch never lands in an editor's branch or in another
|
|
173
|
+
* ref's workspace that shares its key.
|
|
174
|
+
*/
|
|
175
|
+
async function resolveSyncWorkspace(name, projectDir, keys, byRef) {
|
|
176
|
+
const config = await loadBranchParamConfig(projectDir);
|
|
177
|
+
const param = byRef ? exactBranchSegment(name) : name;
|
|
178
|
+
const resolved = await resolveBranchParam(param, config, { basePathOverride: projectDir }).catch((err) => {
|
|
179
|
+
if (namesNoWorkspace(err))
|
|
180
|
+
return null;
|
|
181
|
+
throw err;
|
|
182
|
+
});
|
|
183
|
+
if (resolved) {
|
|
184
|
+
// The base and active branch resolve by directory alone, without branch.json's name.
|
|
185
|
+
const ref = refOf(name);
|
|
186
|
+
if (resolved.branch.name !== ref && resolved.branchRoot === refKeyPath(ref, projectDir)) {
|
|
187
|
+
throw holdsAnotherBranch(resolved.branchRoot, resolved.branch.name, ref);
|
|
188
|
+
}
|
|
189
|
+
await assertOwnRepository(resolved.branchRoot);
|
|
190
|
+
return { branchName: resolved.branch.name, branchPath: resolved.branchRoot };
|
|
191
|
+
}
|
|
192
|
+
if (!byRef && keys.includes(name))
|
|
193
|
+
return keyWorkspace(name, projectDir);
|
|
194
|
+
const ref = refOf(name);
|
|
195
|
+
const branchPath = refKeyPath(ref, projectDir);
|
|
196
|
+
const present = await filePathExists(branchPath).catch((err) => {
|
|
197
|
+
if (namesNoWorkspace(err))
|
|
198
|
+
throw new SyncError(`"${ref}" cannot name a branch workspace.`);
|
|
199
|
+
throw err;
|
|
200
|
+
});
|
|
201
|
+
if (!present)
|
|
202
|
+
return null;
|
|
203
|
+
await assertOwnRepository(branchPath);
|
|
204
|
+
const meta = await readBranchMetadataFile(branchPath);
|
|
205
|
+
const holder = meta ? meta.branch.name : await workspaceHead(branchPath);
|
|
206
|
+
if (holder !== ref)
|
|
207
|
+
throw holdsAnotherBranch(branchPath, holder, ref);
|
|
208
|
+
return { branchName: ref, branchPath };
|
|
209
|
+
}
|
|
210
|
+
/** The refusal for a workspace another ref, sharing `ref`'s key, holds (`holder`; null: detached). */
|
|
211
|
+
function holdsAnotherBranch(branchPath, holder, ref) {
|
|
212
|
+
// mergeIntoWorkspace's temporary branch, left checked out when `both` was interrupted.
|
|
213
|
+
const recovery = holder?.startsWith('sync-incoming-')
|
|
214
|
+
? ` An interrupted "sync both" left it there: run "git checkout ${ref}" in that workspace.`
|
|
215
|
+
: '';
|
|
216
|
+
return new SyncError(`Branch workspace "${path.basename(branchPath)}" holds ${holder === null ? 'a detached HEAD' : `branch "${holder}"`}, not "${ref}".${recovery}`);
|
|
217
|
+
}
|
|
218
|
+
/** The workspace directory the runtime keys ref `name` to (`resolveBranchPath`). */
|
|
219
|
+
function refKeyPath(name, projectDir) {
|
|
220
|
+
try {
|
|
221
|
+
return resolveBranchPath({ mode: 'dev', branchName: name, basePathOverride: projectDir })
|
|
222
|
+
.branchRoot;
|
|
223
|
+
}
|
|
224
|
+
catch (err) {
|
|
225
|
+
if (namesNoWorkspace(err))
|
|
226
|
+
throw new SyncError(`"${name}" cannot name a branch workspace.`);
|
|
227
|
+
throw err;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Auto-create a minimal workspace for the branch `name` gives (git init + empty commit) so push
|
|
232
|
+
* can copy content in, at the directory the dev server reads for that ref. This is lighter than
|
|
233
|
+
* BranchWorkspaceManager's clone-based setup -- no remote config, content structure or
|
|
234
|
+
* branch.json -- and the dev server adopts it in place when it provisions that ref
|
|
235
|
+
* (BranchWorkspaceManager.provisionBranch).
|
|
236
|
+
*/
|
|
237
|
+
async function createSyncWorkspace(name, projectDir) {
|
|
238
|
+
const ref = refOf(name);
|
|
239
|
+
const parsed = parseBranchRef(ref);
|
|
240
|
+
if (!parsed.ok)
|
|
241
|
+
throw new SyncError(`"${ref}" is not a valid branch name: ${parsed.error}`);
|
|
242
|
+
const branchPath = refKeyPath(ref, projectDir);
|
|
243
|
+
await fs.mkdir(branchPath, { recursive: true });
|
|
244
|
+
const wsGit = simpleGit({ baseDir: branchPath });
|
|
245
|
+
await wsGit.init();
|
|
246
|
+
await wsGit.checkoutLocalBranch(ref);
|
|
247
|
+
await wsGit.raw(['commit', '--allow-empty', '-m', 'init: workspace created by sync']);
|
|
248
|
+
// Runtime metadata (.canopy-meta/: branch metadata, comments, content-index
|
|
249
|
+
// marker) must never be staged by sync's `add -A`. Fully provisioned workspaces
|
|
250
|
+
// get this exclude from GitManager.initializeWorkspace; this minimal one needs it
|
|
251
|
+
// too. Sync is dev-mode-only, so this asks the dev strategy for the pattern
|
|
252
|
+
// rather than hardcoding it.
|
|
253
|
+
await ensureGitExcludePattern(branchPath, operatingStrategy('dev').getGitExcludePattern());
|
|
254
|
+
p.log.info(`Created branch workspace: ${path.basename(branchPath)}`);
|
|
255
|
+
return { branchName: ref, branchPath };
|
|
256
|
+
}
|
|
81
257
|
/**
|
|
82
|
-
* Select a branch workspace.
|
|
83
|
-
*
|
|
84
|
-
* When autoCreate is true, creates a new workspace
|
|
258
|
+
* Select a branch workspace. Uses the --branch flag (defaulting to the current git branch),
|
|
259
|
+
* resolved as the dev server resolves it ({@link resolveSyncWorkspace}); else auto-selects the
|
|
260
|
+
* only workspace directory, or prompts for one. When autoCreate is true, creates a new workspace
|
|
261
|
+
* if a named branch has none.
|
|
85
262
|
*/
|
|
86
|
-
async function selectBranch(options
|
|
263
|
+
async function selectBranch(options) {
|
|
264
|
+
const branchesDir = operatingStrategy('dev').getContentBranchesRoot(options.projectDir);
|
|
87
265
|
let branches = [];
|
|
88
266
|
if (await filePathExists(branchesDir)) {
|
|
89
267
|
const entries = await fs.readdir(branchesDir, { withFileTypes: true });
|
|
@@ -93,6 +271,7 @@ async function selectBranch(options, branchesDir) {
|
|
|
93
271
|
branches = entries.filter((e) => e.isDirectory() && !e.name.startsWith('.')).map((e) => e.name);
|
|
94
272
|
}
|
|
95
273
|
let branchName = options.branch;
|
|
274
|
+
const byRef = !branchName;
|
|
96
275
|
if (!branchName) {
|
|
97
276
|
try {
|
|
98
277
|
branchName = await detectCurrentBranch(options.projectDir);
|
|
@@ -105,48 +284,25 @@ async function selectBranch(options, branchesDir) {
|
|
|
105
284
|
if (branches.length === 0) {
|
|
106
285
|
throw new SyncError('No branch workspaces found and could not detect current git branch. Use --branch to specify.');
|
|
107
286
|
}
|
|
108
|
-
if (branches.length === 1)
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
p.cancel('Sync cancelled.');
|
|
118
|
-
return null;
|
|
119
|
-
}
|
|
120
|
-
branchName = result;
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
// Validate branch name does not escape the branches directory
|
|
124
|
-
const branchPath = path.join(branchesDir, branchName);
|
|
125
|
-
assertWithinDir(branchPath, branchesDir, '--branch');
|
|
126
|
-
if (!branches.includes(branchName)) {
|
|
127
|
-
if (options.autoCreate) {
|
|
128
|
-
// Auto-create a minimal workspace (git init + empty commit) so push can
|
|
129
|
-
// copy content in. This is lighter than BranchWorkspaceManager's clone-based
|
|
130
|
-
// setup — no remote config or content structure — but sufficient for sync.
|
|
131
|
-
await fs.mkdir(branchPath, { recursive: true });
|
|
132
|
-
const wsGit = simpleGit({ baseDir: branchPath });
|
|
133
|
-
await wsGit.init();
|
|
134
|
-
await wsGit.checkoutLocalBranch(branchName);
|
|
135
|
-
await wsGit.raw(['commit', '--allow-empty', '-m', 'init: workspace created by sync']);
|
|
136
|
-
// Runtime metadata (.canopy-meta/: branch metadata, comments, content-index
|
|
137
|
-
// marker) must never be staged by sync's `add -A`. Fully provisioned workspaces
|
|
138
|
-
// get this exclude from GitManager.initializeWorkspace; this minimal one needs it
|
|
139
|
-
// too. Sync is dev-mode-only (see branchesDir), so this asks the dev strategy for
|
|
140
|
-
// the pattern rather than hardcoding it.
|
|
141
|
-
await ensureGitExcludePattern(branchPath, operatingStrategy('dev').getGitExcludePattern());
|
|
142
|
-
p.log.info(`Created branch workspace: ${branchName}`);
|
|
143
|
-
}
|
|
144
|
-
else {
|
|
145
|
-
const available = branches.length > 0 ? ` Available branches: ${branches.join(', ')}` : '';
|
|
146
|
-
throw new SyncError(`Branch workspace "${branchName}" not found.${available}`);
|
|
287
|
+
if (branches.length === 1)
|
|
288
|
+
return keyWorkspace(branches[0], options.projectDir);
|
|
289
|
+
const result = await p.select({
|
|
290
|
+
message: 'Which branch?',
|
|
291
|
+
options: branches.map((b) => ({ value: b, label: b })),
|
|
292
|
+
});
|
|
293
|
+
if (p.isCancel(result)) {
|
|
294
|
+
p.cancel('Sync cancelled.');
|
|
295
|
+
return null;
|
|
147
296
|
}
|
|
148
|
-
|
|
149
|
-
|
|
297
|
+
return keyWorkspace(result, options.projectDir);
|
|
298
|
+
}
|
|
299
|
+
const existing = await resolveSyncWorkspace(branchName, options.projectDir, branches, byRef);
|
|
300
|
+
if (existing)
|
|
301
|
+
return existing;
|
|
302
|
+
if (options.autoCreate)
|
|
303
|
+
return createSyncWorkspace(branchName, options.projectDir);
|
|
304
|
+
const available = branches.length > 0 ? ` Available branches: ${branches.join(', ')}` : '';
|
|
305
|
+
throw new SyncError(`Branch workspace "${branchName}" not found.${available}`);
|
|
150
306
|
}
|
|
151
307
|
/**
|
|
152
308
|
* Push: copy working-tree content into a branch workspace and commit.
|
|
@@ -155,7 +311,6 @@ async function selectBranch(options, branchesDir) {
|
|
|
155
311
|
async function syncPush(options) {
|
|
156
312
|
const { projectDir } = options;
|
|
157
313
|
const contentRoot = options.contentRoot || 'content';
|
|
158
|
-
const branchesDir = path.join(projectDir, '.canopy-dev', 'content-branches');
|
|
159
314
|
// Validate source content BEFORE selectBranch, which may create a workspace —
|
|
160
315
|
// a push with nothing to push must not leave side effects behind.
|
|
161
316
|
const srcContentDir = path.join(projectDir, contentRoot);
|
|
@@ -163,11 +318,10 @@ async function syncPush(options) {
|
|
|
163
318
|
if (!(await filePathExists(srcContentDir))) {
|
|
164
319
|
throw new SyncError(`Content directory not found: ${contentRoot}/ (expected at ${srcContentDir})`);
|
|
165
320
|
}
|
|
166
|
-
const
|
|
167
|
-
if (!
|
|
321
|
+
const ws = await selectBranch({ ...options, projectDir, autoCreate: true });
|
|
322
|
+
if (!ws)
|
|
168
323
|
return { fileCount: 0 };
|
|
169
|
-
const branchPath =
|
|
170
|
-
assertWithinDir(branchPath, branchesDir, '--branch');
|
|
324
|
+
const { branchName, branchPath } = ws;
|
|
171
325
|
const wsGit = simpleGit({ baseDir: branchPath });
|
|
172
326
|
const status = await wsGit.status();
|
|
173
327
|
if (status.conflicted.length > 0) {
|
|
@@ -235,18 +389,20 @@ async function syncPush(options) {
|
|
|
235
389
|
* Pull: copy content from a branch workspace back into the working tree.
|
|
236
390
|
*/
|
|
237
391
|
async function syncPull(options) {
|
|
392
|
+
const ws = await selectBranch({ ...options, projectDir: options.projectDir });
|
|
393
|
+
if (!ws)
|
|
394
|
+
return { fileCount: 0 };
|
|
395
|
+
return pullFromWorkspace(options, ws);
|
|
396
|
+
}
|
|
397
|
+
/** The body of {@link syncPull}, once its workspace is chosen. */
|
|
398
|
+
async function pullFromWorkspace(options, ws) {
|
|
238
399
|
const { projectDir } = options;
|
|
239
400
|
const contentRoot = options.contentRoot || 'content';
|
|
240
|
-
const
|
|
241
|
-
const branchName = await selectBranch({ ...options, projectDir }, branchesDir);
|
|
242
|
-
if (!branchName)
|
|
243
|
-
return { fileCount: 0 };
|
|
244
|
-
const branchPath = path.join(branchesDir, branchName);
|
|
245
|
-
assertWithinDir(branchPath, branchesDir, '--branch');
|
|
401
|
+
const { branchName, branchPath } = ws;
|
|
246
402
|
const branchContentDir = path.join(branchPath, contentRoot);
|
|
247
403
|
assertWithinDir(branchContentDir, branchPath, '--content-root');
|
|
248
404
|
if (!(await filePathExists(branchContentDir))) {
|
|
249
|
-
throw new SyncError(`Content directory not found in branch workspace: ${
|
|
405
|
+
throw new SyncError(`Content directory not found in branch workspace: ${path.basename(branchPath)}/${contentRoot}`);
|
|
250
406
|
}
|
|
251
407
|
p.log.step(`Pulling content from branch: ${branchName}`);
|
|
252
408
|
const destContentDir = path.join(projectDir, contentRoot);
|
|
@@ -342,18 +498,16 @@ async function syncPull(options) {
|
|
|
342
498
|
async function syncBoth(options) {
|
|
343
499
|
const { projectDir } = options;
|
|
344
500
|
const contentRoot = options.contentRoot || 'content';
|
|
345
|
-
const branchesDir = path.join(projectDir, '.canopy-dev', 'content-branches');
|
|
346
501
|
// Validate source content BEFORE selectBranch, which may create a workspace
|
|
347
502
|
const srcContentDir = path.join(projectDir, contentRoot);
|
|
348
503
|
assertWithinDir(srcContentDir, projectDir, '--content-root');
|
|
349
504
|
if (!(await filePathExists(srcContentDir))) {
|
|
350
505
|
throw new SyncError(`Content directory not found: ${contentRoot}/ (expected at ${srcContentDir})`);
|
|
351
506
|
}
|
|
352
|
-
const
|
|
353
|
-
if (!
|
|
507
|
+
const ws = await selectBranch({ ...options, projectDir, autoCreate: true });
|
|
508
|
+
if (!ws)
|
|
354
509
|
return { pushed: 0, pulled: 0 };
|
|
355
|
-
const branchPath =
|
|
356
|
-
assertWithinDir(branchPath, branchesDir, '--branch');
|
|
510
|
+
const { branchName, branchPath } = ws;
|
|
357
511
|
const wsGit = simpleGit({ baseDir: branchPath });
|
|
358
512
|
const status = await wsGit.status();
|
|
359
513
|
if (status.conflicted.length > 0) {
|
|
@@ -375,7 +529,7 @@ async function syncBoth(options) {
|
|
|
375
529
|
}
|
|
376
530
|
/** The locked body of {@link syncBoth}: everything that rewrites the workspace. */
|
|
377
531
|
async function mergeIntoWorkspace(options, ws) {
|
|
378
|
-
const {
|
|
532
|
+
const { branchPath, srcContentDir, contentRoot } = ws;
|
|
379
533
|
const wsGit = simpleGit({ baseDir: branchPath });
|
|
380
534
|
const status = await wsGit.status();
|
|
381
535
|
// Auto-commit uncommitted workspace changes (preserves editor work for the merge)
|
|
@@ -421,7 +575,7 @@ async function mergeIntoWorkspace(options, ws) {
|
|
|
421
575
|
await wsGit.checkout(currentBranch);
|
|
422
576
|
await wsGit.raw(['branch', '-D', incomingBranch]);
|
|
423
577
|
p.log.info('No working-tree changes to merge — pulling editor changes only');
|
|
424
|
-
const pullResult = await
|
|
578
|
+
const pullResult = await pullFromWorkspace({ ...options, force: true }, ws);
|
|
425
579
|
return { pushed: 0, pulled: pullResult.fileCount };
|
|
426
580
|
}
|
|
427
581
|
await wsGit.commit('sync: incoming working-tree changes');
|
|
@@ -446,8 +600,9 @@ async function mergeIntoWorkspace(options, ws) {
|
|
|
446
600
|
p.log.error(` ${file}`);
|
|
447
601
|
}
|
|
448
602
|
p.log.info('The branch workspace is now in a merge state.');
|
|
449
|
-
|
|
450
|
-
p.log.info(
|
|
603
|
+
const branchFlag = options.branch ? ` --branch ${options.branch}` : '';
|
|
604
|
+
p.log.info(`Resolve the conflicts in the workspace, then run: canopycms sync pull${branchFlag}`);
|
|
605
|
+
p.log.info(`Or abort the merge with: canopycms sync abort${branchFlag}`);
|
|
451
606
|
// Clean up the incoming branch (leave merge state for user resolution)
|
|
452
607
|
await wsGit.raw(['branch', '-D', incomingBranch]).catch(() => { });
|
|
453
608
|
return { pushed: 0, pulled: 0 };
|
|
@@ -459,7 +614,7 @@ async function mergeIntoWorkspace(options, ws) {
|
|
|
459
614
|
await wsGit.raw(['branch', '-D', incomingBranch]);
|
|
460
615
|
await wsGit.tag(['-f', SYNC_BASE_TAG]);
|
|
461
616
|
p.log.success('Merged working-tree changes with editor changes');
|
|
462
|
-
const pullResult = await
|
|
617
|
+
const pullResult = await pullFromWorkspace({ ...options, force: true }, ws);
|
|
463
618
|
return { pushed: incomingFiles.length, pulled: pullResult.fileCount };
|
|
464
619
|
}
|
|
465
620
|
finally {
|
|
@@ -471,13 +626,10 @@ async function mergeIntoWorkspace(options, ws) {
|
|
|
471
626
|
* Restores the workspace to its pre-merge state.
|
|
472
627
|
*/
|
|
473
628
|
async function syncAbort(options) {
|
|
474
|
-
const { projectDir }
|
|
475
|
-
|
|
476
|
-
const branchName = await selectBranch({ ...options, projectDir }, branchesDir);
|
|
477
|
-
if (!branchName)
|
|
629
|
+
const ws = await selectBranch({ ...options, projectDir: options.projectDir });
|
|
630
|
+
if (!ws)
|
|
478
631
|
return;
|
|
479
|
-
const branchPath =
|
|
480
|
-
assertWithinDir(branchPath, branchesDir, '--branch');
|
|
632
|
+
const { branchName, branchPath } = ws;
|
|
481
633
|
if (!(await abortWorkspaceMerge(branchPath, branchName))) {
|
|
482
634
|
p.log.info(`Branch workspace "${branchName}" is not in a merge state — nothing to abort.`);
|
|
483
635
|
return;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import type { OperatingMode } from '../operating-mode/index.js';
|
|
3
|
-
import type { ConflictStatus, PullRequestState, SyncStatus } from '../types.js';
|
|
3
|
+
import type { BranchHold, ConflictStatus, PullRequestState, SyncStatus } from '../types.js';
|
|
4
4
|
import type { CommentThread } from '../comment-store.js';
|
|
5
5
|
import type { UserSearchResult } from '../auth/types.js';
|
|
6
6
|
import type { CreateRefusal } from './hooks/create-branch-request.js';
|
|
@@ -32,17 +32,21 @@ export interface BranchSummary {
|
|
|
32
32
|
conflictStatus?: ConflictStatus;
|
|
33
33
|
/** ContentIds of entries where --theirs was applied during rebase; cleared on clean rebase */
|
|
34
34
|
conflictFiles?: string[];
|
|
35
|
+
/** What is stuck and needs a person (api/branch.ts). */
|
|
36
|
+
hold?: BranchHold;
|
|
35
37
|
commentCount?: number;
|
|
36
38
|
isProtected?: boolean;
|
|
37
39
|
readOnly?: boolean;
|
|
38
40
|
/**
|
|
39
|
-
* Server-computed compound submit rule (base-branch
|
|
40
|
-
* status) -- see `useBranchManager.tsx`'s
|
|
41
|
-
* `BranchWriteProtection.submitBlockedIncludingStatus` for the full
|
|
41
|
+
* Server-computed compound submit rule (base-branch, non-'editing'
|
|
42
|
+
* status, or rewritten on GitHub) -- see `useBranchManager.tsx`'s
|
|
43
|
+
* `BranchSummary.submitBlocked` and `BranchWriteProtection.submitBlockedIncludingStatus` for the full
|
|
42
44
|
* rationale. `getBranchPermissions`'s `canSubmit` consumes this directly
|
|
43
45
|
* instead of re-deriving `status === 'editing' && !isProtected`.
|
|
44
46
|
*/
|
|
45
47
|
submitBlocked?: boolean;
|
|
48
|
+
/** Server-computed: the API refuses Approve, as the last submit didn't finish. */
|
|
49
|
+
approveBlocked?: boolean;
|
|
46
50
|
}
|
|
47
51
|
export interface UserContext {
|
|
48
52
|
userId: string;
|