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.
This commit is contained in:
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user