PocketSync Three-way reconciliation for two phone libraries Techlosoft - Phone Bridge WHAT IT IS ========== PocketSync answers the question the rest of the Phone Bridge line does not: "both phones have been used since they last matched, and I want to lose neither - what actually differs, and what should move where?" The family case. Photos were taken on the old phone AND on the new one. Some were edited. Some were renamed. One was deleted. Nobody can remember which. PocketSync compares three things: side A, side B, and a BASELINE - a recorded snapshot of the state the two libraries last agreed on. With three inputs instead of two it can tell the difference between "this file is new on A" and "this file was deleted on B", which a plain two-way comparison cannot. It then produces a plan: what to copy in each direction, what was renamed rather than added, and what genuinely conflicts and needs you. A phone is modelled as a mounted directory tree. Point it at two directories and it works anywhere, on anything. Its sibling MovePhone does a one-way verified transfer: source to destination, nothing to reconcile. PocketSync is the other job entirely - its core is a merge algorithm against a baseline, not a copier. Copying is the small part at the end, and it is opt-in. INSTALL ======= Pre-built binaries are in dist/. There is nothing to install - copy the one for your platform anywhere on your PATH and run it. dist/pocketsync-linux-amd64 Linux, x86-64 dist/pocketsync-darwin-arm64 macOS, Apple Silicon dist/pocketsync-darwin-amd64 macOS, Intel dist/pocketsync-windows-amd64.exe Windows, x86-64 On macOS and Linux you may need to mark it executable: chmod +x pocketsync-linux-amd64 To build from source you need Go 1.24 or newer. There are no dependencies of any kind, so no network access is required: go build -o pocketsync . go test ./... COMMANDS ======== pocketsync status --a --b [--baseline ] [--json] pocketsync plan --a --b [--baseline ] [--conflict keep-both|prefer-a|prefer-b] [--json] pocketsync sync --a --b [--baseline ] [--conflict keep-both|prefer-a|prefer-b] --apply [--json] pocketsync baseline --a --b --baseline [--json] pocketsync help | -h | --help status The short human answer: "12 new photos on the old phone, 3 on the new one, 1 needs your decision." Plus the per-direction copy totals. Reads only. plan The full three-way classification of every path, the exact copy operations that WOULD run, the renames, the conflicts and the deletions. plan never writes anything, anywhere. This is the default view and the one to read. sync The same computation. WITHOUT --apply it is identical to plan and touches nothing. WITH --apply it performs the copies, then advances the baseline. baseline Records the agreed-identical state: every path that exists on both sides with byte-identical content. Run it once when the two libraries match, or after you have resolved things by hand. Creating or refreshing a baseline never overwrites the previous one - it is renamed aside first. FLAGS --a Side A. A mounted phone library, or any directory tree. --b Side B. --baseline The JSON manifest of the last agreed state. Required by `baseline`, optional everywhere else. --conflict keep-both, prefer-a or prefer-b. No default - see below. --apply Required by sync before anything is written. --name-a Label for side A in reports, e.g. "the old phone". --name-b Label for side B in reports. --json Machine-readable output. Available on status, plan, sync and baseline. Short forms -a, -b, -f (--baseline) and -c (--conflict) are accepted. Flags may appear before or after positional arguments; two bare positional arguments are read as side A and side B, so `pocketsync status /mnt/old /mnt/new` works. THE THREE-WAY DECISION TABLE ============================ For every path in the union of (baseline, side A, side B), PocketSync compares the SHA-256 of the file in each place. A path resolves to exactly one verdict. This is the whole engine, and every cell below is covered by a test. baseline side A side B verdict action -------- -------- -------- ---------------------------- --------------- absent present absent new-on-A copy A -> B absent absent present new-on-B copy B -> A absent X X identical nothing absent X Y changed-on-both-different CONFLICT present =base absent deleted-on-B report only present changed absent deleted-on-B (+ edited on A) report, decide present absent =base deleted-on-A report only present absent changed deleted-on-A (+ edited on B) report, decide present absent absent deleted-on-both report only present =base =base identical nothing present X X (X!=base) changed-on-both-same-content nothing (converged) present =base changed changed-on-B copy B -> A present changed =base changed-on-A copy A -> B present X Y (both!=base) changed-on-both-different CONFLICT Two rows deserve a note. changed-on-both-same-content is a real state, not a curiosity. Both sides applied the same edit, or the same photo was re-imported on both. There is nothing to copy: the sides have converged on their own. It is counted separately so the report does not pretend work was done. A file removed on one side while the OTHER side edited it is still reported as a deletion - that is the fact that matters, and it is never propagated - but it is flagged as needing your decision, because the two sides disagree about whether that photo should exist at all. WITHOUT A BASELINE If you do not pass --baseline, or the file does not exist yet, the baseline is treated as empty. Every path then looks new, and no deletion can be detected. That is safe rather than dangerous, because nothing is ever deleted either way - but it does mean the first useful run is `pocketsync baseline`. CONTENT IDENTITY: RENAMES AND RELOCATIONS ========================================= Comparing by path alone turns a rename into a deletion plus an addition, which is the single most alarming and most wrong thing a sync tool can report. PocketSync compares content as well. Two shapes are detected: rename-on-A the baseline path is gone from side A, and its exact bytes turned up under a new name on side A. Side B still has the old name. relocated the same bytes are new on both sides under different names - the same photo filed as DCIM/IMG_0009.JPG on one phone and Photos/sunset.jpg on the other. Both are REPORTED, and neither produces a copy. The reasoning is simple: the content is already present and safe on both sides, so there is nothing to rescue. Making the names match would mean removing a file, and PocketSync does not remove files. Rename reports therefore persist until you tidy the names by hand; that is deliberate, not a bug. CONFLICT POLICY - THERE IS NO DEFAULT ===================================== A conflict is one path changed on BOTH sides to different content. There is no correct automatic answer, so there is no default that silently loses data. no --conflict The conflict is reported and skipped. Both versions stay exactly where they are. This is what happens if you say nothing. --conflict keep-both Both versions are written to BOTH sides under stable suffixed names: DCIM/IMG_0004.pocketsync-a.JPG side A's version DCIM/IMG_0004.pocketsync-b.JPG side B's version The original filename is left untouched on each side, so each phone still opens its own version by its own name. The path stays divergent and keeps being reported until you decide - PocketSync will not decide for you. --conflict prefer-a Side A's version wins at the original path. Before anything is overwritten, side B's losing version is written to BOTH sides as DCIM/IMG_0004.pocketsync-b.JPG. Only then is the original path on side B replaced. The losing bytes still exist, twice. --conflict prefer-b The exact mirror image. If a suffixed name is already taken by different content, a counter is added (.pocketsync-a-2.JPG) rather than clobbering it. If it is already taken by the SAME content, that operation is simply skipped, which is what makes running twice a no-op. DELETIONS - THE ONE PROMISE =========================== A DELETION ON ONE SIDE IS NEVER PROPAGATED TO THE OTHER SIDE. Not with a flag, not with a policy, not with a confirmation prompt. There is no code path in this program that unlinks a file of yours. The only thing it will ever remove is one of its own half-written .part temporaries, and the function that does it refuses any path that does not end in .pocketsync.part - there is a test that reads the source and fails the build if os.Remove is called anywhere else, and a test that hands the helper a real user file and requires it to be refused. So a deletion is reported, the surviving copy is left exactly where it is, and the deletion is NOT undone on the side that made it either. Deleting a photo on your new phone does not delete it from the old one, and does not resurrect it on the new one at the next sync. It stays reported until you make the two sides agree by hand. The single case where a file's previous contents are replaced is changed-on-A / changed-on-B: the destination still holds exactly the baseline content, that is re-verified by hash immediately before writing, and the newer version replaces it. That is what propagating an edit means. Conflicts never overwrite anything without preserving the loser first. HOW APPLY WRITES ================ Every copy, including the local preserves, goes through the same path: 1. Re-hash the SOURCE. If it no longer matches what the plan recorded, the operation is skipped, not guessed at. 2. Inspect the DESTINATION. If it is absent, proceed. If it already holds the exact content, skip (this is what makes syncing twice do nothing). If it holds something else, proceed ONLY when that something else is exactly the content the plan expected to replace. Otherwise refuse and report. 3. Write to .pocketsync.part, hashing as it goes. 4. Verify: the hash of the bytes written, the hash of the file as it now sits on disk, and a fresh hash of the source must all agree, and the byte count must match. Any mismatch removes the .part file and fails the operation. 5. Restore the source's permissions and modification time. 6. Rename the .part over the destination. The rename is the only moment the destination changes, so a destination is never left half written. Operations are ordered in three phases - preserve, add, replace - so a version that is about to be replaced has already been written somewhere else, and no operation ever reads a file that an earlier operation has already overwritten. After a successful sync the baseline is advanced (see below), and the previous baseline file is renamed aside, never overwritten in place. THE BASELINE FILE ================= A single JSON object. Map keys are sorted, so the file diffs cleanly: { "tool": "pocketsync", "version": 1, "created": "2026-08-11T07:08:32Z", "side_a": "/mnt/oldphone", "side_b": "/mnt/newphone", "files": { "DCIM/IMG_0001.JPG": { "sha256": "82be2784...", "size": 40000, "modtime": "2026-08-11T07:08:32Z" } } } Paths are relative to each side's root and always use forward slashes, so a baseline written on Windows is readable on macOS. WHAT GOES IN, AND WHY 1. A path on BOTH sides with identical content is the agreed state, and is recorded as it stands now. 2. A path the PREVIOUS baseline knew about which no longer agrees - divergent, or now present on one side only - keeps its OLD entry. This is the rule that stops a deletion from being quietly undone: the path stays known as "was agreed, then removed on one side", so the next run classifies it as a deletion again instead of as a brand new file to copy back. 3. A path the previous baseline knew about that is now gone from BOTH sides is dropped. Both sides agree it is gone. 4. Anything else - a file on one side only that was never agreed - is left out, so the next run sees it as new and copies it. That is why syncing twice does nothing the second time, and why the third, fourth and hundredth run also do nothing. QUICK START =========== # 1. When the two libraries match, record that fact. pocketsync baseline --a /mnt/oldphone/DCIM --b /mnt/newphone/DCIM \ --baseline ~/pocketsync-family.json # ... months pass, both phones get used ... # 2. What happened? pocketsync status --a /mnt/oldphone/DCIM --b /mnt/newphone/DCIM \ --baseline ~/pocketsync-family.json \ --name-a "the old phone" --name-b "the new phone" # 3. Exactly what would move, in detail. Writes nothing. pocketsync plan --a /mnt/oldphone/DCIM --b /mnt/newphone/DCIM \ --baseline ~/pocketsync-family.json # 4. Do it, keeping both versions of anything that conflicts. pocketsync sync --a /mnt/oldphone/DCIM --b /mnt/newphone/DCIM \ --baseline ~/pocketsync-family.json \ --conflict keep-both --apply # 5. Feed a script. pocketsync plan --a /mnt/oldphone/DCIM --b /mnt/newphone/DCIM \ --baseline ~/pocketsync-family.json --json EXAMPLE OUTPUT ============== PocketSync - three-way reconciliation side A : /srv/demo/oldphone (the old phone) 7 files, 227.6 KiB side B : /srv/demo/newphone (the new phone) 5 files, 188.5 KiB baseline : /srv/demo/baseline.json (5 paths, recorded 2026-08-11T07:08:32Z) conflicts: keep-both SUMMARY 2 new files on the old phone, 1 on the new phone, 1 needs your decision. 1 file edited on the old phone and 0 on the new phone; 1 file was renamed rather than added; 1 file is gone from at least one side (never propagated). CLASSIFICATION (9 paths) identical 1 new-on-A 3 new-on-B 1 changed-on-A 1 changed-on-B 0 changed-on-both-same-content 0 changed-on-both-different 1 deleted-on-A 1 deleted-on-B 1 deleted-on-both 0 renames/relocations detected 1 (of the new/deleted counts above) OPERATIONS (6 copies, 2 local preserves) the old phone -> the new phone : 4 files, 110.4 KiB the new phone -> the old phone : 2 files, 71.3 KiB A->B copy DCIM/IMG_0100.JPG from A:DCIM/IMG_0100.JPG 60.5 KiB 21a4a095a407 reason new-on-A A->B copy DCIM/IMG_0002.JPG from A:DCIM/IMG_0002.JPG 24 B 6adb46153c0e replaces the baseline copy 5a8f6919db49 (verified again before writing) reason changed-on-A RENAMES AND RELOCATIONS (1) rename-on-A DCIM/IMG_0005.JPG -> DCIM/kite-festival.jpg (39.1 KiB, 921b1f5aed01) CONFLICTS (1 - changed on both sides to different content) DCIM/IMG_0004.JPG the old phone: 30 B 9b817332a329 the new phone: 30 B 67ce0d59da89 policy keep-both: both versions written to both sides as DCIM/IMG_0004.pocketsync-a.JPG and DCIM/IMG_0004.pocketsync-b.JPG; the original path is left untouched on each side DELETIONS (2) A deletion on one side is NEVER propagated to the other side. missing on B DCIM/IMG_0003.JPG gone from side B, still on side A; the deletion is NOT propagated and side A's copy is left untouched WHAT IS SCANNED =============== - Every regular file under each root, at any depth. - Identity is the SHA-256 of the contents. Not size, not timestamp. Two files are the same file when their bytes are the same, and different otherwise. Size and modification time are recorded for the report, not for the decision, so a copy that lands with a different timestamp is still recognised as identical. - Symbolic links are NOT followed and NOT counted; they are reported as skipped. A link loop cannot make the scan recurse forever. - Sockets, devices and other non-regular entries are skipped and reported. - Files ending in .pocketsync.part are ignored - they are this program's own half-written temporaries, never library content. - The baseline file itself is excluded from the scan even if you keep it inside one of the trees. - Unreadable files and directories are recorded and skipped, never fatal. A permission-denied subdirectory does not abort the run. SCOPE / WHAT THIS DOES NOT DO ============================= Read this part before you buy anything. NO PHONE PROTOCOLS. PocketSync does not speak MTP (Android) or AFC/lockdownd (iOS). It does not see a phone plugged into USB. It works on MOUNTED DIRECTORY TREES: a mounted phone volume, an SD card, a DCIM folder copied to a laptop, a NAS share, two folders on one disk. If your phone does not appear as a folder, PocketSync cannot see it, and no flag will change that. Mounting is somebody else's job. NO CLOUD ACCOUNTS. No iCloud, no Google Photos, no OneDrive, no Dropbox, no Amazon Photos. No account is logged into, no token is stored, and nothing is uploaded anywhere. There is no network code in this binary at all. NO MESSAGES, CONTACTS, CALL LOGS OR APP DATA. This tool reconciles files. SMS and iMessage threads, WhatsApp history, contacts, calendars, voicemail, health data and app sandboxes live in device databases reachable only through the protocols above, and none of them are touched, exported or converted. NO BACKUPS. It does not read or write iTunes/Finder backups, .ab files, or any vendor backup format, and it is not a backup tool. Two divergent copies of your photos are not a backup of your photos. NO DELETION PROPAGATION, BY DESIGN. Covered at length above. If you want a true mirror where removing a file on one side removes it on the other, PocketSync is the wrong tool and will never be the right one. NO CONTINUOUS BACKGROUND SYNCING. It never daemonises, never installs a service, never watches a folder and never runs on its own. It does one pass and exits. Run it from cron, a systemd timer, launchd or Task Scheduler if you want it regularly - but note that `sync --apply` is deliberately unattended-hostile: the interesting cases stop and ask for you. NO CONTENT-AWARE PHOTO MATCHING. Identity is an exact byte hash. Two exports of the same shot at different JPEG qualities, a HEIC and its JPEG twin, or the same photo with a rewritten EXIF tag are DIFFERENT files and will both be kept. Perceptual hashing and EXIF-based deduplication are not implemented. NO MEDIA HANDLING. No transcoding, no HEIC-to-JPEG conversion, no thumbnail generation, no album or playlist structure, no live-photo pairing, no sidecar/XMP awareness. Files are bytes. NO PARTIAL OR RESUMABLE TRANSFERS. Each file is copied whole. There is no delta transfer, no block-level diffing, no compression, no resume of an interrupted copy - an interrupted copy leaves a .part file that is ignored and rewritten next time. NO CONCURRENCY. One file at a time, single-threaded, hashing everything on every run. That is honest but not fast: expect a full re-hash of both libraries per invocation. There is no cached scan index yet. NO LOCKING. If something else writes to either tree while PocketSync is running, the source or destination checks will notice and skip that file rather than do something wrong, but there is no lock preventing it. NO PERMISSIONS, OWNERSHIP OR EXTENDED ATTRIBUTES. Permission bits and the modification time are carried over. Owner, group, ACLs, xattrs, resource forks and alternate data streams are not. NO UNDO. Copies are additive and verified, but there is no transaction log and no `pocketsync undo`. The previous baseline is kept, which tells you what the world looked like before; it does not roll anything back. NO GUI. This is the headless engine. The Phone Bridge shell is a separate product. EXIT CODES ========== 0 Success. This includes runs that found conflicts, deletions or renames - those are legitimate answers, not failures, and a plan that reports one conflict and copies nothing has done its job. Explicit help exits 0. 1 Bad invocation (unknown command, missing --a/--b, unknown --conflict policy, a path that is not a directory, both sides the same directory), an I/O error, a corrupt baseline, or a sync in which at least one operation failed. Usage on a bad invocation goes to stderr; usage from help goes to stdout. TESTS ===== go test ./... Covers every cell of the decision table, the symmetry of the whole plan under swapping the two sides, rename and relocation detection, all four conflict policies, idempotence over three consecutive syncs, that a dry run leaves both trees byte-identical, that a deletion is never propagated and never undone, that a copy refuses a destination that changed under it, and that the source contains exactly one os.Remove call site.