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.
- package/CHANGELOG.md +326 -48
- package/dist/api/admin.d.ts +4 -4
- package/dist/api/branch-review.d.ts +20 -2
- package/dist/api/branch-review.js +153 -32
- package/dist/api/branch-status.d.ts +23 -2
- package/dist/api/branch-status.js +16 -2
- package/dist/api/branch-update-from-base.d.ts +23 -0
- package/dist/api/branch-update-from-base.js +227 -0
- package/dist/api/branch.d.ts +10 -0
- package/dist/api/branch.js +28 -2
- package/dist/api/client.d.ts +15 -2
- package/dist/api/client.js +24 -2
- package/dist/api/comments.d.ts +2 -2
- package/dist/api/content.d.ts +38 -1
- package/dist/api/content.js +41 -5
- package/dist/api/merge-conflicts.d.ts +235 -0
- package/dist/api/merge-conflicts.js +709 -0
- package/dist/api/routes.js +2 -0
- package/dist/api/types.d.ts +8 -3
- package/dist/base-update.d.ts +32 -0
- package/dist/base-update.js +31 -0
- package/dist/branch-metadata-file.d.ts +66 -0
- package/dist/branch-metadata-file.js +16 -1
- package/dist/branch-schema-cache.d.ts +12 -0
- package/dist/branch-schema-cache.js +27 -2
- package/dist/cli/backfill-item-keys.d.ts +77 -0
- package/dist/cli/backfill-item-keys.js +320 -0
- package/dist/cli/cli.d.ts +1 -1
- package/dist/cli/cli.js +9798 -1509
- package/dist/cli/generate-ai-content.js +373 -249
- package/dist/cli/github-app-manifest.d.ts +4 -1
- package/dist/cli/github-app-manifest.js +5 -1
- package/dist/cli/init.js +12 -0
- package/dist/cli/site-config.d.ts +14 -2
- package/dist/cli/site-config.js +20 -4
- package/dist/comment-store.d.ts +8 -0
- package/dist/comment-store.js +28 -0
- package/dist/content-index-generation.d.ts +1 -4
- package/dist/content-index-generation.js +1 -4
- package/dist/content-store.d.ts +39 -3
- package/dist/content-store.js +48 -15
- package/dist/editor/BranchManager.d.ts +4 -1
- package/dist/editor/BranchManager.js +9 -5
- package/dist/editor/Editor.js +462 -62
- package/dist/editor/EntryNavigator.d.ts +10 -2
- package/dist/editor/EntryNavigator.js +12 -4
- package/dist/editor/FormRenderer.d.ts +8 -2
- package/dist/editor/FormRenderer.js +5 -6
- package/dist/editor/admin/ConflictsStatusAlert.d.ts +8 -0
- package/dist/editor/admin/ConflictsStatusAlert.js +22 -0
- package/dist/editor/admin/SystemHealthPanel.js +4 -3
- package/dist/editor/base-update-wait.d.ts +78 -0
- package/dist/editor/base-update-wait.js +98 -0
- package/dist/editor/branch-status.js +1 -1
- package/dist/editor/components/BaseUpdateNotice.d.ts +12 -0
- package/dist/editor/components/BaseUpdateNotice.js +24 -0
- package/dist/editor/components/BranchPill.d.ts +4 -1
- package/dist/editor/components/BranchPill.js +5 -2
- package/dist/editor/components/ConflictsGroup.d.ts +112 -0
- package/dist/editor/components/ConflictsGroup.js +404 -0
- package/dist/editor/components/EditorHeader.d.ts +18 -1
- package/dist/editor/components/EditorHeader.js +99 -33
- package/dist/editor/components/EntryChangesDrawer.d.ts +16 -2
- package/dist/editor/components/EntryChangesDrawer.js +85 -167
- package/dist/editor/components/EntryHeader.d.ts +3 -1
- package/dist/editor/components/EntryHeader.js +2 -2
- package/dist/editor/components/GetLatestChangesButton.d.ts +15 -0
- package/dist/editor/components/GetLatestChangesButton.js +29 -0
- package/dist/editor/components/ReleaseHoldButton.d.ts +20 -0
- package/dist/editor/components/ReleaseHoldButton.js +36 -0
- package/dist/editor/components/RetryToastMessage.js +1 -1
- package/dist/editor/components/change-parts.d.ts +51 -0
- package/dist/editor/components/change-parts.js +171 -0
- package/dist/editor/conflict-cards.d.ts +93 -0
- package/dist/editor/conflict-cards.js +284 -0
- package/dist/editor/conflict-choices.d.ts +60 -0
- package/dist/editor/conflict-choices.js +108 -0
- package/dist/editor/conflict-notice.d.ts +25 -0
- package/dist/editor/conflict-notice.js +39 -0
- package/dist/editor/copy.d.ts +12 -0
- package/dist/editor/copy.js +12 -0
- package/dist/editor/entry-diff.d.ts +2 -1
- package/dist/editor/entry-diff.js +5 -21
- package/dist/editor/hooks/useBaseUpdate.d.ts +38 -0
- package/dist/editor/hooks/useBaseUpdate.js +279 -0
- package/dist/editor/hooks/useBranchChangesData.d.ts +7 -1
- package/dist/editor/hooks/useBranchChangesData.js +13 -0
- package/dist/editor/hooks/useBranchManager.d.ts +8 -1
- package/dist/editor/hooks/useBranchManager.js +108 -13
- package/dist/editor/hooks/useBranchesData.js +3 -0
- package/dist/editor/hooks/useConflictChoices.d.ts +24 -0
- package/dist/editor/hooks/useConflictChoices.js +75 -0
- package/dist/editor/hooks/useConflictsData.d.ts +26 -0
- package/dist/editor/hooks/useConflictsData.js +89 -0
- package/dist/editor/hooks/useDraftManager.d.ts +13 -1
- package/dist/editor/hooks/useDraftManager.js +40 -9
- package/dist/editor/hooks/useEntryManager.d.ts +17 -3
- package/dist/editor/hooks/useEntryManager.js +11 -5
- package/dist/editor/release-hold.d.ts +12 -0
- package/dist/editor/release-hold.js +39 -0
- package/dist/editor/server-clock.d.ts +4 -0
- package/dist/editor/server-clock.js +15 -0
- package/dist/editor/undo-change.d.ts +7 -0
- package/dist/editor/undo-change.js +19 -4
- package/dist/git-manager.d.ts +6 -0
- package/dist/git-manager.js +38 -3
- package/dist/item-keys.d.ts +13 -0
- package/dist/item-keys.js +36 -14
- package/dist/merge/conflict-path.d.ts +40 -0
- package/dist/merge/conflict-path.js +128 -0
- package/dist/merge/diff3.d.ts +27 -0
- package/dist/merge/diff3.js +113 -0
- package/dist/merge/entry-merge.d.ts +94 -0
- package/dist/merge/entry-merge.js +876 -0
- package/dist/merge/list-alignment.d.ts +55 -0
- package/dist/merge/list-alignment.js +490 -0
- package/dist/merge/rekey.d.ts +11 -0
- package/dist/merge/rekey.js +75 -0
- package/dist/merge/text-merge.d.ts +29 -0
- package/dist/merge/text-merge.js +59 -0
- package/dist/merge/value-equality.d.ts +18 -0
- package/dist/merge/value-equality.js +50 -0
- package/dist/merge-conflicts.d.ts +232 -0
- package/dist/merge-conflicts.js +451 -0
- package/dist/operating-mode/client-safe-strategy.d.ts +2 -0
- package/dist/operating-mode/client-safe-strategy.js +6 -0
- package/dist/operating-mode/client-unsafe-strategy.js +6 -0
- package/dist/operating-mode/types.d.ts +13 -0
- package/dist/services.d.ts +24 -1
- package/dist/services.js +138 -22
- package/dist/task-queue/cms-task-queue.d.ts +3 -1
- package/dist/types.d.ts +63 -5
- package/dist/utils/content-serialize.d.ts +18 -1
- package/dist/utils/content-serialize.js +12 -0
- package/dist/utils/content-write-lock.d.ts +3 -3
- package/dist/utils/content-write-lock.js +3 -3
- package/dist/utils/git.d.ts +7 -0
- package/dist/utils/git.js +35 -0
- package/dist/utils/markdown-body-splice.d.ts +1 -1
- package/dist/utils/markdown-body-splice.js +1 -19
- package/dist/utils/markdown-parse.d.ts +9 -0
- package/dist/utils/markdown-parse.js +27 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker/branch-merge.d.ts +80 -40
- package/dist/worker/branch-merge.js +244 -58
- package/dist/worker/branch-policy.d.ts +18 -2
- package/dist/worker/branch-policy.js +36 -0
- package/dist/worker/cms-worker.d.ts +37 -3
- package/dist/worker/cms-worker.js +88 -16
- package/dist/worker/conflicts-status.d.ts +20 -0
- package/dist/worker/conflicts-status.js +204 -0
- package/dist/worker/field-merge.d.ts +80 -0
- package/dist/worker/field-merge.js +729 -0
- package/dist/worker/git-sync.d.ts +3 -2
- package/dist/worker/git-sync.js +16 -6
- package/dist/worker/github-commits.d.ts +42 -10
- package/dist/worker/github-commits.js +444 -52
- package/dist/worker/github-errors.d.ts +15 -0
- package/dist/worker/github-errors.js +47 -0
- package/dist/worker/github-gateway-http.js +24 -9
- package/dist/worker/github-gateway-wire.d.ts +49 -20
- package/dist/worker/github-gateway-wire.js +19 -3
- package/dist/worker/github-gateway.d.ts +27 -1
- package/dist/worker/github-gateway.js +55 -7
- package/dist/worker/github-transfer.d.ts +5 -3
- package/dist/worker/github-transfer.js +8 -5
- package/dist/worker/history-rewrite.d.ts +40 -8
- package/dist/worker/history-rewrite.js +51 -23
- package/dist/worker/publish-decision.d.ts +37 -0
- package/dist/worker/publish-decision.js +16 -0
- package/dist/worker/rebase.d.ts +29 -8
- package/dist/worker/rebase.js +492 -211
- package/dist/worker/task-runner.d.ts +1 -1
- package/dist/worker/task-runner.js +30 -41
- package/dist/worker/worker-context.d.ts +24 -2
- package/package.json +1 -1
|
@@ -1,14 +1,30 @@
|
|
|
1
1
|
import type { SimpleGit } from 'simple-git';
|
|
2
|
-
import {
|
|
3
|
-
import type {
|
|
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
|
|
6
|
-
export type
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|
|
50
|
+
export declare function planMerge(git: SimpleGit, clonePath: string, args: {
|
|
27
51
|
head: string;
|
|
28
52
|
other: string;
|
|
29
|
-
|
|
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
|
-
*
|
|
34
|
-
*
|
|
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
|
|
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
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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 =
|
|
54
|
+
const unsettled = [];
|
|
50
55
|
for (i++; i < fields.length && fields[i] !== '';) {
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
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
|
|
69
|
-
const { head, other,
|
|
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:
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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,
|
|
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
|
|
90
|
-
*
|
|
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
|
-
|
|
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
|
-
/**
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
const
|
|
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',
|
|
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
|
-
|
|
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);
|