Compare commits

...

4 Commits

Author SHA1 Message Date
Julien Calixte
b8c9d07930 feat(notes): auto-merge non-overlapping edits on conflict
All checks were successful
CI / verify (push) Successful in 1m13s
When a save hits a GitHub conflict, try a three-way merge first: if our
edits and the remote ones don't overlap, commit the result silently with
a tailored toast. Only genuinely overlapping edits open the conflict
modal. Adds an optional successMessage override to updateFile.
2026-06-30 00:27:07 +02:00
Julien Calixte
cb22c755df feat(notes): resolve base and remote sources for merge
resolveMergeSources reads the edited-from snapshot as the merge base
(its own immutable cache entry, never the path pointer) and the current
remote blob, returning the decoded text a three-way merge needs.
2026-06-30 00:27:03 +02:00
Julien Calixte
3a528c63e0 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.
2026-06-30 00:26:59 +02:00
Julien Calixte
c2764deb49 chore(deps): add node-diff3 for three-way merge 2026-06-30 00:26:54 +02:00
8 changed files with 336 additions and 11 deletions

View File

@@ -49,6 +49,7 @@
"markdown-it-shikiji": "^0.10.2",
"mermaid": "^11.12.1",
"nanoid": "^5.1.6",
"node-diff3": "^3.2.1",
"notyf": "^3.10.0",
"pastel-color": "^1.0.3",
"pinia": "^2.2.6",

9
pnpm-lock.yaml generated
View File

@@ -104,6 +104,9 @@ importers:
nanoid:
specifier: ^5.1.6
version: 5.1.6
node-diff3:
specifier: ^3.2.1
version: 3.2.1
notyf:
specifier: ^3.10.0
version: 3.10.0
@@ -5130,6 +5133,10 @@ packages:
node-addon-api@7.1.1:
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
node-diff3@3.2.1:
resolution: {integrity: sha512-eKZcJ8RtMQ3cIaA15EgmtwG927fYnRlhtdA4Q9HAcCpAZPQhhU2XptnTH9GeAkMiX2bAXxlJSAFa9shFbztPgw==}
engines: {bun: '>=1.3.10'}
node-fetch@2.6.12:
resolution: {integrity: sha512-C/fGU2E8ToujUivIO0H+tpQ6HWo4eEmchoPIoXtxCrVghxdKq+QOHqEZW7tuP3KlV3bC8FRMO5nMCC7Zm1VP6g==}
engines: {node: 4.x || >=6.0.0}
@@ -12079,6 +12086,8 @@ snapshots:
node-addon-api@7.1.1:
optional: true
node-diff3@3.2.1: {}
node-fetch@2.6.12:
dependencies:
whatwg-url: 5.0.0

View File

@@ -23,6 +23,7 @@ import { encodeUTF8ToBase64 } from "@/utils/decodeBase64ToUTF8"
import { getFileLanguage, isMarkdownPath } from "@/utils/fileLanguage"
import { filenameToNoteTitle } from "@/utils/noteTitle"
import { errorMessage } from "@/utils/notif"
import { threeWayMerge } from "@/utils/threeWayMerge"
const LinkedNotes = defineAsyncComponent(
() => import("@/components/LinkedNotes.vue")
@@ -160,7 +161,8 @@ const {
lastCheckedAt,
latestSha,
check: checkFreshness,
pullLatest
pullLatest,
resolveMergeSources
} = useNoteFreshness({
user: user.value,
repo: repo.value,
@@ -220,9 +222,7 @@ const performSave = async (overrideSha?: string) => {
})
if (conflict) {
await checkFreshness()
conflictOpen.value = true
if (mode.value === "read") toggleMode()
await handleConflict()
return
}
@@ -239,6 +239,47 @@ const performSave = async (overrideSha?: string) => {
advanceStackTo(newSha)
}
// On a save/freshness conflict, try a 3-way merge first: if our edits and the
// remote ones don't overlap, commit the merge silently (just a toast) so the
// user is never interrupted. Only genuinely overlapping edits open the modal.
const handleConflict = async () => {
if (!path.value) return
const sources = await resolveMergeSources()
if (sources) {
const { clean, merged } = threeWayMerge(
sources.base,
rawContent.value,
sources.theirs
)
if (clean) {
const { sha: newSha, conflict } = await updateFile({
content: merged,
path: path.value,
sha: sources.remoteSha,
successMessage: "✅ Merged remote changes & saved"
})
if (!conflict && newSha) {
rawContent.value = merged
await saveCacheNote(encodeUTF8ToBase64(merged), {
editedSha: newSha,
path: path.value
})
initialRawContent.value = merged
advanceStackTo(newSha)
return
}
// Remote moved again between fetch and commit — fall back to the modal.
}
}
// Overlapping edits, or merge sources unavailable — let the user decide.
// Ensure latestSha is set so the modal's "overwrite" has a target.
if (!latestSha.value) await checkFreshness()
conflictOpen.value = true
if (mode.value === "read") toggleMode()
}
watch(mode, async (newMode) => {
if (newMode === "edit") {
void checkFreshness()
@@ -258,7 +299,7 @@ watch(mode, async (newMode) => {
await checkFreshness()
if (freshnessStatus.value === "outdated") {
conflictOpen.value = true
await handleConflict()
return
}
@@ -296,7 +337,7 @@ const onBadgeClick = async () => {
const hasUnsavedEdits = rawContent.value !== initialRawContent.value
if (hasUnsavedEdits) {
conflictOpen.value = true
await handleConflict()
return
}

View File

@@ -88,18 +88,20 @@ export const useGitHubContent = ({
const putFile = async ({
content,
path,
sha
sha,
successMessage = "✅ Note saved"
}: {
content: string
path: string
sha?: string
successMessage?: string
}): Promise<{ sha: string | null; conflict: boolean }> =>
putRaw({
contentBase64: encodeUTF8ToBase64(content),
path,
sha,
message: `Updating ${path} from Remanso`,
successMessage: "✅ Note saved",
successMessage,
conflictMessage: "⚠ Conflict: this note changed on GitHub",
failureMessage: "❌ Note could not be saved"
})
@@ -126,6 +128,7 @@ export const useGitHubContent = ({
content: string
path: string
sha: string
successMessage?: string
}) => putFile(props),
createFile: async (props: { content: string; path: string }) =>
putFile(props),

View File

@@ -2,18 +2,27 @@ import { createPinia, setActivePinia } from "pinia"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { ref } from "vue"
import { DataType } from "@/data/DataType.enum"
const {
fetchLatestSha,
addFile,
getRawContent,
saveCacheNote,
queryFileContent
queryFileContent,
dataGet
} = vi.hoisted(() => ({
fetchLatestSha: vi.fn(),
addFile: vi.fn(),
getRawContent: vi.fn((s: string) => s),
saveCacheNote: vi.fn(),
queryFileContent: vi.fn()
queryFileContent: vi.fn(),
dataGet: vi.fn()
}))
vi.mock("@/data/data", () => ({
data: { get: dataGet },
generateId: (type: string, id: string) => `${type}-${id}`
}))
vi.mock("@/hooks/useGitHubContent.hook", () => ({
@@ -232,3 +241,87 @@ describe("useNoteFreshness.pullLatest", () => {
expect(queryFileContent).toHaveBeenCalledWith("alice", "notes", "cached-sha")
})
})
describe("useNoteFreshness.resolveMergeSources", () => {
beforeEach(() => {
fetchLatestSha.mockReset()
queryFileContent.mockReset()
dataGet.mockReset()
getRawContent.mockReset()
getRawContent.mockImplementation((s: string) => s)
skipSpinnerWait()
})
afterEach(() => {
vi.restoreAllMocks()
})
it("returns null when path is empty", async () => {
const { resolveMergeSources } = setup({ path: "" })
expect(await resolveMergeSources()).toBeNull()
})
it("resolves base (from cache snapshot) + theirs + remoteSha", async () => {
fetchLatestSha.mockResolvedValue({ kind: "ok", sha: "remote-sha" })
queryFileContent.mockResolvedValue("THEIRS_B64") // theirs blob
dataGet.mockResolvedValue({ content: "BASE_B64" }) // base snapshot in cache
const { resolveMergeSources, latestSha } = setup({
sha: "live",
edited: "base-sha"
})
const result = await resolveMergeSources()
expect(result).toEqual({
base: "BASE_B64",
theirs: "THEIRS_B64",
remoteSha: "remote-sha"
})
// base must be read from its own immutable snapshot, never the path pointer
expect(dataGet).toHaveBeenCalledWith(`${DataType.Note}-base-sha`)
expect(queryFileContent).toHaveBeenCalledWith("alice", "notes", "remote-sha")
expect(latestSha.value).toBe("remote-sha")
})
it("falls back to fetching the base blob when not cached", async () => {
fetchLatestSha.mockResolvedValue({ kind: "ok", sha: "remote-sha" })
dataGet.mockResolvedValue(null) // cache miss for base
queryFileContent.mockImplementation((_u, _r, s: string) =>
Promise.resolve(s === "remote-sha" ? "THEIRS_B64" : "BASE_FROM_BLOB")
)
const { resolveMergeSources } = setup({ sha: "base-sha" })
const result = await resolveMergeSources()
expect(result?.base).toBe("BASE_FROM_BLOB")
expect(queryFileContent).toHaveBeenCalledWith("alice", "notes", "base-sha")
})
it("returns null when the remote sha cannot be resolved", async () => {
fetchLatestSha.mockResolvedValue({ kind: "offline" })
const { resolveMergeSources } = setup()
expect(await resolveMergeSources()).toBeNull()
})
it("returns null when the remote (theirs) blob fetch fails", async () => {
fetchLatestSha.mockResolvedValue({ kind: "ok", sha: "remote-sha" })
queryFileContent.mockResolvedValue(null)
const { resolveMergeSources, latestSha } = setup()
expect(await resolveMergeSources()).toBeNull()
// latestSha is still surfaced so a modal fallback's overwrite has a target
expect(latestSha.value).toBe("remote-sha")
})
it("returns null when the base blob is unavailable (cache miss + blob null)", async () => {
fetchLatestSha.mockResolvedValue({ kind: "ok", sha: "remote-sha" })
dataGet.mockResolvedValue(null)
queryFileContent.mockImplementation((_u, _r, s: string) =>
Promise.resolve(s === "remote-sha" ? "THEIRS_B64" : null)
)
const { resolveMergeSources } = setup({ sha: "base-sha" })
expect(await resolveMergeSources()).toBeNull()
})
})

View File

@@ -1,8 +1,11 @@
import { Ref, ref } from "vue"
import { data, generateId } from "@/data/data"
import { DataType } from "@/data/DataType.enum"
import { useGitHubContent } from "@/hooks/useGitHubContent.hook"
import { markdownBuilder } from "@/hooks/useMarkdown.hook"
import { prepareNoteCache } from "@/modules/note/cache/prepareNoteCache"
import { Note } from "@/modules/note/models/Note"
import { queryFileContent } from "@/modules/repo/services/repo"
import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
@@ -114,11 +117,52 @@ export const useNoteFreshness = ({
return { raw: getRawContent(fileContent), failureStatus: null }
}
// The base blob (common ancestor) must come from its own immutable snapshot,
// never from the path pointer — that holds the latest, which is "theirs".
const getCachedBlob = async (blobSha: string): Promise<string | null> => {
const note = await data.get<DataType.Note, Note>(
generateId(DataType.Note, blobSha)
)
return note?.content ?? null
}
// Resolves the decoded raw text needed for a 3-way merge: `base` is the
// version we edited from (the rejected sha), `theirs` is the current remote.
// Returns null when any piece can't be resolved (offline, missing base).
const resolveMergeSources = async (): Promise<{
base: string
theirs: string
remoteSha: string
} | null> => {
if (!path.value) return null
const { sha: remoteSha } = await resolveRemoteSha(path.value)
if (!remoteSha) return null
// Surface the resolved sha so a modal fallback's Overwrite can use it.
latestSha.value = remoteSha
const theirsBlob = await queryFileContent(user, repo, remoteSha)
if (!theirsBlob) return null
const baseSha = (await getEditedSha()) ?? sha.value
const baseBlob =
(await getCachedBlob(baseSha)) ??
(await queryFileContent(user, repo, baseSha))
if (!baseBlob) return null
const { getRawContent } = markdownBuilder(sha.value)
return {
base: getRawContent(baseBlob),
theirs: getRawContent(theirsBlob),
remoteSha
}
}
return {
status,
lastCheckedAt,
latestSha,
check,
pullLatest
pullLatest,
resolveMergeSources
}
}

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