gent-cli 6.0.0 → 7.0.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.
@@ -1,38 +1,46 @@
1
1
  /**
2
2
  * ============================================================================
3
- * Merge Engine - Three-Way Smart Merge with Auto-Resolution
3
+ * Merge Engine - Three-Way Smart Merge (diff3) with Auto-Resolution
4
4
  * ============================================================================
5
5
  *
6
6
  * PURPOSE:
7
7
  * Merge two diverged branches using their common ancestor as reference.
8
- * Minimizes manual conflict resolution through aggressive auto-resolution.
8
+ * Minimizes manual conflict resolution through aggressive auto-resolution
9
+ * while NEVER silently producing an incorrect merge — when in doubt, it
10
+ * emits conflict markers for a human (or `gent resolve`) to decide.
9
11
  *
10
- * THREE-WAY MERGE ALGORITHM:
12
+ * THREE-WAY MERGE ALGORITHM (diff3):
11
13
  * Given: BASE (common ancestor), OURS (current branch), THEIRS (incoming)
12
14
  *
13
- * 1. Compute diff: BASE OURS (what we changed)
14
- * 2. Compute diff: BASE THEIRS (what they changed)
15
- * 3. Build "change regions" from each diff (contiguous modified areas)
16
- * 4. Walk base line-by-line, apply resolution rules:
15
+ * 1. Match BASE↔OURS and BASE↔THEIRS line-for-line via LCS. A base line is a
16
+ * "stable anchor" when it survives unchanged in BOTH sides — at an anchor
17
+ * all three files are synchronized.
18
+ * 2. Walk anchors in order. Between two consecutive anchors lies one
19
+ * "unstable region": baseSeg / oursSeg / theirsSeg (each possibly empty).
20
+ * 3. Classify each region with the rules below, then emit the anchor line.
17
21
  *
18
- * AUTO-RESOLUTION RULES (in priority order):
19
- * ┌─────────────────────────────────────────────────────────────────┐
20
- * │ Scenario │ Result
21
- * ├─────────────────────────────────────────────────────────────────┤
22
- * │ Only OURS modified region Take OURS
23
- * │ Only THEIRS modified region │ Take THEIRS
24
- * │ Both modified identically │ Take either (same)
25
- * │ Both modified, same-length,
26
- * │ non-overlapping line changes │ Line-by-line merge
27
- * │ Whitespace-only difference │ Take OURS
28
- * │ Modify/delete conflict Keep modified (smart)
29
- * │ True overlapping conflict │ Insert conflict markers │
30
- * └─────────────────────────────────────────────────────────────────┘
22
+ * AUTO-RESOLUTION RULES (per unstable region):
23
+ * ┌────────────────────────────────────────────┬──────────────────────────┐
24
+ * │ Scenario │ Result
25
+ * ├────────────────────────────────────────────┼──────────────────────────┤
26
+ * │ Neither side changed the region Keep base
27
+ * │ Only OURS changed │ Take OURS
28
+ * │ Only THEIRS changed │ Take THEIRS
29
+ * │ Both changed identically Take either
30
+ * │ Both changed, same length, disjoint lines │ Line-by-line sub-merge
31
+ * │ Whitespace-only difference │ Take OURS
32
+ * │ True overlapping conflict Insert conflict markers
33
+ * └────────────────────────────────────────────┴──────────────────────────┘
31
34
  *
32
- * MERGE BASE FINDER:
33
- * Walks parent chain from both branch tips.
34
- * Collects all ancestors of branch A, then walks B until first hit.
35
- * Returns the most recent common ancestor. O(n) where n = total commits.
35
+ * Why diff3 (vs. the previous region-walk): anchoring whole unstable regions
36
+ * between synchronized lines correctly handles one-sided pure insertions
37
+ * (zero base lines consumed) and overlapping edits on both sides the two
38
+ * cases that crashed / dropped data before.
39
+ *
40
+ * MERGE BASE FINDER (DAG-aware):
41
+ * Walks BOTH `parent` and `mergeParent` edges so the lowest common ancestor
42
+ * is found correctly even after merge commits exist. BFS from one tip,
43
+ * nearest-first, returns the first ancestor shared with the other tip.
36
44
  *
37
45
  * TREE-LEVEL MERGE:
38
46
  * Compares file presence/absence + blob hashes across base/ours/theirs trees.
@@ -59,127 +67,164 @@ const { splitLines } = require('./hash-engine');
59
67
  const { buildLineOperations } = require('./diff-engine');
60
68
  const { readBlobAsString, treeToMap, storeBlob } = require('./hash-engine');
61
69
 
62
- // ─── Line-Level 3-Way Merge ─────────────────────────────
70
+ // ─── Helpers ────────────────────────────────────────────
71
+
72
+ /**
73
+ * Shallow array equality (line arrays).
74
+ * @param {String[]} a
75
+ * @param {String[]} b
76
+ * @returns {Boolean}
77
+ */
78
+ function linesEqual(a, b) {
79
+ if (a.length !== b.length) return false;
80
+ for (let i = 0; i < a.length; i++) {
81
+ if (a[i] !== b[i]) return false;
82
+ }
83
+ return true;
84
+ }
85
+
86
+ // Matches dependency/import declarations across common languages:
87
+ // JS/TS import/require, Python import/from, Go/Rust/C# use/using, C #include.
88
+ const IMPORT_LINE = /^\s*(import\s|from\s.+\simport\s|(const|let|var)\s+.+=\s*require\(|require\(|#include\s|using\s|use\s|@import\s)/;
89
+
90
+ /** True when a region is non-empty and every non-blank line is an import/require. */
91
+ function isImportRegion(lines) {
92
+ const nonBlank = lines.filter(l => l.trim() !== '');
93
+ if (nonBlank.length === 0) return false;
94
+ return nonBlank.every(l => IMPORT_LINE.test(l));
95
+ }
96
+
97
+ /** Union of two line lists: all of `a`, then lines of `b` not already present. */
98
+ function unionLines(a, b) {
99
+ const seen = new Set(a);
100
+ const out = [...a];
101
+ for (const line of b) {
102
+ if (!seen.has(line)) { out.push(line); seen.add(line); }
103
+ }
104
+ return out;
105
+ }
106
+
107
+ /**
108
+ * Map each base line index to the matching line index in `other` (via LCS),
109
+ * or -1 when that base line does not survive unchanged in `other`.
110
+ * @param {String[]} baseLines
111
+ * @param {String[]} otherLines
112
+ * @returns {Int32Array} length === baseLines.length
113
+ */
114
+ function matchBaseToOther(baseLines, otherLines) {
115
+ const map = new Int32Array(baseLines.length).fill(-1);
116
+ const ops = buildLineOperations(baseLines, otherLines);
117
+ for (const op of ops) {
118
+ if (op.type === 'equal') {
119
+ // op.oldLine / op.newLine are 1-based positions
120
+ map[op.oldLine - 1] = op.newLine - 1;
121
+ }
122
+ }
123
+ return map;
124
+ }
125
+
126
+ // ─── Line-Level 3-Way Merge (diff3) ─────────────────────
63
127
 
64
128
  /**
65
- * Three-way merge of line arrays.
129
+ * Three-way merge of line arrays using the diff3 algorithm.
66
130
  * @param {String[]} baseLines
67
131
  * @param {String[]} oursLines
68
132
  * @param {String[]} theirsLines
69
133
  * @returns {{merged: String[], conflicts: Array, hasConflicts: Boolean}}
70
134
  */
71
135
  function threeWayMerge(baseLines, oursLines, theirsLines) {
72
- const ourOps = buildLineOperations(baseLines, oursLines);
73
- const theirOps = buildLineOperations(baseLines, theirsLines);
74
-
75
- const ourRegions = buildChangeRegions(ourOps);
76
- const theirRegions = buildChangeRegions(theirOps);
77
-
78
- const ourMap = mapRegionsByBase(ourRegions);
79
- const theirMap = mapRegionsByBase(theirRegions);
136
+ const oMatch = matchBaseToOther(baseLines, oursLines);
137
+ const tMatch = matchBaseToOther(baseLines, theirsLines);
138
+
139
+ // Stable anchors: base lines present unchanged in BOTH sides.
140
+ // oMatch/tMatch are individually monotonic (LCS), so the shared subset is
141
+ // simultaneously monotonic in base/ours/theirs indices.
142
+ const anchors = [];
143
+ for (let bi = 0; bi < baseLines.length; bi++) {
144
+ if (oMatch[bi] !== -1 && tMatch[bi] !== -1) {
145
+ anchors.push({ b: bi, o: oMatch[bi], t: tMatch[bi] });
146
+ }
147
+ }
148
+ // Sentinel anchor at the end so the trailing region is processed.
149
+ anchors.push({ b: baseLines.length, o: oursLines.length, t: theirsLines.length });
80
150
 
81
- const allStarts = new Set([...ourMap.keys(), ...theirMap.keys()]);
82
151
  const merged = [];
83
152
  const conflicts = [];
84
- let baseIdx = 0;
85
-
86
- while (baseIdx <= baseLines.length) {
87
- if (allStarts.has(baseIdx)) {
88
- const ourR = ourMap.get(baseIdx);
89
- const theirR = theirMap.get(baseIdx);
90
-
91
- if (ourR && theirR) {
92
- const ourText = ourR.newLines.join('\n');
93
- const theirText = theirR.newLines.join('\n');
94
-
95
- if (ourText === theirText) {
96
- merged.push(...ourR.newLines);
97
- } else {
98
- const sub = subMergeRegion(ourR.oldLines, ourR.newLines, theirR.newLines);
99
- if (sub.hasConflicts) {
100
- conflicts.push({
101
- baseLine: baseIdx,
102
- baseContent: ourR.oldLines,
103
- oursContent: ourR.newLines,
104
- theirsContent: theirR.newLines
105
- });
106
- merged.push('<<<<<<< ours');
107
- merged.push(...ourR.newLines);
108
- merged.push('=======');
109
- merged.push(...theirR.newLines);
110
- merged.push('>>>>>>> theirs');
111
- } else {
112
- merged.push(...sub.lines);
113
- }
114
- }
115
- const skip = Math.max(ourR.oldLines.length, theirR.oldLines.length);
116
- baseIdx += skip;
117
- continue;
118
- } else if (ourR) {
119
- merged.push(...ourR.newLines);
120
- baseIdx += ourR.oldLines.length;
121
- continue;
122
- } else if (theirR) {
123
- merged.push(...theirR.newLines);
124
- baseIdx += theirR.oldLines.length;
125
- continue;
126
- }
127
- }
153
+ let prevB = 0, prevO = 0, prevT = 0;
154
+
155
+ for (const a of anchors) {
156
+ const baseSeg = baseLines.slice(prevB, a.b);
157
+ const oursSeg = oursLines.slice(prevO, a.o);
158
+ const theirsSeg = theirsLines.slice(prevT, a.t);
128
159
 
129
- if (baseIdx < baseLines.length) {
130
- merged.push(baseLines[baseIdx]);
160
+ resolveRegion(baseSeg, oursSeg, theirsSeg, merged, conflicts);
161
+
162
+ // Emit the synchronized anchor line (skip the end sentinel).
163
+ if (a.b < baseLines.length) {
164
+ merged.push(baseLines[a.b]);
131
165
  }
132
- baseIdx++;
166
+
167
+ prevB = a.b + 1;
168
+ prevO = a.o + 1;
169
+ prevT = a.t + 1;
133
170
  }
134
171
 
135
172
  return { merged, conflicts, hasConflicts: conflicts.length > 0 };
136
173
  }
137
174
 
138
- // ─── Region Building ────────────────────────────────────
139
-
140
175
  /**
141
- * Extract contiguous change regions from diff ops, anchored to base line indices.
142
- * @param {Array} ops
143
- * @returns {Array<{baseStart, oldLines, newLines}>}
176
+ * Classify and resolve a single unstable region, appending to `merged`
177
+ * (and `conflicts` when unresolved).
178
+ * @param {String[]} baseSeg
179
+ * @param {String[]} oursSeg
180
+ * @param {String[]} theirsSeg
181
+ * @param {String[]} merged - output accumulator (mutated)
182
+ * @param {Array} conflicts - output accumulator (mutated)
144
183
  */
145
- function buildChangeRegions(ops) {
146
- const regions = [];
147
- let current = null;
148
- let baseIdx = 0;
184
+ function resolveRegion(baseSeg, oursSeg, theirsSeg, merged, conflicts) {
185
+ if (baseSeg.length === 0 && oursSeg.length === 0 && theirsSeg.length === 0) {
186
+ return;
187
+ }
149
188
 
150
- for (const op of ops) {
151
- if (op.type === 'equal') {
152
- if (current) { regions.push(current); current = null; }
153
- baseIdx++;
154
- } else if (op.type === 'delete') {
155
- if (!current) current = { baseStart: baseIdx, oldLines: [], newLines: [] };
156
- current.oldLines.push(op.content);
157
- baseIdx++;
158
- } else if (op.type === 'insert') {
159
- if (!current) current = { baseStart: baseIdx, oldLines: [], newLines: [] };
160
- current.newLines.push(op.content);
161
- }
189
+ const oursChanged = !linesEqual(baseSeg, oursSeg);
190
+ const theirsChanged = !linesEqual(baseSeg, theirsSeg);
191
+
192
+ if (!oursChanged && !theirsChanged) { merged.push(...baseSeg); return; }
193
+ if (!oursChanged) { merged.push(...theirsSeg); return; } // only theirs changed
194
+ if (!theirsChanged) { merged.push(...oursSeg); return; } // only ours changed
195
+ if (linesEqual(oursSeg, theirsSeg)) { merged.push(...oursSeg); return; } // same change
196
+
197
+ // Language-aware rule: when both sides ADD import/require lines at the same
198
+ // spot (no base lines involved), union them instead of conflicting. This is
199
+ // the classic "both branches added an import" false conflict, and unioning
200
+ // additions is safe. Anything with base content falls through to a conflict.
201
+ if (baseSeg.length === 0 && isImportRegion(oursSeg) && isImportRegion(theirsSeg)) {
202
+ merged.push(...unionLines(oursSeg, theirsSeg));
203
+ return;
162
204
  }
163
- if (current) regions.push(current);
164
- return regions;
165
- }
166
205
 
167
- /**
168
- * Map regions by baseStart index.
169
- * @param {Array} regions
170
- * @returns {Map}
171
- */
172
- function mapRegionsByBase(regions) {
173
- const map = new Map();
174
- for (const r of regions) map.set(r.baseStart, r);
175
- return map;
206
+ // Both changed differently — attempt fine-grained line-by-line merge.
207
+ const sub = subMergeRegion(baseSeg, oursSeg, theirsSeg);
208
+ if (!sub.hasConflicts) { merged.push(...sub.lines); return; }
209
+
210
+ // Unresolved — emit conflict markers.
211
+ conflicts.push({
212
+ baseContent: baseSeg,
213
+ oursContent: oursSeg,
214
+ theirsContent: theirsSeg
215
+ });
216
+ merged.push('<<<<<<< ours');
217
+ merged.push(...oursSeg);
218
+ merged.push('=======');
219
+ merged.push(...theirsSeg);
220
+ merged.push('>>>>>>> theirs');
176
221
  }
177
222
 
178
223
  // ─── Sub-Merge (fine-grained) ───────────────────────────
179
224
 
180
225
  /**
181
- * Attempt line-by-line merge within a region.
182
- * Catches non-overlapping edits inside same region.
226
+ * Attempt line-by-line merge within a region where both sides changed.
227
+ * Catches non-overlapping edits inside the same region.
183
228
  * @param {String[]} baseLines
184
229
  * @param {String[]} oursLines
185
230
  * @param {String[]} theirsLines
@@ -210,16 +255,149 @@ function subMergeRegion(baseLines, oursLines, theirsLines) {
210
255
  return { lines: [], hasConflicts: true };
211
256
  }
212
257
 
258
+ // ─── JSON-Aware Merge ───────────────────────────────────
259
+
260
+ function isPlainObject(v) {
261
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
262
+ }
263
+
264
+ /** Structural deep equality (objects, arrays, primitives). */
265
+ function deepEqual(a, b) {
266
+ if (a === b) return true;
267
+ if (typeof a !== typeof b) return false;
268
+ if (Array.isArray(a) || Array.isArray(b)) {
269
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
270
+ return a.every((x, i) => deepEqual(x, b[i]));
271
+ }
272
+ if (isPlainObject(a) && isPlainObject(b)) {
273
+ const ka = Object.keys(a), kb = Object.keys(b);
274
+ if (ka.length !== kb.length) return false;
275
+ return ka.every(k => Object.prototype.hasOwnProperty.call(b, k) && deepEqual(a[k], b[k]));
276
+ }
277
+ return false;
278
+ }
279
+
280
+ /** Decide the merged value for a single key across base/ours/theirs. */
281
+ function decideJsonKey(base, ours, theirs, key) {
282
+ const hasB = Object.prototype.hasOwnProperty.call(base, key);
283
+ const hasO = Object.prototype.hasOwnProperty.call(ours, key);
284
+ const hasT = Object.prototype.hasOwnProperty.call(theirs, key);
285
+ const bv = base[key], ov = ours[key], tv = theirs[key];
286
+
287
+ const oChanged = hasO !== hasB || !deepEqual(ov, bv);
288
+ const tChanged = hasT !== hasB || !deepEqual(tv, bv);
289
+
290
+ if (!oChanged && !tChanged) return hasB ? { action: 'set', value: bv } : { action: 'delete' };
291
+ if (!oChanged) return hasT ? { action: 'set', value: tv } : { action: 'delete' }; // only theirs
292
+ if (!tChanged) return hasO ? { action: 'set', value: ov } : { action: 'delete' }; // only ours
293
+ if (hasO && hasT && deepEqual(ov, tv)) return { action: 'set', value: ov }; // same change
294
+ if (hasO && hasT && isPlainObject(ov) && isPlainObject(tv)) { // recurse
295
+ const sub = mergeJsonObjects(isPlainObject(bv) ? bv : {}, ov, tv);
296
+ return sub.hasConflicts ? { action: 'conflict' } : { action: 'set', value: sub.merged };
297
+ }
298
+ return { action: 'conflict' };
299
+ }
300
+
301
+ /** Key-level 3-way merge of two objects. Returns merged object + conflict flag. */
302
+ function mergeJsonObjects(base, ours, theirs) {
303
+ const keys = [];
304
+ const seen = new Set();
305
+ for (const k of [...Object.keys(ours), ...Object.keys(theirs), ...Object.keys(base)]) {
306
+ if (!seen.has(k)) { keys.push(k); seen.add(k); }
307
+ }
308
+
309
+ const merged = {};
310
+ let hasConflicts = false;
311
+ for (const k of keys) {
312
+ const d = decideJsonKey(base, ours, theirs, k);
313
+ if (d.action === 'set') merged[k] = d.value;
314
+ else if (d.action === 'conflict') hasConflicts = true;
315
+ // 'delete' → omit
316
+ }
317
+ return { merged, hasConflicts };
318
+ }
319
+
320
+ /**
321
+ * Attempt a JSON-aware merge. Returns a clean result only when the structures
322
+ * parse as objects AND every key auto-resolves; otherwise returns null so the
323
+ * caller can fall back to line-based merge (which can emit conflict markers).
324
+ * @returns {{content: String, hasConflicts: Boolean, conflicts: Array}|null}
325
+ */
326
+ function mergeJsonContent(baseContent, oursContent, theirsContent) {
327
+ let base, ours, theirs;
328
+ try {
329
+ base = baseContent && baseContent.trim() ? JSON.parse(baseContent) : {};
330
+ ours = JSON.parse(oursContent);
331
+ theirs = JSON.parse(theirsContent);
332
+ } catch {
333
+ return null; // not valid JSON → fall back
334
+ }
335
+ if (!isPlainObject(base) || !isPlainObject(ours) || !isPlainObject(theirs)) {
336
+ return null; // non-object roots (e.g. arrays) → fall back
337
+ }
338
+
339
+ const { merged, hasConflicts } = mergeJsonObjects(base, ours, theirs);
340
+ if (hasConflicts) return null; // let the line merge surface markers
341
+ return { content: JSON.stringify(merged, null, 2) + '\n', hasConflicts: false, conflicts: [] };
342
+ }
343
+
344
+ // ─── Conflict Marker Parsing ────────────────────────────
345
+
346
+ /** True if the text contains a conflict start marker. */
347
+ function hasConflictMarkers(content) {
348
+ return /^<<<<<<</m.test(content || '');
349
+ }
350
+
351
+ /**
352
+ * Parse conflict-marked text into ordered segments.
353
+ * @param {String} content
354
+ * @returns {Array<{type:'text', lines:String[]} | {type:'conflict', ours:String[], theirs:String[]}>}
355
+ */
356
+ function parseConflictMarkers(content) {
357
+ const lines = (content || '').split('\n');
358
+ const segments = [];
359
+ let textBuf = [];
360
+ const flush = () => { if (textBuf.length) { segments.push({ type: 'text', lines: textBuf }); textBuf = []; } };
361
+
362
+ let i = 0;
363
+ while (i < lines.length) {
364
+ if (lines[i].startsWith('<<<<<<<')) {
365
+ flush();
366
+ i++;
367
+ const ours = [];
368
+ while (i < lines.length && !lines[i].startsWith('=======')) ours.push(lines[i++]);
369
+ i++; // skip '======='
370
+ const theirs = [];
371
+ while (i < lines.length && !lines[i].startsWith('>>>>>>>')) theirs.push(lines[i++]);
372
+ i++; // skip '>>>>>>>'
373
+ segments.push({ type: 'conflict', ours, theirs });
374
+ } else {
375
+ textBuf.push(lines[i++]);
376
+ }
377
+ }
378
+ flush();
379
+ return segments;
380
+ }
381
+
213
382
  // ─── File-Level Merge ───────────────────────────────────
214
383
 
215
384
  /**
216
- * Merge single file content strings.
385
+ * Merge single file content strings. When `fileName` indicates a structured
386
+ * format (currently .json) a language-aware strategy is tried first; it falls
387
+ * back to the line-based diff3 merge if the structured merge cannot fully and
388
+ * safely resolve the change.
217
389
  * @param {String} baseContent
218
390
  * @param {String} oursContent
219
391
  * @param {String} theirsContent
392
+ * @param {String} [fileName] - used to pick a language-aware strategy
220
393
  * @returns {{content: String, hasConflicts: Boolean, conflicts: Array}}
221
394
  */
222
- function mergeFileContent(baseContent, oursContent, theirsContent) {
395
+ function mergeFileContent(baseContent, oursContent, theirsContent, fileName) {
396
+ if (fileName && /\.json$/i.test(fileName)) {
397
+ const jsonResult = mergeJsonContent(baseContent || '', oursContent || '', theirsContent || '');
398
+ if (jsonResult) return jsonResult;
399
+ }
400
+
223
401
  const base = splitLines(baseContent || '');
224
402
  const ours = splitLines(oursContent || '');
225
403
  const theirs = splitLines(theirsContent || '');
@@ -236,9 +414,9 @@ function mergeFileContent(baseContent, oursContent, theirsContent) {
236
414
  */
237
415
  function autoMerge(baseText, oursText, theirsText) {
238
416
  // Fast paths
239
- if (oursText === theirsText) return { mergedText: oursText, hasConflicts: false, conflicts: [], algorithm: 'three-way-line-v2', confidence: 1 };
240
- if (oursText === baseText) return { mergedText: theirsText, hasConflicts: false, conflicts: [], algorithm: 'three-way-line-v2', confidence: 1 };
241
- if (theirsText === baseText) return { mergedText: oursText, hasConflicts: false, conflicts: [], algorithm: 'three-way-line-v2', confidence: 1 };
417
+ if (oursText === theirsText) return { mergedText: oursText, hasConflicts: false, conflicts: [], algorithm: 'diff3-line-v3', confidence: 1 };
418
+ if (oursText === baseText) return { mergedText: theirsText, hasConflicts: false, conflicts: [], algorithm: 'diff3-line-v3', confidence: 1 };
419
+ if (theirsText === baseText) return { mergedText: oursText, hasConflicts: false, conflicts: [], algorithm: 'diff3-line-v3', confidence: 1 };
242
420
 
243
421
  const result = mergeFileContent(baseText, oursText, theirsText);
244
422
  const maxLen = Math.max(splitLines(baseText).length, 1);
@@ -248,7 +426,7 @@ function autoMerge(baseText, oursText, theirsText) {
248
426
  mergedText: result.content,
249
427
  hasConflicts: result.hasConflicts,
250
428
  conflicts: result.conflicts,
251
- algorithm: 'three-way-line-v2',
429
+ algorithm: 'diff3-line-v3',
252
430
  confidence
253
431
  };
254
432
  }
@@ -301,7 +479,7 @@ async function mergeTreeEntries(gentPath, baseEntries, oursEntries, theirsEntrie
301
479
  readBlobAsString(gentPath, oH),
302
480
  readBlobAsString(gentPath, tH)
303
481
  ]);
304
- const result = mergeFileContent(baseC, oursC, theirsC);
482
+ const result = mergeFileContent(baseC, oursC, theirsC, filePath);
305
483
  const mergedHash = await storeBlob(gentPath, result.content);
306
484
  mergedEntries.push({ mode: '100644', name: filePath, hash: mergedHash, type: 'blob' });
307
485
  if (result.hasConflicts) conflicts.push({ file: filePath, type: 'content', details: result.conflicts });
@@ -326,7 +504,7 @@ async function mergeTreeEntries(gentPath, baseEntries, oursEntries, theirsEntrie
326
504
  readBlobAsString(gentPath, oH),
327
505
  readBlobAsString(gentPath, tH)
328
506
  ]);
329
- const result = mergeFileContent('', oursC, theirsC);
507
+ const result = mergeFileContent('', oursC, theirsC, filePath);
330
508
  const mergedHash = await storeBlob(gentPath, result.content);
331
509
  mergedEntries.push({ mode: '100644', name: filePath, hash: mergedHash, type: 'blob' });
332
510
  if (result.hasConflicts) conflicts.push({ file: filePath, type: 'add-add', details: result.conflicts });
@@ -336,11 +514,12 @@ async function mergeTreeEntries(gentPath, baseEntries, oursEntries, theirsEntrie
336
514
  return { mergedEntries, conflicts, hasConflicts: conflicts.length > 0 };
337
515
  }
338
516
 
339
- // ─── Merge Base Finder ──────────────────────────────────
517
+ // ─── Merge Base Finder (DAG-aware) ──────────────────────
340
518
 
341
519
  /**
342
- * Find common ancestor of two branch tips by walking parents.
343
- * @param {Array} commits
520
+ * Find the lowest common ancestor of two commits by walking BOTH `parent` and
521
+ * `mergeParent` edges. Correct even after merge commits exist.
522
+ * @param {Array} commits - all commit objects
344
523
  * @param {String} hashA
345
524
  * @param {String} hashB
346
525
  * @returns {String|null}
@@ -349,21 +528,35 @@ function findMergeBase(commits, hashA, hashB) {
349
528
  const commitMap = new Map();
350
529
  for (const c of commits) commitMap.set(c.hash, c);
351
530
 
352
- // Collect all ancestors of A
531
+ const parentsOf = (hash) => {
532
+ const c = commitMap.get(hash);
533
+ if (!c) return [];
534
+ const out = [];
535
+ if (c.parent) out.push(c.parent);
536
+ if (c.mergeParent) out.push(c.mergeParent);
537
+ return out;
538
+ };
539
+
540
+ // Collect every ancestor of A (including A) across both edges.
353
541
  const ancestorsA = new Set();
354
- let cur = hashA;
355
- while (cur) {
542
+ const stack = [hashA];
543
+ while (stack.length) {
544
+ const cur = stack.pop();
545
+ if (!cur || ancestorsA.has(cur)) continue;
356
546
  ancestorsA.add(cur);
357
- const c = commitMap.get(cur);
358
- cur = c ? c.parent : null;
547
+ for (const p of parentsOf(cur)) stack.push(p);
359
548
  }
360
549
 
361
- // Walk B ancestors → first hit in A's set = merge base
362
- cur = hashB;
363
- while (cur) {
550
+ // BFS from B (nearest-first) → first node also in A's ancestor set is the
551
+ // most-recent common ancestor.
552
+ const seen = new Set();
553
+ const queue = [hashB];
554
+ while (queue.length) {
555
+ const cur = queue.shift();
556
+ if (!cur || seen.has(cur)) continue;
557
+ seen.add(cur);
364
558
  if (ancestorsA.has(cur)) return cur;
365
- const c = commitMap.get(cur);
366
- cur = c ? c.parent : null;
559
+ for (const p of parentsOf(cur)) queue.push(p);
367
560
  }
368
561
  return null;
369
562
  }
@@ -371,9 +564,16 @@ function findMergeBase(commits, hashA, hashB) {
371
564
  module.exports = {
372
565
  threeWayMerge,
373
566
  mergeFileContent,
567
+ mergeJsonContent,
568
+ mergeJsonObjects,
374
569
  autoMerge,
375
570
  mergeTreeEntries,
376
571
  findMergeBase,
377
- buildChangeRegions,
378
- subMergeRegion
572
+ subMergeRegion,
573
+ resolveRegion,
574
+ isImportRegion,
575
+ unionLines,
576
+ linesEqual,
577
+ hasConflictMarkers,
578
+ parseConflictMarkers
379
579
  };