Compare commits
4 Commits
57ef4a9e50
...
b8c9d07930
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8c9d07930 | ||
|
|
cb22c755df | ||
|
|
3a528c63e0 | ||
|
|
c2764deb49 |
@@ -49,6 +49,7 @@
|
|||||||
"markdown-it-shikiji": "^0.10.2",
|
"markdown-it-shikiji": "^0.10.2",
|
||||||
"mermaid": "^11.12.1",
|
"mermaid": "^11.12.1",
|
||||||
"nanoid": "^5.1.6",
|
"nanoid": "^5.1.6",
|
||||||
|
"node-diff3": "^3.2.1",
|
||||||
"notyf": "^3.10.0",
|
"notyf": "^3.10.0",
|
||||||
"pastel-color": "^1.0.3",
|
"pastel-color": "^1.0.3",
|
||||||
"pinia": "^2.2.6",
|
"pinia": "^2.2.6",
|
||||||
|
|||||||
9
pnpm-lock.yaml
generated
9
pnpm-lock.yaml
generated
@@ -104,6 +104,9 @@ importers:
|
|||||||
nanoid:
|
nanoid:
|
||||||
specifier: ^5.1.6
|
specifier: ^5.1.6
|
||||||
version: 5.1.6
|
version: 5.1.6
|
||||||
|
node-diff3:
|
||||||
|
specifier: ^3.2.1
|
||||||
|
version: 3.2.1
|
||||||
notyf:
|
notyf:
|
||||||
specifier: ^3.10.0
|
specifier: ^3.10.0
|
||||||
version: 3.10.0
|
version: 3.10.0
|
||||||
@@ -5130,6 +5133,10 @@ packages:
|
|||||||
node-addon-api@7.1.1:
|
node-addon-api@7.1.1:
|
||||||
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
|
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
|
||||||
|
|
||||||
|
node-diff3@3.2.1:
|
||||||
|
resolution: {integrity: sha512-eKZcJ8RtMQ3cIaA15EgmtwG927fYnRlhtdA4Q9HAcCpAZPQhhU2XptnTH9GeAkMiX2bAXxlJSAFa9shFbztPgw==}
|
||||||
|
engines: {bun: '>=1.3.10'}
|
||||||
|
|
||||||
node-fetch@2.6.12:
|
node-fetch@2.6.12:
|
||||||
resolution: {integrity: sha512-C/fGU2E8ToujUivIO0H+tpQ6HWo4eEmchoPIoXtxCrVghxdKq+QOHqEZW7tuP3KlV3bC8FRMO5nMCC7Zm1VP6g==}
|
resolution: {integrity: sha512-C/fGU2E8ToujUivIO0H+tpQ6HWo4eEmchoPIoXtxCrVghxdKq+QOHqEZW7tuP3KlV3bC8FRMO5nMCC7Zm1VP6g==}
|
||||||
engines: {node: 4.x || >=6.0.0}
|
engines: {node: 4.x || >=6.0.0}
|
||||||
@@ -12079,6 +12086,8 @@ snapshots:
|
|||||||
node-addon-api@7.1.1:
|
node-addon-api@7.1.1:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
node-diff3@3.2.1: {}
|
||||||
|
|
||||||
node-fetch@2.6.12:
|
node-fetch@2.6.12:
|
||||||
dependencies:
|
dependencies:
|
||||||
whatwg-url: 5.0.0
|
whatwg-url: 5.0.0
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { encodeUTF8ToBase64 } from "@/utils/decodeBase64ToUTF8"
|
|||||||
import { getFileLanguage, isMarkdownPath } from "@/utils/fileLanguage"
|
import { getFileLanguage, isMarkdownPath } from "@/utils/fileLanguage"
|
||||||
import { filenameToNoteTitle } from "@/utils/noteTitle"
|
import { filenameToNoteTitle } from "@/utils/noteTitle"
|
||||||
import { errorMessage } from "@/utils/notif"
|
import { errorMessage } from "@/utils/notif"
|
||||||
|
import { threeWayMerge } from "@/utils/threeWayMerge"
|
||||||
|
|
||||||
const LinkedNotes = defineAsyncComponent(
|
const LinkedNotes = defineAsyncComponent(
|
||||||
() => import("@/components/LinkedNotes.vue")
|
() => import("@/components/LinkedNotes.vue")
|
||||||
@@ -160,7 +161,8 @@ const {
|
|||||||
lastCheckedAt,
|
lastCheckedAt,
|
||||||
latestSha,
|
latestSha,
|
||||||
check: checkFreshness,
|
check: checkFreshness,
|
||||||
pullLatest
|
pullLatest,
|
||||||
|
resolveMergeSources
|
||||||
} = useNoteFreshness({
|
} = useNoteFreshness({
|
||||||
user: user.value,
|
user: user.value,
|
||||||
repo: repo.value,
|
repo: repo.value,
|
||||||
@@ -220,9 +222,7 @@ const performSave = async (overrideSha?: string) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (conflict) {
|
if (conflict) {
|
||||||
await checkFreshness()
|
await handleConflict()
|
||||||
conflictOpen.value = true
|
|
||||||
if (mode.value === "read") toggleMode()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,6 +239,47 @@ const performSave = async (overrideSha?: string) => {
|
|||||||
advanceStackTo(newSha)
|
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) => {
|
watch(mode, async (newMode) => {
|
||||||
if (newMode === "edit") {
|
if (newMode === "edit") {
|
||||||
void checkFreshness()
|
void checkFreshness()
|
||||||
@@ -258,7 +299,7 @@ watch(mode, async (newMode) => {
|
|||||||
|
|
||||||
await checkFreshness()
|
await checkFreshness()
|
||||||
if (freshnessStatus.value === "outdated") {
|
if (freshnessStatus.value === "outdated") {
|
||||||
conflictOpen.value = true
|
await handleConflict()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,7 +337,7 @@ const onBadgeClick = async () => {
|
|||||||
|
|
||||||
const hasUnsavedEdits = rawContent.value !== initialRawContent.value
|
const hasUnsavedEdits = rawContent.value !== initialRawContent.value
|
||||||
if (hasUnsavedEdits) {
|
if (hasUnsavedEdits) {
|
||||||
conflictOpen.value = true
|
await handleConflict()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -88,18 +88,20 @@ export const useGitHubContent = ({
|
|||||||
const putFile = async ({
|
const putFile = async ({
|
||||||
content,
|
content,
|
||||||
path,
|
path,
|
||||||
sha
|
sha,
|
||||||
|
successMessage = "✅ Note saved"
|
||||||
}: {
|
}: {
|
||||||
content: string
|
content: string
|
||||||
path: string
|
path: string
|
||||||
sha?: string
|
sha?: string
|
||||||
|
successMessage?: string
|
||||||
}): Promise<{ sha: string | null; conflict: boolean }> =>
|
}): Promise<{ sha: string | null; conflict: boolean }> =>
|
||||||
putRaw({
|
putRaw({
|
||||||
contentBase64: encodeUTF8ToBase64(content),
|
contentBase64: encodeUTF8ToBase64(content),
|
||||||
path,
|
path,
|
||||||
sha,
|
sha,
|
||||||
message: `Updating ${path} from Remanso`,
|
message: `Updating ${path} from Remanso`,
|
||||||
successMessage: "✅ Note saved",
|
successMessage,
|
||||||
conflictMessage: "⚠ Conflict: this note changed on GitHub",
|
conflictMessage: "⚠ Conflict: this note changed on GitHub",
|
||||||
failureMessage: "❌ Note could not be saved"
|
failureMessage: "❌ Note could not be saved"
|
||||||
})
|
})
|
||||||
@@ -126,6 +128,7 @@ export const useGitHubContent = ({
|
|||||||
content: string
|
content: string
|
||||||
path: string
|
path: string
|
||||||
sha: string
|
sha: string
|
||||||
|
successMessage?: string
|
||||||
}) => putFile(props),
|
}) => putFile(props),
|
||||||
createFile: async (props: { content: string; path: string }) =>
|
createFile: async (props: { content: string; path: string }) =>
|
||||||
putFile(props),
|
putFile(props),
|
||||||
|
|||||||
@@ -2,18 +2,27 @@ import { createPinia, setActivePinia } from "pinia"
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||||
import { ref } from "vue"
|
import { ref } from "vue"
|
||||||
|
|
||||||
|
import { DataType } from "@/data/DataType.enum"
|
||||||
|
|
||||||
const {
|
const {
|
||||||
fetchLatestSha,
|
fetchLatestSha,
|
||||||
addFile,
|
addFile,
|
||||||
getRawContent,
|
getRawContent,
|
||||||
saveCacheNote,
|
saveCacheNote,
|
||||||
queryFileContent
|
queryFileContent,
|
||||||
|
dataGet
|
||||||
} = vi.hoisted(() => ({
|
} = vi.hoisted(() => ({
|
||||||
fetchLatestSha: vi.fn(),
|
fetchLatestSha: vi.fn(),
|
||||||
addFile: vi.fn(),
|
addFile: vi.fn(),
|
||||||
getRawContent: vi.fn((s: string) => s),
|
getRawContent: vi.fn((s: string) => s),
|
||||||
saveCacheNote: vi.fn(),
|
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", () => ({
|
vi.mock("@/hooks/useGitHubContent.hook", () => ({
|
||||||
@@ -232,3 +241,87 @@ describe("useNoteFreshness.pullLatest", () => {
|
|||||||
expect(queryFileContent).toHaveBeenCalledWith("alice", "notes", "cached-sha")
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { Ref, ref } from "vue"
|
import { Ref, ref } from "vue"
|
||||||
|
|
||||||
|
import { data, generateId } from "@/data/data"
|
||||||
|
import { DataType } from "@/data/DataType.enum"
|
||||||
import { useGitHubContent } from "@/hooks/useGitHubContent.hook"
|
import { useGitHubContent } from "@/hooks/useGitHubContent.hook"
|
||||||
import { markdownBuilder } from "@/hooks/useMarkdown.hook"
|
import { markdownBuilder } from "@/hooks/useMarkdown.hook"
|
||||||
import { prepareNoteCache } from "@/modules/note/cache/prepareNoteCache"
|
import { prepareNoteCache } from "@/modules/note/cache/prepareNoteCache"
|
||||||
|
import { Note } from "@/modules/note/models/Note"
|
||||||
import { queryFileContent } from "@/modules/repo/services/repo"
|
import { queryFileContent } from "@/modules/repo/services/repo"
|
||||||
import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
|
import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
|
||||||
|
|
||||||
@@ -114,11 +117,52 @@ export const useNoteFreshness = ({
|
|||||||
return { raw: getRawContent(fileContent), failureStatus: null }
|
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 {
|
return {
|
||||||
status,
|
status,
|
||||||
lastCheckedAt,
|
lastCheckedAt,
|
||||||
latestSha,
|
latestSha,
|
||||||
check,
|
check,
|
||||||
pullLatest
|
pullLatest,
|
||||||
|
resolveMergeSources
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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