Handle multiple conflict revisions deterministically

This commit is contained in:
vorotamoroz
2026-07-23 06:38:16 +00:00
parent d784799969
commit f8f11f358a
15 changed files with 576 additions and 97 deletions
@@ -8,6 +8,8 @@
*/
export const liveSyncProvisionalEnglishMessages = {
"This file has unresolved conflicts.": "This file has unresolved conflicts.",
"This file has ${COUNT} unresolved versions. They will be reviewed one pair at a time.":
"This file has ${COUNT} unresolved versions. They will be reviewed one pair at a time.",
} as const;
export type LiveSyncProvisionalMessageKey = keyof typeof liveSyncProvisionalEnglishMessages;
+1 -1
View File
@@ -143,6 +143,6 @@ export function $msg<T extends AllMessageKeys>(
export type LiveSyncMessageTranslator = MessageTranslator<AllMessageKeys>;
export const translateLiveSyncMessage: LiveSyncMessageTranslator = (key, params) => {
if (!isLiveSyncMessageKey(key)) return englishMessageTranslator(key as CommonlibMessageKey, params);
if (!isLiveSyncMessageKey(key)) return englishMessageTranslator(key, params);
return $msg(key, params === undefined ? undefined : { ...params });
};
+5
View File
@@ -28,6 +28,11 @@ describe("LiveSync-owned translation catalogue", () => {
it("uses LiveSync-owned provisional English without extending Commonlib's message contract", () => {
expect($msg("This file has unresolved conflicts.")).toBe("This file has unresolved conflicts.");
expect(
$msg("This file has ${COUNT} unresolved versions. They will be reviewed one pair at a time.", {
COUNT: "3",
})
).toBe("This file has 3 unresolved versions. They will be reviewed one pair at a time.");
expect(translateLiveSyncMessage("This file has unresolved conflicts.")).toBe(
"This file has unresolved conflicts."
);
@@ -12,6 +12,8 @@ import { ModuleConflictResolver } from "./ModuleConflictResolver";
function createModule(files: FilePathWithPrefix[] = []) {
const resolveByDeletingRevision = vi.fn(async () => AUTO_MERGED);
const tryAutoMerge = vi.fn();
const queueCheckFor = vi.fn(async () => undefined);
const resolveByUserInteraction = vi.fn(async () => false);
const core = {
_services: {
API: {
@@ -27,6 +29,17 @@ function createModule(files: FilePathWithPrefix[] = []) {
conflict: {
resolveByNewest: vi.fn(async () => true),
resolveByDeletingRevision,
resolveByUserInteraction,
queueCheckFor,
},
appLifecycle: {
isSuspended: vi.fn(() => false),
},
replication: {
replicateByEvent: vi.fn(async () => true),
},
vault: {
getActiveFilePath: vi.fn(() => undefined),
},
},
settings: DEFAULT_SETTINGS,
@@ -49,7 +62,7 @@ function createModule(files: FilePathWithPrefix[] = []) {
const module = new ModuleConflictResolver(core);
module._log = vi.fn();
return { module, resolveByDeletingRevision, tryAutoMerge };
return { module, queueCheckFor, resolveByDeletingRevision, resolveByUserInteraction, tryAutoMerge };
}
describe("ModuleConflictResolver bulk newest resolution", () => {
@@ -193,4 +206,40 @@ describe("ModuleConflictResolver sensible merge hand-off", () => {
);
expect(resolveByDeletingRevision).toHaveBeenCalledWith(path, "2-right", "Sensible");
});
it("commits a sensible pair before rechecking the remaining manual pair", async () => {
const path = "three-versions.md" as FilePathWithPrefix;
const { module, queueCheckFor, resolveByDeletingRevision, resolveByUserInteraction, tryAutoMerge } =
createModule();
const remainingManualPair = {
leftRev: "3-merged",
rightRev: "2-third",
leftLeaf: { rev: "3-merged", data: "Merged\n", ctime: 1, mtime: 3 },
rightLeaf: { rev: "2-third", data: "Overlapping\n", ctime: 1, mtime: 2 },
};
tryAutoMerge
.mockResolvedValueOnce({
result: "Merged\n",
conflictedRev: "2-second",
})
.mockResolvedValueOnce(remainingManualPair);
await (module as any)._resolveConflict(path);
expect(module.core.databaseFileAccess.storeContent).toHaveBeenCalledWith(path, "Merged\n");
expect(resolveByDeletingRevision).toHaveBeenCalledWith(path, "2-second", "Sensible");
expect(queueCheckFor).toHaveBeenCalledWith(path);
expect(resolveByUserInteraction).not.toHaveBeenCalled();
await (module as any)._resolveConflict(path);
expect(tryAutoMerge).toHaveBeenCalledTimes(2);
expect(resolveByUserInteraction).toHaveBeenCalledWith(
path,
expect.objectContaining({
left: remainingManualPair.leftLeaf,
right: remainingManualPair.rightLeaf,
})
);
});
});
@@ -1,27 +1,26 @@
import { App, Modal } from "@/deps.ts";
import { DIFF_DELETE, DIFF_EQUAL, DIFF_INSERT } from "diff-match-patch";
import { CANCELLED, LEAVE_TO_SUBSEQUENT, type diff_result } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { delay } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import {
CANCELLED,
LEAVE_TO_SUBSEQUENT,
type diff_result,
type FilePathWithPrefix,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { EVENT_CONFLICT_CANCELLED, eventHub } from "@/common/events.ts";
import { globalSlipBoard } from "@vrtmrz/livesync-commonlib/compat/bureau/bureau";
import { promiseWithResolvers } from "octagonal-wheels/promises";
export const POSTPONED = Symbol("postponed");
export type MergeDialogResult = typeof CANCELLED | typeof POSTPONED | typeof LEAVE_TO_SUBSEQUENT | string;
declare global {
interface Slips {
"conflict-resolved": typeof CANCELLED | MergeDialogResult;
}
}
export class ConflictResolveModal extends Modal {
result: diff_result;
filename: string;
filename: FilePathWithPrefix;
response: MergeDialogResult = CANCELLED;
isClosed = false;
consumed = false;
private readonly resultPromise = promiseWithResolvers<MergeDialogResult>();
title: string = "Conflicting changes";
@@ -33,7 +32,13 @@ export class ConflictResolveModal extends Modal {
diffView!: HTMLDivElement;
diffNavIndicator!: HTMLSpanElement;
constructor(app: App, filename: string, diff: diff_result, pluginPickMode?: boolean, remoteName?: string) {
constructor(
app: App,
filename: FilePathWithPrefix,
diff: diff_result,
pluginPickMode?: boolean,
remoteName?: string
) {
super(app);
this.result = diff;
this.filename = filename;
@@ -43,9 +48,6 @@ export class ConflictResolveModal extends Modal {
this.remoteName = `${remoteName || "Remote"}`;
this.localName = "Local";
}
// Send cancel signal for the previous merge dialogue
// if not there, simply be ignored.
// sendValue("close-resolve-conflict:" + this.filename, false);
}
appendDiffFragment(container: HTMLDivElement, text: string, cls: string) {
@@ -96,18 +98,19 @@ export class ConflictResolveModal extends Modal {
override onOpen() {
const { contentEl } = this;
// Send cancel signal for the previous merge dialogue
// if not there, simply be ignored.
globalSlipBoard.submit("conflict-resolved", this.filename, CANCELLED);
if (this.offEvent) {
this.offEvent();
}
// Cancel an older dialogue for this path before subscribing this
// instance. Emitting after subscription would close the replacement
// itself; the instance-owned result promise then completes the older
// caller even when it only begins waiting after this event.
eventHub.emitEvent(EVENT_CONFLICT_CANCELLED, this.filename);
this.offEvent = eventHub.onEvent(EVENT_CONFLICT_CANCELLED, (path) => {
if (path === this.filename) {
this.sendResponse(CANCELLED);
}
});
// sendValue("close-resolve-conflict:" + this.filename, false);
this.titleEl.setText(this.title);
contentEl.empty();
const diffOptionsRow = contentEl.createDiv("");
@@ -155,21 +158,22 @@ export class ConflictResolveModal extends Modal {
new Date(this.result.right.mtime).toLocaleString() + (this.result.right.deleted ? " (Deleted)" : "");
this.appendVersionInfo(div2, "deleted", this.localName, date1);
this.appendVersionInfo(div2, "added", this.remoteName, date2);
contentEl.createEl("button", { text: `Use ${this.localName}` }, (e) => {
const actionContainer = contentEl.createDiv("conflict-action-container");
actionContainer.createEl("button", { text: `Use ${this.localName}` }, (e) => {
e.addClass("conflict-action-button");
e.addEventListener("click", () => this.sendResponse(this.result.right.rev));
});
contentEl.createEl("button", { text: `Use ${this.remoteName}` }, (e) => {
actionContainer.createEl("button", { text: `Use ${this.remoteName}` }, (e) => {
e.addClass("conflict-action-button");
e.addEventListener("click", () => this.sendResponse(this.result.left.rev));
});
if (!this.pluginPickMode) {
contentEl.createEl("button", { text: "Concat both" }, (e) => {
actionContainer.createEl("button", { text: "Concat both" }, (e) => {
e.addClass("conflict-action-button");
e.addEventListener("click", () => this.sendResponse(LEAVE_TO_SUBSEQUENT));
});
}
contentEl.createEl("button", { text: !this.pluginPickMode ? "Not now" : "Cancel" }, (e) => {
actionContainer.createEl("button", { text: !this.pluginPickMode ? "Not now" : "Cancel" }, (e) => {
e.addClass("conflict-action-button");
e.addEventListener("click", () => this.sendResponse(this.pluginPickMode ? CANCELLED : POSTPONED));
});
@@ -196,12 +200,10 @@ export class ConflictResolveModal extends Modal {
return;
}
this.consumed = true;
globalSlipBoard.submit("conflict-resolved", this.filename, this.response);
this.resultPromise.resolve(this.response);
}
async waitForResult(): Promise<MergeDialogResult> {
await delay(100);
const r = await globalSlipBoard.awaitNext("conflict-resolved", this.filename);
return r;
return await this.resultPromise.promise;
}
}
@@ -0,0 +1,85 @@
import { describe, expect, it, vi } from "vitest";
import { POSTPONED, ConflictResolveModal } from "./ConflictResolveModal.ts";
import { CANCELLED, type diff_result, type FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
vi.mock("@/deps.ts", () => ({
App: class App {},
Modal: class Modal {
private createElement(): Record<string, unknown> {
const element: Record<string, unknown> = {
addClass: vi.fn(),
addEventListener: vi.fn(),
appendText: vi.fn(),
classList: {
add: vi.fn(),
remove: vi.fn(),
},
empty: vi.fn(),
querySelector: vi.fn(() => null),
querySelectorAll: vi.fn(() => []),
scrollIntoView: vi.fn(),
setText: vi.fn(),
};
element.createDiv = vi.fn(() => this.createElement());
element.createEl = vi.fn((_tag: string, _options?: unknown, callback?: (child: unknown) => void) => {
const child = this.createElement();
callback?.(child);
return child;
});
element.createSpan = vi.fn(() => this.createElement());
return element;
}
contentEl = this.createElement();
titleEl = {
setText: vi.fn(),
};
close() {
(this as { onClose?: () => void }).onClose?.();
}
},
}));
const conflict: diff_result = {
left: { rev: "2-left", data: "left", ctime: 1, mtime: 2 },
right: { rev: "2-right", data: "right", ctime: 1, mtime: 2 },
diff: [],
};
describe("ConflictResolveModal result lifecycle", () => {
it("returns a response which closes the dialogue before the caller begins waiting", async () => {
const modal = new ConflictResolveModal({} as never, "early-response.md" as FilePathWithPrefix, conflict);
modal.sendResponse(POSTPONED);
const result = await Promise.race([
modal.waitForResult(),
new Promise<"timed-out">((resolve) => setTimeout(() => resolve("timed-out"), 250)),
]);
expect(result).toBe(POSTPONED);
});
it("cancels the previous same-path dialogue without cancelling the replacement", async () => {
const filename = "same-path.md" as FilePathWithPrefix;
const previous = new ConflictResolveModal({} as never, filename, conflict);
const replacement = new ConflictResolveModal({} as never, filename, conflict);
previous.onOpen();
replacement.onOpen();
const previousResult = await Promise.race([
previous.waitForResult(),
new Promise<"timed-out">((resolve) => setTimeout(() => resolve("timed-out"), 250)),
]);
const replacementState = await Promise.race([
replacement.waitForResult(),
new Promise<"still-open">((resolve) => setTimeout(() => resolve("still-open"), 25)),
]);
previous.sendResponse(CANCELLED);
replacement.sendResponse(CANCELLED);
expect(previousResult).toBe(CANCELLED);
expect(replacementState).toBe("still-open");
});
});
@@ -22,9 +22,10 @@ import type { Editor, MarkdownFileInfo, MarkdownView } from "@/deps.ts";
export class ModuleInteractiveConflictResolver extends AbstractObsidianModule {
private postponedConflictEpisodes = new Set<FilePathWithPrefix>();
private async isConflicted(filename: FilePathWithPrefix): Promise<boolean | undefined> {
private async getConflictVersionCount(filename: FilePathWithPrefix): Promise<number | undefined> {
try {
return (await this.core.databaseFileAccess.getConflictedRevs(filename)).length > 0;
const conflictCount = (await this.core.databaseFileAccess.getConflictedRevs(filename)).length;
return conflictCount === 0 ? 0 : conflictCount + 1;
} catch (error) {
this._log(`Could not inspect the conflict state of ${filename}`, LOG_LEVEL_VERBOSE);
this._log(error, LOG_LEVEL_VERBOSE);
@@ -35,19 +36,26 @@ export class ModuleInteractiveConflictResolver extends AbstractObsidianModule {
private async getActiveConflictMessages(): Promise<string[]> {
const filename = this.services.vault.getActiveFilePath();
if (!filename) return [];
const conflicted = await this.isConflicted(filename);
if (conflicted === false) {
const versionCount = await this.getConflictVersionCount(filename);
if (versionCount === 0) {
this.postponedConflictEpisodes.delete(filename);
return [];
}
if (conflicted === true || this.postponedConflictEpisodes.has(filename)) {
if (versionCount !== undefined && versionCount >= 3) {
return [
$msg("This file has ${COUNT} unresolved versions. They will be reviewed one pair at a time.", {
COUNT: `${versionCount}`,
}),
];
}
if (versionCount === 2 || this.postponedConflictEpisodes.has(filename)) {
return [$msg("This file has unresolved conflicts.")];
}
return [];
}
private async refreshConflictState(filename: FilePathWithPrefix): Promise<void> {
if ((await this.isConflicted(filename)) === false) {
if ((await this.getConflictVersionCount(filename)) === 0) {
this.postponedConflictEpisodes.delete(filename);
}
eventHub.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
@@ -115,8 +123,21 @@ export class ModuleInteractiveConflictResolver extends AbstractObsidianModule {
this._log(`Merge: Could not read ${filename} from the local database`, LOG_LEVEL_VERBOSE);
return false;
}
if (!testDoc._conflicts) {
if (!testDoc._conflicts || testDoc._conflicts.length === 0) {
this._log(`Merge: Nothing to do ${filename}`, LOG_LEVEL_VERBOSE);
await this.refreshConflictState(filename);
return false;
}
if (
testDoc._rev !== conflictCheckResult.left.rev ||
!testDoc._conflicts.includes(conflictCheckResult.right.rev)
) {
this._log(
`Merge: The compared revisions changed while the dialogue was open: ${filename}`,
LOG_LEVEL_INFO
);
await this.refreshConflictState(filename);
await this.services.conflict.queueCheckFor(filename);
return false;
}
const toDelete = selected;
@@ -125,7 +146,7 @@ export class ModuleInteractiveConflictResolver extends AbstractObsidianModule {
// Concatenate both conflicted revisions.
// Create a new file by concatenating both conflicted revisions.
const p = conflictCheckResult.diff.map((e) => e[1]).join("");
const delRev = testDoc._conflicts[0];
const delRev = conflictCheckResult.right.rev;
if (!(await this.core.databaseFileAccess.storeContent(filename, p))) {
this._log(`Concatenated content cannot be stored:${filename}`, LOG_LEVEL_NOTICE);
return false;
@@ -141,7 +162,10 @@ export class ModuleInteractiveConflictResolver extends AbstractObsidianModule {
);
return false;
}
} else if (typeof toDelete === "string") {
} else if (
typeof toDelete === "string" &&
(toDelete === conflictCheckResult.left.rev || toDelete === conflictCheckResult.right.rev)
) {
// Select one of the conflicted revision to delete.
if (
(await this.services.conflict.resolveByDeletingRevision(filename, toDelete, "UI Selected")) ==
@@ -151,7 +175,7 @@ export class ModuleInteractiveConflictResolver extends AbstractObsidianModule {
return false;
}
} else {
this._log(`Merge: Something went wrong: ${filename}, (${toDelete as string})`, LOG_LEVEL_NOTICE);
this._log(`Merge: Something went wrong: ${filename}, (${String(toDelete)})`, LOG_LEVEL_NOTICE);
return false;
}
// In here, some merge has been processed.
@@ -166,10 +190,13 @@ export class ModuleInteractiveConflictResolver extends AbstractObsidianModule {
});
}
async allConflictCheck() {
while (await this.pickFileForResolve());
let notifyIfEmpty = true;
while (await this.pickFileForResolve(notifyIfEmpty)) {
notifyIfEmpty = false;
}
}
async pickFileForResolve() {
async pickFileForResolve(notifyIfEmpty = true) {
const notes: { id: DocumentID; path: FilePathWithPrefix; dispPath: string; mtime: number }[] = [];
for await (const doc of this.localDatabase.findAllDocs({ conflicts: true })) {
if (!("_conflicts" in doc)) continue;
@@ -183,7 +210,9 @@ export class ModuleInteractiveConflictResolver extends AbstractObsidianModule {
notes.sort((a, b) => b.mtime - a.mtime);
const notesList = notes.map((e) => e.dispPath);
if (notesList.length == 0) {
this._log("There are no conflicted documents", LOG_LEVEL_NOTICE);
if (notifyIfEmpty) {
this._log("There are no conflicted documents", LOG_LEVEL_NOTICE);
}
return false;
}
const target = await this.core.confirm.askSelectString("File to resolve conflict", notesList);
@@ -1,7 +1,10 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
AUTO_MERGED,
CANCELLED,
DEFAULT_SETTINGS,
LEAVE_TO_SUBSEQUENT,
LOG_LEVEL_NOTICE,
type FilePathWithPrefix,
type diff_result,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
@@ -40,6 +43,12 @@ const conflict: diff_result = {
diff: [],
};
async function* documents(items: unknown[]) {
for (const item of items) {
yield item;
}
}
function createModule(conflictedRevisions: string[] = ["2-right"]) {
const handlers = {
unresolvedMessages: undefined as undefined | (() => Promise<string[]>),
@@ -64,24 +73,28 @@ function createModule(conflictedRevisions: string[] = ["2-right"]) {
},
conflict: {
resolveByUserInteraction: { addHandler: vi.fn() },
resolveByDeletingRevision: vi.fn(),
resolveByDeletingRevision: vi.fn(async () => AUTO_MERGED),
queueCheckFor: vi.fn(async () => undefined),
ensureAllProcessed: vi.fn(async () => true),
},
replication: { replicateByEvent: vi.fn(async () => true) },
vault: { getActiveFilePath: vi.fn(() => path) },
path: { getPath: vi.fn(), getPathWithoutPrefix: vi.fn() },
path: { getPath: vi.fn((entry: { path: FilePathWithPrefix }) => entry.path) },
};
const core = {
_services: services,
services,
settings: { ...DEFAULT_SETTINGS, syncAfterMerge: false },
localDatabase: {
getDBEntry: vi.fn(async () => false),
findAllDocs: vi.fn(),
getDBEntry: vi.fn(async (): Promise<false | { _rev: string; _conflicts?: string[] }> => false),
findAllDocs: vi.fn(() => documents([])),
},
databaseFileAccess: {
getConflictedRevs: vi.fn(async () => conflictedRevisions),
storeContent: vi.fn(async () => true),
},
confirm: {
askSelectString: vi.fn(async (): Promise<string | undefined> => undefined),
},
};
const plugin = { app: {} };
@@ -158,4 +171,111 @@ describe("ModuleInteractiveConflictResolver postponement", () => {
conflictedRevisions.splice(0);
await expect(handlers.unresolvedMessages?.()).resolves.toEqual([]);
});
it("reports the number of live versions and reduces it after each resolved pair", async () => {
const conflictedRevisions = ["2-second", "2-third"];
const { core, handlers, module, services } = createModule(conflictedRevisions);
module.onBindFunction(core as never, services as never);
await expect(handlers.unresolvedMessages?.()).resolves.toEqual([
"This file has 3 unresolved versions. They will be reviewed one pair at a time.",
]);
conflictedRevisions.shift();
await (module as any).refreshConflictState(path);
await expect(handlers.unresolvedMessages?.()).resolves.toEqual(["This file has unresolved conflicts."]);
conflictedRevisions.shift();
await (module as any).refreshConflictState(path);
await expect(handlers.unresolvedMessages?.()).resolves.toEqual([]);
});
it("reconstructs the remaining pair after a postponed session is restarted", async () => {
const conflictedRevisions = ["2-second", "2-third"];
const firstSession = createModule(conflictedRevisions);
await firstSession.module._anyResolveConflictByUI(path, conflict);
conflictedRevisions.shift();
const restartedSession = createModule(conflictedRevisions);
restartedSession.module.onBindFunction(restartedSession.core as never, restartedSession.services as never);
await expect(restartedSession.handlers.unresolvedMessages?.()).resolves.toEqual([
"This file has unresolved conflicts.",
]);
await restartedSession.module._anyResolveConflictByUI(path, {
left: { rev: "3-merged", data: "merged", ctime: 1, mtime: 3 },
right: { rev: "2-third", data: "third", ctime: 1, mtime: 2 },
diff: [],
});
expect(modalState.constructed).toBe(2);
});
it("deletes the compared right leaf when concatenating a deterministically selected pair", async () => {
const { core, module, services } = createModule(["2-unrelated", "2-right"]);
modalState.result = LEAVE_TO_SUBSEQUENT;
core.localDatabase.getDBEntry.mockResolvedValue({
_rev: "2-left",
_conflicts: ["2-unrelated", "2-right"],
});
await module._anyResolveConflictByUI(path, conflict);
expect(core.databaseFileAccess.storeContent).toHaveBeenCalledWith(path, "");
expect(services.conflict.resolveByDeletingRevision).toHaveBeenCalledWith(path, "2-right", "UI Concatenated");
});
it("rechecks the live leaves instead of applying a stale dialogue selection", async () => {
const { core, module, services } = createModule(["2-other"]);
modalState.result = "2-right";
core.localDatabase.getDBEntry.mockResolvedValue({
_rev: "3-new-winner",
_conflicts: ["2-other"],
});
await module._anyResolveConflictByUI(path, conflict);
expect(services.conflict.resolveByDeletingRevision).not.toHaveBeenCalled();
expect(services.conflict.queueCheckFor).toHaveBeenCalledWith(path);
});
});
describe("ModuleInteractiveConflictResolver file selection", () => {
beforeEach(() => {
modalState.constructed = 0;
modalState.result = modalState.postponed;
});
it("does not show a no-conflicts notice when an automatic repeat reaches its normal end", async () => {
const { core, module } = createModule();
core.localDatabase.findAllDocs
.mockImplementationOnce(() =>
documents([
{
_id: "note-id",
_rev: "2-left",
_conflicts: ["2-right"],
path,
mtime: 2,
},
])
)
.mockImplementationOnce(() => documents([]));
core.confirm.askSelectString.mockResolvedValue(path);
await module.allConflictCheck();
expect(core.confirm.askSelectString).toHaveBeenCalledOnce();
expect(module._log).not.toHaveBeenCalledWith("There are no conflicted documents", LOG_LEVEL_NOTICE);
});
it("shows one no-conflicts notice for an explicit selection request which starts empty", async () => {
const { module } = createModule();
await module.pickFileForResolve();
expect(module._log).toHaveBeenCalledTimes(1);
expect(module._log).toHaveBeenCalledWith("There are no conflicted documents", LOG_LEVEL_NOTICE);
});
});