356 lines
9.9 KiB
TypeScript
356 lines
9.9 KiB
TypeScript
/**
|
|
* Line-level diff engine for three-way merge.
|
|
*
|
|
* Computes diffs between base↔ours and base↔theirs, then aligns
|
|
* them into MergeHunks classified as left-only, right-only, or conflict.
|
|
*/
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface DiffHunk {
|
|
/** Start index in the "old" (base) array, 0-based */
|
|
baseStart: number
|
|
/** Number of lines removed from base (0 = pure insertion) */
|
|
baseCount: number
|
|
/** Replacement lines from the "new" side */
|
|
newLines: string[]
|
|
}
|
|
|
|
export type HunkStatus = "pending" | "applied" | "ignored"
|
|
|
|
export interface MergeHunk {
|
|
id: string
|
|
/** Start index in base lines, 0-based */
|
|
baseStart: number
|
|
/** Number of base lines covered */
|
|
baseCount: number
|
|
/** Diff hunk from ours (left) side, null if unchanged */
|
|
leftHunk: DiffHunk | null
|
|
/** Diff hunk from theirs (right) side, null if unchanged */
|
|
rightHunk: DiffHunk | null
|
|
type: "left-only" | "right-only" | "conflict"
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// LCS-based line diff
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Compute the Longest Common Subsequence table for two string arrays.
|
|
* Returns a 2D array where dp[i][j] = LCS length for a[0..i-1], b[0..j-1].
|
|
*/
|
|
function lcsTable(a: string[], b: string[]): number[][] {
|
|
const m = a.length
|
|
const n = b.length
|
|
const dp: number[][] = Array.from({ length: m + 1 }, () =>
|
|
new Array<number>(n + 1).fill(0)
|
|
)
|
|
for (let i = 1; i <= m; i++) {
|
|
for (let j = 1; j <= n; j++) {
|
|
if (a[i - 1] === b[j - 1]) {
|
|
dp[i][j] = dp[i - 1][j - 1] + 1
|
|
} else {
|
|
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1])
|
|
}
|
|
}
|
|
}
|
|
return dp
|
|
}
|
|
|
|
/**
|
|
* Backtrack the LCS table to produce edit operations.
|
|
* Returns an array of { type, aIdx, bIdx } entries.
|
|
*/
|
|
interface EditOp {
|
|
type: "equal" | "delete" | "insert"
|
|
aIdx: number // index in a (-1 for insert)
|
|
bIdx: number // index in b (-1 for delete)
|
|
}
|
|
|
|
function backtrackLCS(a: string[], b: string[], dp: number[][]): EditOp[] {
|
|
const ops: EditOp[] = []
|
|
let i = a.length
|
|
let j = b.length
|
|
|
|
while (i > 0 || j > 0) {
|
|
if (i > 0 && j > 0 && a[i - 1] === b[j - 1]) {
|
|
ops.push({ type: "equal", aIdx: i - 1, bIdx: j - 1 })
|
|
i--
|
|
j--
|
|
} else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
|
|
ops.push({ type: "insert", aIdx: -1, bIdx: j - 1 })
|
|
j--
|
|
} else {
|
|
ops.push({ type: "delete", aIdx: i - 1, bIdx: -1 })
|
|
i--
|
|
}
|
|
}
|
|
|
|
return ops.reverse()
|
|
}
|
|
|
|
/**
|
|
* Compute line-level diff hunks between old (a) and new (b) arrays.
|
|
*/
|
|
export function computeLineDiff(a: string[], b: string[]): DiffHunk[] {
|
|
const dp = lcsTable(a, b)
|
|
const ops = backtrackLCS(a, b, dp)
|
|
|
|
const hunks: DiffHunk[] = []
|
|
let idx = 0
|
|
|
|
while (idx < ops.length) {
|
|
const op = ops[idx]
|
|
|
|
if (op.type === "equal") {
|
|
idx++
|
|
continue
|
|
}
|
|
|
|
// Start of a change region
|
|
let baseStart = op.type === "delete" ? op.aIdx : -1
|
|
let baseCount = 0
|
|
const newLines: string[] = []
|
|
|
|
while (idx < ops.length && ops[idx].type !== "equal") {
|
|
const cur = ops[idx]
|
|
if (cur.type === "delete") {
|
|
if (baseStart === -1) baseStart = cur.aIdx
|
|
baseCount++
|
|
} else {
|
|
// insert
|
|
if (baseStart === -1) {
|
|
// Pure insertion — position it at the next base line
|
|
// Find the previous equal op's aIdx + 1, or 0
|
|
baseStart = findInsertionPoint(ops, idx)
|
|
}
|
|
newLines.push(b[cur.bIdx])
|
|
}
|
|
idx++
|
|
}
|
|
|
|
hunks.push({ baseStart, baseCount, newLines })
|
|
}
|
|
|
|
return hunks
|
|
}
|
|
|
|
/**
|
|
* For a pure insertion (no deletes in this hunk), determine
|
|
* where in the base array to anchor it.
|
|
*/
|
|
function findInsertionPoint(ops: EditOp[], currentIdx: number): number {
|
|
// Walk backwards to find the last "equal" or "delete" op
|
|
for (let k = currentIdx - 1; k >= 0; k--) {
|
|
if (ops[k].type === "equal" || ops[k].type === "delete") {
|
|
return ops[k].aIdx + 1
|
|
}
|
|
}
|
|
// If nothing found, insert at start
|
|
return 0
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Three-way merge hunk computation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface RangedHunk {
|
|
baseStart: number
|
|
baseEnd: number // exclusive
|
|
hunk: DiffHunk
|
|
side: "left" | "right"
|
|
}
|
|
|
|
/**
|
|
* Given diff hunks from base→ours and base→theirs, produce
|
|
* a list of MergeHunks sorted by base position.
|
|
*/
|
|
export function computeMergeHunks(
|
|
base: string,
|
|
ours: string,
|
|
theirs: string
|
|
): MergeHunk[] {
|
|
const baseLines = base.split("\n")
|
|
const oursLines = ours.split("\n")
|
|
const theirsLines = theirs.split("\n")
|
|
|
|
const leftDiffs = computeLineDiff(baseLines, oursLines)
|
|
const rightDiffs = computeLineDiff(baseLines, theirsLines)
|
|
|
|
// Convert to ranged hunks for overlap detection
|
|
const ranged: RangedHunk[] = []
|
|
|
|
for (const h of leftDiffs) {
|
|
ranged.push({
|
|
baseStart: h.baseStart,
|
|
baseEnd: h.baseStart + Math.max(h.baseCount, 1), // at least 1 for insertions
|
|
hunk: h,
|
|
side: "left",
|
|
})
|
|
}
|
|
for (const h of rightDiffs) {
|
|
ranged.push({
|
|
baseStart: h.baseStart,
|
|
baseEnd: h.baseStart + Math.max(h.baseCount, 1),
|
|
hunk: h,
|
|
side: "right",
|
|
})
|
|
}
|
|
|
|
// Sort by baseStart, then by side (left first)
|
|
ranged.sort(
|
|
(a, b) => a.baseStart - b.baseStart || (a.side === "left" ? -1 : 1)
|
|
)
|
|
|
|
// Merge overlapping hunks from different sides into conflicts
|
|
const mergeHunks: MergeHunk[] = []
|
|
const used = new Set<number>()
|
|
|
|
for (let i = 0; i < ranged.length; i++) {
|
|
if (used.has(i)) continue
|
|
|
|
const r = ranged[i]
|
|
|
|
// Check for overlapping hunk from the other side
|
|
let paired: RangedHunk | null = null
|
|
let pairedIdx = -1
|
|
|
|
for (let j = i + 1; j < ranged.length; j++) {
|
|
if (used.has(j)) continue
|
|
const s = ranged[j]
|
|
if (s.side === r.side) continue
|
|
// Check overlap: ranges [r.baseStart, r.baseEnd) and [s.baseStart, s.baseEnd)
|
|
if (s.baseStart < r.baseEnd && r.baseStart < s.baseEnd) {
|
|
paired = s
|
|
pairedIdx = j
|
|
break
|
|
}
|
|
// If s starts beyond r, no more overlaps possible
|
|
if (s.baseStart >= r.baseEnd) break
|
|
}
|
|
|
|
if (paired && pairedIdx >= 0) {
|
|
used.add(pairedIdx)
|
|
|
|
// Check if both sides made identical changes — treat as non-conflict
|
|
const leftH = r.side === "left" ? r.hunk : paired.hunk
|
|
const rightH = r.side === "right" ? r.hunk : paired.hunk
|
|
|
|
const identical =
|
|
leftH.baseStart === rightH.baseStart &&
|
|
leftH.baseCount === rightH.baseCount &&
|
|
leftH.newLines.length === rightH.newLines.length &&
|
|
leftH.newLines.every((line, k) => line === rightH.newLines[k])
|
|
|
|
if (identical) {
|
|
// Both sides made the same change — treat as left-only (auto-applicable)
|
|
const bStart = Math.min(r.baseStart, paired.baseStart)
|
|
const bEnd = Math.max(r.baseEnd, paired.baseEnd)
|
|
mergeHunks.push({
|
|
id: `hunk-${mergeHunks.length}`,
|
|
baseStart: bStart,
|
|
baseCount: bEnd - bStart,
|
|
leftHunk: leftH,
|
|
rightHunk: null,
|
|
type: "left-only",
|
|
})
|
|
} else {
|
|
// Conflict
|
|
const bStart = Math.min(r.baseStart, paired.baseStart)
|
|
const bEnd = Math.max(r.baseEnd, paired.baseEnd)
|
|
mergeHunks.push({
|
|
id: `hunk-${mergeHunks.length}`,
|
|
baseStart: bStart,
|
|
baseCount: bEnd - bStart,
|
|
leftHunk: r.side === "left" ? r.hunk : paired.hunk,
|
|
rightHunk: r.side === "right" ? r.hunk : paired.hunk,
|
|
type: "conflict",
|
|
})
|
|
}
|
|
} else {
|
|
// Single-side change
|
|
mergeHunks.push({
|
|
id: `hunk-${mergeHunks.length}`,
|
|
baseStart: r.baseStart,
|
|
baseCount: r.hunk.baseCount,
|
|
leftHunk: r.side === "left" ? r.hunk : null,
|
|
rightHunk: r.side === "right" ? r.hunk : null,
|
|
type: r.side === "left" ? "left-only" : "right-only",
|
|
})
|
|
}
|
|
}
|
|
|
|
// Sort by baseStart
|
|
mergeHunks.sort((a, b) => a.baseStart - b.baseStart)
|
|
|
|
return mergeHunks
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Result builder
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface AppliedHunkInfo {
|
|
id: string
|
|
side: "left" | "right"
|
|
}
|
|
|
|
/**
|
|
* Build the result content by starting from base and applying
|
|
* hunks that have been accepted.
|
|
*
|
|
* @param base Original base content
|
|
* @param hunks All merge hunks
|
|
* @param applied Map of hunk id → which side was applied
|
|
*/
|
|
export function buildResult(
|
|
base: string,
|
|
hunks: MergeHunk[],
|
|
applied: Map<string, "left" | "right">
|
|
): string {
|
|
const baseLines = base.split("\n")
|
|
const result: string[] = []
|
|
let baseIdx = 0
|
|
|
|
// Process hunks in order of baseStart
|
|
const sorted = [...hunks].sort((a, b) => a.baseStart - b.baseStart)
|
|
|
|
for (const hunk of sorted) {
|
|
// Copy unchanged base lines before this hunk
|
|
while (baseIdx < hunk.baseStart) {
|
|
result.push(baseLines[baseIdx])
|
|
baseIdx++
|
|
}
|
|
|
|
const appliedSide = applied.get(hunk.id)
|
|
|
|
if (appliedSide) {
|
|
// Apply the chosen side's content
|
|
const diffHunk = appliedSide === "left" ? hunk.leftHunk : hunk.rightHunk
|
|
if (diffHunk) {
|
|
result.push(...diffHunk.newLines)
|
|
}
|
|
// Skip over the base lines that were replaced
|
|
baseIdx = hunk.baseStart + hunk.baseCount
|
|
} else {
|
|
// Not applied — keep base content
|
|
for (let i = 0; i < hunk.baseCount; i++) {
|
|
if (baseIdx < baseLines.length) {
|
|
result.push(baseLines[baseIdx])
|
|
baseIdx++
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Copy remaining base lines
|
|
while (baseIdx < baseLines.length) {
|
|
result.push(baseLines[baseIdx])
|
|
baseIdx++
|
|
}
|
|
|
|
return result.join("\n")
|
|
}
|