@theia/scm 1.71.0-next.72 → 1.71.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 (58) hide show
  1. package/lib/browser/dirty-diff/dirty-diff-widget.js +1 -1
  2. package/lib/browser/dirty-diff/dirty-diff-widget.js.map +1 -1
  3. package/lib/browser/scm-context-key-service.d.ts +6 -0
  4. package/lib/browser/scm-context-key-service.d.ts.map +1 -1
  5. package/lib/browser/scm-context-key-service.js +12 -0
  6. package/lib/browser/scm-context-key-service.js.map +1 -1
  7. package/lib/browser/scm-contribution.d.ts.map +1 -1
  8. package/lib/browser/scm-contribution.js +129 -1
  9. package/lib/browser/scm-contribution.js.map +1 -1
  10. package/lib/browser/scm-frontend-module.d.ts.map +1 -1
  11. package/lib/browser/scm-frontend-module.js +14 -0
  12. package/lib/browser/scm-frontend-module.js.map +1 -1
  13. package/lib/browser/scm-history-graph-helpers.d.ts +39 -0
  14. package/lib/browser/scm-history-graph-helpers.d.ts.map +1 -0
  15. package/lib/browser/scm-history-graph-helpers.js +167 -0
  16. package/lib/browser/scm-history-graph-helpers.js.map +1 -0
  17. package/lib/browser/scm-history-graph-lanes.d.ts +59 -0
  18. package/lib/browser/scm-history-graph-lanes.d.ts.map +1 -0
  19. package/lib/browser/scm-history-graph-lanes.js +183 -0
  20. package/lib/browser/scm-history-graph-lanes.js.map +1 -0
  21. package/lib/browser/scm-history-graph-lanes.spec.d.ts +2 -0
  22. package/lib/browser/scm-history-graph-lanes.spec.d.ts.map +1 -0
  23. package/lib/browser/scm-history-graph-lanes.spec.js +554 -0
  24. package/lib/browser/scm-history-graph-lanes.spec.js.map +1 -0
  25. package/lib/browser/scm-history-graph-model.d.ts +46 -0
  26. package/lib/browser/scm-history-graph-model.d.ts.map +1 -0
  27. package/lib/browser/scm-history-graph-model.js +184 -0
  28. package/lib/browser/scm-history-graph-model.js.map +1 -0
  29. package/lib/browser/scm-history-graph-model.spec.d.ts +2 -0
  30. package/lib/browser/scm-history-graph-model.spec.d.ts.map +1 -0
  31. package/lib/browser/scm-history-graph-model.spec.js +131 -0
  32. package/lib/browser/scm-history-graph-model.spec.js.map +1 -0
  33. package/lib/browser/scm-history-graph-tooltip.d.ts +14 -0
  34. package/lib/browser/scm-history-graph-tooltip.d.ts.map +1 -0
  35. package/lib/browser/scm-history-graph-tooltip.js +190 -0
  36. package/lib/browser/scm-history-graph-tooltip.js.map +1 -0
  37. package/lib/browser/scm-history-graph-widget.d.ts +77 -0
  38. package/lib/browser/scm-history-graph-widget.d.ts.map +1 -0
  39. package/lib/browser/scm-history-graph-widget.js +490 -0
  40. package/lib/browser/scm-history-graph-widget.js.map +1 -0
  41. package/lib/browser/scm-provider.d.ts +61 -0
  42. package/lib/browser/scm-provider.d.ts.map +1 -1
  43. package/lib/browser/scm-provider.js.map +1 -1
  44. package/package.json +7 -7
  45. package/src/browser/dirty-diff/dirty-diff-widget.ts +1 -1
  46. package/src/browser/scm-context-key-service.ts +18 -0
  47. package/src/browser/scm-contribution.ts +141 -0
  48. package/src/browser/scm-frontend-module.ts +15 -0
  49. package/src/browser/scm-history-graph-helpers.ts +175 -0
  50. package/src/browser/scm-history-graph-lanes.spec.ts +635 -0
  51. package/src/browser/scm-history-graph-lanes.ts +258 -0
  52. package/src/browser/scm-history-graph-model.spec.ts +171 -0
  53. package/src/browser/scm-history-graph-model.ts +207 -0
  54. package/src/browser/scm-history-graph-tooltip.ts +213 -0
  55. package/src/browser/scm-history-graph-widget.tsx +712 -0
  56. package/src/browser/scm-provider.ts +68 -0
  57. package/src/browser/style/index.css +12 -13
  58. package/src/browser/style/scm-history-graph.css +313 -0
@@ -0,0 +1,635 @@
1
+ // *****************************************************************************
2
+ // Copyright (C) 2026 EclipseSource GmbH and others.
3
+ //
4
+ // This program and the accompanying materials are made available under the
5
+ // terms of the Eclipse Public License v. 2.0 which is available at
6
+ // http://www.eclipse.org/legal/epl-2.0.
7
+ //
8
+ // This Source Code may also be made available under the following Secondary
9
+ // Licenses when the conditions for such availability set forth in the Eclipse
10
+ // Public License v. 2.0 are satisfied: GNU General Public License, version 2
11
+ // with the GNU Classpath Exception which is available at
12
+ // https://www.gnu.org/software/classpath/license.html.
13
+ //
14
+ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
15
+ // *****************************************************************************
16
+
17
+ import { expect } from 'chai';
18
+ import { computeGraphRows } from './scm-history-graph-lanes';
19
+
20
+ describe('computeGraphRows', () => {
21
+
22
+ function lanes(commits: { id: string; parentIds?: string[] }[]): number[] {
23
+ return computeGraphRows(commits).map(r => r.lane);
24
+ }
25
+
26
+ // -------------------------------------------------------------------------
27
+ // Linear history
28
+ // -------------------------------------------------------------------------
29
+ describe('linear history', () => {
30
+ const commits = [
31
+ { id: 'C', parentIds: ['B'] },
32
+ { id: 'B', parentIds: ['A'] },
33
+ { id: 'A', parentIds: [] },
34
+ ];
35
+
36
+ it('all commits stay in lane 0', () => {
37
+ expect(lanes(commits)).to.deep.equal([0, 0, 0]);
38
+ });
39
+
40
+ it('colors are all 0', () => {
41
+ const rows = computeGraphRows(commits);
42
+ expect(rows.map(r => r.color)).to.deep.equal([0, 0, 0]);
43
+ });
44
+
45
+ it('first parent same-lane continuation emits no separate edge (handled by commit line)', () => {
46
+ const rows = computeGraphRows(commits);
47
+ // Row 0 (C): first parent B is in the same lane — no edge emitted
48
+ // Only pass-through edges from other lanes (none here) are emitted
49
+ const edgesC = rows[0].edges.filter(e => e.fromLane === 0 && e.toLane === 0);
50
+ expect(edgesC.length).to.equal(0);
51
+ });
52
+
53
+ it('root commit (A) emits no edges', () => {
54
+ const rows = computeGraphRows(commits);
55
+ expect(rows[2].edges).to.be.empty;
56
+ });
57
+ });
58
+
59
+ // -------------------------------------------------------------------------
60
+ // Single branch + merge
61
+ // -------------------------------------------------------------------------
62
+ describe('branch and merge', () => {
63
+ // M ← merge commit (parents: D, E)
64
+ // |\
65
+ // D E ← two parallel commits
66
+ // | |
67
+ // B B (same B — E's parent is B too)
68
+ // \ |
69
+ // B
70
+ // |
71
+ // A
72
+ //
73
+ // Topological order (newest first): M, D, E, B, A
74
+ const commits = [
75
+ { id: 'M', parentIds: ['D', 'E'] },
76
+ { id: 'D', parentIds: ['B'] },
77
+ { id: 'E', parentIds: ['B'] },
78
+ { id: 'B', parentIds: ['A'] },
79
+ { id: 'A', parentIds: [] },
80
+ ];
81
+
82
+ it('merge commit M is in lane 0', () => {
83
+ expect(lanes(commits)[0]).to.equal(0);
84
+ });
85
+
86
+ it('D (first parent) stays in lane 0', () => {
87
+ expect(lanes(commits)[1]).to.equal(0);
88
+ });
89
+
90
+ it('E (second parent) is in a different lane', () => {
91
+ expect(lanes(commits)[2]).to.not.equal(0);
92
+ });
93
+
94
+ it('B and A eventually converge back to lane 0', () => {
95
+ const ls = lanes(commits);
96
+ // B must be in lane 0 (first parent chain)
97
+ expect(ls[3]).to.equal(0);
98
+ // A also stays in lane 0
99
+ expect(ls[4]).to.equal(0);
100
+ });
101
+
102
+ it('M emits a branch-out edge for second parent E', () => {
103
+ const rows = computeGraphRows(commits);
104
+ const mEdges = rows[0].edges;
105
+ // Branch-out: from commit lane 0 to E's new lane
106
+ const branchOut = mEdges.filter(e => e.type === 'branch-out' && e.fromLane === 0);
107
+ expect(branchOut.length).to.be.greaterThan(0);
108
+ });
109
+
110
+ it('M does not emit a merge-in edge (no existing lane converges into M)', () => {
111
+ const rows = computeGraphRows(commits);
112
+ const mEdges = rows[0].edges;
113
+ const mergeIn = mEdges.filter(e => e.type === 'merge-in');
114
+ expect(mergeIn.length).to.equal(0);
115
+ });
116
+
117
+ it('E emits a merge-in edge (E parent B is already in lane 0)', () => {
118
+ const rows = computeGraphRows(commits);
119
+ // E is at index 2, in lane 1; its parent B is already in lane 0.
120
+ // merge-in edge: fromLane=0 (B's lane), toLane=1 (E's commit lane)
121
+ const eRow = rows[2];
122
+ const eEdges = eRow.edges;
123
+ const mergeIn = eEdges.filter(e => e.type === 'merge-in' && e.fromLane === 0 && e.toLane === eRow.lane);
124
+ expect(mergeIn.length).to.be.greaterThan(0);
125
+ });
126
+ });
127
+
128
+ // -------------------------------------------------------------------------
129
+ // Parallel independent branches
130
+ // -------------------------------------------------------------------------
131
+ describe('parallel independent branches', () => {
132
+ // Two completely independent branches interleaved in the list.
133
+ // Newest first: A2, B2, A1, B1
134
+ // A2 and A1 are on one branch, B2 and B1 on another.
135
+ const commits = [
136
+ { id: 'A2', parentIds: ['A1'] },
137
+ { id: 'B2', parentIds: ['B1'] },
138
+ { id: 'A1', parentIds: [] },
139
+ { id: 'B1', parentIds: [] },
140
+ ];
141
+
142
+ it('A2 is in lane 0', () => {
143
+ expect(lanes(commits)[0]).to.equal(0);
144
+ });
145
+
146
+ it('B2 is in a different lane from A2', () => {
147
+ const ls = lanes(commits);
148
+ expect(ls[1]).to.not.equal(ls[0]);
149
+ });
150
+
151
+ it('A1 is in lane 0 (continues A2 branch)', () => {
152
+ expect(lanes(commits)[2]).to.equal(0);
153
+ });
154
+
155
+ it('B1 continues in the same lane as B2', () => {
156
+ const ls = lanes(commits);
157
+ expect(ls[3]).to.equal(ls[1]);
158
+ });
159
+ });
160
+
161
+ // -------------------------------------------------------------------------
162
+ // Octopus merge (3 parents)
163
+ // -------------------------------------------------------------------------
164
+ describe('octopus merge', () => {
165
+ // O merges P1, P2, P3
166
+ // O, P1, P2, P3
167
+ const commits = [
168
+ { id: 'O', parentIds: ['P1', 'P2', 'P3'] },
169
+ { id: 'P1', parentIds: [] },
170
+ { id: 'P2', parentIds: [] },
171
+ { id: 'P3', parentIds: [] },
172
+ ];
173
+
174
+ it('O is in lane 0', () => {
175
+ expect(lanes(commits)[0]).to.equal(0);
176
+ });
177
+
178
+ it('P1 is in lane 0 (first parent)', () => {
179
+ expect(lanes(commits)[1]).to.equal(0);
180
+ });
181
+
182
+ it('P2 is in a distinct lane', () => {
183
+ const ls = lanes(commits);
184
+ expect(ls[2]).to.not.equal(0);
185
+ });
186
+
187
+ it('P3 is in a distinct lane different from P2', () => {
188
+ const ls = lanes(commits);
189
+ expect(ls[3]).to.not.equal(ls[2]);
190
+ });
191
+
192
+ it('O emits 2 branch-out edges (for P2 and P3; P1 same-lane has no edge)', () => {
193
+ const rows = computeGraphRows(commits);
194
+ const oEdges = rows[0].edges.filter(e => e.type === 'branch-out');
195
+ expect(oEdges.length).to.equal(2);
196
+ });
197
+
198
+ it('O emits no merge-in edges (no existing lanes converge into O)', () => {
199
+ const rows = computeGraphRows(commits);
200
+ const mergeIn = rows[0].edges.filter(e => e.type === 'merge-in');
201
+ expect(mergeIn.length).to.equal(0);
202
+ });
203
+
204
+ it('lane colors are correct modulo 8', () => {
205
+ const rows = computeGraphRows(commits);
206
+ rows.forEach(r => expect(r.color).to.equal(r.lane % 8));
207
+ });
208
+ });
209
+
210
+ // -------------------------------------------------------------------------
211
+ // Root commit with no parents
212
+ // -------------------------------------------------------------------------
213
+ describe('single root commit', () => {
214
+ const commits = [{ id: 'R', parentIds: [] }];
215
+
216
+ it('is placed in lane 0', () => {
217
+ expect(lanes(commits)).to.deep.equal([0]);
218
+ });
219
+
220
+ it('emits no edges', () => {
221
+ const rows = computeGraphRows(commits);
222
+ expect(rows[0].edges).to.be.empty;
223
+ });
224
+ });
225
+
226
+ // -------------------------------------------------------------------------
227
+ // Empty input
228
+ // -------------------------------------------------------------------------
229
+ describe('empty input', () => {
230
+ it('returns an empty array', () => {
231
+ expect(computeGraphRows([])).to.deep.equal([]);
232
+ });
233
+ });
234
+
235
+ // -------------------------------------------------------------------------
236
+ // hasContinuation and hasTopLine
237
+ // -------------------------------------------------------------------------
238
+ describe('hasContinuation and hasTopLine', () => {
239
+
240
+ describe('linear history', () => {
241
+ const commits = [
242
+ { id: 'C', parentIds: ['B'] },
243
+ { id: 'B', parentIds: ['A'] },
244
+ { id: 'A', parentIds: [] },
245
+ ];
246
+
247
+ it('first (newest/HEAD) commit has hasContinuation:true, hasTopLine:false', () => {
248
+ const rows = computeGraphRows(commits);
249
+ expect(rows[0].hasContinuation).to.equal(true);
250
+ expect(rows[0].hasTopLine).to.equal(false);
251
+ });
252
+
253
+ it('middle commit has hasContinuation:true, hasTopLine:true', () => {
254
+ const rows = computeGraphRows(commits);
255
+ expect(rows[1].hasContinuation).to.equal(true);
256
+ expect(rows[1].hasTopLine).to.equal(true);
257
+ });
258
+
259
+ it('root (oldest) commit has hasContinuation:false, hasTopLine:true', () => {
260
+ const rows = computeGraphRows(commits);
261
+ expect(rows[2].hasContinuation).to.equal(false);
262
+ expect(rows[2].hasTopLine).to.equal(true);
263
+ });
264
+ });
265
+
266
+ describe('branch-and-merge', () => {
267
+ // Topology: E (lane 1, parent B already in lane 0) — the merge
268
+ // convergence commit whose lane is freed after it is rendered.
269
+ //
270
+ // M (lane 0, parents: D, E)
271
+ // |\
272
+ // D E (D lane 0, E lane 1)
273
+ // | /
274
+ // B (lane 0)
275
+ // |
276
+ // A (lane 0)
277
+ const commits = [
278
+ { id: 'M', parentIds: ['D', 'E'] },
279
+ { id: 'D', parentIds: ['B'] },
280
+ { id: 'E', parentIds: ['B'] },
281
+ { id: 'B', parentIds: ['A'] },
282
+ { id: 'A', parentIds: [] },
283
+ ];
284
+
285
+ it('merge commit E (whose parent B is already in lane 0) has hasContinuation:false, hasTopLine:true', () => {
286
+ const rows = computeGraphRows(commits);
287
+ // E is at index 2; its first parent B is already in lane 0,
288
+ // so its own lane is freed — hasContinuation must be false.
289
+ // E was reserved as a parent by M, so hasTopLine must be true.
290
+ expect(rows[2].hasContinuation).to.equal(false);
291
+ expect(rows[2].hasTopLine).to.equal(true);
292
+ });
293
+ });
294
+
295
+ describe('single root commit (no parents)', () => {
296
+ it('has hasContinuation:false, hasTopLine:false', () => {
297
+ const rows = computeGraphRows([{ id: 'R', parentIds: [] }]);
298
+ expect(rows[0].hasContinuation).to.equal(false);
299
+ expect(rows[0].hasTopLine).to.equal(false);
300
+ });
301
+ });
302
+
303
+ });
304
+
305
+ // -------------------------------------------------------------------------
306
+ // Lane freeing on merge convergence
307
+ // -------------------------------------------------------------------------
308
+ describe('lane freeing on merge convergence', () => {
309
+ // Topology: M→D,E → B → A
310
+ //
311
+ // M (lane 0, parents: D, E)
312
+ // |\
313
+ // D E (D in lane 0, E in lane 1)
314
+ // | /
315
+ // B (lane 0 — E's parent B is already tracked in lane 0)
316
+ // |
317
+ // A (lane 0)
318
+ //
319
+ // After rendering E (lane 1, parent B already in lane 0), lane 1 must
320
+ // be freed so that subsequent commits can reuse it.
321
+ const commits = [
322
+ { id: 'M', parentIds: ['D', 'E'] },
323
+ { id: 'D', parentIds: ['B'] },
324
+ { id: 'E', parentIds: ['B'] },
325
+ { id: 'B', parentIds: ['A'] },
326
+ { id: 'A', parentIds: [] },
327
+ ];
328
+
329
+ it('E is placed in lane 1', () => {
330
+ const rows = computeGraphRows(commits);
331
+ expect(rows[2].lane).to.equal(1); // E
332
+ });
333
+
334
+ it('B is in lane 0 (converges back after E)', () => {
335
+ const rows = computeGraphRows(commits);
336
+ expect(rows[3].lane).to.equal(0); // B
337
+ });
338
+
339
+ it('A is in lane 0', () => {
340
+ const rows = computeGraphRows(commits);
341
+ expect(rows[4].lane).to.equal(0); // A
342
+ });
343
+
344
+ it('lane 1 is freed after E — B row has no pass-through edge for lane 1', () => {
345
+ const rows = computeGraphRows(commits);
346
+ // Row for B (index 3): there should be no pass-through edge that
347
+ // goes from lane 1 to lane 1, because E has already been rendered
348
+ // and its lane should have been freed.
349
+ const bRow = rows[3];
350
+ const spuriousEdge = bRow.edges.find(
351
+ e => e.fromLane === 1 && e.toLane === 1
352
+ );
353
+ expect(spuriousEdge).to.be.undefined;
354
+ });
355
+
356
+ it('lane 1 is reusable after E — new branch between B and A uses lane 1', () => {
357
+ // Insert an independent branch X→Y between B and A so that at the
358
+ // point X is processed, lane 0 is still occupied by 'A' (B's
359
+ // parent). Lane 1 must have been freed after E, so X should
360
+ // occupy lane 1 rather than opening a new lane 2.
361
+ const commitsWithExtra = [
362
+ { id: 'M', parentIds: ['D', 'E'] },
363
+ { id: 'D', parentIds: ['B'] },
364
+ { id: 'E', parentIds: ['B'] },
365
+ { id: 'B', parentIds: ['A'] },
366
+ // X is an independent commit whose parent Y hasn't appeared
367
+ // yet. At this point lane 0 holds 'A', lane 1 was freed after
368
+ // E — so X should claim lane 1.
369
+ { id: 'X', parentIds: ['Y'] },
370
+ { id: 'A', parentIds: [] },
371
+ { id: 'Y', parentIds: [] },
372
+ ];
373
+ const rows = computeGraphRows(commitsWithExtra);
374
+ // X (index 4) should reuse the freed lane 1 rather than lane 2.
375
+ expect(rows[4].lane).to.equal(1);
376
+ });
377
+
378
+ it('E emits a merge-in edge from lane 0 into E\'s commit lane (B already in lane 0)', () => {
379
+ const rows = computeGraphRows(commits);
380
+ // E is at index 2, in lane 1; B is in lane 0.
381
+ // merge-in edge: fromLane=0 (B's existing lane), toLane=1 (E's commit lane)
382
+ const eRow = rows[2];
383
+ const eEdges = eRow.edges;
384
+ const mergeIn = eEdges.filter(e => e.type === 'merge-in' && e.fromLane === 0 && e.toLane === eRow.lane);
385
+ expect(mergeIn.length).to.equal(1);
386
+ });
387
+ });
388
+
389
+ // -------------------------------------------------------------------------
390
+ // Lane color wraps at 8
391
+ // -------------------------------------------------------------------------
392
+ describe('color wrapping', () => {
393
+ it('lane color is always lane % 8', () => {
394
+ // 4 parallel chains so we have lanes 0, 1, 2, 3 occupied at once.
395
+ // Chain structure: X0→X1→X2, Y0→Y1→Y2, etc., interleaved.
396
+ const commits = [
397
+ { id: 'X0', parentIds: ['X1'] },
398
+ { id: 'Y0', parentIds: ['Y1'] },
399
+ { id: 'Z0', parentIds: ['Z1'] },
400
+ { id: 'W0', parentIds: ['W1'] },
401
+ { id: 'X1', parentIds: [] },
402
+ { id: 'Y1', parentIds: [] },
403
+ { id: 'Z1', parentIds: [] },
404
+ { id: 'W1', parentIds: [] },
405
+ ];
406
+ const rows = computeGraphRows(commits);
407
+ // Every row's color must equal lane % 8
408
+ rows.forEach(r => expect(r.color).to.equal(r.lane % 8));
409
+ });
410
+
411
+ it('assigns distinct lanes to 8 simultaneous branches', () => {
412
+ // 8 independent commits whose parents haven't been seen yet
413
+ const commits = [
414
+ { id: 'A', parentIds: ['a'] },
415
+ { id: 'B', parentIds: ['b'] },
416
+ { id: 'C', parentIds: ['c'] },
417
+ { id: 'D', parentIds: ['d'] },
418
+ { id: 'E', parentIds: ['e'] },
419
+ { id: 'F', parentIds: ['f'] },
420
+ { id: 'G', parentIds: ['g'] },
421
+ { id: 'H', parentIds: ['h'] },
422
+ ];
423
+ const rows = computeGraphRows(commits);
424
+ const laneSet = new Set(rows.map(r => r.lane));
425
+ expect(laneSet.size).to.equal(8); // all distinct lanes
426
+ // Color of lane 7 is 7, lane 0 is 0
427
+ const lane7 = rows.find(r => r.lane === 7);
428
+ expect(lane7).to.not.be.undefined;
429
+ expect(lane7!.color).to.equal(7);
430
+ });
431
+ });
432
+
433
+ // -------------------------------------------------------------------------
434
+ // Edge type correctness
435
+ // -------------------------------------------------------------------------
436
+ describe('edge types', () => {
437
+ it('pass-through edges have fromLane === toLane', () => {
438
+ const commits = [
439
+ { id: 'A', parentIds: ['a'] },
440
+ { id: 'B', parentIds: ['b'] },
441
+ { id: 'C', parentIds: ['c'] },
442
+ ];
443
+ const rows = computeGraphRows(commits);
444
+ // Row for B (index 1): A's lane (0) passes through as pass-through
445
+ const bPassThrough = rows[1].edges.filter(e => e.type === 'pass-through');
446
+ bPassThrough.forEach(e => expect(e.fromLane).to.equal(e.toLane));
447
+ });
448
+
449
+ it('branch-out edges have fromLane equal to commit lane', () => {
450
+ const commits = [
451
+ { id: 'M', parentIds: ['D', 'E'] },
452
+ { id: 'D', parentIds: [] },
453
+ { id: 'E', parentIds: [] },
454
+ ];
455
+ const rows = computeGraphRows(commits);
456
+ const mRow = rows[0];
457
+ const branchOut = mRow.edges.filter(e => e.type === 'branch-out');
458
+ branchOut.forEach(e => expect(e.fromLane).to.equal(mRow.lane));
459
+ });
460
+
461
+ it('merge-in edges have toLane equal to commit lane', () => {
462
+ // M (lane 0) parents: D (lane 0, new), E (lane 1, new)
463
+ // E (lane 1) parent: B (already in lane 0 from D)
464
+ const commits2 = [
465
+ { id: 'M', parentIds: ['D', 'E'] },
466
+ { id: 'D', parentIds: ['B'] },
467
+ { id: 'E', parentIds: ['B'] }, // B already in lane 0
468
+ { id: 'B', parentIds: [] },
469
+ ];
470
+ const rows = computeGraphRows(commits2);
471
+ const eRow = rows[2]; // E
472
+ const mergeIn = eRow.edges.filter(e => e.type === 'merge-in');
473
+ // toLane of merge-in must equal the commit's own lane
474
+ mergeIn.forEach(e => expect(e.toLane).to.equal(eRow.lane));
475
+ });
476
+ });
477
+
478
+ // -------------------------------------------------------------------------
479
+ // Sibling branches (branch tips sharing the same parent)
480
+ // -------------------------------------------------------------------------
481
+ describe('sibling branches sharing the same parent', () => {
482
+ // Two branch tips that were NOT pre-reserved, both pointing at the same
483
+ // parent P. This mirrors the real-world case where two refs (e.g. HEAD
484
+ // and origin/HEAD) diverge from a common ancestor:
485
+ //
486
+ // A (lane 0, parent P) ← branch tip, not pre-reserved
487
+ // B (lane 1, parent P) ← sibling branch tip, not pre-reserved
488
+ // P (lane 0, parent Q) ← common parent
489
+ // Q (lane 0) ← root
490
+ const commits = [
491
+ { id: 'A', parentIds: ['P'] },
492
+ { id: 'B', parentIds: ['P'] },
493
+ { id: 'P', parentIds: ['Q'] },
494
+ { id: 'Q', parentIds: [] },
495
+ ];
496
+
497
+ it('A is placed in lane 0', () => {
498
+ expect(computeGraphRows(commits)[0].lane).to.equal(0);
499
+ });
500
+
501
+ it('B is placed in lane 1 (sibling gets new lane)', () => {
502
+ expect(computeGraphRows(commits)[1].lane).to.equal(1);
503
+ });
504
+
505
+ it('P converges back to lane 0', () => {
506
+ expect(computeGraphRows(commits)[2].lane).to.equal(0);
507
+ });
508
+
509
+ it('A row (row 0) emits NO branch-out edges (siblings are not connected to each other)', () => {
510
+ const rows = computeGraphRows(commits);
511
+ const aEdges = rows[0].edges;
512
+ const branchOut = aEdges.filter(e => e.type === 'branch-out');
513
+ expect(branchOut.length).to.equal(0);
514
+ });
515
+
516
+ it('B row (row 1) has hasTopLine:false (lane starts fresh, no connection from above)', () => {
517
+ const rows = computeGraphRows(commits);
518
+ expect(rows[1].hasTopLine).to.equal(false);
519
+ });
520
+
521
+ it('B row (row 1) has hasContinuation:true (lane continues as pass-through to parent P at row 2)', () => {
522
+ const rows = computeGraphRows(commits);
523
+ expect(rows[1].hasContinuation).to.equal(true);
524
+ });
525
+
526
+ it('B row (row 1) does NOT emit a merge-in edge', () => {
527
+ // A merge-in on B\'s row would mean "a top-of-row line curves into B\'s
528
+ // circle", which is wrong since B was not pre-reserved.
529
+ const rows = computeGraphRows(commits);
530
+ const mergeIn = rows[1].edges.filter(e => e.type === 'merge-in');
531
+ expect(mergeIn.length).to.equal(0);
532
+ });
533
+
534
+ it('A row (row 0) has hasTopLine:false (A was not pre-reserved)', () => {
535
+ const rows = computeGraphRows(commits);
536
+ expect(rows[0].hasTopLine).to.equal(false);
537
+ });
538
+
539
+ it('P row (row 2) has hasTopLine:true (continuing from A\'s lane)', () => {
540
+ const rows = computeGraphRows(commits);
541
+ expect(rows[2].hasTopLine).to.equal(true);
542
+ });
543
+
544
+ it('B row (row 1) has a pass-through edge for lane 0', () => {
545
+ // Lane 0 (pointing at P) continues as a pass-through through B\'s row.
546
+ const rows = computeGraphRows(commits);
547
+ const passThrough = rows[1].edges.find(e => e.type === 'pass-through' && e.fromLane === 0 && e.toLane === 0);
548
+ expect(passThrough).to.not.be.undefined;
549
+ });
550
+
551
+ it('P row (row 2) has a merge-in edge from lane 1', () => {
552
+ // Lane 1 (kept alive through B) converges into P\'s lane 0 with a merge-in.
553
+ const rows = computeGraphRows(commits);
554
+ const mergeIn = rows[2].edges.find(e => e.type === 'merge-in' && e.fromLane === 1 && e.toLane === 0);
555
+ expect(mergeIn).to.not.be.undefined;
556
+ });
557
+
558
+ it('P row (row 2) has no pass-through for lane 1 (lane 1 is freed at P)', () => {
559
+ const rows = computeGraphRows(commits);
560
+ const spurious = rows[2].edges.find(e => e.fromLane === 1 && e.toLane === 1 && e.type === 'pass-through');
561
+ expect(spurious).to.be.undefined;
562
+ });
563
+ });
564
+
565
+ // -------------------------------------------------------------------------
566
+ // Three sibling branches sharing the same parent
567
+ // -------------------------------------------------------------------------
568
+ describe('three sibling branches sharing the same parent', () => {
569
+ // A, B, C all have parent P and none were pre-reserved.
570
+ // Rows: A(lane0), B(lane1), C(lane2), P(lane0), ...
571
+ const commits = [
572
+ { id: 'A', parentIds: ['P'] },
573
+ { id: 'B', parentIds: ['P'] },
574
+ { id: 'C', parentIds: ['P'] },
575
+ { id: 'P', parentIds: [] },
576
+ ];
577
+
578
+ it('A is lane 0', () => {
579
+ const rows = computeGraphRows(commits);
580
+ expect(rows[0].lane).to.equal(0);
581
+ });
582
+
583
+ it('B is lane 1', () => {
584
+ const rows = computeGraphRows(commits);
585
+ expect(rows[1].lane).to.equal(1);
586
+ });
587
+
588
+ it('C is in a lane other than lane 0', () => {
589
+ // C is a sibling tip; its lane is re-assigned from freed lanes
590
+ const rows = computeGraphRows(commits);
591
+ expect(rows[2].lane).to.not.equal(0);
592
+ });
593
+
594
+ it('A row emits NO branch-out edges (siblings are not connected to each other)', () => {
595
+ const rows = computeGraphRows(commits);
596
+ const branchOuts = rows[0].edges.filter(e => e.type === 'branch-out');
597
+ expect(branchOuts.length).to.equal(0);
598
+ });
599
+
600
+ it('B row emits NO branch-out edges', () => {
601
+ const rows = computeGraphRows(commits);
602
+ const branchOuts = rows[1].edges.filter(e => e.type === 'branch-out');
603
+ expect(branchOuts.length).to.equal(0);
604
+ });
605
+
606
+ it('B and C rows both have hasTopLine:false (lanes start fresh, no connection from above)', () => {
607
+ const rows = computeGraphRows(commits);
608
+ expect(rows[1].hasTopLine).to.equal(false);
609
+ expect(rows[2].hasTopLine).to.equal(false);
610
+ });
611
+
612
+ it('B and C rows have no merge-in edges', () => {
613
+ const rows = computeGraphRows(commits);
614
+ expect(rows[1].edges.filter(e => e.type === 'merge-in').length).to.equal(0);
615
+ expect(rows[2].edges.filter(e => e.type === 'merge-in').length).to.equal(0);
616
+ });
617
+
618
+ it('P row has merge-in edges from both B\'s lane and C\'s lane', () => {
619
+ const rows = computeGraphRows(commits);
620
+ // P is at index 3, in lane 0. Both lane 1 (B) and lane 2 (C) must
621
+ // converge into P with merge-in edges.
622
+ const pRow = rows[3];
623
+ const mergeInFromLane1 = pRow.edges.find(
624
+ e => e.type === 'merge-in' && e.fromLane === rows[1].lane && e.toLane === pRow.lane
625
+ );
626
+ const mergeInFromLane2 = pRow.edges.find(
627
+ e => e.type === 'merge-in' && e.fromLane === rows[2].lane && e.toLane === pRow.lane
628
+ );
629
+ expect(mergeInFromLane1).to.not.be.undefined;
630
+ expect(mergeInFromLane2).to.not.be.undefined;
631
+ });
632
+ });
633
+
634
+ });
635
+