canopycms 0.12.1 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/CHANGELOG.md +211 -49
  2. package/dist/api/branch-review.d.ts +20 -2
  3. package/dist/api/branch-review.js +112 -23
  4. package/dist/api/branch-status.d.ts +11 -2
  5. package/dist/api/branch-status.js +3 -2
  6. package/dist/api/branch-update-from-base.js +57 -6
  7. package/dist/api/branch.d.ts +5 -0
  8. package/dist/api/branch.js +27 -2
  9. package/dist/api/client.d.ts +2 -2
  10. package/dist/api/client.js +2 -2
  11. package/dist/api/content.js +1 -1
  12. package/dist/api/merge-conflicts.d.ts +10 -1
  13. package/dist/api/merge-conflicts.js +42 -7
  14. package/dist/api/types.d.ts +3 -2
  15. package/dist/base-update.d.ts +6 -0
  16. package/dist/branch-metadata-file.d.ts +15 -0
  17. package/dist/branch-metadata-file.js +5 -1
  18. package/dist/cli/cli.js +238 -108
  19. package/dist/cli/generate-ai-content.js +20 -6
  20. package/dist/cli/github-app-manifest.d.ts +4 -1
  21. package/dist/cli/github-app-manifest.js +5 -1
  22. package/dist/cli/init.js +7 -1
  23. package/dist/editor/Editor.js +39 -16
  24. package/dist/editor/admin/ConflictsStatusAlert.d.ts +8 -0
  25. package/dist/editor/admin/ConflictsStatusAlert.js +22 -0
  26. package/dist/editor/admin/SystemHealthPanel.js +4 -3
  27. package/dist/editor/base-update-wait.d.ts +4 -0
  28. package/dist/editor/base-update-wait.js +7 -0
  29. package/dist/editor/components/ConflictsGroup.d.ts +13 -1
  30. package/dist/editor/components/ConflictsGroup.js +52 -12
  31. package/dist/editor/components/EditorHeader.d.ts +3 -1
  32. package/dist/editor/components/EditorHeader.js +11 -7
  33. package/dist/editor/components/EntryChangesDrawer.d.ts +1 -1
  34. package/dist/editor/components/EntryChangesDrawer.js +58 -6
  35. package/dist/editor/conflict-cards.d.ts +4 -1
  36. package/dist/editor/conflict-cards.js +14 -9
  37. package/dist/editor/hooks/useBaseUpdate.d.ts +2 -0
  38. package/dist/editor/hooks/useBaseUpdate.js +12 -2
  39. package/dist/editor/hooks/useBranchChangesData.d.ts +7 -1
  40. package/dist/editor/hooks/useBranchChangesData.js +13 -0
  41. package/dist/editor/hooks/useBranchManager.d.ts +2 -0
  42. package/dist/editor/hooks/useBranchManager.js +80 -6
  43. package/dist/editor/hooks/useDraftManager.d.ts +8 -1
  44. package/dist/editor/hooks/useDraftManager.js +16 -1
  45. package/dist/editor/hooks/useEntryManager.d.ts +8 -3
  46. package/dist/editor/hooks/useEntryManager.js +6 -5
  47. package/dist/git-manager.d.ts +1 -0
  48. package/dist/git-manager.js +8 -3
  49. package/dist/merge/entry-merge.js +4 -3
  50. package/dist/merge/list-alignment.d.ts +9 -6
  51. package/dist/merge/list-alignment.js +65 -16
  52. package/dist/merge/rekey.d.ts +11 -0
  53. package/dist/merge/rekey.js +75 -0
  54. package/dist/operating-mode/client-safe-strategy.js +1 -1
  55. package/dist/operating-mode/client-unsafe-strategy.js +6 -0
  56. package/dist/operating-mode/types.d.ts +10 -2
  57. package/dist/services.d.ts +12 -4
  58. package/dist/services.js +56 -0
  59. package/dist/types.d.ts +35 -3
  60. package/dist/utils/git.d.ts +7 -0
  61. package/dist/utils/git.js +35 -0
  62. package/dist/version.d.ts +1 -1
  63. package/dist/version.js +1 -1
  64. package/dist/worker/branch-merge.d.ts +27 -3
  65. package/dist/worker/branch-merge.js +106 -5
  66. package/dist/worker/branch-policy.d.ts +18 -2
  67. package/dist/worker/branch-policy.js +36 -0
  68. package/dist/worker/cms-worker.d.ts +21 -0
  69. package/dist/worker/cms-worker.js +29 -4
  70. package/dist/worker/conflicts-status.d.ts +20 -0
  71. package/dist/worker/conflicts-status.js +204 -0
  72. package/dist/worker/field-merge.d.ts +17 -2
  73. package/dist/worker/field-merge.js +95 -2
  74. package/dist/worker/git-sync.d.ts +3 -2
  75. package/dist/worker/git-sync.js +9 -0
  76. package/dist/worker/github-commits.d.ts +3 -3
  77. package/dist/worker/github-commits.js +192 -40
  78. package/dist/worker/github-errors.d.ts +15 -0
  79. package/dist/worker/github-errors.js +47 -0
  80. package/dist/worker/github-gateway-http.js +7 -0
  81. package/dist/worker/github-gateway-wire.d.ts +30 -10
  82. package/dist/worker/github-gateway-wire.js +14 -2
  83. package/dist/worker/github-gateway.d.ts +17 -0
  84. package/dist/worker/github-gateway.js +37 -1
  85. package/dist/worker/history-rewrite.d.ts +40 -8
  86. package/dist/worker/history-rewrite.js +51 -23
  87. package/dist/worker/publish-decision.d.ts +37 -0
  88. package/dist/worker/publish-decision.js +16 -0
  89. package/dist/worker/rebase.d.ts +25 -5
  90. package/dist/worker/rebase.js +225 -81
  91. package/dist/worker/task-runner.js +16 -39
  92. package/dist/worker/worker-context.d.ts +6 -0
  93. package/package.json +1 -1
@@ -7,6 +7,7 @@ import { GitManager } from './git-manager.js';
7
7
  import { BranchRegistry } from './branch-registry.js';
8
8
  import { type GitHubService } from './github-service.js';
9
9
  import { BranchSchemaCache } from './branch-schema-cache.js';
10
+ import type { BaseUpdateRun } from './base-update.js';
10
11
  import { type EditorLookup, type SubmissionEditor } from './submission-attribution.js';
11
12
  import { type StampedEntry } from './stamp-on-publish.js';
12
13
  /**
@@ -80,10 +81,9 @@ export interface CanopyServices {
80
81
  message: string;
81
82
  }) => Promise<void>;
82
83
  /**
83
- * Commit the branch's saved but uncommitted work in its clone, pushing nothing. A later submit
84
- * pushes it, or, on a branch published before, the worker's merge of the base once it moves
85
- * (worker/rebase.ts, `aheadOfPublished`). Its trailers name the editors who saved since the last
86
- * commit.
84
+ * Commit the branch's saved but uncommitted work in its clone, pushing nothing: only a later
85
+ * submit publishes it, whatever the worker merges on top (worker/publish-decision.ts). Its
86
+ * trailers name the editors who saved since the last commit.
87
87
  * Holds the branch's content-write lock; rejects with `ContentWriteLockBusyError` when busy.
88
88
  * Resolves whether there was anything to commit.
89
89
  */
@@ -110,6 +110,14 @@ export interface CanopyServices {
110
110
  lookupEditor?: EditorLookup;
111
111
  message?: string;
112
112
  }) => Promise<SubmitBranchResult>;
113
+ /**
114
+ * Bring the base into the branch now and answer its request for the latest changes, where the
115
+ * mode's strategy runs that in the request (operating-mode `baseUpdateRunsInRequest`); rejects
116
+ * in any other mode, where only the worker may. Resolves `busy` when a lock held the merge back,
117
+ * the request then answered `failed`, and `other-remote`, running nothing, for a clone of another
118
+ * remote than the workspace's `remote.git`.
119
+ */
120
+ runBaseUpdateNow: (context: BranchContext) => Promise<BaseUpdateRun>;
113
121
  /** Commit to the settings branch (for permissions/groups) and push it; never opens a PR */
114
122
  commitToSettingsBranch: (options: {
115
123
  branchRoot: string;
package/dist/services.js CHANGED
@@ -17,6 +17,7 @@
17
17
  */
18
18
  import fs from 'node:fs/promises';
19
19
  import path from 'node:path';
20
+ import { fileURLToPath } from 'node:url';
20
21
  import { getConfigDefaults } from './config/index.js';
21
22
  import { createCheckBranchAccess, createCheckContentAccess, createContentAccessChecker, loadPathPermissions, } from './authorization/index.js';
22
23
  import { GitManager, GitRemoteRefMissingError } from './git-manager.js';
@@ -139,6 +140,33 @@ export const createTestCanopyServices = async (config, options = {}) => {
139
140
  };
140
141
  return _createCanopyServicesInternal(testConfig, options);
141
142
  };
143
+ /**
144
+ * A remote URL's local path as git reads it: a `file://` URL, a drive path, or a path relative to
145
+ * `cwd` with its links resolved; undefined for a scheme or git's scp form (a colon before any
146
+ * slash), which name another host.
147
+ */
148
+ const localPathOf = async (url, cwd) => {
149
+ if (url.startsWith('file://')) {
150
+ try {
151
+ return fileURLToPath(url);
152
+ }
153
+ catch {
154
+ return undefined;
155
+ }
156
+ }
157
+ if (process.platform === 'win32' && /^[A-Za-z]:[\\/]/.test(url))
158
+ return path.resolve(url);
159
+ const colon = url.indexOf(':');
160
+ const slash = url.search(/[\\/]/);
161
+ if (colon >= 0 && (slash < 0 || colon < slash))
162
+ return undefined;
163
+ return path.resolve(await fs.realpath(cwd).catch(() => cwd), url);
164
+ };
165
+ /** Whether two paths name one directory, symlinks resolved where they exist. */
166
+ const samePath = async (a, b) => {
167
+ const real = (p) => fs.realpath(p).catch(() => path.resolve(p));
168
+ return (await real(a)) === (await real(b));
169
+ };
142
170
  /** Shared implementation; call createCanopyServices or createTestCanopyServices. */
143
171
  async function _createCanopyServicesInternal(config, options) {
144
172
  // Mode-specific requirements, e.g. prod's git bot credentials for GitHub.
@@ -269,6 +297,10 @@ async function _createCanopyServicesInternal(config, options) {
269
297
  // stopped on a conflict is discarded by its abort.
270
298
  const committed = await withContentWriteLock(options.context.branchRoot, async () => {
271
299
  await refuseDuringRebase(options.context.branchRoot);
300
+ // A submit since the caller's read: a branch in review takes no commit until it is withdrawn.
301
+ const branchStatus = (await readBranchMetadataFile(options.context.branchRoot))?.branch.status;
302
+ if (branchStatus !== undefined && branchStatus !== 'editing')
303
+ return false;
272
304
  await git.checkoutBranch(options.context.branch.name);
273
305
  const status = await git.status();
274
306
  const uncommitted = (await readRecordedEditors(options.context))?.uncommittedEditors ?? [];
@@ -509,6 +541,29 @@ async function _createCanopyServicesInternal(config, options) {
509
541
  };
510
542
  }
511
543
  };
544
+ const runBaseUpdateNow = async (context) => {
545
+ const mode = services.config.mode;
546
+ const strategy = operatingStrategy(mode);
547
+ if (!strategy.baseUpdateRunsInRequest()) {
548
+ throw new Error(`CanopyCMS: in ${mode} mode the worker answers requests for the latest changes, never a request.`);
549
+ }
550
+ // baseRoot is the workspace's content-branches directory, where the worker looks.
551
+ const workspacePath = path.dirname(context.baseRoot);
552
+ const origin = await createGitManagerFor(context.branchRoot).getRemoteUrl();
553
+ const originPath = origin && (await localPathOf(origin, context.branchRoot));
554
+ if (!originPath || !(await samePath(originPath, path.join(workspacePath, 'remote.git')))) {
555
+ return 'other-remote';
556
+ }
557
+ // Loaded only when a run is asked for: a server that never runs one never loads the worker.
558
+ const { createLocalWorker } = await import('./worker/cms-worker.js');
559
+ const worker = createLocalWorker({
560
+ workspacePath,
561
+ baseBranch: context.branch.baseBranch ?? baseBranchOf(services.config),
562
+ contentRoot: services.config.contentRoot || 'content',
563
+ settingsBranch: strategy.getSettingsBranchName(services.config),
564
+ });
565
+ return worker.syncBranchNow(context.branch.name);
566
+ };
512
567
  const operatingMode = config.mode;
513
568
  const modeStrategy = operatingStrategy(operatingMode);
514
569
  const registry = modeStrategy.supportsBranching()
@@ -528,6 +583,7 @@ async function _createCanopyServicesInternal(config, options) {
528
583
  commitFiles,
529
584
  commitSavedWork,
530
585
  submitBranch,
586
+ runBaseUpdateNow,
531
587
  commitToSettingsBranch,
532
588
  getSettingsBranchRoot,
533
589
  recordBranchEditor,
package/dist/types.d.ts CHANGED
@@ -49,6 +49,8 @@ export interface BranchReview {
49
49
  };
50
50
  /** ISO timestamp of the verdict. */
51
51
  at: string;
52
+ /** The head commit the reviewer's page showed, which the API checked against the clone's. */
53
+ commit?: string;
52
54
  }
53
55
  /** What keeps a pull request behind its base. `base` is the branch GitHub says it targets now. */
54
56
  export type PullRequestHold = {
@@ -108,9 +110,10 @@ export interface BranchMetadata {
108
110
  */
109
111
  pushedToGitHubAt?: string;
110
112
  /**
111
- * The latest editor URL a submit took from its request (editor-url.ts); a submit that took
112
- * none, or used config.editorUrl, leaves it as it was. A comment change re-renders the PR's
113
- * comment summary with config.editorUrl or this, so a commenter never supplies the links.
113
+ * The editor URL the latest submit resolved (editor-url.ts): config.editorUrl, else one taken
114
+ * from its request; a submit that resolved none leaves it as it was. A comment change re-renders
115
+ * the PR's comment summary with config.editorUrl or this, so a commenter never supplies the
116
+ * links. The worker links the conflicts status with it (worker/conflicts-status.ts).
114
117
  */
115
118
  editorUrl?: string;
116
119
  /** Sync status for async GitHub operations (used when Lambda has no internet) */
@@ -165,6 +168,12 @@ export interface BranchMetadata {
165
168
  * rebase loop's self-heal pass.
166
169
  */
167
170
  historyRewrittenFrom?: string;
171
+ /**
172
+ * The rebased tip of the latest rewrite while `historyRewrittenFrom` is set: the one commit a
173
+ * rewrite's publish moves `remote.git` to, since the clone can hold a commit made after it that
174
+ * no one has submitted. Advanced by each rewrite, cleared with the marker.
175
+ */
176
+ historyRewrittenTo?: string;
168
177
  /**
169
178
  * The newest commit this deployment has seen on GitHub's branch, or knows it will hold once a
170
179
  * queued push of a rewrite lands: the last one it pushed, took in or published, and any
@@ -228,6 +237,12 @@ export interface BranchMetadata {
228
237
  baseUpdateRequestedAt?: string;
229
238
  /** The worker's answer to the latest request for the latest changes (worker/rebase.ts). */
230
239
  lastBaseUpdate?: LastBaseUpdate;
240
+ /**
241
+ * The first base commit the worker's automatic sync left out because the branch holds saved but
242
+ * uncommitted work, which it never commits (worker/rebase.ts). The editor shows that the latest
243
+ * changes are waiting; cleared once the sync finds the branch holding the base, or in review.
244
+ */
245
+ latestChangesWaiting?: string;
231
246
  }
232
247
  /** How a requested update from the base went. */
233
248
  export type BaseUpdateOutcome = 'merged' | 'up-to-date' | 'conflicts' | 'failed';
@@ -334,6 +349,21 @@ export interface UnpublishableBranchesReport {
334
349
  count: number;
335
350
  at: string;
336
351
  }
352
+ /**
353
+ * The last git sync's failures to set the "CanopyCMS conflicts" status on pull request heads
354
+ * (worker/conflicts-status.ts): `permission` when GitHub refused the credential (it lacks the Commit
355
+ * statuses permission), `unreachable` when GitHub or the gateway could not be reached or GitHub was
356
+ * busy, `other` for anything else, a gateway refusal or an unreadable conflict record included.
357
+ * `branches` lists at most 50 names, `count` all of them.
358
+ */
359
+ export interface ConflictsStatusFailure {
360
+ reason: 'permission' | 'unreachable' | 'other';
361
+ /** [REDACT] Served to the admin panel, so sanitized. */
362
+ message: string;
363
+ branches: string[];
364
+ count: number;
365
+ at: string;
366
+ }
337
367
  /**
338
368
  * Wire shape of the worker's self-reported status file (worker-status.json, under
339
369
  * the task queue dir), written by the CmsWorker daemon. Read-only here: GET
@@ -432,6 +462,8 @@ export interface WorkerStatusReport {
432
462
  * into a restarted worker's first snapshot, so the bound survives restarts.
433
463
  */
434
464
  baseHold?: BaseSchemaHold;
465
+ /** Present while the last git sync failed to set a conflicts status; not carried over a restart. */
466
+ conflictsStatusFailure?: ConflictsStatusFailure;
435
467
  /**
436
468
  * How the last worker that ran stopped, carried into each new worker's snapshot
437
469
  * (task-queue/worker-status.ts's `readCarriedOverStatus`). A worker that fails at startup
@@ -99,6 +99,13 @@ export declare function stageAllExceptCanopyState(git: SimpleGit): Promise<void>
99
99
  * for).
100
100
  */
101
101
  export declare function isRebaseInProgress(repoPath: string): Promise<boolean>;
102
+ /**
103
+ * The commit HEAD names, read from the files: a detached HEAD's sha, or the branch's loose ref,
104
+ * else its line in `packed-refs`. Null for any other layout, a reftable store among them, which
105
+ * the caller reads with git instead. Files only, as {@link resolveGitDir} is, for the branch
106
+ * listing that reads every clone in review (api/branch.ts).
107
+ */
108
+ export declare function readHeadCommitFromFiles(repoPath: string): Promise<string | null>;
102
109
  /**
103
110
  * The repository's current HEAD branch name, or `fallback` when detection fails or HEAD is
104
111
  * detached.
package/dist/utils/git.js CHANGED
@@ -223,6 +223,41 @@ export async function isRebaseInProgress(repoPath) {
223
223
  .catch(() => false)));
224
224
  return results.some(Boolean);
225
225
  }
226
+ const FULL_SHA = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
227
+ /**
228
+ * The commit HEAD names, read from the files: a detached HEAD's sha, or the branch's loose ref,
229
+ * else its line in `packed-refs`. Null for any other layout, a reftable store among them, which
230
+ * the caller reads with git instead. Files only, as {@link resolveGitDir} is, for the branch
231
+ * listing that reads every clone in review (api/branch.ts).
232
+ */
233
+ export async function readHeadCommitFromFiles(repoPath) {
234
+ const gitDir = await resolveGitDir(repoPath);
235
+ if (!gitDir)
236
+ return null;
237
+ try {
238
+ const head = (await fs.readFile(path.join(gitDir, 'HEAD'), 'utf-8')).trim();
239
+ if (FULL_SHA.test(head))
240
+ return head;
241
+ const ref = /^ref: (refs\/heads\/\S+)$/.exec(head)?.[1];
242
+ if (!ref || ref.split('/').includes('..'))
243
+ return null;
244
+ const commonFile = await fs.readFile(path.join(gitDir, 'commondir'), 'utf-8').catch(() => null);
245
+ const refsDir = commonFile ? path.resolve(gitDir, commonFile.trim()) : gitDir;
246
+ const loose = await fs.readFile(path.join(refsDir, ref), 'utf-8').catch(() => null);
247
+ if (loose !== null)
248
+ return FULL_SHA.test(loose.trim()) ? loose.trim() : null;
249
+ const packed = await fs.readFile(path.join(refsDir, 'packed-refs'), 'utf-8').catch(() => '');
250
+ for (const line of packed.split('\n')) {
251
+ const [sha, name] = line.split(' ');
252
+ if (name === ref && FULL_SHA.test(sha))
253
+ return sha;
254
+ }
255
+ return null;
256
+ }
257
+ catch {
258
+ return null;
259
+ }
260
+ }
226
261
  /**
227
262
  * The repository's current HEAD branch name, or `fallback` when detection fails or HEAD is
228
263
  * detached.
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const CANOPYCMS_VERSION = "0.12.1";
1
+ export declare const CANOPYCMS_VERSION = "0.13.0";
package/dist/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  // Generated by scripts/bump-version.mjs, its only writer. The package builds with plain
2
2
  // tsc (rootDir: src), so src/ cannot import ../package.json; version.test.ts keeps the two equal.
3
- export const CANOPYCMS_VERSION = '0.12.1';
3
+ export const CANOPYCMS_VERSION = '0.13.0';
@@ -67,10 +67,34 @@ export declare function commitMerge(git: SimpleGit, plan: Extract<MergePlan, {
67
67
  /** Whether `plan` is git's own merge with nothing for a person. */
68
68
  export declare const mergesAsGitDoes: (plan: MergePlan) => boolean;
69
69
  /**
70
- * Whether `merged` holds exactly what `from` and `other` merge to cleanly, and so nothing else. A
71
- * conflict counts as no: whoever settled it chose what `merged` holds.
70
+ * Whether `merged` holds exactly what `from` and `other` merge to cleanly, and so nothing else, the
71
+ * item keys of entries under `contentRoot` aside: a re-key ({@link buildRekey}) changes no content.
72
+ * A conflict counts as no: whoever settled it chose what `merged` holds.
72
73
  */
73
- export declare function isCleanMergeOf(git: SimpleGit, merged: string, from: string, other: string): Promise<boolean>;
74
+ export declare function isCleanMergeOf(git: SimpleGit, merged: string, from: string, other: string, contentRoot: string): Promise<boolean>;
75
+ /**
76
+ * A commit on `tip` that gives the items of every entry a merge on the branch's own line in
77
+ * `from..tip` (first parents: never the base's history) brought in the keys the field merge of
78
+ * that merge's two parents gives them, where it holds what git merged
79
+ * (field-merge.ts `rekeyOnly`) and `tip` still holds the merge's version. Git keeps each item under
80
+ * the key its side's copy held, which can name another item once the other side changed it; the
81
+ * field merge's are the keys a later merge reads correctly. Null when no item needs a new key.
82
+ */
83
+ export declare function buildRekey(git: SimpleGit, clonePath: string, args: {
84
+ from: string;
85
+ tip: string;
86
+ contentRoot: string;
87
+ timeoutMs: number;
88
+ message: string;
89
+ /**
90
+ * Only entries `head` holds as `since` held them: a merge of `head` with the re-key reads an
91
+ * entry `head` changed against `since`'s keys, which git's merge keeps and a re-key would not.
92
+ */
93
+ unchangedIn?: {
94
+ head: string;
95
+ since: string;
96
+ };
97
+ }): Promise<BuiltMerge | null>;
74
98
  /**
75
99
  * Move `refs/heads/<branchRef>` in remote.git to `sha` as a plain push, so it can only ever
76
100
  * fast-forward. Resolves false when it would not: under the caller's locks, the only writers left
@@ -7,7 +7,8 @@ import { normalizeFilesystemPath } from '../paths/normalize.js';
7
7
  import { ROOT_COLLECTION_ID } from '../paths/types.js';
8
8
  import { isNonFastForwardRejection } from '../utils/git.js';
9
9
  import { getErrorMessage, redactCredentials } from '../utils/error.js';
10
- import { resolveFieldMerge } from './field-merge.js';
10
+ import { parseTypedFilename } from '../utils/typed-filename.js';
11
+ import { changedFiles, resolveFieldMerge, differsInItemKeysOnly, } from './field-merge.js';
11
12
  import { runGit } from './git-process.js';
12
13
  import { pinnedReceivePack, sharedRepoGit, sharedRepoProcessOptions } from './shared-repo-git.js';
13
14
  /**
@@ -190,10 +191,11 @@ export async function commitMerge(git, plan, { head, other, message }) {
190
191
  /** Whether `plan` is git's own merge with nothing for a person. */
191
192
  export const mergesAsGitDoes = (plan) => plan.kind === 'planned' && plan.asGitDoes;
192
193
  /**
193
- * Whether `merged` holds exactly what `from` and `other` merge to cleanly, and so nothing else. A
194
- * conflict counts as no: whoever settled it chose what `merged` holds.
194
+ * Whether `merged` holds exactly what `from` and `other` merge to cleanly, and so nothing else, the
195
+ * item keys of entries under `contentRoot` aside: a re-key ({@link buildRekey}) changes no content.
196
+ * A conflict counts as no: whoever settled it chose what `merged` holds.
195
197
  */
196
- export async function isCleanMergeOf(git, merged, from, other) {
198
+ export async function isCleanMergeOf(git, merged, from, other, contentRoot) {
197
199
  const output = await git.raw([
198
200
  'merge-tree',
199
201
  '--write-tree',
@@ -205,7 +207,106 @@ export async function isCleanMergeOf(git, merged, from, other) {
205
207
  const { tree, stages } = parseMergeTree(output, from, other);
206
208
  if (stages.length > 0)
207
209
  return false;
208
- return (await git.revparse(['--verify', '--end-of-options', `${merged}^{tree}`])).trim() === tree;
210
+ const mergedTree = (await git.revparse(['--verify', '--end-of-options', `${merged}^{tree}`])).trim();
211
+ if (mergedTree === tree)
212
+ return true;
213
+ for (const { before, after } of await changedFiles(git, tree, mergedTree)) {
214
+ if (!before || !after || !(await differsInItemKeysOnly(git, before, after, contentRoot))) {
215
+ return false;
216
+ }
217
+ }
218
+ return true;
219
+ }
220
+ /**
221
+ * A commit on `tip` that gives the items of every entry a merge on the branch's own line in
222
+ * `from..tip` (first parents: never the base's history) brought in the keys the field merge of
223
+ * that merge's two parents gives them, where it holds what git merged
224
+ * (field-merge.ts `rekeyOnly`) and `tip` still holds the merge's version. Git keeps each item under
225
+ * the key its side's copy held, which can name another item once the other side changed it; the
226
+ * field merge's are the keys a later merge reads correctly. Null when no item needs a new key.
227
+ */
228
+ export async function buildRekey(git, clonePath, args) {
229
+ const { from, tip, contentRoot, timeoutMs, message, unchangedIn } = args;
230
+ const merges = (await git.raw([
231
+ 'rev-list',
232
+ '--first-parent',
233
+ '--merges',
234
+ '--reverse',
235
+ '--end-of-options',
236
+ `${from}..${tip}`,
237
+ ]))
238
+ .split('\n')
239
+ .filter((line) => OBJECT_ID.test(line));
240
+ if (merges.length === 0)
241
+ return null;
242
+ const flatSchema = await readSchemaSnapshot(clonePath);
243
+ const updates = new Map();
244
+ const keyRemaps = [];
245
+ for (const merge of merges) {
246
+ const parents = (await git.raw(['rev-list', '--parents', '-n', '1', '--end-of-options', merge]))
247
+ .trim()
248
+ .split(' ')
249
+ .slice(1);
250
+ if (parents.length !== 2)
251
+ continue;
252
+ const bases = (await git.raw(['merge-base', '--all', '--end-of-options', ...parents]))
253
+ .split('\n')
254
+ .filter((line) => OBJECT_ID.test(line));
255
+ if (bases.length !== 1)
256
+ continue;
257
+ const field = await resolveFieldMerge({
258
+ git,
259
+ clonePath,
260
+ ancestor: bases[0],
261
+ head: parents[0],
262
+ other: parents[1],
263
+ mergedTree: (await git.revparse(['--verify', '--end-of-options', `${merge}^{tree}`])).trim(),
264
+ conflicted: new Set(),
265
+ contentRoot,
266
+ flatSchema,
267
+ collectionIdOf: (file) => conflictFilesToContentIds([file], contentRoot)[0],
268
+ timeoutMs,
269
+ rekeyOnly: true,
270
+ });
271
+ const applied = new Set();
272
+ for (const [file, blob] of field.updates) {
273
+ // A later commit that changed the entry again decided its keys itself.
274
+ if (!blob || (await blobAt(git, merge, file)) !== (await blobAt(git, tip, file)))
275
+ continue;
276
+ if (unchangedIn &&
277
+ (await blobAt(git, unchangedIn.head, file)) !== (await blobAt(git, unchangedIn.since, file))) {
278
+ continue;
279
+ }
280
+ updates.set(file, blob);
281
+ const id = parseTypedFilename(path.posix.basename(file))?.id;
282
+ if (id !== undefined)
283
+ applied.add(id);
284
+ }
285
+ keyRemaps.push(...field.keyRemaps.filter((r) => r.contentId !== undefined && applied.has(r.contentId)));
286
+ }
287
+ if (updates.size === 0)
288
+ return null;
289
+ const tipTree = (await git.revparse(['--verify', '--end-of-options', `${tip}^{tree}`])).trim();
290
+ const tree = await withUpdates(clonePath, { gitTree: tipTree, updates, timeoutMs });
291
+ const sha = (await git.raw(['commit-tree', '-p', tip, '-m', message, '--end-of-options', tree])).trim();
292
+ return {
293
+ kind: 'built',
294
+ sha,
295
+ gitConflicted: [],
296
+ conflicts: [],
297
+ keptPaths: [],
298
+ asGitDoes: false,
299
+ keyRemaps,
300
+ paths: new Map(),
301
+ };
302
+ }
303
+ /** The blob `commit` holds at `file`, or null for none. */
304
+ async function blobAt(git, commit, file) {
305
+ const out = await git
306
+ .raw(['rev-parse', '--verify', '--quiet', '--end-of-options', `${commit}:${file}`])
307
+ .catch(() => '');
308
+ const oid = out.trim();
309
+ return OBJECT_ID.test(oid) ? oid : null;
209
310
  }
210
311
  /** `gitTree` with each of `updates` in place, through a throwaway index. */
211
312
  async function withUpdates(clonePath, args) {
@@ -15,8 +15,8 @@ export declare const MAX_PULL_REQUEST_TITLE_LENGTH = 1024;
15
15
  * @internal Exported for tests.
16
16
  */
17
17
  export declare const MAX_PULL_REQUEST_BODY_LENGTH = 65536;
18
- export type GatewayOperation = 'push' | 'delete' | 'pull-request';
19
- export type RefusalReason = 'invalid' | 'protected' | 'settings' | 'other-settings' | 'outside-namespace' | 'not-a-commit' | 'too-long';
18
+ export type GatewayOperation = 'push' | 'delete' | 'pull-request' | 'commit-status';
19
+ export type RefusalReason = 'invalid' | 'protected' | 'settings' | 'other-settings' | 'outside-namespace' | 'not-a-commit' | 'too-long' | 'not-head';
20
20
  /** The refusals {@link BranchPolicy.assertPushAllowed} makes. */
21
21
  export type PushPolicyRefusal = 'protected' | 'settings' | 'other-settings' | 'outside-namespace';
22
22
  /**
@@ -217,3 +217,19 @@ export declare function assertPullRequestText(refusing: string, branch: string,
217
217
  title?: string;
218
218
  body?: string;
219
219
  }): void;
220
+ /**
221
+ * The longest commit status description the gateway sends, the length GitHub documents for a
222
+ * deployment status's; its own descriptions are far shorter.
223
+ * @internal Exported for tests.
224
+ */
225
+ export declare const MAX_COMMIT_STATUS_DESCRIPTION_LENGTH = 140;
226
+ /** The longest commit status target URL the gateway sends, as the editor URL is capped. */
227
+ export declare const MAX_COMMIT_STATUS_URL_LENGTH = 2048;
228
+ /**
229
+ * Refuse a commit status description over the cap or holding a control character, and a target
230
+ * URL that is not plain http(s) or carries credentials. `branch` names the pull request's head.
231
+ */
232
+ export declare function assertCommitStatusText(refusing: string, branch: string, text: {
233
+ description: string;
234
+ targetUrl?: string;
235
+ }): void;
@@ -442,6 +442,42 @@ export function assertPullRequestText(refusing, branch, text) {
442
442
  throw new GatewayRefusalError('pull-request', branch, 'too-long', `${refusing}: ${over}`);
443
443
  }
444
444
  }
445
+ /**
446
+ * The longest commit status description the gateway sends, the length GitHub documents for a
447
+ * deployment status's; its own descriptions are far shorter.
448
+ * @internal Exported for tests.
449
+ */
450
+ export const MAX_COMMIT_STATUS_DESCRIPTION_LENGTH = 140;
451
+ /** The longest commit status target URL the gateway sends, as the editor URL is capped. */
452
+ export const MAX_COMMIT_STATUS_URL_LENGTH = 2048;
453
+ /**
454
+ * Refuse a commit status description over the cap or holding a control character, and a target
455
+ * URL that is not plain http(s) or carries credentials. `branch` names the pull request's head.
456
+ */
457
+ export function assertCommitStatusText(refusing, branch, text) {
458
+ if (text.description.length > MAX_COMMIT_STATUS_DESCRIPTION_LENGTH) {
459
+ throw new GatewayRefusalError('commit-status', branch, 'too-long', `${refusing}: its description is longer than ${MAX_COMMIT_STATUS_DESCRIPTION_LENGTH} characters`);
460
+ }
461
+ // eslint-disable-next-line no-control-regex -- the control characters are what it refuses
462
+ if (/[\u0000-\u001f\u007f]/.test(text.description)) {
463
+ throw new GatewayRefusalError('commit-status', branch, 'invalid', `${refusing}: its description holds a control character`);
464
+ }
465
+ if (text.targetUrl !== undefined && !isPlainWebUrl(text.targetUrl)) {
466
+ throw new GatewayRefusalError('commit-status', branch, 'invalid', `${refusing}: its target URL is not a plain http(s) URL of at most ${MAX_COMMIT_STATUS_URL_LENGTH} characters`);
467
+ }
468
+ }
469
+ function isPlainWebUrl(raw) {
470
+ if (raw.length > MAX_COMMIT_STATUS_URL_LENGTH)
471
+ return false;
472
+ let url;
473
+ try {
474
+ url = new URL(raw);
475
+ }
476
+ catch {
477
+ return false;
478
+ }
479
+ return (url.protocol === 'https:' || url.protocol === 'http:') && !url.username && !url.password;
480
+ }
445
481
  /** Equal, or naming the same workspace directory. */
446
482
  function sameBranch(a, b) {
447
483
  return a === b || sanitizeBranchName(a) === sanitizeBranchName(b);
@@ -1,6 +1,7 @@
1
1
  import { type GitHubAuthConfig } from './github-auth.js';
2
2
  import { type GitHubGateway } from './github-gateway.js';
3
3
  import type { WorkerStatusReport } from '../types.js';
4
+ import { type RebaseSummary } from './rebase.js';
4
5
  export { PermanentTaskError, isPermanentTaskFailure } from './task-runner.js';
5
6
  export { workerLog, workerLogWarn, workerLogError, installWorkerLogger } from './log.js';
6
7
  export { resolveWorkerGitHubAuth, normalizeGitHubAppPrivateKey, DEFAULT_GIT_TOKEN_MINT_TIMEOUT_MS, DEFAULT_GITHUB_TOKEN_REFRESH_MIN_INTERVAL_MS, type GitHubAppAuth, type GitHubAuthConfig, } from './github-auth.js';
@@ -205,6 +206,7 @@ export declare class CmsWorker {
205
206
  private log;
206
207
  private statusReport?;
207
208
  private warnedDivergences;
209
+ private conflictsStatusesSet;
208
210
  private branchSyncRequests;
209
211
  private gitSyncRequested;
210
212
  private wakeGitSync;
@@ -510,6 +512,13 @@ export declare class CmsWorker {
510
512
  */
511
513
  protected afterRebaseCompletedForTesting(): Promise<void>;
512
514
  private rebaseActiveBranches;
515
+ /**
516
+ * Bring the base into one branch now and answer its request for the latest changes
517
+ * (rebase.ts `syncBranchNow`).
518
+ */
519
+ syncBranchNow(branchName: string): Promise<'answered' | 'busy'>;
520
+ /** Bring the base into every branch now, answering their requests from this run's start. */
521
+ syncBranchesNow(): Promise<RebaseSummary>;
513
522
  syncGit(): Promise<void>;
514
523
  private pushSettingsBranches;
515
524
  private refreshBaseBranchWorkspace;
@@ -517,3 +526,15 @@ export declare class CmsWorker {
517
526
  private pollMergeState;
518
527
  refreshAuthCache(): Promise<void>;
519
528
  }
529
+ /**
530
+ * A worker over a workspace with no GitHub, for git steps run where no worker loop runs: dev's
531
+ * "Get latest changes" (services.ts `runBaseUpdateNow`) and the test app's e2e sync route. It is
532
+ * never started, so it takes no worker lock. Its GitHub settings are placeholders: a sync asks
533
+ * GitHub only about a branch's pull request, which dev never opens.
534
+ */
535
+ export declare function createLocalWorker(options: {
536
+ workspacePath: string;
537
+ baseBranch: string;
538
+ contentRoot: string;
539
+ settingsBranch?: string;
540
+ }): CmsWorker;
@@ -20,7 +20,7 @@ import { DEFAULT_SCHEMA_HOLD_MAX_MS, readCarriedBaseHold } from './schema-gate.j
20
20
  import { readCarriedGatewayPolicy, readCarriedUnpublishableBranches, } from './unpublishable-branches.js';
21
21
  import { SharedRepoRefusalError, UntrustedRepoConfigError, assertSharedRepoConfig, sharedRepoGit, } from './shared-repo-git.js';
22
22
  import { executeTask, orphanRecoveryMaxAgeMs, processTaskQueue, pushBranchToGitHub, updateBranchMetadata, } from './task-runner.js';
23
- import { pollMergeState, runRebaseCycle } from './rebase.js';
23
+ import { pollMergeState, runRebaseCycle, syncBranchNow } from './rebase.js';
24
24
  import { cleanupTrashedBranchDirs, pushSettingsBranches, refreshBaseBranchWorkspace, syncGit, } from './git-sync.js';
25
25
  // Re-exported because this module is the package's advertised worker
26
26
  // entrypoint (`canopycms/worker/cms-worker`).
@@ -246,6 +246,7 @@ export class CmsWorker {
246
246
  this.releaseLockFn = null;
247
247
  this.log = cmsTaskQueueLogger;
248
248
  this.warnedDivergences = new Map();
249
+ this.conflictsStatusesSet = new Map();
249
250
  // Branches asked for since the git sync last took them (requestBranchSync), by directory name.
250
251
  this.branchSyncRequests = new Set();
251
252
  // Set by any request; cleared by the next cycle, which takes it.
@@ -365,6 +366,7 @@ export class CmsWorker {
365
366
  ensureStatusReport: () => this.ensureStatusReport(),
366
367
  ensureSettingsBranch: () => this.ensureSettingsBranch(),
367
368
  warnedDivergences: () => this.warnedDivergences,
369
+ conflictsStatusesSet: () => this.conflictsStatusesSet,
368
370
  requestBranchSync: (branchRef) => this.requestBranchSync(branchRef),
369
371
  takeBranchSyncRequests: () => this.takeBranchSyncRequests(),
370
372
  afterConflictDetectedForTesting: () => this.afterConflictDetectedForTesting(),
@@ -1305,12 +1307,21 @@ export class CmsWorker {
1305
1307
  // directly and that calls `pollMergeState` directly. Neither is on
1306
1308
  // WorkerContext, so a stub installed on either is a no-op for production.
1307
1309
  //
1308
- // `rebaseActiveBranches` also has a consumer outside the test files:
1309
- // apps/test-app/app/api/e2e-test/rebase/route.ts drives it from an e2e
1310
- // fixture route. Do not delete it as unused.
1311
1310
  async rebaseActiveBranches(gatewayPolicy, gitHub, requested) {
1312
1311
  return runRebaseCycle(this.ctx(), gatewayPolicy, gitHub, requested);
1313
1312
  }
1313
+ // --- Runs with no loop ({@link createLocalWorker}) -----------------------
1314
+ /**
1315
+ * Bring the base into one branch now and answer its request for the latest changes
1316
+ * (rebase.ts `syncBranchNow`).
1317
+ */
1318
+ async syncBranchNow(branchName) {
1319
+ return syncBranchNow(this.ctx(), branchName);
1320
+ }
1321
+ /** Bring the base into every branch now, answering their requests from this run's start. */
1322
+ async syncBranchesNow() {
1323
+ return runRebaseCycle(this.ctx(), undefined, undefined, new Set(), { fetchedAt: Date.now() }, true);
1324
+ }
1314
1325
  // --- Git-sync cluster (worker/git-sync.ts) -----------------------------
1315
1326
  //
1316
1327
  // `syncGit` is the public loop entry `scheduleLoop` drives. The three private
@@ -1349,3 +1360,17 @@ export class CmsWorker {
1349
1360
  }
1350
1361
  }
1351
1362
  }
1363
+ /**
1364
+ * A worker over a workspace with no GitHub, for git steps run where no worker loop runs: dev's
1365
+ * "Get latest changes" (services.ts `runBaseUpdateNow`) and the test app's e2e sync route. It is
1366
+ * never started, so it takes no worker lock. Its GitHub settings are placeholders: a sync asks
1367
+ * GitHub only about a branch's pull request, which dev never opens.
1368
+ */
1369
+ export function createLocalWorker(options) {
1370
+ return new CmsWorker({
1371
+ ...options,
1372
+ githubOwner: 'local',
1373
+ githubRepo: 'local',
1374
+ githubToken: 'local',
1375
+ });
1376
+ }
@@ -0,0 +1,20 @@
1
+ import type { ConflictsStatusFailure } from '../types.js';
2
+ import type { GitHubConflictsStatus } from './github-gateway.js';
3
+ import type { WorkerContext } from './worker-context.js';
4
+ export type ConflictsStatusContext = Pick<WorkerContext, 'contentBranchesPath' | 'sanitizedBaseBranch' | 'github' | 'conflictsStatusesSet' | 'shutdownSignal' | 'taskTimeoutMs' | 'requestBranchSync'>;
5
+ /** The state and description a branch's conflict record calls for. */
6
+ type ConflictsStatusText = Pick<GitHubConflictsStatus, 'state' | 'description'>;
7
+ /**
8
+ * What `branchRoot`'s conflict record calls for: failure with the number of choices open, or while
9
+ * the record is held after a damaged one was set aside, or does not parse; success otherwise.
10
+ * @internal Exported for tests.
11
+ */
12
+ export declare function conflictsStatusOf(branchRoot: string): Promise<ConflictsStatusText>;
13
+ /**
14
+ * Set the conflicts status on every pull request whose head, as `githubTips` (this cycle's GitHub
15
+ * fetch) holds it, lacks the one its branch calls for. Returns the failure to report, undefined
16
+ * when every status was set, or null when the stage could not say: the branches could not be
17
+ * listed, or a drain cut it off. Never throws.
18
+ */
19
+ export declare function setConflictsStatuses(ctx: ConflictsStatusContext, githubTips: ReadonlyMap<string, string>): Promise<ConflictsStatusFailure | undefined | null>;
20
+ export {};