@dxos/versioning 0.10.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/dist/lib/browser/index.mjs +417 -0
- package/dist/lib/browser/index.mjs.map +7 -0
- package/dist/lib/browser/meta.json +1 -0
- package/dist/lib/node-esm/index.mjs +418 -0
- package/dist/lib/node-esm/index.mjs.map +7 -0
- package/dist/lib/node-esm/meta.json +1 -0
- package/dist/types/src/Branch.d.ts +3 -0
- package/dist/types/src/Branch.d.ts.map +1 -0
- package/dist/types/src/History.d.ts +3 -0
- package/dist/types/src/History.d.ts.map +1 -0
- package/dist/types/src/Version.d.ts +3 -0
- package/dist/types/src/Version.d.ts.map +1 -0
- package/dist/types/src/diff.d.ts +35 -0
- package/dist/types/src/diff.d.ts.map +1 -0
- package/dist/types/src/diff.test.d.ts +2 -0
- package/dist/types/src/diff.test.d.ts.map +1 -0
- package/dist/types/src/index.d.ts +5 -0
- package/dist/types/src/index.d.ts.map +1 -0
- package/dist/types/src/internal/index.d.ts +3 -0
- package/dist/types/src/internal/index.d.ts.map +1 -0
- package/dist/types/src/internal/model.d.ts +53 -0
- package/dist/types/src/internal/model.d.ts.map +1 -0
- package/dist/types/src/internal/types.d.ts +69 -0
- package/dist/types/src/internal/types.d.ts.map +1 -0
- package/dist/types/src/model.test.d.ts +2 -0
- package/dist/types/src/model.test.d.ts.map +1 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -0
- package/package.json +43 -0
- package/src/Branch.ts +21 -0
- package/src/History.ts +8 -0
- package/src/Version.ts +14 -0
- package/src/diff.test.ts +195 -0
- package/src/diff.ts +225 -0
- package/src/index.ts +8 -0
- package/src/internal/index.ts +6 -0
- package/src/internal/model.ts +189 -0
- package/src/internal/types.ts +76 -0
- package/src/model.test.ts +182 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { Text as EchoText, Obj, Ref } from '@dxos/echo';
|
|
6
|
+
import { checkoutVersion } from '@dxos/echo-client';
|
|
7
|
+
import { invariant } from '@dxos/invariant';
|
|
8
|
+
import { Text } from '@dxos/schema';
|
|
9
|
+
|
|
10
|
+
import { merge3 } from '../diff';
|
|
11
|
+
import * as Versioning from './types';
|
|
12
|
+
|
|
13
|
+
/** Any ECHO object that carries a versioning history (e.g. a markdown document). */
|
|
14
|
+
export type VersionedObject = Obj.Unknown & { history?: Versioning.History | undefined };
|
|
15
|
+
|
|
16
|
+
/** Initializes the history struct on first use so existing documents need no migration. */
|
|
17
|
+
export const ensureHistory = (doc: VersionedObject): Versioning.History => {
|
|
18
|
+
if (!doc.history) {
|
|
19
|
+
Obj.update(doc, (doc) => {
|
|
20
|
+
doc.history = { branches: [], versions: [] };
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
const history = doc.history;
|
|
24
|
+
invariant(history, 'history not initialized');
|
|
25
|
+
return history;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const getHeads = (text: Text.Text): string[] => {
|
|
29
|
+
const version = Obj.version(text);
|
|
30
|
+
invariant(version.versioned && version.automergeHeads, 'text is not versioned (not persisted?)');
|
|
31
|
+
return [...version.automergeHeads];
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** @returns The Text content at the given automerge heads (read-only time travel). */
|
|
35
|
+
export const contentAt = (text: Text.Text, heads: readonly string[]): string => {
|
|
36
|
+
// `checkoutVersion` returns `unknown` (raw historical data) — narrow via runtime checks.
|
|
37
|
+
const snapshot = checkoutVersion(text, [...heads]);
|
|
38
|
+
if (snapshot && typeof snapshot === 'object' && 'content' in snapshot) {
|
|
39
|
+
const { content } = snapshot;
|
|
40
|
+
return typeof content === 'string' ? content : '';
|
|
41
|
+
}
|
|
42
|
+
return '';
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export type CreateCheckpointProps = { target: Text.Text; name: string; message?: string; creator?: string };
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Records a named checkpoint of the target Text's current automerge heads and appends it to
|
|
49
|
+
* the object's history. Zero-copy: only the heads are stored.
|
|
50
|
+
*/
|
|
51
|
+
export const createCheckpoint = (doc: VersionedObject, props: CreateCheckpointProps): Versioning.Version => {
|
|
52
|
+
const text = props.target;
|
|
53
|
+
const version = Versioning.makeVersion({
|
|
54
|
+
name: props.name,
|
|
55
|
+
target: Ref.make(text),
|
|
56
|
+
heads: getHeads(text),
|
|
57
|
+
...(props.message !== undefined && { message: props.message }),
|
|
58
|
+
...(props.creator !== undefined && { creator: props.creator }),
|
|
59
|
+
});
|
|
60
|
+
const history = ensureHistory(doc);
|
|
61
|
+
Obj.update(doc, () => {
|
|
62
|
+
history.versions.push(version);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// Return the stored record (the pushed plain object is detached from the database).
|
|
66
|
+
const stored = history.versions.find(({ id }) => id === version.id);
|
|
67
|
+
invariant(stored, 'checkpoint not stored');
|
|
68
|
+
return stored;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/** Display label for a checkpoint: its name, or the formatted creation time when unnamed. */
|
|
72
|
+
export const versionLabel = (version: Versioning.Version): string =>
|
|
73
|
+
version.name || new Date(version.createdAt).toLocaleString();
|
|
74
|
+
|
|
75
|
+
/** Display label for a branch: its name, or the formatted creation time when unnamed. */
|
|
76
|
+
export const branchLabel = (branch: Versioning.Branch): string =>
|
|
77
|
+
branch.name || new Date(branch.createdAt).toLocaleString();
|
|
78
|
+
|
|
79
|
+
/** Find the branch record owning a given Text (undefined for the root). */
|
|
80
|
+
export const findBranch = (doc: VersionedObject, text: Text.Text): Versioning.Branch | undefined =>
|
|
81
|
+
doc.history?.branches.find((branch) => branch.content.target?.id === text.id);
|
|
82
|
+
|
|
83
|
+
export type CreateBranchProps = {
|
|
84
|
+
name: string;
|
|
85
|
+
parent: Text.Text;
|
|
86
|
+
heads?: readonly string[];
|
|
87
|
+
creator?: string;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Forks a draft branch: a new Text seeded with the parent's content at the anchor heads
|
|
92
|
+
* (defaults to the parent's current heads), recorded in the object's history. The anchor is
|
|
93
|
+
* auto-checkpointed so the fork point stays addressable in the timeline.
|
|
94
|
+
*/
|
|
95
|
+
export const createBranch = (doc: VersionedObject, props: CreateBranchProps): Versioning.Branch => {
|
|
96
|
+
const parent = props.parent;
|
|
97
|
+
const anchor = props.heads ? [...props.heads] : getHeads(parent);
|
|
98
|
+
const baseContent = contentAt(parent, anchor);
|
|
99
|
+
|
|
100
|
+
const branchText = Text.make({ content: baseContent });
|
|
101
|
+
const db = Obj.getDatabase(doc);
|
|
102
|
+
invariant(db, 'document not in a database');
|
|
103
|
+
db.add(branchText);
|
|
104
|
+
Obj.setParent(branchText, doc);
|
|
105
|
+
|
|
106
|
+
const branch = Versioning.makeBranch({
|
|
107
|
+
name: props.name,
|
|
108
|
+
content: Ref.make(branchText),
|
|
109
|
+
parent: Ref.make(parent),
|
|
110
|
+
anchor,
|
|
111
|
+
...(props.creator !== undefined && { creator: props.creator }),
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const history = ensureHistory(doc);
|
|
115
|
+
Obj.update(doc, () => {
|
|
116
|
+
history.branches.push(branch);
|
|
117
|
+
});
|
|
118
|
+
// The anchor must stay addressable by name in the timeline.
|
|
119
|
+
if (!history.versions.some((version) => sameHeads(version.heads, anchor))) {
|
|
120
|
+
const version = Versioning.makeVersion({
|
|
121
|
+
name: `fork: ${branchLabel(branch)}`,
|
|
122
|
+
target: Ref.make(parent),
|
|
123
|
+
heads: anchor,
|
|
124
|
+
});
|
|
125
|
+
Obj.update(doc, () => {
|
|
126
|
+
history.versions.push(version);
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Return the stored record (the pushed plain object is detached from the database).
|
|
131
|
+
const stored = history.branches.find(({ id }) => id === branch.id);
|
|
132
|
+
invariant(stored, 'branch not stored');
|
|
133
|
+
return stored;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
/** Applies the checkpoint's content to the tip as a new forward edit — history is never rewritten. */
|
|
137
|
+
export const restore = (doc: VersionedObject, version: Versioning.Version): void => {
|
|
138
|
+
const text = version.target.target;
|
|
139
|
+
invariant(text, 'checkpoint target not loaded');
|
|
140
|
+
const historical = contentAt(text, version.heads);
|
|
141
|
+
Obj.update(text, () => {
|
|
142
|
+
EchoText.update(text, 'content', historical);
|
|
143
|
+
});
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
export type MergeResult = { conflicts: number };
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* 3-way merge: base = parent@anchor, ours = parent tip, theirs = branch tip.
|
|
150
|
+
* Conflicting hunks are left in the document with git-style markers for manual cleanup.
|
|
151
|
+
*/
|
|
152
|
+
export const mergeBranch = (doc: VersionedObject, branch: Versioning.Branch): MergeResult => {
|
|
153
|
+
// Callers may hold a detached copy of the record — mutate the stored one.
|
|
154
|
+
const stored = resolveBranch(doc, branch);
|
|
155
|
+
invariant(stored.status === 'active', 'branch is not active');
|
|
156
|
+
const parent = stored.parent.target;
|
|
157
|
+
const branchText = stored.content.target;
|
|
158
|
+
invariant(parent && branchText, 'branch refs not loaded');
|
|
159
|
+
|
|
160
|
+
const base = contentAt(parent, stored.anchor);
|
|
161
|
+
const { text, conflicts } = merge3({ base, ours: parent.content, theirs: branchText.content });
|
|
162
|
+
Obj.update(parent, () => {
|
|
163
|
+
EchoText.update(parent, 'content', text);
|
|
164
|
+
});
|
|
165
|
+
Obj.update(doc, () => {
|
|
166
|
+
stored.status = 'merged';
|
|
167
|
+
stored.mergedAt = new Date().toISOString();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
createCheckpoint(doc, { name: `merge: ${branchLabel(stored)}`, target: parent });
|
|
171
|
+
return { conflicts };
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
/** Archives the branch; its Text is retained for recovery. */
|
|
175
|
+
export const discardBranch = (doc: VersionedObject, branch: Versioning.Branch): void => {
|
|
176
|
+
const stored = resolveBranch(doc, branch);
|
|
177
|
+
Obj.update(doc, () => {
|
|
178
|
+
stored.status = 'archived';
|
|
179
|
+
});
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const resolveBranch = (doc: VersionedObject, branch: Versioning.Branch): Versioning.Branch => {
|
|
183
|
+
const stored = doc.history?.branches.find(({ id }) => id === branch.id);
|
|
184
|
+
invariant(stored, `branch not found: ${branch.id}`);
|
|
185
|
+
return stored;
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const sameHeads = (a: readonly string[], b: readonly string[]) =>
|
|
189
|
+
a.length === b.length && a.every((head) => b.includes(head));
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import * as Schema from 'effect/Schema';
|
|
6
|
+
|
|
7
|
+
import { Key, Ref } from '@dxos/echo';
|
|
8
|
+
import { Text } from '@dxos/schema';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Named checkpoint: a pointer to the automerge heads of a Text's backing document.
|
|
12
|
+
* Heads are content-addressed change hashes, stable across peers, so a checkpoint is zero-copy.
|
|
13
|
+
* Generic over any object holding a `history` field (see model.ts).
|
|
14
|
+
*/
|
|
15
|
+
export const Version = Schema.mutable(
|
|
16
|
+
Schema.Struct({
|
|
17
|
+
id: Schema.String,
|
|
18
|
+
name: Schema.String,
|
|
19
|
+
target: Ref.Ref(Text.Text),
|
|
20
|
+
heads: Schema.mutable(Schema.Array(Schema.String)),
|
|
21
|
+
createdAt: Schema.String,
|
|
22
|
+
creator: Schema.optional(Schema.String),
|
|
23
|
+
message: Schema.optional(Schema.String),
|
|
24
|
+
}),
|
|
25
|
+
);
|
|
26
|
+
export interface Version extends Schema.Schema.Type<typeof Version> {}
|
|
27
|
+
|
|
28
|
+
export const BranchStatus = Schema.Literal('active', 'merged', 'archived');
|
|
29
|
+
export type BranchStatus = Schema.Schema.Type<typeof BranchStatus>;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A draft Text forked from a parent Text at a specific revision (anchor heads).
|
|
33
|
+
* The branch tree is formed by `parent` references; the root is Document.content.
|
|
34
|
+
*/
|
|
35
|
+
export const Branch = Schema.mutable(
|
|
36
|
+
Schema.Struct({
|
|
37
|
+
id: Schema.String,
|
|
38
|
+
name: Schema.String,
|
|
39
|
+
content: Ref.Ref(Text.Text),
|
|
40
|
+
parent: Ref.Ref(Text.Text),
|
|
41
|
+
anchor: Schema.mutable(Schema.Array(Schema.String)),
|
|
42
|
+
status: BranchStatus,
|
|
43
|
+
createdAt: Schema.String,
|
|
44
|
+
creator: Schema.optional(Schema.String),
|
|
45
|
+
mergedAt: Schema.optional(Schema.String),
|
|
46
|
+
}),
|
|
47
|
+
);
|
|
48
|
+
export interface Branch extends Schema.Schema.Type<typeof Branch> {}
|
|
49
|
+
|
|
50
|
+
export const History = Schema.mutable(
|
|
51
|
+
Schema.Struct({
|
|
52
|
+
branches: Schema.mutable(Schema.Array(Branch)),
|
|
53
|
+
versions: Schema.mutable(Schema.Array(Version)),
|
|
54
|
+
}),
|
|
55
|
+
);
|
|
56
|
+
export interface History extends Schema.Schema.Type<typeof History> {}
|
|
57
|
+
|
|
58
|
+
export type MakeVersionProps = Pick<Version, 'target' | 'heads' | 'name'> &
|
|
59
|
+
Partial<Pick<Version, 'creator' | 'message'>>;
|
|
60
|
+
|
|
61
|
+
/** Constructs a Version checkpoint record with a generated id and creation timestamp. */
|
|
62
|
+
export const makeVersion = (props: MakeVersionProps): Version => ({
|
|
63
|
+
id: Key.EntityId.random(),
|
|
64
|
+
createdAt: new Date().toISOString(),
|
|
65
|
+
...props,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
export type MakeBranchProps = Pick<Branch, 'content' | 'parent' | 'anchor' | 'name'> & Partial<Pick<Branch, 'creator'>>;
|
|
69
|
+
|
|
70
|
+
/** Constructs an active Branch record with a generated id and creation timestamp. */
|
|
71
|
+
export const makeBranch = (props: MakeBranchProps): Branch => ({
|
|
72
|
+
id: Key.EntityId.random(),
|
|
73
|
+
status: 'active',
|
|
74
|
+
createdAt: new Date().toISOString(),
|
|
75
|
+
...props,
|
|
76
|
+
});
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import * as Schema from 'effect/Schema';
|
|
6
|
+
import { afterEach, beforeEach, describe, test } from 'vitest';
|
|
7
|
+
|
|
8
|
+
import { DXN, Obj, Ref, Type } from '@dxos/echo';
|
|
9
|
+
import { EchoTestBuilder } from '@dxos/echo-client/testing';
|
|
10
|
+
import { Text } from '@dxos/schema';
|
|
11
|
+
|
|
12
|
+
import * as Branch from './Branch';
|
|
13
|
+
import * as History from './History';
|
|
14
|
+
import * as Version from './Version';
|
|
15
|
+
|
|
16
|
+
/** Minimal versioned host: a document-like object holding a root Text and a history. */
|
|
17
|
+
const TestDoc = Type.makeObject(DXN.make('org.dxos.test.versioning.Doc', '0.1.0'))(
|
|
18
|
+
Schema.Struct({
|
|
19
|
+
name: Schema.optional(Schema.String),
|
|
20
|
+
content: Ref.Ref(Text.Text),
|
|
21
|
+
history: History.History.pipe(Schema.optional),
|
|
22
|
+
}),
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
describe('versioning model', () => {
|
|
26
|
+
let builder: EchoTestBuilder;
|
|
27
|
+
|
|
28
|
+
beforeEach(async () => {
|
|
29
|
+
builder = await new EchoTestBuilder().open();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
afterEach(async () => {
|
|
33
|
+
await builder.close();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const setup = async (content: string) => {
|
|
37
|
+
const { db } = await builder.createDatabase({ types: [TestDoc, Text.Text] });
|
|
38
|
+
const doc = db.add(Obj.make(TestDoc, { content: Ref.make(Text.make({ content })) }));
|
|
39
|
+
await db.flush();
|
|
40
|
+
const root = await doc.content.load();
|
|
41
|
+
return { db, doc, root };
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
test('ensureHistory initializes once', async ({ expect }) => {
|
|
45
|
+
const { doc } = await setup('one');
|
|
46
|
+
const history = History.ensure(doc);
|
|
47
|
+
expect(history.versions).toEqual([]);
|
|
48
|
+
expect(History.ensure(doc)).toBe(doc.history);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('checkpoint records current heads; contentAt returns historical content', async ({ expect }) => {
|
|
52
|
+
const { doc, root } = await setup('one');
|
|
53
|
+
|
|
54
|
+
const checkpoint = Version.create(doc, { name: 'v1', target: root });
|
|
55
|
+
expect(checkpoint.heads.length).toBeGreaterThan(0);
|
|
56
|
+
expect(doc.history?.versions).toHaveLength(1);
|
|
57
|
+
|
|
58
|
+
Obj.update(root, (root) => {
|
|
59
|
+
root.content = 'one two';
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
expect(Version.contentAt(root, checkpoint.heads)).toBe('one');
|
|
63
|
+
expect(root.content).toBe('one two');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('branch forks content at anchor; edits stay isolated', async ({ expect }) => {
|
|
67
|
+
const { doc, root } = await setup('one two three');
|
|
68
|
+
|
|
69
|
+
const branch = Branch.create(doc, { name: 'draft', parent: root });
|
|
70
|
+
const branchText = await branch.content.load();
|
|
71
|
+
expect(branchText.content).toBe('one two three');
|
|
72
|
+
// Fork point auto-checkpointed on the parent.
|
|
73
|
+
expect(doc.history?.versions.some((version) => version.name === 'fork: draft')).toBe(true);
|
|
74
|
+
|
|
75
|
+
Obj.update(branchText, (branchText) => {
|
|
76
|
+
branchText.content = 'one two three four';
|
|
77
|
+
});
|
|
78
|
+
expect(root.content).toBe('one two three');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('branch at a historical checkpoint seeds historical content', async ({ expect }) => {
|
|
82
|
+
const { doc, root } = await setup('first');
|
|
83
|
+
const checkpoint = Version.create(doc, { name: 'v1', target: root });
|
|
84
|
+
|
|
85
|
+
Obj.update(root, (root) => {
|
|
86
|
+
root.content = 'second';
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const branch = Branch.create(doc, { name: 'from-v1', parent: root, heads: checkpoint.heads });
|
|
90
|
+
const branchText = await branch.content.load();
|
|
91
|
+
expect(branchText.content).toBe('first');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('merge applies branch changes to parent as one edit', async ({ expect }) => {
|
|
95
|
+
const { doc, root } = await setup('alpha\nbravo\n');
|
|
96
|
+
|
|
97
|
+
const branch = Branch.create(doc, { name: 'draft', parent: root });
|
|
98
|
+
const branchText = await branch.content.load();
|
|
99
|
+
Obj.update(branchText, (branchText) => {
|
|
100
|
+
branchText.content = 'alpha\nbravo\ncharlie\n';
|
|
101
|
+
});
|
|
102
|
+
// Concurrent parent edit after the fork.
|
|
103
|
+
Obj.update(root, (root) => {
|
|
104
|
+
root.content = 'alpha edited\nbravo\n';
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const result = Branch.merge(doc, branch);
|
|
108
|
+
expect(result.conflicts).toBe(0);
|
|
109
|
+
expect(root.content).toBe('alpha edited\nbravo\ncharlie\n');
|
|
110
|
+
expect(branch.status).toBe('merged');
|
|
111
|
+
expect(doc.history?.versions.some((version) => version.name === 'merge: draft')).toBe(true);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('merge leaves conflict markers when both sides edit the same line', async ({ expect }) => {
|
|
115
|
+
const { doc, root } = await setup('alpha\nbravo\n');
|
|
116
|
+
|
|
117
|
+
const branch = Branch.create(doc, { name: 'draft', parent: root });
|
|
118
|
+
const branchText = await branch.content.load();
|
|
119
|
+
Obj.update(branchText, (branchText) => {
|
|
120
|
+
branchText.content = 'alpha theirs\nbravo\n';
|
|
121
|
+
});
|
|
122
|
+
Obj.update(root, (root) => {
|
|
123
|
+
root.content = 'alpha ours\nbravo\n';
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const result = Branch.merge(doc, branch);
|
|
127
|
+
expect(result.conflicts).toBe(1);
|
|
128
|
+
expect(root.content).toContain('<<<<<<< branch');
|
|
129
|
+
expect(root.content).toContain('alpha theirs');
|
|
130
|
+
expect(root.content).toContain('alpha ours');
|
|
131
|
+
expect(branch.status).toBe('merged');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('branch of a branch forks from and merges into its parent branch', async ({ expect }) => {
|
|
135
|
+
const { doc, root } = await setup('alpha\n');
|
|
136
|
+
|
|
137
|
+
const parentBranch = Branch.create(doc, { name: 'draft', parent: root });
|
|
138
|
+
const parentText = await parentBranch.content.load();
|
|
139
|
+
Obj.update(parentText, (parentText) => {
|
|
140
|
+
parentText.content = 'alpha\nbravo\n';
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const childBranch = Branch.create(doc, { name: 'nested', parent: parentText });
|
|
144
|
+
const childText = await childBranch.content.load();
|
|
145
|
+
expect(childText.content).toBe('alpha\nbravo\n');
|
|
146
|
+
|
|
147
|
+
Obj.update(childText, (childText) => {
|
|
148
|
+
childText.content = 'alpha\nbravo\ncharlie\n';
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// Merging the child lands on the parent BRANCH, not the root.
|
|
152
|
+
const result = Branch.merge(doc, childBranch);
|
|
153
|
+
expect(result.conflicts).toBe(0);
|
|
154
|
+
expect(parentText.content).toBe('alpha\nbravo\ncharlie\n');
|
|
155
|
+
expect(root.content).toBe('alpha\n');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('restore applies historical content as a new forward edit', async ({ expect }) => {
|
|
159
|
+
const { doc, root } = await setup('first');
|
|
160
|
+
const checkpoint = Version.create(doc, { name: 'v1', target: root });
|
|
161
|
+
|
|
162
|
+
Obj.update(root, (root) => {
|
|
163
|
+
root.content = 'second';
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
Version.restore(doc, checkpoint);
|
|
167
|
+
expect(root.content).toBe('first');
|
|
168
|
+
// History retained: heads advanced, not rewound.
|
|
169
|
+
expect(Obj.version(root).automergeHeads).not.toEqual(checkpoint.heads);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('discard archives without touching parent', async ({ expect }) => {
|
|
173
|
+
const { doc, root } = await setup('one');
|
|
174
|
+
|
|
175
|
+
const branch = Branch.create(doc, { name: 'draft', parent: root });
|
|
176
|
+
await branch.content.load();
|
|
177
|
+
Branch.discard(doc, branch);
|
|
178
|
+
|
|
179
|
+
expect(branch.status).toBe('archived');
|
|
180
|
+
expect(root.content).toBe('one');
|
|
181
|
+
});
|
|
182
|
+
});
|