Safety model

photosift is built so that a bad plan, a crash or a change of heart costs nothing.

Rules

  1. Nothing moves without a plan. scan, dupes, stats and plan only read your files (and write the cache and reports). Only apply and fix-dates --execute modify photos.
  2. Dry run by default. apply, undo and fix-dates print what they would do and need --execute to act.
  3. Reviewable plan. plan.json lists every file with its action, destination, reason, size and SHA-256. Edit or delete entries before applying.
  4. Copy by default. --move is opt-in.
  5. Verify before removing. Each file is copied to a hidden temp file in the destination directory while hashing the source stream, flushed with fsync, re-read and compared, then renamed into place with os.replace. Only after that is the journal line written and, in move mode, the source removed. A crash leaves at most a .photosift-*.tmp file or a copy that is already in the journal.
  6. Source drift is detected. The plan stores each file's SHA-256. If the source differs when applying, the file is refused and left alone.
  7. Never overwrite. If the destination exists, the plan picks a new sequence number; at apply time an existing destination is a failure, not an overwrite. A destination that already holds identical bytes is planned as skip, which makes re-planning after an interrupted run safe.
  8. "Delete" is recoverable. --dupes delete in move mode moves the loser to <dest>/.photosift-trash/<run>/ with the same verification. Nothing is unlinked without an intact copy, and undo restores it. Empty the trash folder yourself once you are happy. In copy mode delete entries are skipped.

Journal and undo

apply --execute appends one JSON line per completed file, fsynced, to a journal (default ./photosift-<time>.journal.jsonl):

{"type": "header", "version": 1, "mode": "move", "dest": "/demo/library", "started": "20261016-192403"}
{"type": "op", "op": "move", "src": "/demo/lib/a.jpg", "dest": "/demo/library/2023/a.jpg", "sha256": "...", "size": 1234}

undo walks the journal in reverse:

Re-running apply on the same plan after an interruption is safe: files whose source is gone and whose destination exists are reported as already applied.

fix-dates

EXIF is written to a temp copy, checked with Pillow (verify plus reading the tag back), then swapped in with os.replace; the original mtime is restored. Only JPEGs lacking an EXIF date whose filename carries one are candidates. This one operation is not journaled, so run it on files you have backed up.

What is not covered