feat(notes): add line-level three-way merge util
Wraps node-diff3 to merge two edits derived from a common ancestor. Conservative by design: edits to immediately-adjacent lines are reported as conflicts so the caller can fall back to manual resolution rather than risk a wrong auto-merge.
This commit is contained in:
94
src/utils/threeWayMerge.spec.ts
Normal file
94
src/utils/threeWayMerge.spec.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { describe, expect, it } from "vitest"
|
||||||
|
|
||||||
|
import { threeWayMerge } from "./threeWayMerge"
|
||||||
|
|
||||||
|
const base = "line1\nline2\nline3\nline4\nline5"
|
||||||
|
|
||||||
|
describe("threeWayMerge", () => {
|
||||||
|
it("merges non-overlapping edits cleanly, keeping both changes", () => {
|
||||||
|
const ours = "OURS1\nline2\nline3\nline4\nline5"
|
||||||
|
const theirs = "line1\nline2\nline3\nline4\nTHEIRS5"
|
||||||
|
|
||||||
|
const { clean, merged } = threeWayMerge(base, ours, theirs)
|
||||||
|
|
||||||
|
expect(clean).toBe(true)
|
||||||
|
expect(merged).toBe("OURS1\nline2\nline3\nline4\nTHEIRS5")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("treats identical edits on both sides as clean (false conflict)", () => {
|
||||||
|
const edited = "line1\nCHANGED\nline3\nline4\nline5"
|
||||||
|
|
||||||
|
const { clean, merged } = threeWayMerge(base, edited, edited)
|
||||||
|
|
||||||
|
expect(clean).toBe(true)
|
||||||
|
expect(merged).toBe(edited)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("flags overlapping edits on the same line as not clean", () => {
|
||||||
|
const ours = "line1\nline2\nOURS3\nline4\nline5"
|
||||||
|
const theirs = "line1\nline2\nTHEIRS3\nline4\nline5"
|
||||||
|
|
||||||
|
const { clean } = threeWayMerge(base, ours, theirs)
|
||||||
|
|
||||||
|
expect(clean).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns the base unchanged when neither side edited", () => {
|
||||||
|
const { clean, merged } = threeWayMerge(base, base, base)
|
||||||
|
|
||||||
|
expect(clean).toBe(true)
|
||||||
|
expect(merged).toBe(base)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns our edits when only we changed", () => {
|
||||||
|
const ours = "OURS1\nline2\nline3\nline4\nline5"
|
||||||
|
|
||||||
|
const { clean, merged } = threeWayMerge(base, ours, base)
|
||||||
|
|
||||||
|
expect(clean).toBe(true)
|
||||||
|
expect(merged).toBe(ours)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns their edits when only they changed", () => {
|
||||||
|
const theirs = "line1\nline2\nline3\nline4\nTHEIRS5"
|
||||||
|
|
||||||
|
const { clean, merged } = threeWayMerge(base, base, theirs)
|
||||||
|
|
||||||
|
expect(clean).toBe(true)
|
||||||
|
expect(merged).toBe(theirs)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("preserves a trailing newline", () => {
|
||||||
|
const baseNl = "a\nb\nc\n"
|
||||||
|
const ours = "A\nb\nc\n"
|
||||||
|
const theirs = "a\nb\nC\n"
|
||||||
|
|
||||||
|
const { clean, merged } = threeWayMerge(baseNl, ours, theirs)
|
||||||
|
|
||||||
|
expect(clean).toBe(true)
|
||||||
|
expect(merged).toBe("A\nb\nC\n")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("conservatively flags edits on immediately-adjacent lines as not clean", () => {
|
||||||
|
// No unchanged line separates the two edits, so node-diff3 groups them
|
||||||
|
// into one region and reports a conflict (git would merge this).
|
||||||
|
const ours = "A\nline2\nline3\nline4\nline5"
|
||||||
|
const theirs = "line1\nB\nline3\nline4\nline5"
|
||||||
|
|
||||||
|
const { clean } = threeWayMerge(base, ours, theirs)
|
||||||
|
|
||||||
|
expect(clean).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("merges added lines on different sides without conflict", () => {
|
||||||
|
const ours = "line1\nOURS-NEW\nline2\nline3\nline4\nline5"
|
||||||
|
const theirs = "line1\nline2\nline3\nline4\nline5\nTHEIRS-NEW"
|
||||||
|
|
||||||
|
const { clean, merged } = threeWayMerge(base, ours, theirs)
|
||||||
|
|
||||||
|
expect(clean).toBe(true)
|
||||||
|
expect(merged).toBe(
|
||||||
|
"line1\nOURS-NEW\nline2\nline3\nline4\nline5\nTHEIRS-NEW"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
40
src/utils/threeWayMerge.ts
Normal file
40
src/utils/threeWayMerge.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { diff3Merge } from "node-diff3"
|
||||||
|
|
||||||
|
export interface ThreeWayMergeResult {
|
||||||
|
clean: boolean
|
||||||
|
merged: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Line-level 3-way merge. `ours` and `theirs` are both derived from `base`
|
||||||
|
* (the common ancestor). When the two sets of edits are separated by at least
|
||||||
|
* one unchanged line they merge automatically (`clean: true`); when they
|
||||||
|
* overlap, `clean` is false and the caller should fall back to manual
|
||||||
|
* resolution.
|
||||||
|
*
|
||||||
|
* Conservative by design: node-diff3 groups *consecutive* changed lines into a
|
||||||
|
* single region, so edits to immediately-adjacent lines (no unchanged line
|
||||||
|
* between them) are reported as a conflict even when they don't truly overlap.
|
||||||
|
* This errs toward asking the user rather than risking a wrong auto-merge.
|
||||||
|
*
|
||||||
|
* `merged` is only meaningful when `clean` is true — conflicting regions are
|
||||||
|
* dropped from it, so it must not be committed on a non-clean merge.
|
||||||
|
*/
|
||||||
|
export const threeWayMerge = (
|
||||||
|
base: string,
|
||||||
|
ours: string,
|
||||||
|
theirs: string
|
||||||
|
): ThreeWayMergeResult => {
|
||||||
|
// diff3Merge(a, o, b): a = ours, o = ancestor, b = theirs. The default
|
||||||
|
// excludeFalseConflicts folds identical edits made on both sides.
|
||||||
|
const regions = diff3Merge(ours, base, theirs, { stringSeparator: /\r?\n/ })
|
||||||
|
|
||||||
|
const lines: string[] = []
|
||||||
|
let clean = true
|
||||||
|
for (const region of regions) {
|
||||||
|
if (region.ok) lines.push(...region.ok)
|
||||||
|
else clean = false
|
||||||
|
}
|
||||||
|
|
||||||
|
return { clean, merged: lines.join("\n") }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user