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.