mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-26 19:13:20 +00:00
Refactor conflict resolution into service features
This commit is contained in:
1 parent
3b2d5aa5af
commit
6ea906b575
19 files changed
+2322
-1240
No files matched your search
@@ -1,82 +0,0 @@
|
||||
import { AbstractModule } from "@/modules/AbstractModule.ts";
|
||||
import { LOG_LEVEL_NOTICE, type FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { QueueProcessor } from "octagonal-wheels/concurrency/processor";
|
||||
import { sendValue } from "octagonal-wheels/messagepassing/signal";
|
||||
import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
|
||||
import type { LiveSyncCore } from "@/main.ts";
|
||||
|
||||
export class ModuleConflictChecker extends AbstractModule {
|
||||
async _queueConflictCheckIfOpen(file: FilePathWithPrefix): Promise<void> {
|
||||
const path = file;
|
||||
if (this.settings.checkConflictOnlyOnOpen) {
|
||||
const af = this.services.vault.getActiveFilePath();
|
||||
if (af && af != path) {
|
||||
this._log(`${file} is conflicted, merging process has been postponed.`, LOG_LEVEL_NOTICE);
|
||||
return;
|
||||
}
|
||||
}
|
||||
await this.services.conflict.queueCheckFor(path);
|
||||
}
|
||||
|
||||
async _queueConflictCheck(file: FilePathWithPrefix): Promise<void> {
|
||||
const optionalConflictResult = await this.services.conflict.getOptionalConflictCheckMethod(file);
|
||||
if (optionalConflictResult == true) {
|
||||
// The conflict has been resolved by another process.
|
||||
return;
|
||||
} else if (optionalConflictResult === "newer") {
|
||||
// The conflict should be resolved by the newer entry.
|
||||
await this.services.conflict.resolveByNewest(file);
|
||||
} else {
|
||||
this.conflictCheckQueue.enqueue(file);
|
||||
}
|
||||
}
|
||||
|
||||
_waitForAllConflictProcessed(): Promise<boolean> {
|
||||
return this.conflictResolveQueue.waitForAllProcessed();
|
||||
}
|
||||
|
||||
// TODO-> Move to ModuleConflictResolver?
|
||||
conflictResolveQueue = new QueueProcessor(
|
||||
async (filenames: FilePathWithPrefix[]) => {
|
||||
const filename = filenames[0];
|
||||
return await this.services.conflict.resolve(filename);
|
||||
},
|
||||
{
|
||||
suspended: false,
|
||||
batchSize: 1,
|
||||
// No need to limit concurrency to `1` here, subsequent process will handle it,
|
||||
// And, some cases, we do not need to synchronised. (e.g., auto-merge available).
|
||||
// Therefore, limiting global concurrency is performed on resolver with the UI.
|
||||
concurrentLimit: 10,
|
||||
delay: 0,
|
||||
keepResultUntilDownstreamConnected: false,
|
||||
}
|
||||
).replaceEnqueueProcessor((queue, newEntity) => {
|
||||
const filename = newEntity;
|
||||
sendValue("cancel-resolve-conflict:" + filename, true);
|
||||
const newQueue = [...queue].filter((e) => e != newEntity);
|
||||
return [...newQueue, newEntity];
|
||||
});
|
||||
|
||||
conflictCheckQueue = // First process - Check is the file actually need resolve -
|
||||
new QueueProcessor(
|
||||
(files: FilePathWithPrefix[]) => {
|
||||
const filename = files[0];
|
||||
return Promise.resolve([filename]);
|
||||
},
|
||||
{
|
||||
suspended: false,
|
||||
batchSize: 1,
|
||||
concurrentLimit: 10,
|
||||
delay: 0,
|
||||
keepResultUntilDownstreamConnected: true,
|
||||
pipeTo: this.conflictResolveQueue,
|
||||
totalRemainingReactiveSource: this.services.conflict.conflictProcessQueueCount,
|
||||
}
|
||||
);
|
||||
override onBindFunction(core: LiveSyncCore, services: InjectableServiceHub): void {
|
||||
services.conflict.queueCheckForIfOpen.setHandler(this._queueConflictCheckIfOpen.bind(this));
|
||||
services.conflict.queueCheckFor.setHandler(this._queueConflictCheck.bind(this));
|
||||
services.conflict.ensureAllProcessed.setHandler(this._waitForAllConflictProcessed.bind(this));
|
||||
}
|
||||
}
|
||||
@@ -1,240 +0,0 @@
|
||||
import { serialized } from "octagonal-wheels/concurrency/lock";
|
||||
import { AbstractModule } from "@/modules/AbstractModule.ts";
|
||||
import {
|
||||
AUTO_MERGED,
|
||||
CANCELLED,
|
||||
LOG_LEVEL_INFO,
|
||||
LOG_LEVEL_NOTICE,
|
||||
LOG_LEVEL_VERBOSE,
|
||||
MISSING_OR_ERROR,
|
||||
NOT_CONFLICTED,
|
||||
type diff_check_result,
|
||||
type FilePathWithPrefix,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { isCustomisationSyncMetadata, isPluginMetadata } from "@vrtmrz/livesync-commonlib/compat/common/typeUtils";
|
||||
import { TARGET_IS_NEW } from "@vrtmrz/livesync-commonlib/compat/common/models/shared.const.symbols";
|
||||
import { compareMTime, displayRev } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import diff_match_patch from "diff-match-patch";
|
||||
import { stripAllPrefixes, isPlainText } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
|
||||
import { EVENT_CONFLICT_CANCELLED, eventHub } from "@/common/events.ts";
|
||||
import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
|
||||
import type { LiveSyncCore } from "@/main.ts";
|
||||
import { NO_INTERACTION } from "@vrtmrz/livesync-commonlib/replication";
|
||||
|
||||
export class ModuleConflictResolver extends AbstractModule {
|
||||
private async _resolveConflictByDeletingRev(
|
||||
path: FilePathWithPrefix,
|
||||
deleteRevision: string,
|
||||
subTitle = "",
|
||||
showNotice = true
|
||||
): Promise<typeof MISSING_OR_ERROR | typeof AUTO_MERGED> {
|
||||
const title = `Resolving ${subTitle ? `[${subTitle}]` : ""}:`;
|
||||
if (!(await this.core.fileHandler.deleteRevisionFromDB(path, deleteRevision))) {
|
||||
this._log(
|
||||
`${title} Could not delete conflicted revision ${displayRev(deleteRevision)} of ${path}`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
return MISSING_OR_ERROR;
|
||||
}
|
||||
eventHub.emitEvent(EVENT_CONFLICT_CANCELLED, path);
|
||||
this._log(
|
||||
`${title} Conflicted revision has been deleted ${displayRev(deleteRevision)} ${path}`,
|
||||
LOG_LEVEL_INFO
|
||||
);
|
||||
if ((await this.core.databaseFileAccess.getConflictedRevs(path)).length != 0) {
|
||||
this._log(`${title} some conflicts are left in ${path}`, LOG_LEVEL_INFO);
|
||||
return AUTO_MERGED;
|
||||
}
|
||||
if (isPluginMetadata(path) || isCustomisationSyncMetadata(path)) {
|
||||
this._log(`${title} ${path} is a plugin metadata file, no need to write to storage`, LOG_LEVEL_INFO);
|
||||
return AUTO_MERGED;
|
||||
}
|
||||
// If no conflicts were found, write the resolved content to the storage.
|
||||
if (!(await this.core.fileHandler.dbToStorage(path, stripAllPrefixes(path), true))) {
|
||||
this._log(`Could not write the resolved content to the storage: ${path}`, LOG_LEVEL_NOTICE);
|
||||
return MISSING_OR_ERROR;
|
||||
}
|
||||
const level = subTitle.indexOf("same") !== -1 || !showNotice ? LOG_LEVEL_INFO : LOG_LEVEL_NOTICE;
|
||||
this._log(`${path} has been merged automatically`, level);
|
||||
return AUTO_MERGED;
|
||||
}
|
||||
|
||||
async checkConflictAndPerformAutoMerge(path: FilePathWithPrefix): Promise<diff_check_result> {
|
||||
//
|
||||
const ret = await this.localDatabase.tryAutoMerge(path, !this.settings.disableMarkdownAutoMerge);
|
||||
if ("ok" in ret) {
|
||||
return ret.ok;
|
||||
}
|
||||
|
||||
if ("result" in ret) {
|
||||
const p = ret.result;
|
||||
// Merged content is coming.
|
||||
// 1. Store the merged content to the storage
|
||||
if (!(await this.core.databaseFileAccess.storeContent(path, p))) {
|
||||
this._log(`Merged content cannot be stored:${path}`, LOG_LEVEL_NOTICE);
|
||||
return MISSING_OR_ERROR;
|
||||
}
|
||||
// 2. As usual, delete the conflicted revision and if there are no conflicts, write the resolved content to the storage.
|
||||
return await this.services.conflict.resolveByDeletingRevision(path, ret.conflictedRev, "Sensible");
|
||||
}
|
||||
|
||||
const { rightRev, leftLeaf, rightLeaf } = ret;
|
||||
|
||||
// should be one or more conflicts;
|
||||
if (leftLeaf == false) {
|
||||
// what's going on..
|
||||
this._log(`could not get current revisions:${path}`, LOG_LEVEL_NOTICE);
|
||||
return MISSING_OR_ERROR;
|
||||
}
|
||||
if (rightLeaf == false) {
|
||||
// A locally unreadable conflict leaf may still be recoverable from another
|
||||
// replica or backup. Keep it visible for explicit repair instead of treating
|
||||
// missing chunks as evidence that the branch is obsolete.
|
||||
this._log(`could not read conflicted revision ${rightRev}:${path}`, LOG_LEVEL_NOTICE);
|
||||
return MISSING_OR_ERROR;
|
||||
}
|
||||
|
||||
const isSame = leftLeaf.data == rightLeaf.data && leftLeaf.deleted == rightLeaf.deleted;
|
||||
const isBinary = !isPlainText(path);
|
||||
const alwaysNewer = this.settings.resolveConflictsByNewerFile;
|
||||
if (isSame || isBinary || alwaysNewer) {
|
||||
const result = compareMTime(leftLeaf.mtime, rightLeaf.mtime);
|
||||
let loser = leftLeaf;
|
||||
// if (lMtime > rMtime) {
|
||||
if (result != TARGET_IS_NEW) {
|
||||
loser = rightLeaf;
|
||||
}
|
||||
const subTitle = [
|
||||
`${isSame ? "same" : ""}`,
|
||||
`${isBinary ? "binary" : ""}`,
|
||||
`${alwaysNewer ? "alwaysNewer" : ""}`,
|
||||
]
|
||||
.filter((e) => e.trim())
|
||||
.join(",");
|
||||
return await this.services.conflict.resolveByDeletingRevision(path, loser.rev, subTitle);
|
||||
}
|
||||
// make diff.
|
||||
const dmp = new diff_match_patch();
|
||||
const diff = dmp.diff_main(leftLeaf.data, rightLeaf.data);
|
||||
dmp.diff_cleanupSemantic(diff);
|
||||
this._log(`conflict(s) found:${path}`);
|
||||
return {
|
||||
left: leftLeaf,
|
||||
right: rightLeaf,
|
||||
diff: diff,
|
||||
};
|
||||
}
|
||||
|
||||
private async _resolveConflict(filename: FilePathWithPrefix): Promise<void> {
|
||||
// const filename = filenames[0];
|
||||
return await serialized(`conflict-resolve:${filename}`, async () => {
|
||||
const conflictCheckResult = await this.checkConflictAndPerformAutoMerge(filename);
|
||||
if (conflictCheckResult === NOT_CONFLICTED) {
|
||||
eventHub.emitEvent(EVENT_CONFLICT_CANCELLED, filename);
|
||||
this._log(`[conflict] Not conflicted or cancelled: ${filename}`, LOG_LEVEL_VERBOSE);
|
||||
return;
|
||||
}
|
||||
if (conflictCheckResult === MISSING_OR_ERROR || conflictCheckResult === CANCELLED) {
|
||||
// nothing to do.
|
||||
this._log(`[conflict] Not conflicted or cancelled: ${filename}`, LOG_LEVEL_VERBOSE);
|
||||
return;
|
||||
}
|
||||
if (conflictCheckResult === AUTO_MERGED) {
|
||||
//auto resolved, but need check again;
|
||||
if (this.settings.syncAfterMerge && !this.services.appLifecycle.isSuspended()) {
|
||||
//Wait for the running replication, if not running replication, run it once.
|
||||
await this.services.replication.replicateUnattendedByEvent({
|
||||
trigger: "merge",
|
||||
interaction: NO_INTERACTION,
|
||||
});
|
||||
}
|
||||
this._log("[conflict] Automatically merged, but we have to check it again");
|
||||
await this.services.conflict.queueCheckFor(filename);
|
||||
return;
|
||||
}
|
||||
if (this.settings.showMergeDialogOnlyOnActive) {
|
||||
const af = this.services.vault.getActiveFilePath();
|
||||
if (af && af != filename) {
|
||||
this._log(
|
||||
`[conflict] ${filename} is conflicted. Merging process has been postponed to the file have got opened.`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
this._log("[conflict] Manual merge required!");
|
||||
eventHub.emitEvent(EVENT_CONFLICT_CANCELLED, filename);
|
||||
await this.services.conflict.resolveByUserInteraction(filename, conflictCheckResult);
|
||||
});
|
||||
}
|
||||
|
||||
private async _anyResolveConflictByNewest(filename: FilePathWithPrefix, showNotice = true): Promise<boolean> {
|
||||
const currentRev = await this.core.databaseFileAccess.fetchEntryMeta(filename, undefined, true);
|
||||
if (currentRev == false) {
|
||||
this._log(`Could not get current revision of ${filename}`);
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
const revs = await this.core.databaseFileAccess.getConflictedRevs(filename);
|
||||
if (revs.length == 0) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
const mTimeAndRev = (
|
||||
[
|
||||
[currentRev.mtime, currentRev._rev],
|
||||
...(await Promise.all(
|
||||
revs.map(async (rev) => {
|
||||
const leaf = await this.core.databaseFileAccess.fetchEntryMeta(filename, rev);
|
||||
if (leaf == false) {
|
||||
return [0, rev];
|
||||
}
|
||||
return [leaf.mtime, rev];
|
||||
})
|
||||
)),
|
||||
] as [number, string][]
|
||||
).sort((a, b) => {
|
||||
const diff = b[0] - a[0];
|
||||
if (diff == 0) {
|
||||
return a[1].localeCompare(b[1], "en", { numeric: true });
|
||||
}
|
||||
return diff;
|
||||
});
|
||||
// console.warn(mTimeAndRev);
|
||||
this._log(
|
||||
`Resolving conflict by newest: ${filename} (Newest: ${new Date(mTimeAndRev[0][0]).toLocaleString()}) (${mTimeAndRev.length} revisions exists)`
|
||||
);
|
||||
for (let i = 1; i < mTimeAndRev.length; i++) {
|
||||
this._log(
|
||||
`conflict: Deleting the older revision ${mTimeAndRev[i][1]} (${new Date(mTimeAndRev[i][0]).toLocaleString()}) of ${filename}`
|
||||
);
|
||||
await this._resolveConflictByDeletingRev(filename, mTimeAndRev[i][1], "NEWEST", showNotice);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
private async _resolveAllConflictedFilesByNewerOnes() {
|
||||
this._log(`Resolving conflicts by newer ones`, LOG_LEVEL_NOTICE);
|
||||
|
||||
const files = await this.core.storageAccess.getFileNames();
|
||||
|
||||
let i = 0;
|
||||
for (const file of files) {
|
||||
i++;
|
||||
if (i % 10 === 0)
|
||||
this._log(
|
||||
`Check and Processing ${i} / ${files.length}`,
|
||||
LOG_LEVEL_NOTICE,
|
||||
"resolveAllConflictedFilesByNewerOnes"
|
||||
);
|
||||
await this._anyResolveConflictByNewest(file, false);
|
||||
}
|
||||
this._log(`Done!`, LOG_LEVEL_NOTICE, "resolveAllConflictedFilesByNewerOnes");
|
||||
}
|
||||
|
||||
override onBindFunction(core: LiveSyncCore, services: InjectableServiceHub): void {
|
||||
services.conflict.resolveByDeletingRevision.setHandler(this._resolveConflictByDeletingRev.bind(this));
|
||||
services.conflict.resolve.setHandler(this._resolveConflict.bind(this));
|
||||
services.conflict.resolveByNewest.setHandler(this._anyResolveConflictByNewest.bind(this));
|
||||
services.conflict.resolveAllConflictedFilesByNewerOnes.setHandler(
|
||||
this._resolveAllConflictedFilesByNewerOnes.bind(this)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,268 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
AUTO_MERGED,
|
||||
DEFAULT_SETTINGS,
|
||||
LOG_LEVEL_INFO,
|
||||
LOG_LEVEL_NOTICE,
|
||||
MISSING_OR_ERROR,
|
||||
type FilePathWithPrefix,
|
||||
type MetaEntry,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
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: {
|
||||
addLog: vi.fn(),
|
||||
addCommand: vi.fn(),
|
||||
registerWindow: vi.fn(),
|
||||
addRibbonIcon: vi.fn(),
|
||||
registerProtocolHandler: vi.fn(),
|
||||
},
|
||||
setting: {
|
||||
saveSettingData: vi.fn(async () => undefined),
|
||||
},
|
||||
conflict: {
|
||||
resolveByNewest: vi.fn(async () => true),
|
||||
resolveByDeletingRevision,
|
||||
resolveByUserInteraction,
|
||||
queueCheckFor,
|
||||
},
|
||||
appLifecycle: {
|
||||
isSuspended: vi.fn(() => false),
|
||||
},
|
||||
replication: {
|
||||
replicateUnattendedByEvent: vi.fn(async () => ({ status: "completed" as const })),
|
||||
},
|
||||
vault: {
|
||||
getActiveFilePath: vi.fn(() => undefined),
|
||||
},
|
||||
},
|
||||
settings: DEFAULT_SETTINGS,
|
||||
fileHandler: {
|
||||
deleteRevisionFromDB: vi.fn(async () => true),
|
||||
dbToStorage: vi.fn(async () => true),
|
||||
},
|
||||
databaseFileAccess: {
|
||||
getConflictedRevs: vi.fn(async () => []),
|
||||
storeContent: vi.fn(async () => true),
|
||||
},
|
||||
localDatabase: {
|
||||
tryAutoMerge,
|
||||
},
|
||||
storageAccess: {
|
||||
getFileNames: vi.fn(async () => files),
|
||||
},
|
||||
} as any;
|
||||
Object.defineProperty(core, "services", { get: () => core._services });
|
||||
|
||||
const module = new ModuleConflictResolver(core);
|
||||
module._log = vi.fn();
|
||||
return { module, queueCheckFor, resolveByDeletingRevision, resolveByUserInteraction, tryAutoMerge };
|
||||
}
|
||||
|
||||
describe("ModuleConflictResolver bulk newest resolution", () => {
|
||||
it("retains the success notice for a non-bulk newest resolution", async () => {
|
||||
const { module } = createModule();
|
||||
const path = "example.md" as FilePathWithPrefix;
|
||||
module.core.databaseFileAccess.fetchEntryMeta = vi.fn(
|
||||
async (_path: unknown, rev?: string): Promise<MetaEntry> =>
|
||||
({
|
||||
_id: "doc-id",
|
||||
_rev: rev ?? "2-current",
|
||||
path,
|
||||
ctime: 1,
|
||||
mtime: rev ? 1 : 2,
|
||||
size: 0,
|
||||
children: [],
|
||||
type: "plain",
|
||||
eden: {},
|
||||
}) as unknown as MetaEntry
|
||||
);
|
||||
module.core.databaseFileAccess.getConflictedRevs = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(["1-old"])
|
||||
.mockResolvedValue([]);
|
||||
|
||||
await (module as any)._anyResolveConflictByNewest(path);
|
||||
|
||||
expect(module._log).toHaveBeenLastCalledWith(`${path} has been merged automatically`, LOG_LEVEL_NOTICE);
|
||||
});
|
||||
|
||||
it("logs a successful bulk newest resolution without displaying a notice", async () => {
|
||||
const { module } = createModule();
|
||||
const path = "example.md" as FilePathWithPrefix;
|
||||
module.core.databaseFileAccess.fetchEntryMeta = vi.fn(
|
||||
async (_path: unknown, rev?: string): Promise<MetaEntry> =>
|
||||
({
|
||||
_id: "doc-id",
|
||||
_rev: rev ?? "2-current",
|
||||
path,
|
||||
ctime: 1,
|
||||
mtime: rev ? 1 : 2,
|
||||
size: 0,
|
||||
children: [],
|
||||
type: "plain",
|
||||
eden: {},
|
||||
}) as unknown as MetaEntry
|
||||
);
|
||||
module.core.databaseFileAccess.getConflictedRevs = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(["1-old"])
|
||||
.mockResolvedValue([]);
|
||||
|
||||
await (module as any)._anyResolveConflictByNewest(path, false);
|
||||
|
||||
expect(module._log).toHaveBeenLastCalledWith(`${path} has been merged automatically`, LOG_LEVEL_INFO);
|
||||
});
|
||||
|
||||
it("updates notice-level progress once every ten checked files", async () => {
|
||||
const files = Array.from({ length: 11 }, (_, index) => `note-${index}.md` as FilePathWithPrefix);
|
||||
const { module } = createModule(files);
|
||||
const resolveByNewest = vi.spyOn(module as any, "_anyResolveConflictByNewest").mockResolvedValue(true);
|
||||
|
||||
await (module as any)._resolveAllConflictedFilesByNewerOnes();
|
||||
|
||||
expect(resolveByNewest).toHaveBeenCalledTimes(11);
|
||||
expect(resolveByNewest).toHaveBeenCalledWith(files[0], false);
|
||||
expect(module._log).toHaveBeenCalledWith(
|
||||
"Check and Processing 10 / 11",
|
||||
LOG_LEVEL_NOTICE,
|
||||
"resolveAllConflictedFilesByNewerOnes"
|
||||
);
|
||||
expect(module._log).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ModuleConflictResolver independent same-path creation", () => {
|
||||
const path = "independently-created.md" as FilePathWithPrefix;
|
||||
|
||||
function leaf(rev: string, data: string, mtime: number) {
|
||||
return {
|
||||
rev,
|
||||
data,
|
||||
mtime,
|
||||
ctime: mtime,
|
||||
deleted: false,
|
||||
} as any;
|
||||
}
|
||||
|
||||
it("collapses one duplicate revision when independently created files have identical content", async () => {
|
||||
const { module, resolveByDeletingRevision, tryAutoMerge } = createModule();
|
||||
const leftLeaf = leaf("1-left", "Same content\n", 1000);
|
||||
const rightLeaf = leaf("1-right", "Same content\n", 2000);
|
||||
tryAutoMerge.mockResolvedValue({
|
||||
leftRev: leftLeaf.rev,
|
||||
rightRev: rightLeaf.rev,
|
||||
leftLeaf,
|
||||
rightLeaf,
|
||||
});
|
||||
|
||||
const result = await module.checkConflictAndPerformAutoMerge(path);
|
||||
|
||||
expect(result).toBe(AUTO_MERGED);
|
||||
expect(resolveByDeletingRevision).toHaveBeenCalledOnce();
|
||||
expect(resolveByDeletingRevision).toHaveBeenCalledWith(path, "1-left", "same");
|
||||
});
|
||||
|
||||
it("returns a manual diff when independently created files have different content", async () => {
|
||||
const { module, resolveByDeletingRevision, tryAutoMerge } = createModule();
|
||||
const leftLeaf = leaf("1-left", "Left content\n", 1000);
|
||||
const rightLeaf = leaf("1-right", "Right content\n", 2000);
|
||||
tryAutoMerge.mockResolvedValue({
|
||||
leftRev: leftLeaf.rev,
|
||||
rightRev: rightLeaf.rev,
|
||||
leftLeaf,
|
||||
rightLeaf,
|
||||
});
|
||||
|
||||
const result = await module.checkConflictAndPerformAutoMerge(path);
|
||||
|
||||
expect(result).toMatchObject({ left: leftLeaf, right: rightLeaf });
|
||||
expect(result).toHaveProperty("diff");
|
||||
expect(resolveByDeletingRevision).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ModuleConflictResolver sensible merge hand-off", () => {
|
||||
it("keeps an unreadable non-winner revision unresolved", async () => {
|
||||
const path = "missing-conflict-body.md" as FilePathWithPrefix;
|
||||
const { module, resolveByDeletingRevision, tryAutoMerge } = createModule();
|
||||
tryAutoMerge.mockResolvedValue({
|
||||
leftRev: "3-current",
|
||||
rightRev: "2-unreadable",
|
||||
leftLeaf: {
|
||||
rev: "3-current",
|
||||
data: "Readable current body\n",
|
||||
ctime: 1,
|
||||
mtime: 3,
|
||||
deleted: false,
|
||||
},
|
||||
rightLeaf: false,
|
||||
});
|
||||
|
||||
const result = await module.checkConflictAndPerformAutoMerge(path);
|
||||
|
||||
expect(result).toBe(MISSING_OR_ERROR);
|
||||
expect(resolveByDeletingRevision).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stores the merged body and removes the resolved conflict leaf", async () => {
|
||||
const path = "sensible.md" as FilePathWithPrefix;
|
||||
const { module, resolveByDeletingRevision, tryAutoMerge } = createModule();
|
||||
tryAutoMerge.mockResolvedValue({
|
||||
result: "Title\nLeft changed\nRight changed\n",
|
||||
conflictedRev: "2-right",
|
||||
});
|
||||
|
||||
const result = await module.checkConflictAndPerformAutoMerge(path);
|
||||
|
||||
expect(result).toBe(AUTO_MERGED);
|
||||
expect(module.core.databaseFileAccess.storeContent).toHaveBeenCalledWith(
|
||||
path,
|
||||
"Title\nLeft changed\nRight changed\n"
|
||||
);
|
||||
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,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user