canopycms 0.0.67 → 0.0.68-int.101
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/README.md +24 -17
- package/dist/ai/handler.js +8 -0
- package/dist/api/admin-branch-health.js +17 -20
- package/dist/api/admin.d.ts +30 -9
- package/dist/api/admin.js +32 -2
- package/dist/api/assets.js +84 -76
- package/dist/api/branch-create-window.d.ts +12 -0
- package/dist/api/branch-create-window.js +13 -0
- package/dist/api/branch-review.d.ts +3 -3
- package/dist/api/branch-review.js +14 -5
- package/dist/api/branch-status.d.ts +5 -5
- package/dist/api/branch-status.js +26 -6
- package/dist/api/branch-withdraw.d.ts +2 -2
- package/dist/api/branch-withdraw.js +7 -2
- package/dist/api/branch.d.ts +15 -2
- package/dist/api/branch.js +118 -92
- package/dist/api/client.d.ts +18 -6
- package/dist/api/client.js +79 -12
- package/dist/api/content.d.ts +6 -5
- package/dist/api/content.js +44 -41
- package/dist/api/entries.js +16 -15
- package/dist/api/github-sync.d.ts +19 -1
- package/dist/api/github-sync.js +57 -10
- package/dist/api/index.d.ts +1 -1
- package/dist/api/reference-options.js +1 -1
- package/dist/api/resolve-references.js +14 -40
- package/dist/api/settings-helpers.d.ts +1 -1
- package/dist/api/settings-helpers.js +1 -4
- package/dist/api/user.d.ts +3 -0
- package/dist/api/user.js +3 -0
- package/dist/assets/asset-url.d.ts +22 -16
- package/dist/assets/asset-url.js +42 -25
- package/dist/assets/factory.d.ts +5 -0
- package/dist/assets/factory.js +5 -0
- package/dist/assets/finalize.js +3 -0
- package/dist/assets/index.d.ts +1 -1
- package/dist/assets/materialize.d.ts +153 -0
- package/dist/assets/materialize.js +402 -0
- package/dist/assets/store-local.d.ts +19 -5
- package/dist/assets/store-local.js +69 -10
- package/dist/assets/store-s3.d.ts +38 -7
- package/dist/assets/store-s3.js +128 -22
- package/dist/assets/transform-directives.d.ts +52 -13
- package/dist/assets/transform-directives.js +87 -27
- package/dist/assets/transform.d.ts +3 -3
- package/dist/assets/transform.js +13 -8
- package/dist/assets/types.d.ts +31 -4
- package/dist/auth/file-based-auth-cache.js +7 -8
- package/dist/authorization/content.d.ts +5 -4
- package/dist/authorization/content.js +5 -5
- package/dist/authorization/path.d.ts +11 -6
- package/dist/authorization/path.js +11 -6
- package/dist/authorization/types.d.ts +2 -2
- package/dist/branch-health.d.ts +7 -1
- package/dist/branch-health.js +8 -3
- package/dist/branch-metadata.d.ts +5 -5
- package/dist/branch-metadata.js +44 -36
- package/dist/branch-provisioning.d.ts +167 -0
- package/dist/branch-provisioning.js +501 -0
- package/dist/branch-schema-cache.d.ts +5 -3
- package/dist/branch-schema-cache.js +29 -11
- package/dist/branch-sparse.d.ts +23 -0
- package/dist/branch-sparse.js +118 -0
- package/dist/branch-workspace.d.ts +42 -3
- package/dist/branch-workspace.js +240 -94
- package/dist/build/asset-refs.d.ts +93 -0
- package/dist/build/asset-refs.js +251 -0
- package/dist/build-identity.d.ts +3 -0
- package/dist/build-identity.js +13 -0
- package/dist/cli/asset-refs.d.ts +52 -0
- package/dist/cli/asset-refs.js +194 -0
- package/dist/cli/cli.d.ts +15 -1
- package/dist/cli/cli.js +5928 -2195
- package/dist/cli/configured-asset-store.d.ts +12 -0
- package/dist/cli/configured-asset-store.js +46 -0
- package/dist/cli/generate-ai-content.js +2185 -587
- package/dist/cli/github-app-manifest.d.ts +4 -3
- package/dist/cli/github-app-manifest.js +4 -3
- package/dist/cli/init.js +30 -27
- package/dist/cli/migrate.js +12 -8
- package/dist/cli/sync.js +83 -37
- package/dist/cli/template-files/Dockerfile.cms.template +8 -0
- package/dist/cli/template-files/cdk-app.ts.template +4 -0
- package/dist/cli/template-files/cms-stack.ts.template +23 -12
- package/dist/cli/template-files/deploy-cms.yml.template +2 -0
- package/dist/client.d.ts +1 -1
- package/dist/client.js +1 -1
- package/dist/config/helpers.js +1 -2
- package/dist/config/schemas/config.d.ts +19 -32
- package/dist/config/schemas/config.js +9 -11
- package/dist/config/schemas/field.js +1 -0
- package/dist/config/schemas/media.d.ts +4 -13
- package/dist/config/schemas/media.js +5 -8
- package/dist/config/schemas/url.d.ts +4 -20
- package/dist/config/schemas/url.js +6 -22
- package/dist/config/types.d.ts +30 -20
- package/dist/config/validation.d.ts +6 -0
- package/dist/config/validation.js +37 -0
- package/dist/content-listing.d.ts +12 -12
- package/dist/content-listing.js +10 -6
- package/dist/content-reader.d.ts +11 -2
- package/dist/content-reader.js +22 -14
- package/dist/content-store.d.ts +37 -9
- package/dist/content-store.js +70 -43
- package/dist/content-tree.d.ts +1 -1
- package/dist/content-tree.js +2 -2
- package/dist/context.d.ts +27 -9
- package/dist/context.js +154 -70
- package/dist/editor/BranchManager.d.ts +7 -1
- package/dist/editor/BranchManager.js +29 -14
- package/dist/editor/CanopyEditor.d.ts +1 -1
- package/dist/editor/CanopyEditor.js +2 -6
- package/dist/editor/Editor.d.ts +4 -3
- package/dist/editor/Editor.js +32 -22
- package/dist/editor/FormRenderer.js +34 -18
- package/dist/editor/PreviewFrame.d.ts +24 -0
- package/dist/editor/PreviewFrame.js +140 -0
- package/dist/editor/admin/SystemHealthPanel.js +47 -7
- package/dist/editor/components/BranchesDrawer.d.ts +9 -0
- package/dist/editor/components/BranchesDrawer.js +4 -0
- package/dist/editor/components/NoEditPermissionNotice.d.ts +10 -0
- package/dist/editor/components/NoEditPermissionNotice.js +17 -0
- package/dist/editor/context/AssetContext.d.ts +18 -35
- package/dist/editor/context/AssetContext.js +20 -29
- package/dist/editor/context/index.d.ts +1 -1
- package/dist/editor/context/index.js +1 -1
- package/dist/editor/editor-config.d.ts +1 -2
- package/dist/editor/editor-config.js +0 -23
- package/dist/editor/editor-utils.d.ts +24 -21
- package/dist/editor/editor-utils.js +75 -62
- package/dist/editor/fields/BlockField.d.ts +1 -0
- package/dist/editor/fields/BlockField.js +6 -4
- package/dist/editor/fields/CodeField.d.ts +1 -0
- package/dist/editor/fields/CodeField.js +2 -2
- package/dist/editor/fields/DateTimeField.d.ts +1 -0
- package/dist/editor/fields/DateTimeField.js +2 -2
- package/dist/editor/fields/FieldDescription.d.ts +16 -0
- package/dist/editor/fields/FieldDescription.js +11 -0
- package/dist/editor/fields/ImageField.d.ts +1 -0
- package/dist/editor/fields/ImageField.js +3 -2
- package/dist/editor/fields/InlineGroupField.js +4 -1
- package/dist/editor/fields/MarkdownField.d.ts +1 -0
- package/dist/editor/fields/MarkdownField.js +115 -20
- package/dist/editor/fields/NumberField.d.ts +1 -0
- package/dist/editor/fields/NumberField.js +2 -2
- package/dist/editor/fields/NumberListField.d.ts +1 -0
- package/dist/editor/fields/NumberListField.js +2 -2
- package/dist/editor/fields/ObjectField.d.ts +1 -0
- package/dist/editor/fields/ObjectField.js +5 -2
- package/dist/editor/fields/ReferenceField.d.ts +1 -0
- package/dist/editor/fields/ReferenceField.js +5 -4
- package/dist/editor/fields/SelectField.d.ts +1 -0
- package/dist/editor/fields/SelectField.js +2 -2
- package/dist/editor/fields/StringListField.d.ts +1 -0
- package/dist/editor/fields/StringListField.js +2 -2
- package/dist/editor/fields/TextField.d.ts +1 -0
- package/dist/editor/fields/TextField.js +2 -2
- package/dist/editor/fields/ToggleField.d.ts +1 -0
- package/dist/editor/fields/ToggleField.js +7 -4
- package/dist/editor/fields/entry-link/InsertEntryLink.d.ts +1 -1
- package/dist/editor/fields/entry-link/InsertEntryLink.js +3 -2
- package/dist/editor/fields/mdx-jsx-support.d.ts +1 -0
- package/dist/editor/fields/mdx-jsx-support.js +137 -0
- package/dist/editor/hooks/create-branch-request.d.ts +20 -0
- package/dist/editor/hooks/create-branch-request.js +52 -0
- package/dist/editor/hooks/useBranchActions.d.ts +13 -2
- package/dist/editor/hooks/useBranchActions.js +48 -17
- package/dist/editor/hooks/useBranchManager.d.ts +21 -0
- package/dist/editor/hooks/useBranchManager.js +204 -37
- package/dist/editor/hooks/useBranchesData.d.ts +6 -0
- package/dist/editor/hooks/useBranchesData.js +9 -3
- package/dist/editor/hooks/useCommentSystem.js +25 -6
- package/dist/editor/hooks/useDraftManager.d.ts +16 -1
- package/dist/editor/hooks/useDraftManager.js +193 -36
- package/dist/editor/hooks/useEntriesData.js +3 -3
- package/dist/editor/hooks/useEntryManager.d.ts +2 -1
- package/dist/editor/hooks/useEntryManager.js +34 -19
- package/dist/editor/media/AssetCard.d.ts +1 -1
- package/dist/editor/media/crop-math.d.ts +3 -4
- package/dist/editor/media/crop-math.js +11 -22
- package/dist/editor/media/editor-image-src.d.ts +7 -0
- package/dist/editor/media/editor-image-src.js +12 -0
- package/dist/editor/preview-asset-base.d.ts +8 -0
- package/dist/editor/preview-asset-base.js +13 -0
- package/dist/editor/preview-bridge.d.ts +9 -21
- package/dist/editor/preview-bridge.js +19 -139
- package/dist/editor/preview-path.d.ts +10 -0
- package/dist/editor/preview-path.js +20 -0
- package/dist/editor/theme.js +2 -2
- package/dist/entry-schema-registry.d.ts +4 -4
- package/dist/entry-schema-registry.js +10 -6
- package/dist/entry-schema.d.ts +27 -4
- package/dist/entry-schema.js +20 -3
- package/dist/git-manager.d.ts +173 -6
- package/dist/git-manager.js +594 -76
- package/dist/github-service.d.ts +15 -0
- package/dist/github-service.js +19 -1
- package/dist/http/handler.js +37 -13
- package/dist/http/index.d.ts +2 -0
- package/dist/http/index.js +2 -0
- package/dist/http/router.d.ts +2 -0
- package/dist/http/router.js +2 -1
- package/dist/http/types.d.ts +2 -0
- package/dist/http/worker-not-ready.d.ts +10 -0
- package/dist/http/worker-not-ready.js +24 -0
- package/dist/operating-mode/client-unsafe-strategy.js +0 -6
- package/dist/operating-mode/types.d.ts +1 -4
- package/dist/paths/branch-name.d.ts +5 -0
- package/dist/paths/branch-name.js +9 -0
- package/dist/paths/branch.d.ts +6 -2
- package/dist/paths/branch.js +16 -10
- package/dist/paths/index.d.ts +3 -3
- package/dist/paths/index.js +3 -3
- package/dist/paths/normalize.d.ts +7 -0
- package/dist/paths/normalize.js +9 -0
- package/dist/paths/validation.js +0 -2
- package/dist/preview.d.ts +8 -0
- package/dist/preview.js +7 -0
- package/dist/reference-resolver.d.ts +5 -21
- package/dist/reference-resolver.js +9 -44
- package/dist/resolve-canopy-user.js +2 -1
- package/dist/schema/schema-store.d.ts +54 -31
- package/dist/schema/schema-store.js +77 -31
- package/dist/server.d.ts +64 -9
- package/dist/server.js +48 -8
- package/dist/services.d.ts +18 -6
- package/dist/services.js +85 -70
- package/dist/settings-workspace.d.ts +23 -3
- package/dist/settings-workspace.js +197 -45
- package/dist/static/seo.d.ts +2 -14
- package/dist/static/seo.js +2 -25
- package/dist/submission-attribution.d.ts +73 -0
- package/dist/submission-attribution.js +221 -0
- package/dist/sync-core.d.ts +12 -1
- package/dist/sync-core.js +31 -13
- package/dist/task-queue/worker-status.d.ts +8 -0
- package/dist/task-queue/worker-status.js +17 -0
- package/dist/types.d.ts +31 -2
- package/dist/utils/content-serialize.d.ts +5 -2
- package/dist/utils/content-serialize.js +98 -24
- package/dist/utils/content-write-lock.d.ts +18 -7
- package/dist/utils/content-write-lock.js +18 -9
- package/dist/utils/debug.d.ts +8 -0
- package/dist/utils/debug.js +10 -2
- package/dist/utils/git.d.ts +32 -0
- package/dist/utils/git.js +42 -0
- package/dist/utils/occ-json-write.js +10 -2
- package/dist/utils/provision-log.d.ts +25 -0
- package/dist/utils/provision-log.js +49 -0
- package/dist/utils/provisioning-lock.d.ts +35 -5
- package/dist/utils/provisioning-lock.js +73 -12
- package/dist/utils/request-timing.d.ts +25 -0
- package/dist/utils/request-timing.js +101 -0
- package/dist/utils/sanitize-href.d.ts +8 -22
- package/dist/utils/sanitize-href.js +11 -28
- package/dist/utils/url-prefix.d.ts +30 -0
- package/dist/utils/url-prefix.js +61 -0
- package/dist/utils/yaml-source-splice.d.ts +58 -0
- package/dist/utils/yaml-source-splice.js +515 -0
- package/dist/validation/entry-validator.js +9 -3
- package/dist/version.d.ts +1 -0
- package/dist/version.js +3 -0
- package/dist/worker/canopy-state.d.ts +45 -0
- package/dist/worker/canopy-state.js +75 -0
- package/dist/worker/cms-worker.d.ts +8 -3
- package/dist/worker/cms-worker.js +38 -2
- package/dist/worker/git-sync.d.ts +30 -12
- package/dist/worker/git-sync.js +253 -58
- package/dist/worker/history-rewrite.d.ts +1 -1
- package/dist/worker/history-rewrite.js +1 -1
- package/dist/worker/provisioned-workspace.d.ts +35 -0
- package/dist/worker/provisioned-workspace.js +50 -0
- package/dist/worker/rebase.d.ts +1 -1
- package/dist/worker/rebase.js +95 -23
- package/dist/worker/remote-git-maintenance.d.ts +3 -0
- package/dist/worker/remote-git-maintenance.js +11 -0
- package/dist/worker/sparse-cone.d.ts +20 -0
- package/dist/worker/sparse-cone.js +132 -0
- package/dist/worker/task-runner.js +99 -14
- package/dist/worker/worker-context.d.ts +6 -1
- package/package.json +8 -2
package/dist/git-manager.js
CHANGED
|
@@ -19,8 +19,9 @@ import { simpleGit, } from 'simple-git';
|
|
|
19
19
|
import { invalidateContentIndexesForRoot } from './content-index-registry.js';
|
|
20
20
|
import { invalidateBranchContentCaches } from './content-index-generation.js';
|
|
21
21
|
import { createDebugLogger } from './utils/debug.js';
|
|
22
|
-
import { getErrorMessage, isNotFoundError } from './utils/error.js';
|
|
23
|
-
import { isMissingRemoteRefFailure, isNetworkRemoteUrl, resolveBaseBranch } from './utils/git.js';
|
|
22
|
+
import { getErrorMessage, isNodeError, isNotFoundError, redactCredentials } from './utils/error.js';
|
|
23
|
+
import { isMissingRemoteRefFailure, isNetworkRemoteUrl, resolveBaseBranch, stageAllExceptCanopyState, } from './utils/git.js';
|
|
24
|
+
import { canopyLogWarn } from './utils/logger.js';
|
|
24
25
|
import { acquireProvisioningLock } from './utils/provisioning-lock.js';
|
|
25
26
|
const log = createDebugLogger({ prefix: 'GitManager' });
|
|
26
27
|
/**
|
|
@@ -88,6 +89,134 @@ export function gitNetworkChildEnv() {
|
|
|
88
89
|
}
|
|
89
90
|
return { ...env, ...FORCE_C_LOCALE };
|
|
90
91
|
}
|
|
92
|
+
/**
|
|
93
|
+
* `-c` on every git process a GitManager starts, so gc and auto-maintenance
|
|
94
|
+
* never run inside a Lambda, which can be frozen mid-run with a half-written
|
|
95
|
+
* pack or a `gc.pid` left on EFS. A provisioning clone also persists them
|
|
96
|
+
* into the workspace's own config. `remote.git`'s `receive-pack` never sees
|
|
97
|
+
* them; {@link REMOTE_GIT_CONFIG} covers that side.
|
|
98
|
+
*/
|
|
99
|
+
const NO_AUTO_GC_CONFIG = ['gc.auto=0', 'maintenance.auto=false'];
|
|
100
|
+
/**
|
|
101
|
+
* Settings written into every bare `remote.git` CanopyCMS creates: the worker's (prod) and the
|
|
102
|
+
* simulated remote (dev). A push runs `receive-pack` with remote.git's own config and none of the
|
|
103
|
+
* pusher's: git unsets `GIT_CONFIG_PARAMETERS` before spawning it for a local-path push. Unset, a
|
|
104
|
+
* push starts auto-housekeeping there (detached, or inside a Lambda that is frozen mid-run) that
|
|
105
|
+
* repacks while another process clones, and pushes under the default `unpackLimit` (100) leave
|
|
106
|
+
* loose objects that every local clone copies one NFS round trip at a time.
|
|
107
|
+
*
|
|
108
|
+
* Never an `extensions.*` key: once a repo is at `repositoryformatversion` 1, a git that does not
|
|
109
|
+
* know the key refuses it, and Lambda runs git 2.39.
|
|
110
|
+
* @internal Exported for tests.
|
|
111
|
+
*/
|
|
112
|
+
export const REMOTE_GIT_CONFIG = [
|
|
113
|
+
['gc.auto', '0'],
|
|
114
|
+
['receive.autogc', 'false'],
|
|
115
|
+
['maintenance.auto', 'false'],
|
|
116
|
+
['transfer.unpackLimit', '1'],
|
|
117
|
+
];
|
|
118
|
+
/** Write each of {@link REMOTE_GIT_CONFIG} into the bare repo `gitDir` only where it differs. */
|
|
119
|
+
export async function ensureRemoteGitConfig(gitDir) {
|
|
120
|
+
const git = simpleGit().env(gitChildEnv({}));
|
|
121
|
+
const current = new Map();
|
|
122
|
+
const listed = await git.raw(['--git-dir', gitDir, 'config', '--local', '--list']);
|
|
123
|
+
for (const line of listed.split('\n')) {
|
|
124
|
+
const eq = line.indexOf('=');
|
|
125
|
+
if (eq > 0)
|
|
126
|
+
current.set(line.slice(0, eq).toLowerCase(), line.slice(eq + 1));
|
|
127
|
+
}
|
|
128
|
+
for (const [key, value] of REMOTE_GIT_CONFIG) {
|
|
129
|
+
if (current.get(key.toLowerCase()) === value)
|
|
130
|
+
continue;
|
|
131
|
+
await git.raw(['--git-dir', gitDir, 'config', '--local', key, value]);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/** Above either count, {@link repackBareRemoteIfNeeded} repacks. */
|
|
135
|
+
const REMOTE_GIT_MAX_LOOSE_OBJECTS = 50;
|
|
136
|
+
const REMOTE_GIT_MAX_PACKS = 6;
|
|
137
|
+
async function countObjects(git, gitDir) {
|
|
138
|
+
const output = await git.raw(['--git-dir', gitDir, 'count-objects', '-v']);
|
|
139
|
+
const fields = new Map(output.split('\n').map((line) => {
|
|
140
|
+
const [name, value = ''] = line.split(': ');
|
|
141
|
+
return [name, value.trim()];
|
|
142
|
+
}));
|
|
143
|
+
const field = (name) => {
|
|
144
|
+
const value = fields.get(name);
|
|
145
|
+
if (!value || !/^\d+$/.test(value)) {
|
|
146
|
+
throw new Error(`count-objects -v printed no '${name}' count: ${output.trim()}`);
|
|
147
|
+
}
|
|
148
|
+
return Number(value);
|
|
149
|
+
};
|
|
150
|
+
return { loose: field('count'), packs: field('packs') };
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Repack the bare repo `gitDir` once it holds more than {@link REMOTE_GIT_MAX_LOOSE_OBJECTS}
|
|
154
|
+
* loose objects or {@link REMOTE_GIT_MAX_PACKS} packs, then pack its refs. Under
|
|
155
|
+
* {@link REMOTE_GIT_CONFIG} every push adds a pack, and nothing else ever compacts them.
|
|
156
|
+
*
|
|
157
|
+
* Safe beside concurrent pushes and clones, which is why it is `--cruft` with no expiry:
|
|
158
|
+
* unreachable objects move into a cruft pack rather than being dropped, so a push that read an
|
|
159
|
+
* old object before the repack still finds it, and a repack only deletes packs it listed when it
|
|
160
|
+
* began. A clone that hardlinked a pack keeps that inode when the repack unlinks the remote's
|
|
161
|
+
* name for it, and packs are never modified in place. `repack -d` also removes the loose objects
|
|
162
|
+
* it packed and their emptied fan-out directories.
|
|
163
|
+
*/
|
|
164
|
+
export async function repackBareRemoteIfNeeded(gitDir) {
|
|
165
|
+
const git = simpleGit().env(gitChildEnv({}));
|
|
166
|
+
const before = await countObjects(git, gitDir);
|
|
167
|
+
if (before.loose <= REMOTE_GIT_MAX_LOOSE_OBJECTS && before.packs <= REMOTE_GIT_MAX_PACKS) {
|
|
168
|
+
return { repacked: false, before };
|
|
169
|
+
}
|
|
170
|
+
const startedAt = Date.now();
|
|
171
|
+
await git.raw(['--git-dir', gitDir, 'repack', '-a', '-d', '--cruft', '-q']);
|
|
172
|
+
await git.raw(['--git-dir', gitDir, 'pack-refs', '--all']);
|
|
173
|
+
const after = await countObjects(git, gitDir);
|
|
174
|
+
return { repacked: true, before, after, ms: Date.now() - startedAt };
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* simple-git reads a git that exited by signal (exit code null, often with no stderr) as success,
|
|
178
|
+
* so a clone or checkout the OOM killer stopped would pass for complete. Given as `errors` to a
|
|
179
|
+
* GitManager's own instance and to `cloneRepo`'s; its static helpers and the bare-remote functions
|
|
180
|
+
* do not pass it yet (.claude/future-tasks/simple-git-signal-exit-reads-as-success.md).
|
|
181
|
+
*/
|
|
182
|
+
function failOnSignalExit(error, result) {
|
|
183
|
+
if (error)
|
|
184
|
+
return error;
|
|
185
|
+
if (result.exitCode === null) {
|
|
186
|
+
return new Error('git was killed by a signal before it finished');
|
|
187
|
+
}
|
|
188
|
+
return undefined;
|
|
189
|
+
}
|
|
190
|
+
/** EFS cost is per-file latency, which parallel file creation overlaps. git >= 2.32. */
|
|
191
|
+
const PARALLEL_CHECKOUT_ARGS = [
|
|
192
|
+
'-c',
|
|
193
|
+
'checkout.workers=8',
|
|
194
|
+
'-c',
|
|
195
|
+
'checkout.thresholdForParallelism=1',
|
|
196
|
+
];
|
|
197
|
+
async function dirState(dir) {
|
|
198
|
+
try {
|
|
199
|
+
return (await fs.readdir(dir)).length === 0 ? 'empty' : 'occupied';
|
|
200
|
+
}
|
|
201
|
+
catch (err) {
|
|
202
|
+
if (isNodeError(err) && err.code === 'ENOENT')
|
|
203
|
+
return 'absent';
|
|
204
|
+
throw err;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Config every workspace clone writes into its own `.git/config`: the managed
|
|
209
|
+
* marker that ensureRemote's guard checks, and a fallback author for internal
|
|
210
|
+
* commits such as the orphan settings init (ensureAuthor() sets the real one
|
|
211
|
+
* before user-facing commits).
|
|
212
|
+
*/
|
|
213
|
+
export function managedWorkspaceConfig(gitBotAuthorName, gitBotAuthorEmail) {
|
|
214
|
+
return {
|
|
215
|
+
'canopycms.managed': 'true',
|
|
216
|
+
'user.name': gitBotAuthorName,
|
|
217
|
+
'user.email': gitBotAuthorEmail,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
91
220
|
// In-memory lock to prevent concurrent remote.git initialization
|
|
92
221
|
const remoteInitLocks = new Map();
|
|
93
222
|
/**
|
|
@@ -166,13 +295,90 @@ export class GitRemoteRefMissingError extends Error {
|
|
|
166
295
|
this.name = 'GitRemoteRefMissingError';
|
|
167
296
|
}
|
|
168
297
|
}
|
|
298
|
+
/**
|
|
299
|
+
* No git remote is configured and the one the strategy auto-detects does not
|
|
300
|
+
* exist yet: in prod, the EC2 worker creates `{workspaceRoot}/remote.git` on
|
|
301
|
+
* its first boot, so until then every request that needs a workspace fails
|
|
302
|
+
* here. Usually transient, unlike a missing remote in a mode with nothing to wait for.
|
|
303
|
+
* The HTTP layer maps it to a 503 (`http/worker-not-ready.ts`).
|
|
304
|
+
*/
|
|
305
|
+
export class RemoteNotReadyError extends Error {
|
|
306
|
+
constructor(expectedRemotePath) {
|
|
307
|
+
super(`CanopyCMS: no git remote is available yet. defaultRemoteUrl (or CANOPYCMS_REMOTE_URL) is ` +
|
|
308
|
+
`not set and the CMS worker has not created ${expectedRemotePath}; the worker may still be starting.`);
|
|
309
|
+
this.expectedRemotePath = expectedRemotePath;
|
|
310
|
+
this.name = 'RemoteNotReadyError';
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Settings provisioning could not read the remote's settings branch, so it cannot tell a remote
|
|
315
|
+
* that has none from one it failed to reach. It stops instead of starting an empty settings
|
|
316
|
+
* branch: settings that silently vanish drop restrictive path rules, so content they hid
|
|
317
|
+
* becomes readable under `defaultPathAccess: { read: 'allow' }`.
|
|
318
|
+
*/
|
|
319
|
+
class SettingsRemoteUnreadableError extends Error {
|
|
320
|
+
constructor(branch, remote,
|
|
321
|
+
/** The underlying git failure; see {@link GitRemoteRefMissingError.gitError}. */
|
|
322
|
+
gitError) {
|
|
323
|
+
super(`CanopyCMS: could not read settings branch '${branch}' from remote '${remote}', so ` +
|
|
324
|
+
`the settings workspace was not provisioned: ${getErrorMessage(gitError)}`);
|
|
325
|
+
this.branch = branch;
|
|
326
|
+
this.remote = remote;
|
|
327
|
+
this.gitError = gitError;
|
|
328
|
+
this.name = 'SettingsRemoteUnreadableError';
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* The workspace's settings branch shares no history with the remote's and holds work of its
|
|
333
|
+
* own, so neither copy can be chosen automatically, and a settings save could never pull.
|
|
334
|
+
*/
|
|
335
|
+
class SettingsBranchDivergedError extends Error {
|
|
336
|
+
constructor(branch, workspacePath,
|
|
337
|
+
/** What the workspace holds beyond an empty initial commit. */
|
|
338
|
+
localWork) {
|
|
339
|
+
super(`CanopyCMS: settings branch '${branch}' in ${workspacePath} shares no history with the ` +
|
|
340
|
+
`remote's '${branch}', and holds ${localWork}. Settings saves cannot pull until one copy ` +
|
|
341
|
+
`is chosen. To keep the remote's settings: copy anything you need out of ` +
|
|
342
|
+
`${workspacePath}, move it aside, and restart; the next start checks out the remote's ` +
|
|
343
|
+
`'${branch}', and copied changes can then be re-applied in the editor.`);
|
|
344
|
+
this.branch = branch;
|
|
345
|
+
this.workspacePath = workspacePath;
|
|
346
|
+
this.localWork = localWork;
|
|
347
|
+
this.name = 'SettingsBranchDivergedError';
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
/** The only files a settings branch ever commits (explicit paths at its root). */
|
|
351
|
+
const SETTINGS_BRANCH_FILES = new Set(['permissions.json', 'groups.json']);
|
|
352
|
+
/**
|
|
353
|
+
* The remote's settings branch holds content rather than settings. Adopting it would load no
|
|
354
|
+
* groups or path rules, and settings saves would then commit onto content history.
|
|
355
|
+
*/
|
|
356
|
+
class SettingsBranchHasContentHistoryError extends Error {
|
|
357
|
+
constructor(branch, remote,
|
|
358
|
+
/** What shows it is content. */
|
|
359
|
+
evidence) {
|
|
360
|
+
super(`CanopyCMS: the settings branch '${branch}' on remote '${remote}' holds content, not ` +
|
|
361
|
+
`settings (${evidence}), so it was not checked out. Restore '${branch}' on the remote ` +
|
|
362
|
+
`from a copy of the settings (it may hold only permissions.json and groups.json), ` +
|
|
363
|
+
`then restart.`);
|
|
364
|
+
this.branch = branch;
|
|
365
|
+
this.remote = remote;
|
|
366
|
+
this.evidence = evidence;
|
|
367
|
+
this.name = 'SettingsBranchHasContentHistoryError';
|
|
368
|
+
}
|
|
369
|
+
}
|
|
169
370
|
export class GitManager {
|
|
170
371
|
constructor(options, gitOptions) {
|
|
171
372
|
this.repoPath = path.resolve(options.repoPath);
|
|
172
373
|
this.baseBranch = options.baseBranch ?? 'main';
|
|
173
374
|
this.remote = options.remote ?? 'origin';
|
|
174
375
|
this.skipIndexMarker = options.skipIndexMarker ?? false;
|
|
175
|
-
this.git = simpleGit({
|
|
376
|
+
this.git = simpleGit({
|
|
377
|
+
baseDir: this.repoPath,
|
|
378
|
+
errors: failOnSignalExit,
|
|
379
|
+
...gitOptions,
|
|
380
|
+
config: [...NO_AUTO_GC_CONFIG, ...(gitOptions?.config ?? [])],
|
|
381
|
+
});
|
|
176
382
|
// `this.git` is for LOCAL working-tree ops: in the intended prod topology
|
|
177
383
|
// `origin` resolves to a local path (an auto-detected/initialized
|
|
178
384
|
// `remote.git`), so its env is gitChildEnv's allowlist, which drops
|
|
@@ -187,14 +393,47 @@ export class GitManager {
|
|
|
187
393
|
// operate on the host repo above.
|
|
188
394
|
this.git.env(gitChildEnv({ GIT_CEILING_DIRECTORIES: path.dirname(this.repoPath) }));
|
|
189
395
|
}
|
|
190
|
-
|
|
396
|
+
/**
|
|
397
|
+
* Single-branch clone of `baseBranch`, retried once: a clone can lose a race
|
|
398
|
+
* with the worker's repack of `remote.git` deleting a pack it had just
|
|
399
|
+
* listed. Before the retry it clears only what it created itself, so a
|
|
400
|
+
* target that held files beforehand is never touched.
|
|
401
|
+
*/
|
|
402
|
+
static async cloneRepo(remoteUrl, targetPath, baseBranch = 'main', options = {}) {
|
|
191
403
|
log.debug('git', 'Cloning repository', {
|
|
192
404
|
remoteUrl,
|
|
193
405
|
targetPath,
|
|
194
406
|
baseBranch,
|
|
195
407
|
});
|
|
196
|
-
const
|
|
197
|
-
|
|
408
|
+
const args = ['--single-branch', '--branch', baseBranch];
|
|
409
|
+
if (options.noCheckout)
|
|
410
|
+
args.push('--no-checkout');
|
|
411
|
+
if (options.remoteName)
|
|
412
|
+
args.push('--origin', options.remoteName);
|
|
413
|
+
for (const [key, value] of Object.entries(options.config ?? {})) {
|
|
414
|
+
args.push('-c', `${key}=${value}`);
|
|
415
|
+
}
|
|
416
|
+
for (const setting of NO_AUTO_GC_CONFIG)
|
|
417
|
+
args.push('-c', setting);
|
|
418
|
+
const git = simpleGit({ config: [...NO_AUTO_GC_CONFIG], errors: failOnSignalExit });
|
|
419
|
+
git.env({
|
|
420
|
+
...(isNetworkRemoteUrl(remoteUrl) ? gitNetworkChildEnv() : gitChildEnv({})),
|
|
421
|
+
GIT_CEILING_DIRECTORIES: path.dirname(targetPath),
|
|
422
|
+
});
|
|
423
|
+
const before = await dirState(targetPath);
|
|
424
|
+
try {
|
|
425
|
+
await git.clone(remoteUrl, targetPath, args);
|
|
426
|
+
}
|
|
427
|
+
catch (err) {
|
|
428
|
+
if (before === 'occupied')
|
|
429
|
+
throw err;
|
|
430
|
+
canopyLogWarn(`[canopy] clone into ${targetPath} failed, retrying once: ` +
|
|
431
|
+
redactCredentials(getErrorMessage(err)));
|
|
432
|
+
await fs.rm(targetPath, { recursive: true, force: true });
|
|
433
|
+
if (before === 'empty')
|
|
434
|
+
await fs.mkdir(targetPath);
|
|
435
|
+
await git.clone(remoteUrl, targetPath, args);
|
|
436
|
+
}
|
|
198
437
|
log.debug('git', 'Clone complete');
|
|
199
438
|
}
|
|
200
439
|
/**
|
|
@@ -243,9 +482,24 @@ export class GitManager {
|
|
|
243
482
|
if (!isNotFoundError(err))
|
|
244
483
|
throw err;
|
|
245
484
|
}
|
|
485
|
+
if (remoteExists) {
|
|
486
|
+
// The worker's per-cycle repack never runs in dev; once per call keeps pushes from
|
|
487
|
+
// piling up packs. Best-effort: a remote it cannot compact still serves.
|
|
488
|
+
try {
|
|
489
|
+
const repack = await repackBareRemoteIfNeeded(options.remotePath);
|
|
490
|
+
if (repack.repacked)
|
|
491
|
+
log.debug('git', 'Repacked local simulated remote', repack);
|
|
492
|
+
}
|
|
493
|
+
catch (err) {
|
|
494
|
+
log.debug('git', 'Could not repack local simulated remote', {
|
|
495
|
+
error: getErrorMessage(err),
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
}
|
|
246
499
|
if (remoteExists &&
|
|
247
500
|
(await GitManager.bareRemoteHasBranch(options.remotePath, options.baseBranch))) {
|
|
248
501
|
log.debug('git', 'Remote already has base branch, skipping');
|
|
502
|
+
await ensureRemoteGitConfig(options.remotePath);
|
|
249
503
|
return;
|
|
250
504
|
}
|
|
251
505
|
// Find the actual git root directory — the subdirectory snapshot path
|
|
@@ -303,6 +557,7 @@ export class GitManager {
|
|
|
303
557
|
options.remotePath,
|
|
304
558
|
]);
|
|
305
559
|
}
|
|
560
|
+
await ensureRemoteGitConfig(options.remotePath);
|
|
306
561
|
// Push baseBranch to remote (not current HEAD)
|
|
307
562
|
await GitManager.pushBranchToLocalRemote({
|
|
308
563
|
sourceGit,
|
|
@@ -407,7 +662,7 @@ export class GitManager {
|
|
|
407
662
|
* deleteBranchHandler: a head left in `remote.git` forever makes the
|
|
408
663
|
* create -> publish -> squash-merge -> delete -> reuse-the-name cycle reject
|
|
409
664
|
* the reused branch's first publish non-fast-forward against the stale head
|
|
410
|
-
* (`GitManager.push()` pushes
|
|
665
|
+
* (`GitManager.push()` pushes the branch to the same name, and a squash-merged old tip is
|
|
411
666
|
* not an ancestor of the new branch), and a retried submit then skips the
|
|
412
667
|
* local push on a clean tree and enqueues the worker push of the STALE head,
|
|
413
668
|
* resurrecting the deleted branch's content on GitHub as an apparent success.
|
|
@@ -562,6 +817,12 @@ export class GitManager {
|
|
|
562
817
|
`intentionally runs git against a network remote (e.g. a single-VM deployment), set ` +
|
|
563
818
|
`config.allowNetworkRemoteInProd: true to acknowledge this.`);
|
|
564
819
|
}
|
|
820
|
+
/** The not-ready error for a mode whose remote is auto-detected, or `undefined` for other modes. */
|
|
821
|
+
static async remoteNotReadyError(mode) {
|
|
822
|
+
const { operatingStrategy } = await import('./operating-mode/index.js');
|
|
823
|
+
const { autoDetectRemotePath } = operatingStrategy(mode).getRemoteUrlConfig();
|
|
824
|
+
return autoDetectRemotePath ? new RemoteNotReadyError(autoDetectRemotePath) : undefined;
|
|
825
|
+
}
|
|
565
826
|
/**
|
|
566
827
|
* Resolves the remote URL for git operations following the priority:
|
|
567
828
|
* 1. Explicit remoteUrl parameter
|
|
@@ -627,6 +888,33 @@ export class GitManager {
|
|
|
627
888
|
}
|
|
628
889
|
return undefined;
|
|
629
890
|
}
|
|
891
|
+
/**
|
|
892
|
+
* {@link resolveRemoteUrl} for a workspace about to be cloned, which needs a
|
|
893
|
+
* remote: none resolving is the worker-not-ready error in a mode whose
|
|
894
|
+
* remote the worker creates, and a configuration error otherwise.
|
|
895
|
+
*/
|
|
896
|
+
static async resolveCloneRemoteUrl(options) {
|
|
897
|
+
const remoteUrl = await GitManager.resolveRemoteUrl(options);
|
|
898
|
+
if (remoteUrl)
|
|
899
|
+
return remoteUrl;
|
|
900
|
+
const notReady = await GitManager.remoteNotReadyError(options.mode);
|
|
901
|
+
if (notReady)
|
|
902
|
+
throw notReady;
|
|
903
|
+
throw new Error('CanopyCMS: defaultRemoteUrl (or CANOPYCMS_REMOTE_URL) is required to initialize workspace');
|
|
904
|
+
}
|
|
905
|
+
/**
|
|
906
|
+
* {@link cloneRepo} for a workspace, failing with one message that names the
|
|
907
|
+
* workspace, the remote and the base branch, which git's own error mixes.
|
|
908
|
+
*/
|
|
909
|
+
static async cloneWorkspace(remoteUrl, workspacePath, baseBranch, options) {
|
|
910
|
+
try {
|
|
911
|
+
await GitManager.cloneRepo(remoteUrl, workspacePath, baseBranch, options);
|
|
912
|
+
}
|
|
913
|
+
catch (err) {
|
|
914
|
+
throw new Error(`Failed to clone branch workspace at ${workspacePath} ` +
|
|
915
|
+
`from ${remoteUrl} (base branch '${baseBranch}'): ${getErrorMessage(err)}`);
|
|
916
|
+
}
|
|
917
|
+
}
|
|
630
918
|
/**
|
|
631
919
|
* Check whether a git repository is already initialized at `workspacePath`.
|
|
632
920
|
*
|
|
@@ -685,9 +973,12 @@ export class GitManager {
|
|
|
685
973
|
throw cleanupErr;
|
|
686
974
|
}
|
|
687
975
|
}
|
|
976
|
+
const step = (name, run) => options.provisionLog ? options.provisionLog.step(name, run) : run();
|
|
977
|
+
const identity = managedWorkspaceConfig(options.gitBotAuthorName, options.gitBotAuthorEmail);
|
|
688
978
|
let justCloned = false;
|
|
979
|
+
let resolvedRemoteUrl;
|
|
689
980
|
if (!repoExists) {
|
|
690
|
-
const remoteUrl = await GitManager.
|
|
981
|
+
const remoteUrl = await GitManager.resolveCloneRemoteUrl({
|
|
691
982
|
mode: options.mode,
|
|
692
983
|
remoteUrl: options.remoteUrl,
|
|
693
984
|
defaultRemoteUrl: options.defaultRemoteUrl,
|
|
@@ -695,28 +986,15 @@ export class GitManager {
|
|
|
695
986
|
sourceRoot: options.sourceRoot,
|
|
696
987
|
allowNetworkRemoteInProd: options.allowNetworkRemoteInProd,
|
|
697
988
|
});
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
// The raw git error ("Cloning into <workspace>… branch <base> not found")
|
|
706
|
-
// mixes the workspace name and the base branch — spell both out.
|
|
707
|
-
throw new Error(`Failed to clone branch workspace at ${options.workspacePath} ` +
|
|
708
|
-
`from ${remoteUrl} (base branch '${baseBranch}'): ${getErrorMessage(err)}`);
|
|
709
|
-
}
|
|
989
|
+
// A content clone skips its own checkout because checkoutFreshClone runs
|
|
990
|
+
// the one checkout this workspace needs.
|
|
991
|
+
await step('clone', () => GitManager.cloneWorkspace(remoteUrl, options.workspacePath, baseBranch, {
|
|
992
|
+
remoteName,
|
|
993
|
+
noCheckout: options.branchType === 'content',
|
|
994
|
+
config: identity,
|
|
995
|
+
}));
|
|
710
996
|
justCloned = true;
|
|
711
|
-
|
|
712
|
-
// and set a fallback author identity: GIT_CEILING_DIRECTORIES blocks
|
|
713
|
-
// global gitconfig, and internal commits (e.g. orphan branch init) need
|
|
714
|
-
// one. ensureAuthor() sets the real bot author before user-facing commits.
|
|
715
|
-
const freshGit = simpleGit({ baseDir: options.workspacePath });
|
|
716
|
-
freshGit.env(gitChildEnv({ GIT_CEILING_DIRECTORIES: path.dirname(options.workspacePath) }));
|
|
717
|
-
await freshGit.addConfig('canopycms.managed', 'true');
|
|
718
|
-
await freshGit.addConfig('user.name', options.gitBotAuthorName);
|
|
719
|
-
await freshGit.addConfig('user.email', options.gitBotAuthorEmail);
|
|
997
|
+
resolvedRemoteUrl = remoteUrl;
|
|
720
998
|
}
|
|
721
999
|
// Settings (orphan) workspaces never host ContentStores, so they skip the
|
|
722
1000
|
// on-disk content-index generation marker.
|
|
@@ -726,46 +1004,55 @@ export class GitManager {
|
|
|
726
1004
|
remote: remoteName,
|
|
727
1005
|
skipIndexMarker: options.branchType === 'orphan',
|
|
728
1006
|
});
|
|
729
|
-
//
|
|
730
|
-
//
|
|
731
|
-
// commits and needs an author). Idempotent — the clone above may have set them.
|
|
732
|
-
await git.git.addConfig('canopycms.managed', 'true');
|
|
733
|
-
await git.git.addConfig('user.name', options.gitBotAuthorName);
|
|
734
|
-
await git.git.addConfig('user.email', options.gitBotAuthorEmail);
|
|
735
|
-
log.debug('git', 'Marked workspace as CanopyCMS-managed', {
|
|
736
|
-
workspacePath: options.workspacePath,
|
|
737
|
-
});
|
|
738
|
-
// Configure the remote only if we didn't just clone (clone sets up 'origin')
|
|
1007
|
+
// A reused workspace gets the same marker and identity the clone writes,
|
|
1008
|
+
// set before ensureRemote checks the marker; a healthy one writes nothing.
|
|
739
1009
|
if (!justCloned) {
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
remoteUrl
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
1010
|
+
await step('config', async () => {
|
|
1011
|
+
await git.ensureLocalConfig(identity);
|
|
1012
|
+
const remoteUrl = await GitManager.resolveRemoteUrl({
|
|
1013
|
+
mode: options.mode,
|
|
1014
|
+
remoteUrl: options.remoteUrl,
|
|
1015
|
+
defaultRemoteUrl: options.defaultRemoteUrl,
|
|
1016
|
+
baseBranch,
|
|
1017
|
+
sourceRoot: options.sourceRoot,
|
|
1018
|
+
allowNetworkRemoteInProd: options.allowNetworkRemoteInProd,
|
|
1019
|
+
});
|
|
1020
|
+
if (remoteUrl) {
|
|
1021
|
+
await git.ensureRemote(remoteUrl);
|
|
1022
|
+
}
|
|
1023
|
+
resolvedRemoteUrl = remoteUrl;
|
|
747
1024
|
});
|
|
748
|
-
if (remoteUrl) {
|
|
749
|
-
await git.ensureRemote(remoteUrl);
|
|
750
|
-
}
|
|
751
1025
|
}
|
|
752
1026
|
if (options.branchType === 'orphan') {
|
|
753
|
-
|
|
1027
|
+
try {
|
|
1028
|
+
await step('checkout', () => git.createOrphanSettingsBranch(options.branchName, {}));
|
|
1029
|
+
}
|
|
1030
|
+
catch (err) {
|
|
1031
|
+
// A remote that is unreadable because the worker has not (re)created
|
|
1032
|
+
// it yet is the transient not-ready case, not a broken workspace.
|
|
1033
|
+
if (err instanceof SettingsRemoteUnreadableError && !resolvedRemoteUrl) {
|
|
1034
|
+
throw (await GitManager.remoteNotReadyError(options.mode)) ?? err;
|
|
1035
|
+
}
|
|
1036
|
+
throw err;
|
|
1037
|
+
}
|
|
754
1038
|
// Settings mutations hold an OCC lockfile (<file>.lock, see
|
|
755
1039
|
// authorization/settings-file-store.ts) inside this git-committed
|
|
756
1040
|
// workspace. Commits here stage explicit paths, but a crash-orphaned lock
|
|
757
1041
|
// dir must never be committable by a future broad stage either. Runs on
|
|
758
1042
|
// every init, so existing clones pick it up.
|
|
759
|
-
await git.ensureGitExclude('*.lock');
|
|
1043
|
+
await step('exclude', () => git.ensureGitExclude('*.lock'));
|
|
760
1044
|
}
|
|
761
1045
|
else {
|
|
762
|
-
await
|
|
1046
|
+
await step('checkout', () => justCloned
|
|
1047
|
+
? git.checkoutFreshClone(options.branchName)
|
|
1048
|
+
: git.checkoutBranch(options.branchName));
|
|
763
1049
|
// Excludes runtime metadata (.canopy-meta/) from git tracking on content
|
|
764
1050
|
// branches. Settings workspaces don't need it: they stage explicit file
|
|
765
1051
|
// paths at the workspace root and skip the index marker entirely
|
|
766
1052
|
// (skipIndexMarker), so nothing under .canopy-meta/ is ever staged.
|
|
767
|
-
|
|
768
|
-
|
|
1053
|
+
const pattern = options.gitExcludePattern;
|
|
1054
|
+
if (pattern) {
|
|
1055
|
+
await step('exclude', () => git.ensureGitExclude(pattern));
|
|
769
1056
|
}
|
|
770
1057
|
}
|
|
771
1058
|
return git;
|
|
@@ -808,6 +1095,24 @@ export class GitManager {
|
|
|
808
1095
|
await this.invalidateContentIndexes();
|
|
809
1096
|
}
|
|
810
1097
|
}
|
|
1098
|
+
/**
|
|
1099
|
+
* The one checkout of a `--no-checkout` clone, which has the base branch
|
|
1100
|
+
* and its remote-tracking ref and nothing else, so neither the branch
|
|
1101
|
+
* listing nor the fetch that {@link checkoutBranch} starts with is needed.
|
|
1102
|
+
* The missing index makes git populate the whole tree, even for the base
|
|
1103
|
+
* branch HEAD already names.
|
|
1104
|
+
*/
|
|
1105
|
+
async checkoutFreshClone(branch) {
|
|
1106
|
+
const target = branch === this.baseBranch
|
|
1107
|
+
? ['checkout', branch]
|
|
1108
|
+
: ['checkout', '-b', branch, `${this.remote}/${this.baseBranch}`];
|
|
1109
|
+
try {
|
|
1110
|
+
await this.git.raw([...PARALLEL_CHECKOUT_ARGS, ...target]);
|
|
1111
|
+
}
|
|
1112
|
+
finally {
|
|
1113
|
+
await this.invalidateContentIndexes();
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
811
1116
|
async checkoutBranchInner(branch) {
|
|
812
1117
|
const branches = await this.git.branch();
|
|
813
1118
|
if (branches.all.includes(branch)) {
|
|
@@ -893,7 +1198,8 @@ export class GitManager {
|
|
|
893
1198
|
const branches = await this.git.branch();
|
|
894
1199
|
const currentBranch = branches.current;
|
|
895
1200
|
try {
|
|
896
|
-
|
|
1201
|
+
// The full ref: a bare name resolves to a same-named tag first.
|
|
1202
|
+
await this.git.fetch(this.remote, `refs/heads/${currentBranch}`);
|
|
897
1203
|
}
|
|
898
1204
|
catch (err) {
|
|
899
1205
|
// The only benign failure here: the branch has never been pushed, so the
|
|
@@ -966,17 +1272,31 @@ export class GitManager {
|
|
|
966
1272
|
throw err;
|
|
967
1273
|
}
|
|
968
1274
|
}
|
|
1275
|
+
/** `--sparse` stages a named path outside a sparse clone's cone (branch-sparse.ts). */
|
|
969
1276
|
async add(files) {
|
|
970
1277
|
const fileArray = Array.isArray(files) ? files : [files];
|
|
971
|
-
await this.git.add
|
|
1278
|
+
await this.git.raw(['add', '--sparse', '--', ...fileArray]);
|
|
1279
|
+
}
|
|
1280
|
+
/**
|
|
1281
|
+
* Restrict the working tree to `dirs` in cone mode, which always keeps the root-level files.
|
|
1282
|
+
* Runs before a `--no-checkout` clone's one checkout, which invalidates the content caches.
|
|
1283
|
+
*/
|
|
1284
|
+
async setSparseCone(dirs) {
|
|
1285
|
+
await this.git.raw(['sparse-checkout', 'set', '--cone', '--', ...dirs]);
|
|
1286
|
+
}
|
|
1287
|
+
/** Stage every working-tree change except canopycms's own state. See {@link stageAllExceptCanopyState}. */
|
|
1288
|
+
async addAllExceptCanopyState() {
|
|
1289
|
+
await stageAllExceptCanopyState(this.git);
|
|
972
1290
|
}
|
|
973
1291
|
async commit(message) {
|
|
974
1292
|
await this.git.commit(message);
|
|
975
1293
|
}
|
|
976
1294
|
async push(branch) {
|
|
977
|
-
const target = branch ?? (await this.
|
|
978
|
-
// Explicit refspec (local:remote) so push works for branches not
|
|
979
|
-
// remote (e.g. orphan settings branches)
|
|
1295
|
+
const target = branch ?? (await this.currentBranchName());
|
|
1296
|
+
// Explicit full-ref refspec (local:remote) so push works for branches not
|
|
1297
|
+
// yet in the remote (e.g. orphan settings branches), and a same-named tag
|
|
1298
|
+
// cannot redirect it: a short name for a new remote branch would create
|
|
1299
|
+
// `refs/heads/heads/<name>`. Built via raw() rather than the
|
|
980
1300
|
// push() wrapper so `--end-of-options` sits immediately before the
|
|
981
1301
|
// positional remote/refspec, guarding against a refspec starting with '-'
|
|
982
1302
|
// being parsed as a git option (e.g. --receive-pack=...). Real flags must
|
|
@@ -986,9 +1306,20 @@ export class GitManager {
|
|
|
986
1306
|
'--set-upstream',
|
|
987
1307
|
'--end-of-options',
|
|
988
1308
|
this.remote,
|
|
989
|
-
|
|
1309
|
+
`refs/heads/${target}:refs/heads/${target}`,
|
|
990
1310
|
]);
|
|
991
1311
|
}
|
|
1312
|
+
/**
|
|
1313
|
+
* The checked-out branch's plain name. `rev-parse --abbrev-ref HEAD` prints
|
|
1314
|
+
* `heads/<name>` when a same-named tag exists, which is not a branch name.
|
|
1315
|
+
*/
|
|
1316
|
+
async currentBranchName() {
|
|
1317
|
+
const branches = await this.git.branch();
|
|
1318
|
+
if (branches.detached || !branches.current) {
|
|
1319
|
+
throw new Error(`CanopyCMS: no branch is checked out (detached HEAD) in ${this.repoPath}`);
|
|
1320
|
+
}
|
|
1321
|
+
return branches.current;
|
|
1322
|
+
}
|
|
992
1323
|
/**
|
|
993
1324
|
* Whether the local branch has commits the remote mirror doesn't -- i.e.
|
|
994
1325
|
* whether push() would move the remote ref forward.
|
|
@@ -1010,12 +1341,14 @@ export class GitManager {
|
|
|
1010
1341
|
// `--end-of-options` before every caller-influenced ref name, as in push()
|
|
1011
1342
|
// above: names are sanitized upstream, but this file's rule is that a
|
|
1012
1343
|
// positional is guarded where it is passed, not where it was validated.
|
|
1013
|
-
|
|
1014
|
-
|
|
1344
|
+
// Each rev-parse needs `--verify`: without it, rev-parse echoes
|
|
1345
|
+
// `--end-of-options` as its own output line ahead of the result.
|
|
1346
|
+
const target = branch ?? (await this.git.revparse(['--verify', '--abbrev-ref', '--end-of-options', 'HEAD']));
|
|
1347
|
+
const localSha = (await this.git.revparse(['--verify', '--end-of-options', target])).trim();
|
|
1015
1348
|
let fetchedTip;
|
|
1016
1349
|
try {
|
|
1017
1350
|
await this.git.raw(['fetch', '--end-of-options', this.remote, target]);
|
|
1018
|
-
fetchedTip = (await this.git.revparse(['--end-of-options', 'FETCH_HEAD'])).trim();
|
|
1351
|
+
fetchedTip = (await this.git.revparse(['--verify', '--end-of-options', 'FETCH_HEAD'])).trim();
|
|
1019
1352
|
}
|
|
1020
1353
|
catch {
|
|
1021
1354
|
// No ref on the remote yet -- the branch has never been pushed.
|
|
@@ -1028,6 +1361,27 @@ export class GitManager {
|
|
|
1028
1361
|
const aheadCount = (await this.git.raw(['rev-list', '--count', `${fetchedTip}..${localSha}`])).trim();
|
|
1029
1362
|
return aheadCount !== '0';
|
|
1030
1363
|
}
|
|
1364
|
+
/**
|
|
1365
|
+
* Repo-relative paths that differ between HEAD and its merge base with the
|
|
1366
|
+
* base branch: everything the branch changes, across all of its commits.
|
|
1367
|
+
* Diffs against the just-fetched base tip pinned to a SHA — the pullBaseInner
|
|
1368
|
+
* constraint.
|
|
1369
|
+
*/
|
|
1370
|
+
async listChangedPathsSinceBase() {
|
|
1371
|
+
await this.git.raw(['fetch', '--end-of-options', this.remote, this.baseBranch]);
|
|
1372
|
+
// `--verify`: without it rev-parse echoes `--end-of-options` back as output.
|
|
1373
|
+
const baseTip = (await this.git.revparse(['--verify', '--end-of-options', 'FETCH_HEAD'])).trim();
|
|
1374
|
+
// -z: NUL-separated and unquoted, so no path needs unescaping.
|
|
1375
|
+
const output = await this.git.raw([
|
|
1376
|
+
'diff',
|
|
1377
|
+
'--name-only',
|
|
1378
|
+
'-z',
|
|
1379
|
+
'--no-renames',
|
|
1380
|
+
`${baseTip}...HEAD`,
|
|
1381
|
+
'--',
|
|
1382
|
+
]);
|
|
1383
|
+
return output.split('\0').filter((p) => p.length > 0);
|
|
1384
|
+
}
|
|
1031
1385
|
async ensureAuthor(author) {
|
|
1032
1386
|
const config = (await this.git.listConfig());
|
|
1033
1387
|
// Verify this is a CanopyCMS-managed workspace before setting author
|
|
@@ -1046,6 +1400,14 @@ export class GitManager {
|
|
|
1046
1400
|
await this.git.addConfig('user.email', author.email);
|
|
1047
1401
|
}
|
|
1048
1402
|
}
|
|
1403
|
+
/** Writes each key into this repo's own config only where it differs. */
|
|
1404
|
+
async ensureLocalConfig(values) {
|
|
1405
|
+
const local = (await this.git.listConfig('local'));
|
|
1406
|
+
for (const [key, value] of Object.entries(values)) {
|
|
1407
|
+
if (local.all[key] !== value)
|
|
1408
|
+
await this.git.addConfig(key, value);
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1049
1411
|
async ensureRemote(remoteUrl) {
|
|
1050
1412
|
// Safety: verify this is a managed workspace before modifying remotes.
|
|
1051
1413
|
// Prevents accidentally overwriting the host repo's origin if git
|
|
@@ -1089,10 +1451,24 @@ export class GitManager {
|
|
|
1089
1451
|
await ensureGitExcludePattern(this.repoPath, pattern);
|
|
1090
1452
|
}
|
|
1091
1453
|
/**
|
|
1092
|
-
*
|
|
1093
|
-
* deployment-specific settings never pollute content history. It holds
|
|
1094
|
-
* settings files committed by explicit path (permissions.json,
|
|
1095
|
-
* the workspace root).
|
|
1454
|
+
* Put the workspace on the settings branch: an orphan (no shared history),
|
|
1455
|
+
* so deployment-specific settings never pollute content history. It holds
|
|
1456
|
+
* only settings files committed by explicit path (permissions.json,
|
|
1457
|
+
* groups.json at the workspace root).
|
|
1458
|
+
*
|
|
1459
|
+
* The remote is the settings branch's durable copy, and the workspace clone
|
|
1460
|
+
* is `--single-branch` at the base branch, so the remote is asked first:
|
|
1461
|
+
* - no local branch: check out the remote's, or create an empty orphan only
|
|
1462
|
+
* when the remote has none. An unreadable remote throws
|
|
1463
|
+
* {@link SettingsRemoteUnreadableError} rather than guess "none".
|
|
1464
|
+
* - local branch present: a history unrelated to the remote's is repaired
|
|
1465
|
+
* onto the remote's when the local branch is still its empty initial
|
|
1466
|
+
* commit with a clean working tree, and otherwise throws
|
|
1467
|
+
* {@link SettingsBranchDivergedError}; local commits are never discarded.
|
|
1468
|
+
* An unreadable remote throws only for that empty branch, since one that
|
|
1469
|
+
* holds settings can serve them and the next save surfaces the remote error.
|
|
1470
|
+
* Either way a remote branch holding content is never adopted:
|
|
1471
|
+
* {@link SettingsBranchHasContentHistoryError}.
|
|
1096
1472
|
*/
|
|
1097
1473
|
async createOrphanSettingsBranch(branchName, initialFiles) {
|
|
1098
1474
|
try {
|
|
@@ -1104,26 +1480,49 @@ export class GitManager {
|
|
|
1104
1480
|
}
|
|
1105
1481
|
}
|
|
1106
1482
|
async createOrphanSettingsBranchInner(branchName, initialFiles) {
|
|
1107
|
-
log.debug('git', '
|
|
1483
|
+
log.debug('git', 'Ensuring settings branch', { branchName });
|
|
1108
1484
|
const branches = await this.git.branch();
|
|
1109
|
-
|
|
1110
|
-
|
|
1485
|
+
const localExists = branches.all.includes(branchName);
|
|
1486
|
+
if (localExists) {
|
|
1111
1487
|
// No separator here — see checkoutBranch() above for why plain
|
|
1112
1488
|
// `git checkout <branch>` can't safely take one. branchName is always an
|
|
1113
1489
|
// internal/config-derived settings-branch name, never user input.
|
|
1114
1490
|
await this.git.checkout(branchName);
|
|
1491
|
+
}
|
|
1492
|
+
let remoteTip;
|
|
1493
|
+
try {
|
|
1494
|
+
remoteTip = await this.remoteBranchTip(branchName);
|
|
1495
|
+
}
|
|
1496
|
+
catch (err) {
|
|
1497
|
+
if (localExists && !(await this.isEmptyInitialBranch(branchName))) {
|
|
1498
|
+
log.debug('git', 'Remote unreadable; serving the local settings branch', { branchName });
|
|
1499
|
+
return;
|
|
1500
|
+
}
|
|
1501
|
+
throw new SettingsRemoteUnreadableError(branchName, this.remote, err);
|
|
1502
|
+
}
|
|
1503
|
+
if (localExists) {
|
|
1504
|
+
await this.reconcileLocalSettingsBranch(branchName, remoteTip);
|
|
1505
|
+
return;
|
|
1506
|
+
}
|
|
1507
|
+
if (remoteTip) {
|
|
1508
|
+
const fetchedTip = await this.fetchBranchTip(branchName);
|
|
1509
|
+
await this.assertNotContentHistory(branchName, fetchedTip);
|
|
1510
|
+
// `-b` consumes branchName as its literal value; see checkoutBranchInner.
|
|
1511
|
+
await this.git.raw(['checkout', '-b', branchName, fetchedTip]);
|
|
1512
|
+
log.debug('git', 'Checked out the remote settings branch', { branchName });
|
|
1115
1513
|
return;
|
|
1116
1514
|
}
|
|
1117
1515
|
// branchName is consumed as --orphan's literal argument value (like -b/-B
|
|
1118
1516
|
// above), so it can't be reinterpreted as a flag; git's own ref-name
|
|
1119
1517
|
// validation additionally rejects a leading-hyphen value here.
|
|
1120
1518
|
await this.git.raw(['checkout', '--orphan', branchName]);
|
|
1121
|
-
//
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1519
|
+
// The orphan starts from the base branch's index. `--sparse` also removes entries outside a
|
|
1520
|
+
// sparse cone, which a plain `rm` leaves in the index, and so in the first commit.
|
|
1521
|
+
await this.git.raw(['rm', '-r', '-f', '-q', '--sparse', '--ignore-unmatch', '--', '.']);
|
|
1522
|
+
const leftover = (await this.git.raw(['ls-files'])).trim();
|
|
1523
|
+
if (leftover) {
|
|
1524
|
+
throw new Error(`Settings branch '${branchName}' was not created: the base branch's files ` +
|
|
1525
|
+
`(${leftover.split('\n').slice(0, 3).join(', ')}) stayed in its index`);
|
|
1127
1526
|
}
|
|
1128
1527
|
for (const [filePath, content] of Object.entries(initialFiles)) {
|
|
1129
1528
|
const absolutePath = path.join(this.repoPath, filePath);
|
|
@@ -1134,4 +1533,123 @@ export class GitManager {
|
|
|
1134
1533
|
await this.git.commit('Initialize settings branch', ['--allow-empty']);
|
|
1135
1534
|
log.debug('git', 'Orphan settings branch created', { branchName });
|
|
1136
1535
|
}
|
|
1536
|
+
/**
|
|
1537
|
+
* Compares the checked-out local settings branch with the remote's tip.
|
|
1538
|
+
* Related histories are left for the next settings pull to reconcile; only
|
|
1539
|
+
* an unrelated one is acted on here.
|
|
1540
|
+
*/
|
|
1541
|
+
async reconcileLocalSettingsBranch(branchName, remoteTip) {
|
|
1542
|
+
if (!remoteTip)
|
|
1543
|
+
return;
|
|
1544
|
+
const localTip = (await this.git.revparse([`refs/heads/${branchName}`])).trim();
|
|
1545
|
+
if (localTip === remoteTip)
|
|
1546
|
+
return;
|
|
1547
|
+
const fetchedTip = await this.fetchBranchTip(branchName);
|
|
1548
|
+
const [localRoots, remoteRoots] = await Promise.all([
|
|
1549
|
+
this.rootCommits(localTip),
|
|
1550
|
+
this.rootCommits(fetchedTip),
|
|
1551
|
+
]);
|
|
1552
|
+
if (localRoots.some((root) => remoteRoots.includes(root)))
|
|
1553
|
+
return;
|
|
1554
|
+
const localWork = [];
|
|
1555
|
+
if (!(await this.isEmptyInitialBranch(branchName))) {
|
|
1556
|
+
localWork.push('commits beyond its empty initial commit');
|
|
1557
|
+
}
|
|
1558
|
+
const uncommitted = await this.getUncommittedFiles();
|
|
1559
|
+
if (uncommitted.length > 0) {
|
|
1560
|
+
localWork.push(`uncommitted changes to ${uncommitted.join(', ')}`);
|
|
1561
|
+
}
|
|
1562
|
+
if (localWork.length > 0) {
|
|
1563
|
+
throw new SettingsBranchDivergedError(branchName, this.repoPath, localWork.join(' and '));
|
|
1564
|
+
}
|
|
1565
|
+
await this.assertNotContentHistory(branchName, fetchedTip);
|
|
1566
|
+
// `checkout -B` resets the current branch onto the fetched tip, and
|
|
1567
|
+
// refuses rather than overwrite a file written since the check above.
|
|
1568
|
+
await this.git.raw(['checkout', '-B', branchName, fetchedTip]);
|
|
1569
|
+
log.debug('git', 'Replaced an empty settings branch with the remote one', { branchName });
|
|
1570
|
+
}
|
|
1571
|
+
/**
|
|
1572
|
+
* The remote's tip for `branch`, or `undefined` when the remote has no such
|
|
1573
|
+
* branch. A remote that cannot be read throws: `ls-remote` exits 0 with no
|
|
1574
|
+
* output only for a reachable remote without the ref.
|
|
1575
|
+
*/
|
|
1576
|
+
async remoteBranchTip(branch) {
|
|
1577
|
+
const ref = `refs/heads/${branch}`;
|
|
1578
|
+
const output = await this.git.raw(['ls-remote', '--heads', this.remote, ref]);
|
|
1579
|
+
for (const line of output.split('\n')) {
|
|
1580
|
+
const [sha, name] = line.trim().split('\t');
|
|
1581
|
+
if (sha && name === ref)
|
|
1582
|
+
return sha;
|
|
1583
|
+
}
|
|
1584
|
+
return undefined;
|
|
1585
|
+
}
|
|
1586
|
+
/**
|
|
1587
|
+
* Fetch `branch` from the remote and return the fetched commit, pinned as
|
|
1588
|
+
* pullBaseInner explains. The full ref: a bare name resolves to a
|
|
1589
|
+
* same-named tag first.
|
|
1590
|
+
*/
|
|
1591
|
+
async fetchBranchTip(branch) {
|
|
1592
|
+
await this.git.fetch(this.remote, `refs/heads/${branch}`);
|
|
1593
|
+
return (await this.git.revparse(['FETCH_HEAD'])).trim();
|
|
1594
|
+
}
|
|
1595
|
+
/**
|
|
1596
|
+
* Refuse to adopt a remote settings branch that holds content: its tip has a file other than
|
|
1597
|
+
* the settings files, or its roots include the base branch's (a settings branch is an orphan).
|
|
1598
|
+
* Neither alone suffices: a base can have a root of its own, and a content tree can be pruned.
|
|
1599
|
+
* The base comes from the remote when the clone lacks it (it was cloned at an earlier base).
|
|
1600
|
+
*/
|
|
1601
|
+
async assertNotContentHistory(branchName, tip) {
|
|
1602
|
+
const tree = await this.git.raw(['ls-tree', '-r', '--name-only', tip]);
|
|
1603
|
+
const strays = tree.split('\n').filter((file) => file && !SETTINGS_BRANCH_FILES.has(file));
|
|
1604
|
+
if (strays.length > 0) {
|
|
1605
|
+
const shown = strays.slice(0, 3).join(', ') + (strays.length > 3 ? ', …' : '');
|
|
1606
|
+
throw new SettingsBranchHasContentHistoryError(branchName, this.remote, `it holds ${shown}`);
|
|
1607
|
+
}
|
|
1608
|
+
const [tipRoots, baseRoots] = await Promise.all([
|
|
1609
|
+
this.rootCommits(tip),
|
|
1610
|
+
this.baseBranchRev(branchName).then((rev) => this.rootCommits(rev)),
|
|
1611
|
+
]);
|
|
1612
|
+
if (tipRoots.some((root) => baseRoots.includes(root))) {
|
|
1613
|
+
throw new SettingsBranchHasContentHistoryError(branchName, this.remote, `it shares history with the base branch '${this.baseBranch}'`);
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
async baseBranchRev(settingsBranch) {
|
|
1617
|
+
for (const ref of [
|
|
1618
|
+
`refs/heads/${this.baseBranch}`,
|
|
1619
|
+
`refs/remotes/${this.remote}/${this.baseBranch}`,
|
|
1620
|
+
]) {
|
|
1621
|
+
// `--quiet` makes a missing ref a silent non-zero exit, which simple-git resolves
|
|
1622
|
+
// with empty output rather than rejecting.
|
|
1623
|
+
const sha = await this.git
|
|
1624
|
+
.raw(['rev-parse', '--verify', '--quiet', `${ref}^{commit}`])
|
|
1625
|
+
.catch(() => '');
|
|
1626
|
+
if (sha.trim())
|
|
1627
|
+
return sha.trim();
|
|
1628
|
+
}
|
|
1629
|
+
try {
|
|
1630
|
+
return await this.fetchBranchTip(this.baseBranch);
|
|
1631
|
+
}
|
|
1632
|
+
catch (err) {
|
|
1633
|
+
throw new Error(`CanopyCMS: could not read base branch '${this.baseBranch}' from remote ` +
|
|
1634
|
+
`'${this.remote}', so settings branch '${settingsBranch}' was not checked out: ` +
|
|
1635
|
+
`${getErrorMessage(err)}`);
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
async rootCommits(rev) {
|
|
1639
|
+
const output = await this.git.raw(['rev-list', '--max-parents=0', rev]);
|
|
1640
|
+
return output.split('\n').filter(Boolean);
|
|
1641
|
+
}
|
|
1642
|
+
/**
|
|
1643
|
+
* Whether `branch` is a single parentless commit with an empty tree, as
|
|
1644
|
+
* orphan creation leaves it. Full refs throughout: a bare name resolves to a
|
|
1645
|
+
* same-named tag first, and clones fetch tags.
|
|
1646
|
+
*/
|
|
1647
|
+
async isEmptyInitialBranch(branch) {
|
|
1648
|
+
const ref = `refs/heads/${branch}`;
|
|
1649
|
+
const commits = await this.git.raw(['rev-list', '--max-count=2', ref]);
|
|
1650
|
+
if (commits.split('\n').filter(Boolean).length !== 1)
|
|
1651
|
+
return false;
|
|
1652
|
+
const tree = await this.git.raw(['ls-tree', ref]);
|
|
1653
|
+
return tree.trim() === '';
|
|
1654
|
+
}
|
|
1137
1655
|
}
|