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 merges any group with more than one member.

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 is the member whose name is unchanged by the stripping (i.e. not itself a conflict file). Every other member is merged into it:

let primaryDoc = try Document(Data(contentsOf: primaryURL))
for conflictURL in files where conflictURL != primaryURL {
    let conflictDoc = try Document(Data(contentsOf: conflictURL))
    try primaryDoc.merge(other: conflictDoc)
    try fileManager.removeItem(at: conflictURL)
}
primaryDoc.commitWith(timestamp: Date())
try primaryDoc.save().write(to: primaryURL, options: .atomic)

The merge is commutative and idempotent — order does not matter and re-running on already-merged state is a no-op. Resolved conflict files are deleted; the primary is committed and atomically rewritten.

API surface

  • resolveConflicts()ResolutionResult (files processed, conflicts resolved, per-merge errors). Errors on one group do not abort the others.
  • previewConflicts()[ConflictGroup] — scans and reports the groups that would merge without touching disk. 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.

Related