DEV Community

Cover image for Conflict Resolution in a Bidirectional Sync App — How I Handle the Hard Cases
hiyoyo
hiyoyo

Posted on

Conflict Resolution in a Bidirectional Sync App — How I Handle the Hard Cases

All tests run on an 8-year-old MacBook Air. All results from shipping 7 Mac apps as a solo developer. No sponsored opinion.

HiyokoAutoSync does bidirectional sync between Android and Mac. Bidirectional sync has a hard problem: what happens when the same file is modified on both sides? Here's how I handle it.


The conflict cases

  1. File modified on both sides since last sync — which version wins?
  2. File deleted on one side, modified on the other — delete or keep?
  3. File moved on one side — move on the other side or treat as delete + create?

Most sync apps punt on cases 1 and 3. Here's my approach.


Detecting conflicts

Track last-synced state in SQLite:

CREATE TABLE sync_state (
    file_path TEXT PRIMARY KEY,
    mac_hash TEXT,
    android_hash TEXT,
    mac_modified INTEGER,
    android_modified INTEGER,
    last_synced INTEGER
);
Enter fullscreen mode Exit fullscreen mode

On sync check:

fn classify_file(record: &SyncRecord, mac_stat: &FileStat, android_stat: &FileStat) -> SyncAction {
    let mac_changed = mac_stat.hash != record.mac_hash;
    let android_changed = android_stat.hash != record.android_hash;

    match (mac_changed, android_changed) {
        (true, false)  => SyncAction::CopyToAndroid,
        (false, true)  => SyncAction::CopyToMac,
        (false, false) => SyncAction::NoOp,
        (true, true)   => SyncAction::Conflict,
    }
}
Enter fullscreen mode Exit fullscreen mode

Conflict resolution strategies

I offer three strategies, user-configurable:

Newer wins: compare modification timestamps, keep the more recent file.

SyncAction::Conflict => {
    if mac_stat.modified > android_stat.modified {
        SyncAction::CopyToAndroid
    } else {
        SyncAction::CopyToMac
    }
}
Enter fullscreen mode Exit fullscreen mode

Mac always wins: for users who treat Mac as source of truth.

Keep both: rename one file with a conflict suffix, keep both versions.

// Rename Android version to "file.conflict-2026-05-01.ext"
let conflict_name = add_conflict_suffix(&file_path);
copy_to_mac_as(&android_file, &conflict_name)?;
copy_to_android(&mac_file)?;
Enter fullscreen mode Exit fullscreen mode

Delete vs modify conflict

File deleted on Mac, modified on Android:

(Deleted, Modified) => {
    // Default: keep the modified file, restore it on Mac
    // Alternative: delete from both sides
    // User configurable
    SyncAction::RestoreToMac
}
Enter fullscreen mode Exit fullscreen mode

The safe default is to keep data. Deleting across both sides on a conflict can cause data loss users didn't intend.


The verdict

Bidirectional sync without conflict resolution is a bug waiting to happen. The "newer wins" strategy covers 90% of real-world cases. Keep both covers the rest. Make it configurable for power users.


TL;DR: Track sync state (hash + modified time) in SQLite per file. Classify each file into CopyToAndroid, CopyToMac, NoOp, or Conflict using a match on what changed. Offer three strategies: newer wins, Mac wins, or keep both. For delete vs modify conflicts, default to keeping data — accidental deletion is worse than a duplicate.


If this was useful, a ❤️ helps more than you'd think — thanks!

HiyokoAutoSync | X → @hiyoyok

Top comments (0)