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:
Julien Calixte
2026-06-30 00:26:59 +02:00
parent c2764deb49
commit 3a528c63e0
2 changed files with 134 additions and 0 deletions

View 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"
)
})
})

View 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") }
}