ConflictResolutionService

An actor that finds the duplicate .automerge files folder sync services create on concurrent edits, and merges them back into the primary document via Automerge’s CRDT merge.

Source: Shared/Sources/MinutaShared/Services/ConflictResolutionService.swift.

The problem

Minuta syncs by dropping its .automerge files into a folder synced by Dropbox, iCloud Drive, or Google Drive. When two devices edit the same file before it propagates, the sync service does not overwrite — it keeps both copies under a renamed sibling:

  • Dropbox: 04 (conflicted copy 2026-04-22).automerge
  • Google Drive: 04 (1).automerge
  • iCloud: 04 2.automerge (numeric suffix)

Left alone, these copies are invisible to the app and their edits are silently stranded. Because Automerge documents merge without loss, the fix is to merge each conflict copy back into its primary and delete the copy.

See Automerge storage architecture for why the merge is lossless.

How it works

resolveConflicts() enumerates every .automerge file under the storage folder, groups them by their normalized base name, and processes every group that has at least one conflict copy, whether or not its base file exists.

Grouping strips the known conflict suffixes via regular expressions, so all variants of a file collapse to the same group key:

private let conflictPatterns = [
    #"\s*\(conflicted copy[^)]*\)"#,  // Dropbox
    #"\s*\(\d+\)"#,                    // Google Drive
    #"\s+\d+$"#,                       // iCloud numeric suffix
    #"\s*\(conflict[^)]*\)"#           // Generic
]

For each group, the primary (base file) is the path with the stripped name, for example 04.automerge; a member is a conflict copy when stripping changes its name. The copies are merged into the base file under the storage write lock:

let outcome = try StorageFileLock.withExclusiveLock(at: storageURL) {
    try mergeConflictFiles(copies, into: URL(fileURLWithPath: basePath))
}

Inside the lock, mergeConflictFiles:

  1. Re-checks which copies of the group still exist. The scan runs without the lock, so another resolver (the app and minuta conflicts resolve at the same time) may have merged and removed some of them since.
  2. Loads every copy through AutomergeDocument.load(from:). A copy that cannot be read (zero bytes included) is left on disk and reported as an error; the remaining copies go on.
  3. Checks, under the lock, whether the base file exists (a writer may have created it since the scan) and takes one of the two paths below.
  4. Merges the readable copies into the base document, commits and atomically rewrites the base file.
  5. Only then deletes the copies that merged. A copy that cannot be removed is reported as an error.

The base file exists

It is loaded through AutomergeDocument.load(from:), the same loader storage uses, so a zero-byte file is unreadable here too (a sync placeholder is not an empty document). A base file that cannot be read fails the whole group: nothing is written, nothing is removed, and the copies stay until the file reads again. The group’s only error names the file:

04.automerge cannot be read (Deserialization failed: the file is empty); it was not changed and its 2 conflict copy file(s) were left in place.

So the rule “a file that cannot be read is never overwritten, renamed or deleted” holds for the resolver as it does for AutomergeStorageService.

The base file is missing

A group can consist of copies only: the base file was removed, or never arrived. Storage opens files by their exact names, so nothing would ever read such copies. The resolver promotes one: the newest readable copy (by modification date, by name among equal dates) is renamed to the base name with moveItem, which fails instead of replacing if a file is there. The remaining readable copies are merged into it as usual. If no copy is readable, nothing is renamed and the errors are reported.

This applies to any .automerge file under the storage folder whose name matches a conflict pattern, not only to month and tags files. A lone 04.recovery (1).automerge, for example, becomes 04.recovery.automerge.

The merge is commutative and idempotent — order does not matter and re-running on already-merged state is a no-op. The lock is taken once per group that has conflict copies, never for the scan, so a folder without conflicts does not wait on a writer. The lock is not re-entrant: resolveConflicts() must not be called from inside another withExclusiveLock block. See Cross-process safety.

API surface

  • resolveConflicts() → ResolutionResult: filesProcessed (every .automerge file scanned), conflictsResolved (copies merged into a base file, plus one for each copy promoted to a missing base file) and errors. An error on one copy or one group does not abort the others.
  • previewConflicts() → [ConflictGroup] — scans and reports the groups that would be processed without touching disk. A group has primary (the base file’s URL), conflicts (the copies) and primaryExists, which is false when only copies are on disk; primary is then the path the newest readable copy would be renamed to. Backs the CLI’s conflicts resolve --dry-run.

When it runs

  • App: invoked on launch, on scene becoming active, and whenever the ExternalChangeMonitor detects an external write (resolveConflictsIfNeeded() in AppState).
  • CLI: minuta conflicts resolve surfaces it for headless/cron use. See the CLI reference.