canopycms 0.12.0 → 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 (177) hide show
  1. package/CHANGELOG.md +326 -48
  2. package/dist/api/admin.d.ts +4 -4
  3. package/dist/api/branch-review.d.ts +20 -2
  4. package/dist/api/branch-review.js +153 -32
  5. package/dist/api/branch-status.d.ts +23 -2
  6. package/dist/api/branch-status.js +16 -2
  7. package/dist/api/branch-update-from-base.d.ts +23 -0
  8. package/dist/api/branch-update-from-base.js +227 -0
  9. package/dist/api/branch.d.ts +10 -0
  10. package/dist/api/branch.js +28 -2
  11. package/dist/api/client.d.ts +15 -2
  12. package/dist/api/client.js +24 -2
  13. package/dist/api/comments.d.ts +2 -2
  14. package/dist/api/content.d.ts +38 -1
  15. package/dist/api/content.js +41 -5
  16. package/dist/api/merge-conflicts.d.ts +235 -0
  17. package/dist/api/merge-conflicts.js +709 -0
  18. package/dist/api/routes.js +2 -0
  19. package/dist/api/types.d.ts +8 -3
  20. package/dist/base-update.d.ts +32 -0
  21. package/dist/base-update.js +31 -0
  22. package/dist/branch-metadata-file.d.ts +66 -0
  23. package/dist/branch-metadata-file.js +16 -1
  24. package/dist/branch-schema-cache.d.ts +12 -0
  25. package/dist/branch-schema-cache.js +27 -2
  26. package/dist/cli/backfill-item-keys.d.ts +77 -0
  27. package/dist/cli/backfill-item-keys.js +320 -0
  28. package/dist/cli/cli.d.ts +1 -1
  29. package/dist/cli/cli.js +9798 -1509
  30. package/dist/cli/generate-ai-content.js +373 -249
  31. package/dist/cli/github-app-manifest.d.ts +4 -1
  32. package/dist/cli/github-app-manifest.js +5 -1
  33. package/dist/cli/init.js +12 -0
  34. package/dist/cli/site-config.d.ts +14 -2
  35. package/dist/cli/site-config.js +20 -4
  36. package/dist/comment-store.d.ts +8 -0
  37. package/dist/comment-store.js +28 -0
  38. package/dist/content-index-generation.d.ts +1 -4
  39. package/dist/content-index-generation.js +1 -4
  40. package/dist/content-store.d.ts +39 -3
  41. package/dist/content-store.js +48 -15
  42. package/dist/editor/BranchManager.d.ts +4 -1
  43. package/dist/editor/BranchManager.js +9 -5
  44. package/dist/editor/Editor.js +462 -62
  45. package/dist/editor/EntryNavigator.d.ts +10 -2
  46. package/dist/editor/EntryNavigator.js +12 -4
  47. package/dist/editor/FormRenderer.d.ts +8 -2
  48. package/dist/editor/FormRenderer.js +5 -6
  49. package/dist/editor/admin/ConflictsStatusAlert.d.ts +8 -0
  50. package/dist/editor/admin/ConflictsStatusAlert.js +22 -0
  51. package/dist/editor/admin/SystemHealthPanel.js +4 -3
  52. package/dist/editor/base-update-wait.d.ts +78 -0
  53. package/dist/editor/base-update-wait.js +98 -0
  54. package/dist/editor/branch-status.js +1 -1
  55. package/dist/editor/components/BaseUpdateNotice.d.ts +12 -0
  56. package/dist/editor/components/BaseUpdateNotice.js +24 -0
  57. package/dist/editor/components/BranchPill.d.ts +4 -1
  58. package/dist/editor/components/BranchPill.js +5 -2
  59. package/dist/editor/components/ConflictsGroup.d.ts +112 -0
  60. package/dist/editor/components/ConflictsGroup.js +404 -0
  61. package/dist/editor/components/EditorHeader.d.ts +18 -1
  62. package/dist/editor/components/EditorHeader.js +99 -33
  63. package/dist/editor/components/EntryChangesDrawer.d.ts +16 -2
  64. package/dist/editor/components/EntryChangesDrawer.js +85 -167
  65. package/dist/editor/components/EntryHeader.d.ts +3 -1
  66. package/dist/editor/components/EntryHeader.js +2 -2
  67. package/dist/editor/components/GetLatestChangesButton.d.ts +15 -0
  68. package/dist/editor/components/GetLatestChangesButton.js +29 -0
  69. package/dist/editor/components/ReleaseHoldButton.d.ts +20 -0
  70. package/dist/editor/components/ReleaseHoldButton.js +36 -0
  71. package/dist/editor/components/RetryToastMessage.js +1 -1
  72. package/dist/editor/components/change-parts.d.ts +51 -0
  73. package/dist/editor/components/change-parts.js +171 -0
  74. package/dist/editor/conflict-cards.d.ts +93 -0
  75. package/dist/editor/conflict-cards.js +284 -0
  76. package/dist/editor/conflict-choices.d.ts +60 -0
  77. package/dist/editor/conflict-choices.js +108 -0
  78. package/dist/editor/conflict-notice.d.ts +25 -0
  79. package/dist/editor/conflict-notice.js +39 -0
  80. package/dist/editor/copy.d.ts +12 -0
  81. package/dist/editor/copy.js +12 -0
  82. package/dist/editor/entry-diff.d.ts +2 -1
  83. package/dist/editor/entry-diff.js +5 -21
  84. package/dist/editor/hooks/useBaseUpdate.d.ts +38 -0
  85. package/dist/editor/hooks/useBaseUpdate.js +279 -0
  86. package/dist/editor/hooks/useBranchChangesData.d.ts +7 -1
  87. package/dist/editor/hooks/useBranchChangesData.js +13 -0
  88. package/dist/editor/hooks/useBranchManager.d.ts +8 -1
  89. package/dist/editor/hooks/useBranchManager.js +108 -13
  90. package/dist/editor/hooks/useBranchesData.js +3 -0
  91. package/dist/editor/hooks/useConflictChoices.d.ts +24 -0
  92. package/dist/editor/hooks/useConflictChoices.js +75 -0
  93. package/dist/editor/hooks/useConflictsData.d.ts +26 -0
  94. package/dist/editor/hooks/useConflictsData.js +89 -0
  95. package/dist/editor/hooks/useDraftManager.d.ts +13 -1
  96. package/dist/editor/hooks/useDraftManager.js +40 -9
  97. package/dist/editor/hooks/useEntryManager.d.ts +17 -3
  98. package/dist/editor/hooks/useEntryManager.js +11 -5
  99. package/dist/editor/release-hold.d.ts +12 -0
  100. package/dist/editor/release-hold.js +39 -0
  101. package/dist/editor/server-clock.d.ts +4 -0
  102. package/dist/editor/server-clock.js +15 -0
  103. package/dist/editor/undo-change.d.ts +7 -0
  104. package/dist/editor/undo-change.js +19 -4
  105. package/dist/git-manager.d.ts +6 -0
  106. package/dist/git-manager.js +38 -3
  107. package/dist/item-keys.d.ts +13 -0
  108. package/dist/item-keys.js +36 -14
  109. package/dist/merge/conflict-path.d.ts +40 -0
  110. package/dist/merge/conflict-path.js +128 -0
  111. package/dist/merge/diff3.d.ts +27 -0
  112. package/dist/merge/diff3.js +113 -0
  113. package/dist/merge/entry-merge.d.ts +94 -0
  114. package/dist/merge/entry-merge.js +876 -0
  115. package/dist/merge/list-alignment.d.ts +55 -0
  116. package/dist/merge/list-alignment.js +490 -0
  117. package/dist/merge/rekey.d.ts +11 -0
  118. package/dist/merge/rekey.js +75 -0
  119. package/dist/merge/text-merge.d.ts +29 -0
  120. package/dist/merge/text-merge.js +59 -0
  121. package/dist/merge/value-equality.d.ts +18 -0
  122. package/dist/merge/value-equality.js +50 -0
  123. package/dist/merge-conflicts.d.ts +232 -0
  124. package/dist/merge-conflicts.js +451 -0
  125. package/dist/operating-mode/client-safe-strategy.d.ts +2 -0
  126. package/dist/operating-mode/client-safe-strategy.js +6 -0
  127. package/dist/operating-mode/client-unsafe-strategy.js +6 -0
  128. package/dist/operating-mode/types.d.ts +13 -0
  129. package/dist/services.d.ts +24 -1
  130. package/dist/services.js +138 -22
  131. package/dist/task-queue/cms-task-queue.d.ts +3 -1
  132. package/dist/types.d.ts +63 -5
  133. package/dist/utils/content-serialize.d.ts +18 -1
  134. package/dist/utils/content-serialize.js +12 -0
  135. package/dist/utils/content-write-lock.d.ts +3 -3
  136. package/dist/utils/content-write-lock.js +3 -3
  137. package/dist/utils/git.d.ts +7 -0
  138. package/dist/utils/git.js +35 -0
  139. package/dist/utils/markdown-body-splice.d.ts +1 -1
  140. package/dist/utils/markdown-body-splice.js +1 -19
  141. package/dist/utils/markdown-parse.d.ts +9 -0
  142. package/dist/utils/markdown-parse.js +27 -0
  143. package/dist/version.d.ts +1 -1
  144. package/dist/version.js +1 -1
  145. package/dist/worker/branch-merge.d.ts +80 -40
  146. package/dist/worker/branch-merge.js +244 -58
  147. package/dist/worker/branch-policy.d.ts +18 -2
  148. package/dist/worker/branch-policy.js +36 -0
  149. package/dist/worker/cms-worker.d.ts +37 -3
  150. package/dist/worker/cms-worker.js +88 -16
  151. package/dist/worker/conflicts-status.d.ts +20 -0
  152. package/dist/worker/conflicts-status.js +204 -0
  153. package/dist/worker/field-merge.d.ts +80 -0
  154. package/dist/worker/field-merge.js +729 -0
  155. package/dist/worker/git-sync.d.ts +3 -2
  156. package/dist/worker/git-sync.js +16 -6
  157. package/dist/worker/github-commits.d.ts +42 -10
  158. package/dist/worker/github-commits.js +444 -52
  159. package/dist/worker/github-errors.d.ts +15 -0
  160. package/dist/worker/github-errors.js +47 -0
  161. package/dist/worker/github-gateway-http.js +24 -9
  162. package/dist/worker/github-gateway-wire.d.ts +49 -20
  163. package/dist/worker/github-gateway-wire.js +19 -3
  164. package/dist/worker/github-gateway.d.ts +27 -1
  165. package/dist/worker/github-gateway.js +55 -7
  166. package/dist/worker/github-transfer.d.ts +5 -3
  167. package/dist/worker/github-transfer.js +8 -5
  168. package/dist/worker/history-rewrite.d.ts +40 -8
  169. package/dist/worker/history-rewrite.js +51 -23
  170. package/dist/worker/publish-decision.d.ts +37 -0
  171. package/dist/worker/publish-decision.js +16 -0
  172. package/dist/worker/rebase.d.ts +29 -8
  173. package/dist/worker/rebase.js +492 -211
  174. package/dist/worker/task-runner.d.ts +1 -1
  175. package/dist/worker/task-runner.js +30 -41
  176. package/dist/worker/worker-context.d.ts +24 -2
  177. package/package.json +1 -1
@@ -1,14 +1,30 @@
1
1
  import type { SimpleGit } from 'simple-git';
2
- import { type ContentId } from '../paths/types.js';
3
- import type { ConflictStatus } from '../types.js';
2
+ import type { KeyRemap } from '../merge/entry-merge.js';
3
+ import type { FoundEntryConflict } from '../merge-conflicts.js';
4
4
  import type { WorkerContext } from './worker-context.js';
5
- /** What {@link buildMergeCommit} made of the two commits. */
6
- export type BuiltMerge =
7
- /** `sha` is the merge, reachable from no ref yet. `conflicted` kept the branch's version. */
8
- {
9
- kind: 'built';
10
- sha: string;
11
- conflicted: string[];
5
+ /** What {@link planMerge} made of the two commits. */
6
+ export type MergePlan = {
7
+ kind: 'planned';
8
+ /** The merge's tree. */
9
+ tree: string;
10
+ /** The tree git's own merge wrote, conflict markers and all. */
11
+ gitTree: string;
12
+ /** Paths git could not merge, whoever settled them. */
13
+ gitConflicted: string[];
14
+ /** What a person must still decide, entry by entry. */
15
+ conflicts: FoundEntryConflict[];
16
+ /** Paths where the branch's version stands over the other side's change, whole or in part. */
17
+ keptPaths: string[];
18
+ /** Git's own merge with nothing for a person: no conflict, and every file as git merged it. */
19
+ asGitDoes: boolean;
20
+ /** Item keys the merge changed, by the entry path the branch's comment threads name. */
21
+ keyRemaps: Array<{
22
+ entryPath: string;
23
+ contentId?: string;
24
+ remaps: KeyRemap[];
25
+ }>;
26
+ /** Where each entry the merge decided stands in it. */
27
+ paths: Map<string, string>;
12
28
  }
13
29
  /** Git met conflicts no rule settles safely. Nothing was built. */
14
30
  | {
@@ -16,24 +32,69 @@ export type BuiltMerge =
16
32
  conflicts: string[];
17
33
  paths: string[];
18
34
  };
35
+ /** {@link commitMerge}'s commit of a planned merge; `sha` is reachable from no ref yet. */
36
+ export type BuiltMerge = Omit<Extract<MergePlan, {
37
+ kind: 'planned';
38
+ }>, 'kind' | 'tree' | 'gitTree'> & {
39
+ kind: 'built';
40
+ sha: string;
41
+ };
19
42
  /**
20
- * The merge of `other` into `head`, as a commit no ref names, with `head` the first parent. A path
21
- * git cannot merge keeps `head`'s side, stage 2, at the path git reports, or stays deleted when
22
- * `head` has no such stage: the rule the rebase loop applies to a conflict, since `head` has the
23
- * editor's work. The commit takes its identity from the clone's `user.*`, written when the clone
24
- * is provisioned.
43
+ * The merge of `other` into `head`, as a tree. Every entry and `.collection.json` both sides
44
+ * changed is merged field by field (field-merge.ts), which settles git's conflicts there, whatever
45
+ * their kind. A path git cannot merge that is no entry keeps `head`'s side, stage 2, at the path
46
+ * git reports, or stays deleted when `head` has no such stage: `head` has the editor's work. With
47
+ * no single merge base, git's merge stands as that rule leaves it, since a field merge needs one
48
+ * version both sides started from.
25
49
  */
26
- export declare function buildMergeCommit(git: SimpleGit, clonePath: string, args: {
50
+ export declare function planMerge(git: SimpleGit, clonePath: string, args: {
27
51
  head: string;
28
52
  other: string;
29
- message: string;
53
+ contentRoot: string;
30
54
  timeoutMs: number;
55
+ }): Promise<MergePlan>;
56
+ /**
57
+ * {@link planMerge}'s merge as a commit no ref names, with `head` the first parent. The commit takes
58
+ * its identity from the clone's `user.*`, written when the clone is provisioned.
59
+ */
60
+ export declare function commitMerge(git: SimpleGit, plan: Extract<MergePlan, {
61
+ kind: 'planned';
62
+ }>, { head, other, message }: {
63
+ head: string;
64
+ other: string;
65
+ message: string;
31
66
  }): Promise<BuiltMerge>;
67
+ /** Whether `plan` is git's own merge with nothing for a person. */
68
+ export declare const mergesAsGitDoes: (plan: MergePlan) => boolean;
69
+ /**
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.
73
+ */
74
+ export declare function isCleanMergeOf(git: SimpleGit, merged: string, from: string, other: string, contentRoot: string): Promise<boolean>;
32
75
  /**
33
- * Whether `merged` holds exactly what `from` and `other` merge to cleanly, and so nothing else. A
34
- * conflict counts as no: whoever settled it chose what `merged` holds.
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.
35
82
  */
36
- export declare function isCleanMergeOf(git: SimpleGit, merged: string, from: string, other: string): Promise<boolean>;
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>;
37
98
  /**
38
99
  * Move `refs/heads/<branchRef>` in remote.git to `sha` as a plain push, so it can only ever
39
100
  * fast-forward. Resolves false when it would not: under the caller's locks, the only writers left
@@ -45,24 +106,3 @@ export declare function commitsApart(git: SimpleGit, a: string, b: string): Prom
45
106
  onlyA: number;
46
107
  onlyB: number;
47
108
  }>;
48
- /**
49
- * Map the file paths a rebase or merge kept the branch's version of onto the ContentIds the
50
- * editor shows as conflicted, deduplicated. IDs rather than paths because they are
51
- * immutable: a later slug rename must not orphan a conflict marker. Paths that
52
- * carry no recoverable ID are dropped rather than guessed at.
53
- *
54
- * Pure.
55
- */
56
- export declare function conflictFilesToContentIds(conflictedFiles: readonly string[], contentRoot: string): ContentId[];
57
- /**
58
- * The conflict record after a rebase or merge: the IDs already recorded plus
59
- * its own. A conflict means this branch's version replaced base's change to
60
- * that entry, which a later clean rebase does not undo, so the record only
61
- * grows until the branch merges (buildMergedBranchUpdate clears it).
62
- *
63
- * Pure.
64
- */
65
- export declare function recordConflicts(recorded: readonly ContentId[] | undefined, found: readonly ContentId[]): {
66
- conflictStatus: ConflictStatus;
67
- conflictFiles: ContentId[];
68
- };
@@ -1,11 +1,14 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import fs from 'node:fs/promises';
3
3
  import path from 'node:path';
4
+ import { readSchemaSnapshot } from '../branch-schema-cache.js';
4
5
  import { extractIdFromFilename } from '../content-id-index.js';
5
6
  import { normalizeFilesystemPath } from '../paths/normalize.js';
6
7
  import { ROOT_COLLECTION_ID } from '../paths/types.js';
7
8
  import { isNonFastForwardRejection } from '../utils/git.js';
8
9
  import { getErrorMessage, redactCredentials } from '../utils/error.js';
10
+ import { parseTypedFilename } from '../utils/typed-filename.js';
11
+ import { changedFiles, resolveFieldMerge, differsInItemKeysOnly, } from './field-merge.js';
9
12
  import { runGit } from './git-process.js';
10
13
  import { pinnedReceivePack, sharedRepoGit, sharedRepoProcessOptions } from './shared-repo-git.js';
11
14
  /**
@@ -16,10 +19,12 @@ import { pinnedReceivePack, sharedRepoGit, sharedRepoProcessOptions } from './sh
16
19
  */
17
20
  const OBJECT_ID = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
18
21
  /**
19
- * The conflicts {@link buildMergeCommit} settles by keeping `head`'s side of one path. Every other
20
- * kind (a rename colliding with another, a file meeting a directory, a directory rename) moves or
21
- * joins paths, so no one side of one path is the branch's version, and settling it by rule loses a
22
- * file. Messages that are not `CONFLICT` are git's notes, such as `Auto-merging`.
22
+ * The conflicts {@link planMerge} settles by keeping `head`'s side of one path. Every other kind (a
23
+ * rename colliding with another, a file meeting a directory, a directory rename) moves or joins
24
+ * paths, so no one side of one path is the branch's version, and settling it by rule loses a file;
25
+ * only the field merge, which pairs entries by content ID, settles those, and only where it decides
26
+ * every path the conflict names. Messages that are not `CONFLICT` are git's notes, such as
27
+ * `Auto-merging`.
23
28
  */
24
29
  const SETTLED_BY_KEEPING_HEAD = new Set([
25
30
  'CONFLICT (contents)',
@@ -46,27 +51,44 @@ function parseMergeTree(output, head, other) {
46
51
  const [, mode, oid, stage, path] = match;
47
52
  stages.push({ mode, oid, stage, path });
48
53
  }
49
- const unsettled = new Set();
54
+ const unsettled = [];
50
55
  for (i++; i < fields.length && fields[i] !== '';) {
51
- const kind = fields[i + parseInt(fields[i], 10) + 1];
52
- if (kind?.startsWith('CONFLICT') && !SETTLED_BY_KEEPING_HEAD.has(kind))
53
- unsettled.add(kind);
54
- i += parseInt(fields[i], 10) + 3;
56
+ const count = parseInt(fields[i], 10);
57
+ const kind = fields[i + count + 1];
58
+ if (kind?.startsWith('CONFLICT') && !SETTLED_BY_KEEPING_HEAD.has(kind)) {
59
+ unsettled.push({ kind, paths: fields.slice(i + 1, i + 1 + count) });
60
+ }
61
+ i += count + 3;
55
62
  }
56
63
  // A path git could not decode as UTF-8 reads back as U+FFFD, which names no file.
57
- if (stages.some(({ path }) => path.includes('\ufffd')))
58
- unsettled.add('an unreadable file name');
59
- return { tree, stages, unsettled: [...unsettled] };
64
+ const undecodable = stages.filter(({ path }) => path.includes('\ufffd')).map(({ path }) => path);
65
+ if (undecodable.length > 0)
66
+ unsettled.push({ kind: 'an unreadable file name', paths: [] });
67
+ return { tree, stages, unsettled };
60
68
  }
69
+ const NO_FIELD_MERGE = {
70
+ updates: new Map(),
71
+ covered: new Set(),
72
+ conflicts: [],
73
+ keyRemaps: [],
74
+ paths: new Map(),
75
+ };
61
76
  /**
62
- * The merge of `other` into `head`, as a commit no ref names, with `head` the first parent. A path
63
- * git cannot merge keeps `head`'s side, stage 2, at the path git reports, or stays deleted when
64
- * `head` has no such stage: the rule the rebase loop applies to a conflict, since `head` has the
65
- * editor's work. The commit takes its identity from the clone's `user.*`, written when the clone
66
- * is provisioned.
77
+ * The merge of `other` into `head`, as a tree. Every entry and `.collection.json` both sides
78
+ * changed is merged field by field (field-merge.ts), which settles git's conflicts there, whatever
79
+ * their kind. A path git cannot merge that is no entry keeps `head`'s side, stage 2, at the path
80
+ * git reports, or stays deleted when `head` has no such stage: `head` has the editor's work. With
81
+ * no single merge base, git's merge stands as that rule leaves it, since a field merge needs one
82
+ * version both sides started from.
67
83
  */
68
- export async function buildMergeCommit(git, clonePath, args) {
69
- const { head, other, message, timeoutMs } = args;
84
+ export async function planMerge(git, clonePath, args) {
85
+ const { head, other, contentRoot, timeoutMs } = args;
86
+ const bases = (await git.raw(['merge-base', '--all', '--end-of-options', head, other]))
87
+ .split('\n')
88
+ .filter((line) => OBJECT_ID.test(line));
89
+ if (bases.length === 0) {
90
+ return { kind: 'unsettled', conflicts: ['no history in common with it'], paths: [] };
91
+ }
70
92
  const output = await git.raw([
71
93
  'merge-tree',
72
94
  '--write-tree',
@@ -75,21 +97,105 @@ export async function buildMergeCommit(git, clonePath, args) {
75
97
  head,
76
98
  other,
77
99
  ]);
78
- const { tree: mergedTree, stages, unsettled } = parseMergeTree(output, head, other);
79
- const conflicted = [...new Set(stages.map(({ path }) => path))];
80
- if (unsettled.length > 0)
81
- return { kind: 'unsettled', conflicts: unsettled, paths: conflicted };
82
- const tree = stages.length === 0
83
- ? mergedTree
84
- : await keepHeadStages(clonePath, { mergedTree, stages, conflicted, timeoutMs });
100
+ const { tree: gitTree, stages, unsettled } = parseMergeTree(output, head, other);
101
+ const gitConflicted = [...new Set(stages.map(({ path }) => path))];
102
+ const field = bases.length === 1
103
+ ? await resolveFieldMerge({
104
+ git,
105
+ clonePath,
106
+ ancestor: bases[0],
107
+ head,
108
+ other,
109
+ mergedTree: gitTree,
110
+ conflicted: new Set(gitConflicted),
111
+ contentRoot,
112
+ flatSchema: await readSchemaSnapshot(clonePath),
113
+ collectionIdOf: (file) => conflictFilesToContentIds([file], contentRoot)[0],
114
+ timeoutMs,
115
+ })
116
+ : NO_FIELD_MERGE;
117
+ const unsettledLeft = unsettled.filter(({ paths }) => paths.length === 0 || paths.some((p) => !field.covered.has(p)));
118
+ if (field.unsettled)
119
+ unsettledLeft.push({ kind: field.unsettled, paths: [] });
120
+ if (unsettledLeft.length > 0) {
121
+ return {
122
+ kind: 'unsettled',
123
+ conflicts: [...new Set(unsettledLeft.map(({ kind }) => kind))],
124
+ paths: gitConflicted,
125
+ };
126
+ }
127
+ const updates = new Map(field.updates);
128
+ const kept = gitConflicted.filter((file) => !field.covered.has(file));
129
+ const headStages = new Map(stages.filter((s) => s.stage === '2').map((s) => [s.path, s]));
130
+ for (const file of kept) {
131
+ const stage = headStages.get(file);
132
+ updates.set(file, stage ? { path: file, mode: stage.mode, oid: stage.oid } : null);
133
+ }
134
+ const tree = updates.size === 0 ? gitTree : await withUpdates(clonePath, { gitTree, updates, timeoutMs });
135
+ const conflicts = [
136
+ ...field.conflicts,
137
+ ...keptVersions(kept, stages, contentRoot, field.conflicts),
138
+ ];
139
+ return {
140
+ kind: 'planned',
141
+ tree,
142
+ gitTree,
143
+ gitConflicted,
144
+ conflicts,
145
+ keptPaths: [...new Set([...field.conflicts.map(({ path }) => path), ...kept])],
146
+ keyRemaps: field.keyRemaps,
147
+ paths: field.paths,
148
+ asGitDoes: gitConflicted.length === 0 &&
149
+ conflicts.length === 0 &&
150
+ field.keyRemaps.length === 0 &&
151
+ tree === gitTree,
152
+ };
153
+ }
154
+ /**
155
+ * The files the merge kept `head`'s side of as entry-level conflicts, by the content ID their path
156
+ * carries, one per ID and none for an ID the field merge already names. A path with none is
157
+ * dropped rather than guessed at.
158
+ */
159
+ function keptVersions(kept, stages, contentRoot, named) {
160
+ const seen = new Set(named.map(({ contentId }) => contentId));
161
+ const found = [];
162
+ for (const file of kept) {
163
+ const [contentId] = conflictFilesToContentIds([file], contentRoot);
164
+ if (!contentId || seen.has(contentId))
165
+ continue;
166
+ seen.add(contentId);
167
+ const oid = (stage) => stages.find((s) => s.path === file && s.stage === stage)?.oid;
168
+ const [ancestor, branch, base] = [oid('1'), oid('2'), oid('3')];
169
+ found.push({
170
+ contentId,
171
+ path: file,
172
+ kind: 'unreadable',
173
+ sides: {
174
+ ...(ancestor && { ancestor }),
175
+ ...(base && { base }),
176
+ ...(branch && { branch }),
177
+ },
178
+ });
179
+ }
180
+ return found;
181
+ }
182
+ /**
183
+ * {@link planMerge}'s merge as a commit no ref names, with `head` the first parent. The commit takes
184
+ * its identity from the clone's `user.*`, written when the clone is provisioned.
185
+ */
186
+ export async function commitMerge(git, plan, { head, other, message }) {
187
+ const { tree, gitTree: _gitTree, kind: _kind, ...rest } = plan;
85
188
  const sha = (await git.raw(['commit-tree', '-p', head, '-p', other, '-m', message, '--end-of-options', tree])).trim();
86
- return { kind: 'built', sha, conflicted };
189
+ return { kind: 'built', sha, ...rest };
87
190
  }
191
+ /** Whether `plan` is git's own merge with nothing for a person. */
192
+ export const mergesAsGitDoes = (plan) => plan.kind === 'planned' && plan.asGitDoes;
88
193
  /**
89
- * Whether `merged` holds exactly what `from` and `other` merge to cleanly, and so nothing else. A
90
- * 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.
91
197
  */
92
- export async function isCleanMergeOf(git, merged, from, other) {
198
+ export async function isCleanMergeOf(git, merged, from, other, contentRoot) {
93
199
  const output = await git.raw([
94
200
  'merge-tree',
95
201
  '--write-tree',
@@ -101,11 +207,110 @@ export async function isCleanMergeOf(git, merged, from, other) {
101
207
  const { tree, stages } = parseMergeTree(output, from, other);
102
208
  if (stages.length > 0)
103
209
  return false;
104
- 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;
105
219
  }
106
- /** `mergedTree` with each of `conflicted` as `head` has it, through a throwaway index. */
107
- async function keepHeadStages(clonePath, args) {
108
- const { mergedTree, stages, conflicted, timeoutMs } = args;
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;
310
+ }
311
+ /** `gitTree` with each of `updates` in place, through a throwaway index. */
312
+ async function withUpdates(clonePath, args) {
313
+ const { gitTree, updates, timeoutMs } = args;
109
314
  const shared = sharedRepoProcessOptions(clonePath, 'worktree');
110
315
  const indexFile = path.join(clonePath, '.git', `canopy-merge-index-${randomUUID()}`);
111
316
  const options = {
@@ -113,15 +318,11 @@ async function keepHeadStages(clonePath, args) {
113
318
  env: { ...shared.env, GIT_INDEX_FILE: indexFile },
114
319
  inactivityTimeoutMs: timeoutMs,
115
320
  };
116
- const kept = new Map(stages.filter((s) => s.stage === '2').map((s) => [s.path, s]));
117
- // Mode 0 removes a path: what is left when `head` has no side of it.
118
- const zeros = '0'.repeat(mergedTree.length);
119
- const entries = conflicted.map((file) => {
120
- const entry = kept.get(file);
121
- return `${entry?.mode ?? '0'} ${entry?.oid ?? zeros}\t${file}\0`;
122
- });
321
+ // Mode 0 removes a path.
322
+ const zeros = '0'.repeat(gitTree.length);
323
+ const entries = [...updates].map(([file, blob]) => `${blob?.mode ?? '0'} ${blob?.oid ?? zeros}\t${file}\0`);
123
324
  try {
124
- await runGit(['read-tree', '--end-of-options', mergedTree], options);
325
+ await runGit(['read-tree', '--end-of-options', gitTree], options);
125
326
  await runGit(['update-index', '-z', '--index-info'], { ...options, stdin: entries.join('') });
126
327
  return (await runGit(['write-tree'], options)).trim();
127
328
  }
@@ -170,7 +371,7 @@ export async function commitsApart(git, a, b) {
170
371
  *
171
372
  * Pure.
172
373
  */
173
- export function conflictFilesToContentIds(conflictedFiles, contentRoot) {
374
+ function conflictFilesToContentIds(conflictedFiles, contentRoot) {
174
375
  // Entry files carry their ID in the filename. A `.collection.json` has none
175
376
  // of its own (extractIdFromFilename returns null for dot-prefixed files), so
176
377
  // the ID comes from the parent directory; the root content directory has no
@@ -211,18 +412,3 @@ export function conflictFilesToContentIds(conflictedFiles, contentRoot) {
211
412
  .filter((id) => id !== null);
212
413
  return [...new Set(conflictIds)];
213
414
  }
214
- /**
215
- * The conflict record after a rebase or merge: the IDs already recorded plus
216
- * its own. A conflict means this branch's version replaced base's change to
217
- * that entry, which a later clean rebase does not undo, so the record only
218
- * grows until the branch merges (buildMergedBranchUpdate clears it).
219
- *
220
- * Pure.
221
- */
222
- export function recordConflicts(recorded, found) {
223
- const conflictFiles = [...new Set([...(recorded ?? []), ...found])];
224
- return {
225
- conflictStatus: conflictFiles.length > 0 ? 'conflicts-detected' : 'clean',
226
- conflictFiles,
227
- };
228
- }
@@ -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);