@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.
Files changed (38) hide show
  1. package/dist/lib/browser/index.mjs +417 -0
  2. package/dist/lib/browser/index.mjs.map +7 -0
  3. package/dist/lib/browser/meta.json +1 -0
  4. package/dist/lib/node-esm/index.mjs +418 -0
  5. package/dist/lib/node-esm/index.mjs.map +7 -0
  6. package/dist/lib/node-esm/meta.json +1 -0
  7. package/dist/types/src/Branch.d.ts +3 -0
  8. package/dist/types/src/Branch.d.ts.map +1 -0
  9. package/dist/types/src/History.d.ts +3 -0
  10. package/dist/types/src/History.d.ts.map +1 -0
  11. package/dist/types/src/Version.d.ts +3 -0
  12. package/dist/types/src/Version.d.ts.map +1 -0
  13. package/dist/types/src/diff.d.ts +35 -0
  14. package/dist/types/src/diff.d.ts.map +1 -0
  15. package/dist/types/src/diff.test.d.ts +2 -0
  16. package/dist/types/src/diff.test.d.ts.map +1 -0
  17. package/dist/types/src/index.d.ts +5 -0
  18. package/dist/types/src/index.d.ts.map +1 -0
  19. package/dist/types/src/internal/index.d.ts +3 -0
  20. package/dist/types/src/internal/index.d.ts.map +1 -0
  21. package/dist/types/src/internal/model.d.ts +53 -0
  22. package/dist/types/src/internal/model.d.ts.map +1 -0
  23. package/dist/types/src/internal/types.d.ts +69 -0
  24. package/dist/types/src/internal/types.d.ts.map +1 -0
  25. package/dist/types/src/model.test.d.ts +2 -0
  26. package/dist/types/src/model.test.d.ts.map +1 -0
  27. package/dist/types/tsconfig.tsbuildinfo +1 -0
  28. package/package.json +43 -0
  29. package/src/Branch.ts +21 -0
  30. package/src/History.ts +8 -0
  31. package/src/Version.ts +14 -0
  32. package/src/diff.test.ts +195 -0
  33. package/src/diff.ts +225 -0
  34. package/src/index.ts +8 -0
  35. package/src/internal/index.ts +6 -0
  36. package/src/internal/model.ts +189 -0
  37. package/src/internal/types.ts +76 -0
  38. package/src/model.test.ts +182 -0
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@dxos/versioning",
3
+ "version": "0.10.0",
4
+ "description": "Checkpoints, draft branches, and 3-way merge for automerge-backed ECHO objects.",
5
+ "homepage": "https://dxos.org",
6
+ "bugs": "https://github.com/dxos/dxos/issues",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/dxos/dxos"
10
+ },
11
+ "license": "FSL-1.1-Apache-2.0",
12
+ "author": "info@dxos.org",
13
+ "sideEffects": false,
14
+ "type": "module",
15
+ "exports": {
16
+ ".": {
17
+ "source": "./src/index.ts",
18
+ "types": "./dist/types/src/index.d.ts",
19
+ "browser": "./dist/lib/browser/index.mjs",
20
+ "node": "./dist/lib/node-esm/index.mjs"
21
+ }
22
+ },
23
+ "types": "dist/types/src/index.d.ts",
24
+ "files": [
25
+ "dist",
26
+ "src"
27
+ ],
28
+ "dependencies": {
29
+ "@dxos/echo": "workspace:*",
30
+ "@dxos/echo-client": "workspace:*",
31
+ "@dxos/invariant": "workspace:*",
32
+ "@dxos/schema": "workspace:*",
33
+ "diff": "catalog:",
34
+ "effect": "catalog:"
35
+ },
36
+ "devDependencies": {
37
+ "@dxos/typings": "workspace:*",
38
+ "vitest": "catalog:"
39
+ },
40
+ "publishConfig": {
41
+ "access": "public"
42
+ }
43
+ }
package/src/Branch.ts ADDED
@@ -0,0 +1,21 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ // @import-as-namespace
6
+
7
+ export {
8
+ Branch,
9
+ type MakeBranchProps as MakeProps,
10
+ BranchStatus as Status,
11
+ makeBranch as make,
12
+ } from './internal/types';
13
+ export {
14
+ type CreateBranchProps as CreateProps,
15
+ type MergeResult,
16
+ createBranch as create,
17
+ discardBranch as discard,
18
+ findBranch as find,
19
+ branchLabel as label,
20
+ mergeBranch as merge,
21
+ } from './internal/model';
package/src/History.ts ADDED
@@ -0,0 +1,8 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ // @import-as-namespace
6
+
7
+ export { History } from './internal/types';
8
+ export { type VersionedObject, ensureHistory as ensure } from './internal/model';
package/src/Version.ts ADDED
@@ -0,0 +1,14 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ // @import-as-namespace
6
+
7
+ export { type MakeVersionProps as MakeProps, Version, makeVersion as make } from './internal/types';
8
+ export {
9
+ type CreateCheckpointProps as CreateProps,
10
+ contentAt,
11
+ createCheckpoint as create,
12
+ versionLabel as label,
13
+ restore,
14
+ } from './internal/model';
@@ -0,0 +1,195 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { describe, test } from 'vitest';
6
+
7
+ import { diffSpans, diffStats, merge3 } from './diff';
8
+
9
+ describe('diffSpans', () => {
10
+ test('computes insert/delete/equal spans', ({ expect }) => {
11
+ const spans = diffSpans('the quick fox', 'the slow fox');
12
+ expect(spans.map((span) => span.kind)).toEqual(['equal', 'delete', 'insert', 'equal']);
13
+ expect(spans.find((span) => span.kind === 'insert')?.text).toBe('slow');
14
+ });
15
+
16
+ test('offsets index into the after text', ({ expect }) => {
17
+ const spans = diffSpans('one three', 'one two three');
18
+ const insert = spans.find((span) => span.kind === 'insert');
19
+ if (!insert) {
20
+ throw new Error('expected an insert span');
21
+ }
22
+ expect('one two three'.slice(insert.from, insert.to)).toBe(insert.text);
23
+ });
24
+
25
+ test('handles empty inputs', ({ expect }) => {
26
+ expect(diffSpans('', '')).toEqual([]);
27
+ expect(diffSpans('', 'abc')).toEqual([{ kind: 'insert', from: 0, to: 3, text: 'abc' }]);
28
+ expect(diffSpans('abc', '')).toEqual([{ kind: 'delete', from: 0, to: 0, text: 'abc' }]);
29
+ });
30
+ });
31
+
32
+ describe('diffStats', () => {
33
+ test('counts insertions and deletions', ({ expect }) => {
34
+ const stats = diffStats(diffSpans('the quick fox', 'the slow fox jumped'));
35
+ expect(stats.insertions).toBeGreaterThan(0);
36
+ expect(stats.deletions).toBeGreaterThan(0);
37
+ });
38
+ });
39
+
40
+ describe('merge3', () => {
41
+ test('merges non-overlapping changes from both sides', ({ expect }) => {
42
+ const base = 'alpha\nbravo\ncharlie\n';
43
+ const ours = 'alpha edited\nbravo\ncharlie\n';
44
+ const theirs = 'alpha\nbravo\ncharlie edited\n';
45
+ const result = merge3({ base, ours, theirs });
46
+ expect(result.conflicts).toBe(0);
47
+ expect(result.text).toBe('alpha edited\nbravo\ncharlie edited\n');
48
+ });
49
+
50
+ test('applies identical hunks from both sides once', ({ expect }) => {
51
+ const base = 'alpha\nbravo\n';
52
+ const both = 'alpha edited\nbravo\n';
53
+ const result = merge3({ base, ours: both, theirs: both });
54
+ expect(result.conflicts).toBe(0);
55
+ expect(result.text).toBe(both);
56
+ });
57
+
58
+ test('marks conflicting hunks, preferring theirs first', ({ expect }) => {
59
+ const base = 'alpha\nbravo\n';
60
+ const ours = 'alpha ours\nbravo\n';
61
+ const theirs = 'alpha theirs\nbravo\n';
62
+ const result = merge3({ base, ours, theirs });
63
+ expect(result.conflicts).toBe(1);
64
+ expect(result.text).toContain('<<<<<<<');
65
+ expect(result.text.indexOf('alpha theirs')).toBeLessThan(result.text.indexOf('alpha ours'));
66
+ });
67
+
68
+ test('returns theirs when ours is unchanged from base', ({ expect }) => {
69
+ const base = 'alpha\n';
70
+ const theirs = 'alpha\nbeta\n';
71
+ expect(merge3({ base, ours: base, theirs }).text).toBe(theirs);
72
+ });
73
+
74
+ test('returns ours when theirs is unchanged from base', ({ expect }) => {
75
+ const base = 'alpha\n';
76
+ const ours = 'alpha\nbeta\n';
77
+ expect(merge3({ base, ours, theirs: base }).text).toBe(ours);
78
+ });
79
+
80
+ test('merges appends at end of file from one side', ({ expect }) => {
81
+ const base = 'alpha\nbravo\n';
82
+ const ours = 'alpha\nbravo\n';
83
+ const theirs = 'alpha\nbravo\ncharlie\n';
84
+ const result = merge3({ base, ours, theirs });
85
+ expect(result.conflicts).toBe(0);
86
+ expect(result.text).toBe('alpha\nbravo\ncharlie\n');
87
+ });
88
+
89
+ test('merges from an empty base', ({ expect }) => {
90
+ const result = merge3({ base: '', ours: 'ours\n', theirs: 'theirs\n' });
91
+ expect(result.conflicts).toBe(1);
92
+ expect(result.text).toContain('ours');
93
+ expect(result.text).toContain('theirs');
94
+ });
95
+
96
+ test('handles deletion on one side and edit elsewhere on the other', ({ expect }) => {
97
+ const base = 'alpha\nbravo\ncharlie\n';
98
+ const ours = 'alpha\ncharlie\n';
99
+ const theirs = 'alpha\nbravo\ncharlie edited\n';
100
+ const result = merge3({ base, ours, theirs });
101
+ expect(result.conflicts).toBe(0);
102
+ expect(result.text).toBe('alpha\ncharlie edited\n');
103
+ });
104
+
105
+ test('marks staggered overlapping hunks as a conflict', ({ expect }) => {
106
+ // Ours replaces base lines [0,2); theirs edits [1,2) — overlapping but different starts.
107
+ const base = 'alpha\nbravo\ncharlie\n';
108
+ const ours = 'replaced\ncharlie\n';
109
+ const theirs = 'alpha\nbravo edited\ncharlie\n';
110
+ const result = merge3({ base, ours, theirs });
111
+ expect(result.conflicts).toBe(1);
112
+ expect(result.text).toContain('<<<<<<< branch');
113
+ expect(result.text).toContain('bravo edited');
114
+ expect(result.text).toContain('replaced');
115
+ // Untouched trailing content survives outside the conflict block.
116
+ expect(result.text.trimEnd().endsWith('charlie')).toBe(true);
117
+ });
118
+
119
+ test('staggered conflict regions include each side of the union', ({ expect }) => {
120
+ // Theirs replaces [0,3); ours edits [2,3) only — theirs' side must still carry its full block.
121
+ const base = 'one\ntwo\nthree\nfour\n';
122
+ const ours = 'one\ntwo\nthree edited\nfour\n';
123
+ const theirs = 'rewritten\nfour\n';
124
+ const result = merge3({ base, ours, theirs });
125
+ expect(result.conflicts).toBe(1);
126
+ expect(result.text).toContain('rewritten');
127
+ expect(result.text).toContain('three edited');
128
+ // Ours' side of the union keeps the base lines it left unchanged.
129
+ const oursSection = result.text.slice(result.text.indexOf('======='), result.text.indexOf('>>>>>>>'));
130
+ expect(oursSection).toContain('one');
131
+ expect(oursSection).toContain('two');
132
+ });
133
+
134
+ test('adjacent hunks do not conflict', ({ expect }) => {
135
+ const base = 'alpha\nbravo\n';
136
+ const ours = 'alpha edited\nbravo\n';
137
+ const theirs = 'alpha\nbravo edited\n';
138
+ const result = merge3({ base, ours, theirs });
139
+ expect(result.conflicts).toBe(0);
140
+ expect(result.text).toBe('alpha edited\nbravo edited\n');
141
+ });
142
+
143
+ test('one broad hunk overlapping multiple hunks forms a single conflict region', ({ expect }) => {
144
+ // Ours replaces base lines [0,5); theirs edits [1,2) and [3,4) separately.
145
+ const base = 'l0\nl1\nl2\nl3\nl4\ntail\n';
146
+ const ours = 'REPLACED\ntail\n';
147
+ const theirs = 'l0\nl1 edited\nl2\nl3 edited\nl4\ntail\n';
148
+ const result = merge3({ base, ours, theirs });
149
+ expect(result.conflicts).toBe(1);
150
+ // Theirs side of the union carries BOTH edits plus the untouched base lines between them.
151
+ const theirsSection = result.text.slice(result.text.indexOf('<<<<<<<'), result.text.indexOf('======='));
152
+ expect(theirsSection).toContain('l1 edited');
153
+ expect(theirsSection).toContain('l3 edited');
154
+ expect(theirsSection).toContain('l2');
155
+ // No hunk is applied twice and trailing content stays outside the conflict.
156
+ expect(result.text.match(/l1 edited/g)).toHaveLength(1);
157
+ expect(result.text.match(/l3 edited/g)).toHaveLength(1);
158
+ expect(result.text.trimEnd().endsWith('tail')).toBe(true);
159
+ });
160
+
161
+ test('inserts a blank line from one side', ({ expect }) => {
162
+ const base = 'a\nb\n';
163
+ const ours = 'a\nb\nc\n';
164
+ const theirs = 'a\n\nb\n';
165
+ const result = merge3({ base, ours, theirs });
166
+ expect(result.conflicts).toBe(0);
167
+ expect(result.text).toBe('a\n\nb\nc\n');
168
+ });
169
+
170
+ test('deletes a blank line from one side', ({ expect }) => {
171
+ const base = 'a\n\nb\n';
172
+ const ours = 'a\n\nb\nc\n';
173
+ const theirs = 'a\nb\n';
174
+ const result = merge3({ base, ours, theirs });
175
+ expect(result.conflicts).toBe(0);
176
+ expect(result.text).toBe('a\nb\nc\n');
177
+ });
178
+
179
+ test('replaces a blank line from one side', ({ expect }) => {
180
+ const base = 'a\n\nb\n';
181
+ const ours = 'a\n\nb\nc\n';
182
+ const theirs = 'a\nmiddle\nb\n';
183
+ const result = merge3({ base, ours, theirs });
184
+ expect(result.conflicts).toBe(0);
185
+ expect(result.text).toBe('a\nmiddle\nb\nc\n');
186
+ });
187
+
188
+ test('preserves content without trailing newline', ({ expect }) => {
189
+ const base = 'alpha';
190
+ const ours = 'alpha';
191
+ const theirs = 'alpha bravo';
192
+ const result = merge3({ base, ours, theirs });
193
+ expect(result.text).toBe('alpha bravo');
194
+ });
195
+ });
package/src/diff.ts ADDED
@@ -0,0 +1,225 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { diffLines, diffWordsWithSpace } from 'diff';
6
+
7
+ /**
8
+ * Shared diff unit consumed by all diff renderings (inline, side-by-side, gutter).
9
+ * Offsets index into the AFTER text; deletions are zero-width and carry the offset where
10
+ * the removed text used to be, so widgets can render the removed content in place.
11
+ */
12
+ export type DiffSpan = {
13
+ kind: 'equal' | 'insert' | 'delete';
14
+ from: number;
15
+ to: number;
16
+ text: string;
17
+ };
18
+
19
+ /** Computes word-level {@link DiffSpan}s between two texts, offset into the after text. */
20
+ export const diffSpans = (before: string, after: string): DiffSpan[] => {
21
+ const spans: DiffSpan[] = [];
22
+ let offset = 0;
23
+ for (const change of diffWordsWithSpace(before, after)) {
24
+ const kind = change.added ? 'insert' : change.removed ? 'delete' : 'equal';
25
+ const length = kind === 'delete' ? 0 : change.value.length;
26
+ spans.push({ kind, from: offset, to: offset + length, text: change.value });
27
+ offset += length;
28
+ }
29
+ return spans;
30
+ };
31
+
32
+ export type DiffStats = { insertions: number; deletions: number };
33
+
34
+ /** Character counts, used for the `+N −M` branch summaries. */
35
+ export const diffStats = (spans: DiffSpan[]): DiffStats =>
36
+ spans.reduce(
37
+ (stats, span) => {
38
+ if (span.kind === 'insert') {
39
+ stats.insertions += span.text.length;
40
+ } else if (span.kind === 'delete') {
41
+ stats.deletions += span.text.length;
42
+ }
43
+ return stats;
44
+ },
45
+ { insertions: 0, deletions: 0 },
46
+ );
47
+
48
+ export type Merge3Props = { base: string; ours: string; theirs: string };
49
+ export type Merge3Result = { text: string; conflicts: number };
50
+
51
+ /**
52
+ * Line-based 3-way merge. Non-overlapping hunks apply automatically; overlapping hunks
53
+ * produce git-style conflict markers with the branch (theirs) side first, since the merge
54
+ * flow is review-then-accept and the branch content is what was just reviewed.
55
+ */
56
+ export const merge3 = ({ base, ours, theirs }: Merge3Props): Merge3Result => {
57
+ if (ours === base) {
58
+ return { text: theirs, conflicts: 0 };
59
+ }
60
+ if (theirs === base || ours === theirs) {
61
+ return { text: ours, conflicts: 0 };
62
+ }
63
+
64
+ const baseLines = splitLines(base);
65
+ const oursHunks = lineHunks(baseLines, splitLines(ours));
66
+ const theirsHunks = lineHunks(baseLines, splitLines(theirs));
67
+
68
+ const out: string[] = [];
69
+ let conflicts = 0;
70
+ let baseIndex = 0;
71
+ let oursCursor = 0;
72
+ let theirsCursor = 0;
73
+
74
+ while (baseIndex < baseLines.length || oursCursor < oursHunks.length || theirsCursor < theirsHunks.length) {
75
+ const oursHunk = oursHunks[oursCursor];
76
+ const theirsHunk = theirsHunks[theirsCursor];
77
+ const nextOurs = oursHunk?.baseStart ?? Infinity;
78
+ const nextTheirs = theirsHunk?.baseStart ?? Infinity;
79
+ const next = Math.min(nextOurs, nextTheirs);
80
+
81
+ // Copy unchanged base lines up to the next hunk.
82
+ while (baseIndex < Math.min(next, baseLines.length)) {
83
+ out.push(baseLines[baseIndex]);
84
+ baseIndex += 1;
85
+ }
86
+ if (next === Infinity) {
87
+ break;
88
+ }
89
+
90
+ const oursActive = oursHunk !== undefined && oursHunk.baseStart === next;
91
+ const theirsActive = theirsHunk !== undefined && theirsHunk.baseStart === next;
92
+ const identical =
93
+ oursActive &&
94
+ theirsActive &&
95
+ sameLines(oursHunk.lines, theirsHunk.lines) &&
96
+ oursHunk.baseLength === theirsHunk.baseLength;
97
+ // Hunks conflict when their consumed base intervals overlap — same start, or a staggered
98
+ // hunk beginning inside the interval the active hunk consumes (e.g. [0,2) vs [1,2)).
99
+ const overlap =
100
+ !identical &&
101
+ oursHunk !== undefined &&
102
+ theirsHunk !== undefined &&
103
+ (nextOurs === nextTheirs ||
104
+ (oursActive && nextTheirs < hunkEnd(oursHunk)) ||
105
+ (theirsActive && nextOurs < hunkEnd(theirsHunk)));
106
+
107
+ if (overlap) {
108
+ // The conflict region is the connected union of every intersecting hunk on both sides —
109
+ // a broad hunk can overlap several smaller ones, all of which must be consumed together.
110
+ const oursRegion = [oursHunk];
111
+ const theirsRegion = [theirsHunk];
112
+ oursCursor += 1;
113
+ theirsCursor += 1;
114
+ let unionEnd = Math.max(hunkEnd(oursHunk), hunkEnd(theirsHunk));
115
+ let expanded = true;
116
+ while (expanded) {
117
+ expanded = false;
118
+ while (oursCursor < oursHunks.length && oursHunks[oursCursor].baseStart < unionEnd) {
119
+ const hunk = oursHunks[oursCursor];
120
+ oursRegion.push(hunk);
121
+ unionEnd = Math.max(unionEnd, hunkEnd(hunk));
122
+ oursCursor += 1;
123
+ expanded = true;
124
+ }
125
+ while (theirsCursor < theirsHunks.length && theirsHunks[theirsCursor].baseStart < unionEnd) {
126
+ const hunk = theirsHunks[theirsCursor];
127
+ theirsRegion.push(hunk);
128
+ unionEnd = Math.max(unionEnd, hunkEnd(hunk));
129
+ theirsCursor += 1;
130
+ expanded = true;
131
+ }
132
+ }
133
+
134
+ out.push(
135
+ '<<<<<<< branch',
136
+ ...sideContent(baseLines, theirsRegion, next, unionEnd),
137
+ '=======',
138
+ ...sideContent(baseLines, oursRegion, next, unionEnd),
139
+ '>>>>>>> current',
140
+ );
141
+ conflicts += 1;
142
+ baseIndex = unionEnd;
143
+ } else if (identical) {
144
+ out.push(...oursHunk.lines);
145
+ baseIndex = next + oursHunk.baseLength;
146
+ oursCursor += 1;
147
+ theirsCursor += 1;
148
+ } else if (oursActive) {
149
+ out.push(...oursHunk.lines);
150
+ baseIndex = next + oursHunk.baseLength;
151
+ oursCursor += 1;
152
+ } else if (theirsHunk !== undefined) {
153
+ out.push(...theirsHunk.lines);
154
+ baseIndex = next + theirsHunk.baseLength;
155
+ theirsCursor += 1;
156
+ }
157
+ }
158
+
159
+ return { text: joinLines(out, [base, ours, theirs]), conflicts };
160
+ };
161
+
162
+ type Hunk = { baseStart: number; baseLength: number; lines: string[] };
163
+
164
+ const hunkEnd = (hunk: Hunk) => hunk.baseStart + hunk.baseLength;
165
+
166
+ /** One side's content for a conflict region: its hunks plus the union's base lines they leave unchanged. */
167
+ const sideContent = (baseLines: string[], hunks: Hunk[], unionStart: number, unionEnd: number): string[] => {
168
+ const out: string[] = [];
169
+ let position = unionStart;
170
+ for (const hunk of hunks) {
171
+ out.push(...baseLines.slice(position, hunk.baseStart), ...hunk.lines);
172
+ position = Math.max(position, hunkEnd(hunk));
173
+ }
174
+ out.push(...baseLines.slice(position, unionEnd));
175
+ return out;
176
+ };
177
+
178
+ /** Convert jsdiff line changes into base-anchored replacement hunks. */
179
+ const lineHunks = (baseLines: string[], sideLines: string[]): Hunk[] => {
180
+ const hunks: Hunk[] = [];
181
+ let baseIndex = 0;
182
+ let pending: Hunk | undefined;
183
+ for (const change of diffLines(joinForDiff(baseLines), joinForDiff(sideLines))) {
184
+ const lines = chunkLines(change.value);
185
+ if (change.added) {
186
+ pending = pending ?? { baseStart: baseIndex, baseLength: 0, lines: [] };
187
+ pending.lines.push(...lines);
188
+ } else if (change.removed) {
189
+ pending = pending ?? { baseStart: baseIndex, baseLength: 0, lines: [] };
190
+ pending.baseLength += lines.length;
191
+ baseIndex += lines.length;
192
+ } else {
193
+ if (pending) {
194
+ hunks.push(pending);
195
+ pending = undefined;
196
+ }
197
+ baseIndex += lines.length;
198
+ }
199
+ }
200
+ if (pending) {
201
+ hunks.push(pending);
202
+ }
203
+ return hunks;
204
+ };
205
+
206
+ const sameLines = (a: string[], b: string[]) => a.length === b.length && a.every((line, index) => line === b[index]);
207
+
208
+ const splitLines = (text: string): string[] => (text === '' ? [] : stripTrailingNewline(text).split('\n'));
209
+
210
+ // Unlike whole-document splitting, a diff chunk is never empty — a chunk of '\n' is one blank
211
+ // line (jsdiff attaches the terminator to its line), so no empty-string special case applies.
212
+ const chunkLines = (value: string): string[] => stripTrailingNewline(value).split('\n');
213
+
214
+ const stripTrailingNewline = (text: string) => (text.endsWith('\n') ? text.slice(0, -1) : text);
215
+
216
+ // diffLines needs a trailing newline so the last line compares as a whole line.
217
+ const joinForDiff = (lines: string[]) => (lines.length === 0 ? '' : `${lines.join('\n')}\n`);
218
+
219
+ const joinLines = (lines: string[], inputs: string[]) => {
220
+ const text = lines.join('\n');
221
+ // Preserve a trailing newline when every non-empty input has one.
222
+ const nonEmpty = inputs.filter((input) => input.length > 0);
223
+ const trailing = nonEmpty.length > 0 && nonEmpty.every((input) => input.endsWith('\n'));
224
+ return trailing && text.length > 0 ? `${text}\n` : text;
225
+ };
package/src/index.ts ADDED
@@ -0,0 +1,8 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ export * as Branch from './Branch';
6
+ export * as History from './History';
7
+ export * as Version from './Version';
8
+ export * from './diff';
@@ -0,0 +1,6 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ export * from './model';
6
+ export * from './types';