Compare commits
4 Commits
57ef4a9e50
...
b8c9d07930
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8c9d07930 | ||
|
|
cb22c755df | ||
|
|
3a528c63e0 | ||
|
|
c2764deb49 |
@@ -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
9
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
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