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.
- package/QUICKSTART.md +116 -85
- package/README.md +62 -3
- package/package.json +5 -3
- package/src/commands/branch.js +3 -0
- package/src/commands/checkout.js +5 -0
- package/src/commands/commit.js +65 -2
- package/src/commands/explain.js +145 -0
- package/src/commands/log.js +51 -1
- package/src/commands/merge.js +4 -0
- package/src/commands/remote.js +15 -1
- package/src/commands/reset.js +10 -0
- package/src/commands/resolve.js +280 -0
- package/src/commands/summary.js +176 -0
- package/src/commands/undo.js +115 -0
- package/src/index.js +39 -3
- package/src/utils/ai-service.js +156 -0
- package/src/utils/diff-engine.js +68 -2
- package/src/utils/journal.js +215 -0
- package/src/utils/merge-engine.js +340 -140
|
@@ -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.
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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 (
|
|
19
|
-
*
|
|
20
|
-
* │ Scenario
|
|
21
|
-
*
|
|
22
|
-
* │
|
|
23
|
-
* │ Only
|
|
24
|
-
* │
|
|
25
|
-
* │ Both
|
|
26
|
-
* │
|
|
27
|
-
* │ Whitespace-only difference
|
|
28
|
-
* │
|
|
29
|
-
*
|
|
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
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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
|
-
// ───
|
|
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
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const
|
|
79
|
-
|
|
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
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
130
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
142
|
-
*
|
|
143
|
-
* @
|
|
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
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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: '
|
|
240
|
-
if (oursText === baseText) return { mergedText: theirsText, hasConflicts: false, conflicts: [], algorithm: '
|
|
241
|
-
if (theirsText === baseText) return { mergedText: oursText, hasConflicts: false, conflicts: [], algorithm: '
|
|
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: '
|
|
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
|
|
343
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
355
|
-
while (
|
|
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
|
|
358
|
-
cur = c ? c.parent : null;
|
|
547
|
+
for (const p of parentsOf(cur)) stack.push(p);
|
|
359
548
|
}
|
|
360
549
|
|
|
361
|
-
//
|
|
362
|
-
|
|
363
|
-
|
|
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
|
|
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
|
-
|
|
378
|
-
|
|
572
|
+
subMergeRegion,
|
|
573
|
+
resolveRegion,
|
|
574
|
+
isImportRegion,
|
|
575
|
+
unionLines,
|
|
576
|
+
linesEqual,
|
|
577
|
+
hasConflictMarkers,
|
|
578
|
+
parseConflictMarkers
|
|
379
579
|
};
|