

---

# watchfolder.py — running it on an iPhone (no computer needed)

**What it is.** A small script that snapshots a folder, then tells you what
changed since last time: what's gone, what's new, what moved.

**What it is not.** It does not back anything up. It does not save your files.
It makes *loss visible*. That's the first thing you need, because you cannot
notice losing something you never counted.

Verified 2026-09-22 against the a-Shell docs: a-Shell is free on the App Store
(iOS 14+), ships real `python3`, can open folders inside other apps with
`pickFolder`, and Apple Shortcuts can run a-Shell commands on a schedule.

## One time

1. Install a-Shell from the App Store. Open it.
2. Get `watchfolder.py` into a-Shell's folder. Easiest: Shortcuts "Put File",
   or paste it in. (a-Shell can only write in its own `~/Documents`, `~/Library`,
   `~/tmp` — that's iOS, not the script.)
3. Point it at the folder you'd be sick to lose:
   - in a-Shell, type `pickFolder` and choose the folder. a-Shell remembers it.
   - run it once: `python3 watchfolder.py <that folder>`
   That first run is just the baseline. Nothing to read yet.

## Daily (this is the part that makes it a guard)

- Shortcuts app → Automation → New → Time of Day → daily → Run Immediately.
- Action: a-Shell → Execute Command.
- Command:

      cd ~ && python3 watchfolder.py <that folder> --diff --quiet

- It says **nothing** when nothing moved. That silence is the whole point; it's
  the only reason you'd keep it running. When something *did* move:

      watchfolder: 1 gone, 1 new, 0 changed in <folder>
        gone: two.txt
        new: three.txt

`gone` prints first because that's the scary one.

## Honest limits

- The snapshot history lives at `~/.watchfolder` **on the same phone**. If the
  phone is lost or wiped, the history goes with it. This catches silent
  deletion, not device loss. Copy that folder off-device now and then.
- Whether a Shortcut runs quietly or opens a-Shell first depends on iOS:
  a-Shell tries "in Extension" when it can and "in App" otherwise. If it opens
  the app, that's normal, not broken.
- Some system folders can't be reached at all. That's iOS's sandbox, not the
  script.

## The one buried joke

`# silence means nothing moved. that is the whole point.` — line 88-ish, for
whoever reads the source later.

## watchfolder.py

```python
#!/usr/bin/env python3
"""watchfolder.py - a tiny guard for a folder you would hate to lose.

What it does:
    Walks a folder, writes a dated snapshot of what is inside it
    (name, size, modified time), and can tell you what changed since
    the last snapshot.

What it does NOT do:
    It does not back anything up. It does not save your files.
    It makes CHANGE VISIBLE. That is the first thing you need,
    because you cannot notice losing something you never counted.

Usage:
    python3 watchfolder.py ~/Documents          # take a snapshot
    python3 watchfolder.py ~/Documents --diff   # what changed since last time
    python3 watchfolder.py ~/Documents --list   # show past snapshots

    python3 watchfolder.py ~/Documents --diff --quiet
        Only speak when something changed. This is the one to schedule:
        in a-Shell, Shortcuts can run an a-Shell command on a daily trigger,
        and a guard that says nothing when all is well is a guard you keep.
        On the phone: a-Shell -> pickFolder ~/Documents, then run this.
"""

import json
import os
import sys
from datetime import datetime, timezone

STORE = os.path.expanduser("~/.watchfolder")  # where snapshots live (outside the watched folder, so we never watch our own log)


def scan(root):
    """Walk the folder and return {relative_path: [size, mtime]}."""
    found = {}
    for dirpath, dirnames, filenames in os.walk(root):
        # skip hidden folders: caches, trash, and this script's own store
        dirnames[:] = [d for d in dirnames if not d.startswith(".")]
        for name in filenames:
            if name.startswith("."):
                continue
            full = os.path.join(dirpath, name)
            rel = os.path.relpath(full, root)
            try:
                st = os.stat(full)
            except OSError:
                continue  # vanished mid-walk; not our problem today
            found[rel] = [st.st_size, int(st.st_mtime)]
    return found


def store_dir(root):
    # one folder per watched path, so several folders do not share one history
    safe = root.strip("/").replace("/", "_") or "root"
    return os.path.join(STORE, safe)


def _snap_key(name):
    """Sort by (date, number), not as text: __1000 must beat __999."""
    stem = name[:-5] if name.endswith(".json") else name
    day, sep, num = stem.partition("__")
    if not sep:
        return (stem, -1)
    try:
        return (day, int(num))
    except ValueError:
        return (day, -1)


def snapshots(root):
    d = store_dir(root)
    if not os.path.isdir(d):
        return []
    return sorted((f for f in os.listdir(d) if f.endswith(".json")), key=_snap_key)


def next_index(d, day):
    """Highest index already used today, plus one. Ignores anything it cannot parse,
    because a stray file in the store must never kill the run."""
    n = 0
    for f in os.listdir(d):
        if not os.path.isfile(os.path.join(d, f)):
            continue
        stem = f[:-5] if f.endswith(".json") else f
        head, sep, num = stem.partition("__")
        if sep and head == day:
            try:
                n = max(n, int(num))
            except ValueError:
                continue
    return n + 1


def take(root):
    snap = scan(root)
    d = store_dir(root)
    os.makedirs(d, exist_ok=True)
    day = datetime.now(timezone.utc).strftime("%Y-%m-%d")
    n = next_index(d, day)
    path = os.path.join(d, f"{day}__{n:03d}.json")
    with open(path, "w") as f:
        json.dump(snap, f, indent=0, sort_keys=True)
    return path, len(snap)


def load(root, name):
    with open(os.path.join(store_dir(root), name)) as f:
        return json.load(f)


def diff(root, quiet=False):
    names = snapshots(root)
    if len(names) < 2:
        if quiet:
            return 0  # first run of a scheduled guard is a baseline, not a failure
        print("not enough history yet. run it once today, once tomorrow.")
        return 1
    old, new = load(root, names[-2]), load(root, names[-1])
    added = sorted(set(new) - set(old))
    gone = sorted(set(old) - set(new))
    changed = sorted(k for k in set(old) & set(new) if old[k] != new[k])
    if quiet:
        if not (added or gone or changed):
            return 0  # silence means nothing moved. that is the whole point.
        print(f"watchfolder: {len(gone)} gone, {len(added)} new, {len(changed)} changed in {root}")
        for label, items in (("gone", gone), ("new", added)):  # gone first: that is the scary one
            for k in items[:5]:
                print(f"  {label}: {k}")
        return 0
    print("since", names[-2].replace(".json", ""))
    for label, items in (("new", added), ("gone", gone), ("changed", changed)):
        if items:
            print(f"  {label}: {len(items)}")
            for k in items[:20]:
                print("    " + k)
            if len(items) > 20:
                print(f"    ...and {len(items) - 20} more")
    if not (added or gone or changed):
        print("  nothing moved. suspiciously calm. 🐣")
    return 0


def main():
    args = [a for a in sys.argv[1:] if not a.startswith("--")]
    flags = [a for a in sys.argv[1:] if a.startswith("--")]
    if not args:
        print(__doc__)
        return 2
    root = os.path.abspath(os.path.expanduser(args[0]))
    if not os.path.isdir(root):
        print("not a folder:", root)
        return 2
    if "--list" in flags:
        names = snapshots(root)
        print(f"{len(names)} snapshot(s) for {root}")
        for n in names:
            print("  " + n.replace(".json", ""))
        return 0
    quiet = "--quiet" in flags or "-q" in flags
    if "--diff" in flags:
        return diff(root, quiet=quiet)
    path, count = take(root)
    if not quiet:
        print(f"snapped {count} files from {root}")
        print("wrote " + path)
    return 0


if __name__ == "__main__":
    sys.exit(main())
```
