From 3a528c63e0a6d82c73581f20ee95ef23e90b8267 Mon Sep 17 00:00:00 2001 From: Julien Calixte Date: Tue, 30 Jun 2026 00:26:59 +0200 Subject: [PATCH] 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. --- src/utils/threeWayMerge.spec.ts | 94 +++++++++++++++++++++++++++++++++ src/utils/threeWayMerge.ts | 40 ++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 src/utils/threeWayMerge.spec.ts create mode 100644 src/utils/threeWayMerge.ts diff --git a/src/utils/threeWayMerge.spec.ts b/src/utils/threeWayMerge.spec.ts new file mode 100644 index 0000000..5005e4d --- /dev/null +++ b/src/utils/threeWayMerge.spec.ts @@ -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" + ) + }) +}) diff --git a/src/utils/threeWayMerge.ts b/src/utils/threeWayMerge.ts new file mode 100644 index 0000000..4c3420f --- /dev/null +++ b/src/utils/threeWayMerge.ts @@ -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") } +}