From cda50679654593b1777ee1098d5bc0366c59b432 Mon Sep 17 00:00:00 2001 From: Claude Code Bot Date: Wed, 21 Oct 2026 14:42:46 -0700 Subject: [PATCH 1/4] feat(docs-tidy): quarterly plan-then-apply tidy of ~/Documents Repeats the 2026-10-21 manual cleanup on a schedule. A LaunchAgent runs `docs-tidy plan --notify` at 10:00 on Jan/Apr/Jul/Oct 1; nothing moves until Andrew runs `docs-tidy apply`. The plan covers four sections: archive (units untouched since Jan 1 of year-4) to My Drive/Documents Archive, old machine snapshots merged or stashed, cross-unit byte-identical dupes stashed to My Drive/probably-dupes/, and credit-report renames plus Finder tags. Apply copies, re-hashes, then deletes; it refuses stale plans, a missing Drive root, or too little free disk, and prefetches dataless iCloud files with brctl (the manual run lost 9 files to ETIMEDOUT). Python rather than Bash: the job reads DriveFS SQLite, hashes files and writes binary plists. --- LaunchAgents/com.asiago.docs-tidy.plist | 70 ++ docs-tidy.py | 969 ++++++++++++++++++++++ docs/plans/2026-10-21-docs-tidy-design.md | 170 ++++ tests/docs-tidy.bats | 472 +++++++++++ 4 files changed, 1681 insertions(+) create mode 100644 LaunchAgents/com.asiago.docs-tidy.plist create mode 100755 docs-tidy.py create mode 100644 docs/plans/2026-10-21-docs-tidy-design.md create mode 100755 tests/docs-tidy.bats diff --git a/LaunchAgents/com.asiago.docs-tidy.plist b/LaunchAgents/com.asiago.docs-tidy.plist new file mode 100644 index 0000000..ca77d96 --- /dev/null +++ b/LaunchAgents/com.asiago.docs-tidy.plist @@ -0,0 +1,70 @@ + + + + + Label + com.asiago.docs-tidy + ProgramArguments + + /Users/andrewrich/.local/bin/docs-tidy + plan + --notify + + StartCalendarInterval + + + Month + 1 + Day + 1 + Hour + 10 + Minute + 0 + + + Month + 4 + Day + 1 + Hour + 10 + Minute + 0 + + + Month + 7 + Day + 1 + Hour + 10 + Minute + 0 + + + Month + 10 + Day + 1 + Hour + 10 + Minute + 0 + + + RunAtLoad + + LimitLoadToSessionType + Aqua + StandardOutPath + /Users/andrewrich/.local/state/com.asiago.docs-tidy.log + StandardErrorPath + /Users/andrewrich/.local/state/com.asiago.docs-tidy.log + EnvironmentVariables + + PATH + /opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin + + + diff --git a/docs-tidy.py b/docs-tidy.py new file mode 100755 index 0000000..7f58221 --- /dev/null +++ b/docs-tidy.py @@ -0,0 +1,969 @@ +#!/usr/bin/env -S python3 -I +"""docs-tidy: quarterly ~/Documents tidy. Plan, then approve. + +Run `docs-tidy --help` for commands and env overrides. +Design: docs/plans/2026-10-21-docs-tidy-design.md +""" + +import argparse +import datetime +import errno +import glob +import hashlib +import json +import os +import plistlib +import re +import shutil +import sqlite3 +import stat +import subprocess +import sys +import time + +# Top-level Documents items that never archive. +KEEP = { + "army", + "DD214.pdf", + "ssn.pdf", + "Andrew Rich 1989 GED.pdf", + "Issue COE.pdf", + "1Password Emergency Kit A3-KCMDEC-my.pdf", +} + +POINTER = "ARCHIVED-TO-GOOGLE-DRIVE.txt" +TAX_POINTER = "ARCHIVED-YEARS.txt" +CREDIT = "credit-reports" +SNAPSHOT_PREFIX = "Documents - " +SKIPNAMES = {".DS_Store", ".localized"} +SECTIONS = ["archive", "snapshot-merge", "snapshot", "dupes", "credit"] +MAX_PLAN_AGE_DAYS = 14 +FREE_RESERVE = 2 * 1024**3 +DUPE_MIN_SIZE = 1024 +DUPE_EXCLUDE_SUFFIXES = (".app", ".bundle", ".photoslibrary", ".rtfd", ".pages", ".numbers", ".key", ".workflow") +SF_DATALESS = 0x40000000 +TAG_XATTR = "com.apple.metadata:_kMDItemUserTags" +PEOPLE = { + "Andrew": {"ar", "amr", "andrew"}, + "Jen": {"jr", "jmr", "jen", "jennifer"}, +} +AGENCIES = ("Equifax", "Experian", "TransUnion") +CANON_RE = re.compile(r"^\d{4}-\d{2} (Andrew|Jen) (Equifax|Experian|TransUnion)\.pdf$") +REQUEST_RE = re.compile(r"^\d{4}-\d{2} (Andrew|Jen) Request Form\.pdf$") +BLANK_REQUEST = "Blank Annual Credit Report Request Form.pdf" +DATE_RE = re.compile(r"(?.""" + parts = rel.split("/") + if parts[0] == "tax" and len(parts) > 1: + return "/".join(parts[:2]) + return parts[0] + + +def under(rel, prefix): + return rel == prefix or rel.startswith(prefix + "/") + + +# ---------------------------------------------------------------- credit names + + +def read_tags(path): + """Finder tag names (colour suffix stripped), or None when unreadable.""" + if not (IS_MAC and shutil.which("xattr")): + return None + r = subprocess.run(["xattr", "-px", TAG_XATTR, path], capture_output=True, text=True) + if r.returncode != 0: + return [] + try: + tags = plistlib.loads(bytes.fromhex("".join(r.stdout.split()))) + except Exception: + return [] + return [t.split("\n", 1)[0] for t in tags] + + +def write_tags(path, tags): + if not (IS_MAC and shutil.which("xattr")): + return False + hexval = plistlib.dumps(list(tags), fmt=plistlib.FMT_BINARY).hex() + subprocess.run(["xattr", "-wx", TAG_XATTR, hexval, path], check=True, capture_output=True) + return True + + +def canonical_tags(name): + m = CANON_RE.match(name) + if m: + return [m.group(1), m.group(2)] + m = REQUEST_RE.match(name) + if m: + return [m.group(1), "AnnualCreditReport"] + if name == BLANK_REQUEST: + return ["AnnualCreditReport"] + return None + + +def name_date(name, mtime): + m = DATE_RE.search(name) + if m: + return f"{m.group(1)}-{m.group(2)}" + return time.strftime("%Y-%m", time.localtime(mtime)) + + +def parse_credit(name, mtime): + """Return (new_name, tags) or (None, reason).""" + low = name.lower() + if not low.endswith(".pdf"): + return None, "not a PDF" + tokens = set(re.split(r"[^a-z]+", low[:-4])) + ym = name_date(name, mtime) + if low.startswith("fact act request form"): + people = [p for p, keys in PEOPLE.items() if tokens & keys] + if len(people) == 1: + return f"{ym} {people[0]} Request Form.pdf", [people[0], "AnnualCreditReport"] + if not people: + return BLANK_REQUEST, ["AnnualCreditReport"] + return None, "more than one person" + people = [p for p, keys in PEOPLE.items() if tokens & keys] + if len(people) != 1: + return None, "no person" if not people else "more than one person" + squashed = re.sub(r"[^a-z]", "", low) + agencies = [a for a in AGENCIES if a.lower() in squashed] + if len(agencies) != 1: + return None, "no agency" if not agencies else "more than one agency" + return f"{ym} {people[0]} {agencies[0]}.pdf", [people[0], agencies[0]] + + +# ---------------------------------------------------------------- plan + + +def build_plan(doc, drive, drivefs_dirs, day): + drivefs = load_drivefs(doc, drivefs_dirs) + files, skipped = inventory(doc, drivefs) + cut = cutoff_ts(day) + date = day.isoformat() + arch_base = os.path.join(drive, "Documents Archive") + dupe_base = os.path.join(drive, "probably-dupes", date) + entries = [] + + def entry(section, rel, dst, **extra): + f = files[rel] + e = dict(section=section, src=os.path.join(doc, rel), dst=dst, rel=rel, md5=f["md5"], + size=f["size"], mtime=f["mtime"], dataless=f["dataless"]) + e.update(extra) + entries.append(e) + return e + + # 1. archive units + unit_names = [] + for name in sorted(os.listdir(doc)): + p = os.path.join(doc, name) + if (name.startswith(".") or name.startswith(SNAPSHOT_PREFIX) or name in (CREDIT, "tax", POINTER) + or name in KEEP or os.path.islink(p)): + continue + unit_names.append(name) + tax = os.path.join(doc, "tax") + if os.path.isdir(tax) and not os.path.islink(tax): + for y in sorted(os.listdir(tax)): + if y.startswith(".") or y == TAX_POINTER or os.path.islink(os.path.join(tax, y)): + continue + unit_names.append(f"tax/{y}") + units, archived = [], set() + for u in unit_names: + members = [r for r in files if under(r, u)] + if not members: + continue + newest = max(files[r]["mtime"] for r in members) + if newest >= cut: + continue + units.append(dict(rel=u, newest=newest, files=len(members), bytes=sum(files[r]["size"] for r in members), + tax=u.startswith("tax/"))) + for r in sorted(members): + entry("archive", r, os.path.join(arch_base, r), unit=u) + archived.add(r) + + def in_snapshot(r): + return top(r).startswith(SNAPSHOT_PREFIX) + + local = {r for r in files if r not in archived and not in_snapshot(r)} + + # 2. snapshots + outside_md5 = {files[r]["md5"] for r in local if files[r]["md5"]} + merge_dst = {} + snapshots, needs_review = {}, [] + for host_dir in sorted({top(r) for r in files if in_snapshot(r)}): + s = dict(merge=[], stash=0, stash_bytes=0, superseded=[], review=0, files=0) + snapshots[host_dir] = s + for r in sorted(x for x in files if under(x, host_dir) and x != host_dir): + s["files"] += 1 + f = files[r] + inner = r[len(host_dir) + 1:] + stash_dst = os.path.join(dupe_base, r) + if not f["md5"]: + needs_review.append(r) + s["review"] += 1 + continue + if f["md5"] in outside_md5: + entry("snapshot", r, stash_dst, why="dupe") + s["stash"] += 1 + s["stash_bytes"] += f["size"] + continue + if not os.path.lexists(os.path.join(doc, inner)) and inner not in merge_dst: + entry("snapshot-merge", r, os.path.join(doc, inner)) + merge_dst[inner] = f["md5"] + outside_md5.add(f["md5"]) + s["merge"].append(r) + continue + entry("snapshot", r, stash_dst, why="superseded") + s["stash"] += 1 + s["stash_bytes"] += f["size"] + s["superseded"].append(r) + + # 3. dupes + # Skip any file inside a git working tree: stashing it breaks the project. + git_cache = {} + + def in_work_tree(r): + parts = r.split("/")[:-1] + for i in range(len(parts) + 1): + d = "/".join(parts[:i]) + if d not in git_cache: + git_cache[d] = os.path.lexists(os.path.join(doc, d, ".git")) + if git_cache[d]: + return True + return False + + def dupe_ok(r): + f = files[r] + if f["size"] < DUPE_MIN_SIZE or not f["md5"]: + return False + for c in r.split("/"): + if c in (".git", "node_modules") or c.endswith(DUPE_EXCLUDE_SUFFIXES): + return False + return not in_work_tree(r) + + groups = {} + for r in local: + if dupe_ok(r): + groups.setdefault(files[r]["md5"], []).append(r) + dupe_groups, stashed = [], set() + for md5, members in sorted(groups.items(), key=lambda kv: min(kv[1])): + # Copies inside one unit are deliberate (templates, renamed copies): + # only copies in different units count as dupes. + if len({unit_of(r) for r in members}) < 2: + continue + ranked = sorted(members, key=lambda r: (top(r) != CREDIT, top(r) not in KEEP, files[r]["mtime"], len(r), r)) + keep = ranked[0] + # Copies in the keeper's own unit stay with it. KEEP items are + # protected: never stash them, even as a duplicate. + stash = [r for r in ranked[1:] if unit_of(r) != unit_of(keep) and top(r) not in KEEP] + if not stash: + continue + dupe_groups.append(dict(md5=md5, size=files[keep]["size"], keep=keep, stash=stash)) + for r in stash: + entry("dupes", r, os.path.join(dupe_base, r), keep=keep) + stashed.add(r) + + # 4. credit + needs_manual, credit = [], [] + cdir = os.path.join(doc, CREDIT) + if os.path.isdir(cdir) and not os.path.islink(cdir): + names = sorted(os.listdir(cdir)) + existing = set(names) + proposals = [] + for n in names: + rel = f"{CREDIT}/{n}" + if n.startswith(".") or n in SKIPNAMES: + continue + if rel not in files: + if os.path.isdir(os.path.join(cdir, n)) and not os.path.islink(os.path.join(cdir, n)): + needs_manual.append((rel, "directory")) + continue + if rel in stashed: + continue + tags = canonical_tags(n) + if tags is not None: + have = read_tags(os.path.join(cdir, n)) + if have is not None and sorted(have) != sorted(tags): + proposals.append((n, n, tags, have)) + continue + new, why = parse_credit(n, files[rel]["mtime"]) + if new is None: + needs_manual.append((rel, why)) + continue + proposals.append((n, new, why, None)) + targets = {} + for old, new, _tags, _have in proposals: + targets.setdefault(new, []).append(old) + for old, new, tags, have in proposals: + if old != new and (len(targets[new]) > 1 or new in existing): + needs_manual.append((f"{CREDIT}/{old}", f"would collide on {new!r}")) + continue + e = entry("credit", f"{CREDIT}/{old}", os.path.join(cdir, new), tags=tags, + retag_only=(old == new), old_tags=have) + credit.append(e) + needs_manual.sort() + + return dict( + version=1, + date=date, + created=datetime.datetime.now().isoformat(timespec="seconds"), + cutoff=cutoff_date(day).isoformat(), + doc=doc, + drive=drive, + drivefs=bool(drivefs), + inventory=dict(files=len(files), md5_drivefs=sum(1 for f in files.values() if f["md5_src"] == "drivefs"), + md5_local=sum(1 for f in files.values() if f["md5_src"] == "local"), + md5_unknown=sum(1 for f in files.values() if not f["md5"]), + dataless=sum(1 for f in files.values() if f["dataless"])), + units=units, + snapshots=snapshots, + dupe_groups=dupe_groups, + entries=entries, + skipped=[list(x) for x in skipped], + needs_review=needs_review, + needs_manual=[list(x) for x in needs_manual], + ) + + +# ---------------------------------------------------------------- summary + + +def human(n): + for unit in ("B", "KB", "MB", "GB"): + if abs(n) < 1000 or unit == "GB": + return f"{n:.0f} {unit}" if unit == "B" else f"{n:.1f} {unit}" + n /= 1000 + return str(n) + + +def fmt_ts(ts): + return time.strftime("%Y-%m-%d", time.localtime(ts)) + + +def section_counts(plan): + c = {s: [0, 0] for s in SECTIONS} + for e in plan["entries"]: + c[e["section"]][0] += 1 + c[e["section"]][1] += e["size"] + return c + + +def summary(plan): + out = [] + w = out.append + inv = plan["inventory"] + w(f"docs-tidy plan {plan['date']} (cutoff {plan['cutoff']})") + w(f"Documents: {plan['doc']}") + w(f"Drive: {plan['drive']}") + w(f"Inventory: {inv['files']} files; md5 from DriveFS {inv['md5_drivefs']}, local {inv['md5_local']}," + f" unknown {inv['md5_unknown']}; dataless {inv['dataless']}") + w("") + w("Sections:") + for s, (n, b) in section_counts(plan).items(): + w(f" {s:15} {n:6} files {human(b):>10}") + w(f" {'skipped':15} {len(plan['skipped']):6}") + w(f" {'needs-review':15} {len(plan['needs_review']):6}") + w(f" {'needs-manual':15} {len(plan['needs_manual']):6}") + w("") + w(f"Archive units ({len(plan['units'])}):") + for u in plan["units"]: + w(f" {u['rel']} newest {fmt_ts(u['newest'])} {u['files']} files {human(u['bytes'])}") + w("") + w(f"Snapshots ({len(plan['snapshots'])}):") + for host, s in plan["snapshots"].items(): + w(f" {host}: {s['files']} files; merge {len(s['merge'])}, stash {s['stash']} ({human(s['stash_bytes'])})," + f" of which superseded {len(s['superseded'])}; needs review {s['review']}") + for r in s["merge"]: + w(f" merge {r}") + for r in s["superseded"]: + w(f" superseded {r}") + w("") + w(f"Dupe groups ({len(plan['dupe_groups'])}):") + for g in plan["dupe_groups"]: + w(f" {human(g['size'])} x{1 + len(g['stash'])} {g['md5']}") + w(f" keep {g['keep']}") + for r in g["stash"]: + w(f" stash {r}") + w("") + credit = [e for e in plan["entries"] if e["section"] == "credit"] + w(f"Credit renames ({len(credit)}):") + for e in credit: + tags = ", ".join(e["tags"]) + if e["retag_only"]: + w(f" retag {os.path.basename(e['src'])} [{tags}]") + else: + w(f" {os.path.basename(e['src'])} -> {os.path.basename(e['dst'])} [{tags}]") + w("") + w(f"Skipped, not regular files ({len(plan['skipped'])}):") + for rel, kind in plan["skipped"]: + w(f" {kind:8} {rel}") + w("") + w(f"Needs review, md5 unknown ({len(plan['needs_review'])}):") + for r in plan["needs_review"]: + w(f" {r}") + w("") + w(f"Needs manual ({len(plan['needs_manual'])}):") + for rel, why in plan["needs_manual"]: + w(f" {rel} ({why})") + return "\n".join(out) + "\n" + + +def post(title, msg): + if IS_MAC and shutil.which("osascript"): + script = f"display notification {json.dumps(msg)} with title {json.dumps(title)}" + subprocess.run(["osascript", "-e", script], check=False) + else: + log(f"notify: {title}: {msg}") + + +def notify(plan): + c = section_counts(plan) + msg = (f"archive {c['archive'][0]}, merge {c['snapshot-merge'][0]}, snapshot {c['snapshot'][0]}," + f" dupes {c['dupes'][0]}, credit {c['credit'][0]}. Run: docs-tidy show") + post(f"docs-tidy plan {plan['date']}", msg) + + +# ---------------------------------------------------------------- commands + + +def newest_plan(sd): + hits = sorted(glob.glob(os.path.join(sd, "plan-*.json"))) + if not hits: + raise Refuse(f"no plan in {sd}; run: docs-tidy plan") + return hits[-1] + + +def load_plan(path): + path = path or newest_plan(state_dir()) + with open(path) as f: + return json.load(f), path + + +def cmd_plan(args): + day = today() + doc = doc_root() + if not os.path.isdir(doc): + raise Refuse(f"Documents root missing: {doc}") + plan = build_plan(doc, drive_root(), drivefs_dir(), day) + sd = state_dir() + os.makedirs(sd, exist_ok=True) + base = os.path.join(sd, f"plan-{plan['date']}") + with open(base + ".json", "w") as f: + json.dump(plan, f, indent=1) + f.write("\n") + text = summary(plan) + with open(base + ".txt", "w") as f: + f.write(text) + sys.stdout.write(text) + log(f"\nWrote {base}.json and {base}.txt") + if args.notify: + notify(plan) + return 0 + + +def cmd_show(args): + plan, _ = load_plan(args.plan) + sys.stdout.write(summary(plan)) + return 0 + + +def cleanup_one(d): + """rmdir d when it holds only .DS_Store/.localized; return True if removed. + + Never touches a directory that still holds anything else. + """ + try: + names = os.listdir(d) + except OSError: + return False + if any(n not in SKIPNAMES for n in names): + return False + for n in names: + p = os.path.join(d, n) + if os.path.isfile(p) and not os.path.islink(p): + os.remove(p) + try: + os.rmdir(d) + except OSError: + return False + return True + + +def cleanup_tree(root, stop): + """Bottom-up cleanup of a moved tree, then of its parents up to (not including) stop.""" + stop = os.path.abspath(stop) + root = os.path.abspath(root) + if os.path.isdir(root) and not os.path.islink(root): + for r, _dirs, _names in os.walk(root, topdown=False): + if r != root: + cleanup_one(r) + d = root + else: + d = os.path.dirname(root) + while d != stop and d.startswith(stop + os.sep) and cleanup_one(d): + d = os.path.dirname(d) + + +def move_file(e): + """Copy, verify, delete. Return (status, md5, note); status is OK or ERR.""" + src, dst = e["src"], e["dst"] + if not os.path.lexists(src): + if os.path.exists(dst): + return "OK", e.get("md5") or "", "already done" + return "ERR", e.get("md5") or "", "source missing and destination absent" + st = os.lstat(src) + if not stat.S_ISREG(st.st_mode): + return "ERR", "", "source is no longer a regular file" + h = md5_with_retry(src, is_dataless(st)) + if e.get("md5") and h != e["md5"]: + return "ERR", h, f"source changed since plan ({e['md5']} -> {h})" + if os.path.lexists(dst): + if not os.path.isfile(dst) or md5_file(dst) != h: + return "ERR", h, "destination exists with different content" + os.remove(src) + return "OK", h, "destination already had it" + os.makedirs(os.path.dirname(dst), exist_ok=True) + for attempt in range(4): + try: + shutil.copy2(src, dst) + break + except OSError as ex: + if ex.errno not in (60, errno.ETIMEDOUT) or attempt == 3: + raise + brctl_download(src) + time.sleep(1) + if md5_file(dst) != h: + os.remove(dst) + return "ERR", h, "copy verification failed" + os.remove(src) + return "OK", h, "" + + +def unit_newest(doc, unit): + p = os.path.join(doc, unit) + ts = [] + if os.path.isfile(p) and not os.path.islink(p): + return os.lstat(p).st_mtime + for r, _d, names in os.walk(p): + for n in names: + if n in SKIPNAMES: + continue + st = os.lstat(os.path.join(r, n)) + if stat.S_ISREG(st.st_mode): + ts.append(st.st_mtime) + return max(ts) if ts else None + + +def cmd_apply(args): + plan, path = load_plan(args.plan) + day = today() + skip = set(args.skip or []) + bad = skip - set(SECTIONS) + if bad: + raise Refuse(f"unknown section(s) for --skip: {sorted(bad)}; valid: {SECTIONS}") + plan_day = datetime.date.fromisoformat(plan["date"]) + age = (day - plan_day).days + if age > MAX_PLAN_AGE_DAYS: + raise Refuse(f"plan {path} is {age} days old (max {MAX_PLAN_AGE_DAYS}); re-run: docs-tidy plan") + doc, drive = plan["doc"], plan["drive"] + if not os.path.isdir(drive): + raise Refuse(f"Drive root missing: {drive}") + todo = [e for e in plan["entries"] if e["section"] not in skip] + need = sum(e["size"] for e in todo if e.get("dataless")) + FREE_RESERVE + free = shutil.disk_usage(doc).free + if free < need: + raise Refuse(f"free space {human(free)} on the Documents volume is below {human(need)}" + " (dataless sources plus 2 GiB)") + + cut = cutoff_ts(plan_day) + results = [] # (status, section, md5, src, dst, note) + skipped_units = [] + moved_roots = set() + date = day.isoformat() + credit_log = [] + t0 = time.time() + + for section in SECTIONS: + if section in skip: + log(f"skip section {section}") + continue + ents = [e for e in plan["entries"] if e["section"] == section] + if not ents: + continue + log(f"{section}: {len(ents)} entries") + bad_units = set() + if section == "archive": + for u in sorted({e["unit"] for e in ents}): + n = unit_newest(doc, u) + if n is not None and n >= cut: + bad_units.add(u) + skipped_units.append((u, n)) + log(f" unit changed since plan, skipped: {u} (newest {fmt_ts(n)})") + for i, e in enumerate(ents): + if section == "archive" and e["unit"] in bad_units: + continue + try: + if section == "credit": + st, h, note = apply_credit(e, credit_log) + else: + st, h, note = move_file(e) + except Exception as ex: # record and continue; one file must not stop the run + st, h, note = "ERR", e.get("md5") or "", f"{type(ex).__name__}: {ex}" + results.append((st, section, h, e["src"], e["dst"], note)) + if st == "OK" and section != "credit": + if section == "archive": + moved_roots.add(os.path.join(doc, e["unit"])) + elif section in ("snapshot", "snapshot-merge"): + moved_roots.add(os.path.join(doc, top(e["rel"]))) + else: + # The file itself, so cleanup only walks upward from it and + # never sweeps sibling directories nothing moved from. + moved_roots.add(e["src"]) + if st == "ERR": + log(f" ERR {e['src']}: {note}") + if (i + 1) % 500 == 0: + log(f" {i + 1}/{len(ents)} {time.time() - t0:.0f}s") + + ok = [r for r in results if r[0] == "OK"] + errs = [r for r in results if r[0] == "ERR"] + # Credit entries rename in place, so they never belong in the pointer. + moved = [r for r in ok if r[1] != "credit"] + # Pointers first: writing tax/ARCHIVED-YEARS.txt keeps tax/ from being + # removed as empty when its last year moves. + if moved: + write_pointer(plan, moved, date) + for root in sorted(moved_roots, key=len, reverse=True): + cleanup_tree(root, doc) + if credit_log: + lp = os.path.join(doc, CREDIT, f".rename-log-{date}.tsv") + new = not os.path.exists(lp) + with open(lp, "a") as f: + if new: + f.write("old_name\tnew_name\ttags\n") + for row in credit_log: + f.write("\t".join(row) + "\n") + sd = state_dir() + os.makedirs(sd, exist_ok=True) + with open(os.path.join(sd, f"apply-{date}.tsv"), "a") as f: + for st, section, h, src, dst, note in results: + f.write(f"{st}\t{section}\t{h}\t{src}\t{dst}" + (f"\t{note}" if note else "") + "\n") + + by = {} + for st, section, *_ in results: + by.setdefault(section, [0, 0])[0 if st == "OK" else 1] += 1 + log("") + for s in SECTIONS: + if s in by: + log(f"{s:15} ok {by[s][0]:6} err {by[s][1]:6}") + for u, n in skipped_units: + log(f"skipped unit (changed since plan): {u} newest {fmt_ts(n)}") + log(f"done in {time.time() - t0:.0f}s; log: {os.path.join(sd, f'apply-{date}.tsv')}") + if errs: + log(f"\n{len(errs)} error(s):") + for _st, section, _h, src, _dst, note in errs: + log(f" [{section}] {src}: {note}") + return 1 + return 0 + + +def apply_credit(e, credit_log): + src, dst = e["src"], e["dst"] + old, new = os.path.basename(src), os.path.basename(dst) + if not e["retag_only"]: + if not os.path.lexists(src): + if not os.path.exists(dst): + return "ERR", "", "source missing and destination absent" + else: + if os.path.lexists(dst): + return "ERR", "", "destination exists" + if e.get("md5") and md5_file(src) != e["md5"]: + return "ERR", "", "source changed since plan" + os.rename(src, dst) + elif not os.path.exists(dst): + return "ERR", "", "file missing" + tagged = write_tags(dst, e["tags"]) + credit_log.append((old, new, ",".join(e["tags"]))) + return "OK", e.get("md5") or "", "" if tagged else "tags skipped (not macOS)" + + +def write_pointer(plan, ok, date): + doc, drive = plan["doc"], plan["drive"] + by = {} + for _st, section, _h, src, dst, _n in ok: + by.setdefault(section, []).append((src, dst)) + lines = [f"\nOn {date}, docs-tidy (plan {plan['date']}, cutoff {plan['cutoff']}) moved:\n"] + if "archive" in by: + done_src = {src for src, _ in by["archive"]} + done_units = sorted({e["unit"] for e in plan["entries"] + if e["section"] == "archive" and e["src"] in done_src}) + lines.append(f" archive: {len(by['archive'])} files in {len(done_units)} top-level items, unchanged since" + f" before {plan['cutoff']}, to\n {os.path.join(drive, 'Documents Archive')}\n") + for u in done_units: + lines.append(f" {u}\n") + if "snapshot-merge" in by: + lines.append(f" snapshot-merge: {len(by['snapshot-merge'])} files that existed only in a machine snapshot," + " merged into this folder at the same relative paths\n") + for s, label in (("snapshot", "machine-snapshot files"), ("dupes", "duplicate files")): + if s in by: + lines.append(f" {s}: {len(by[s])} {label} to\n" + f" {os.path.join(drive, 'probably-dupes', plan['date'])}\n") + lines.append(" Every move was copy, verify checksum, then delete the original.\n") + with open(os.path.join(doc, POINTER), "a") as f: + f.writelines(lines) + done_src = {src for src, _ in by.get("archive", [])} + years = sorted({e["unit"][4:] for e in plan["entries"] + if e["section"] == "archive" and e["unit"].startswith("tax/") and e["src"] in done_src}) + if years: + tdir = os.path.join(doc, "tax") + if os.path.isdir(tdir): + with open(os.path.join(tdir, TAX_POINTER), "a") as f: + f.write(f"\nOn {date}, these tax years moved to Google Drive:\n {', '.join(years)}\n\n" + f"Location:\n {os.path.join(drive, 'Documents Archive', 'tax')}\n") + + +def main(argv=None): + p = argparse.ArgumentParser( + prog="docs-tidy", + description="Quarterly ~/Documents tidy: plan, then approve. `plan` changes nothing on disk except" + " the state dir; nothing moves until you run `apply`.", + epilog="Sections: " + ", ".join(SECTIONS) + ". State dir: $DOCS_TIDY_STATE or ~/.local/state/docs-tidy." + " Other overrides: DOCS_TIDY_DOC, DOCS_TIDY_DRIVE, DOCS_TIDY_DRIVEFS (none disables)," + " DOCS_TIDY_TODAY (YYYY-MM-DD).", + ) + sub = p.add_subparsers(dest="cmd", required=True) + sp = sub.add_parser("plan", help="inventory Documents and write plan-YYYY-MM-DD.{json,txt}") + sp.add_argument("--notify", action="store_true", help="post a macOS notification with the counts") + ss = sub.add_parser("show", help="print the text summary of a plan") + ss.add_argument("plan", nargs="?", help="plan JSON (default: newest in the state dir)") + sa = sub.add_parser("apply", help="execute a plan: copy, verify, delete") + sa.add_argument("plan", nargs="?", help="plan JSON (default: newest in the state dir)") + sa.add_argument("--skip", action="append", metavar="SECTION", choices=SECTIONS, + help="skip a section; repeat for more: " + ", ".join(SECTIONS)) + args = p.parse_args(argv) + # Under launchd nobody reads stderr, so a failed --notify plan must say so. + alert = args.cmd == "plan" and args.notify + try: + return {"plan": cmd_plan, "show": cmd_show, "apply": cmd_apply}[args.cmd](args) + except Refuse as e: + print(f"docs-tidy: refused: {e}", file=sys.stderr) + if alert: + post("docs-tidy plan FAILED", str(e)) + return 2 + except Exception as e: + if alert: + post("docs-tidy plan FAILED", f"{type(e).__name__}: {e}") + raise + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/docs/plans/2026-10-21-docs-tidy-design.md b/docs/plans/2026-10-21-docs-tidy-design.md new file mode 100644 index 0000000..eaa6fb1 --- /dev/null +++ b/docs/plans/2026-10-21-docs-tidy-design.md @@ -0,0 +1,170 @@ +# docs-tidy: quarterly Documents tidy (design) + +## Why + +On 2026-10-21 `~/Documents` (iCloud-synced, backed up to Google Drive as +machine ASIAGO) was tidied by hand: 9.1 GB of iCloud quota, about 4 GB of it +duplicates. Old items moved to Google Drive, an old machine snapshot was +de-duplicated, and credit reports were renamed to `YYYY-MM Person Agency.pdf` +with Finder tags. This tool repeats that each quarter. + +The run is **plan, then approve**. launchd writes a plan and sends a +notification. Nothing moves until Andrew runs `docs-tidy apply`. + +A launchd spike (2026-10-21) showed an Aqua LaunchAgent running +`/opt/homebrew/bin/python3` can read `~/Documents`, `~/Downloads`, the Drive +mount and the DriveFS databases with no TCC prompt, so the plan step runs +under launchd. + +## Files + +| Path | What | +| --- | --- | +| `docs-tidy.py` | Python entrypoint, mode 755, `#!/usr/bin/env -S python3 -I`. Symlinked as `~/.local/bin/docs-tidy`. | +| `LaunchAgents/com.asiago.docs-tidy.plist` | Runs `docs-tidy plan --notify` at 10:00 on Jan 1, Apr 1, Jul 1, Oct 1. `RunAtLoad` false, Aqua only. | +| `tests/docs-tidy.bats` | Fixture-tree tests; run on Ubuntu CI, so macOS-only calls are guarded. | + +Python, not Bash: the job reads SQLite, hashes files and writes binary +plists. Doing that in Bash would mean shelling out to Python anyway. + +## Commands + +- `docs-tidy plan [--notify]` writes `plan-YYYY-MM-DD.json` and + `plan-YYYY-MM-DD.txt` to the state dir, prints the text summary, and with + `--notify` posts a macOS notification with the counts. +- `docs-tidy show [PLAN]` prints the text summary of the newest plan. +- `docs-tidy apply [PLAN] [--skip SECTION ...]` executes a plan. + +## Configuration (env overrides exist for tests) + +| Setting | Default | Env | +| --- | --- | --- | +| Documents root | `~/Documents` | `DOCS_TIDY_DOC` | +| Drive root | glob `~/Library/CloudStorage/GoogleDrive-*/My Drive` (exactly one match, else error) | `DOCS_TIDY_DRIVE` | +| DriveFS dir | glob `~/Library/Application Support/Google/DriveFS/[0-9]*/` containing `mirror_sqlite.db`; `none` disables | `DOCS_TIDY_DRIVEFS` | +| State dir | `~/.local/state/docs-tidy` | `DOCS_TIDY_STATE` | +| Today | local date | `DOCS_TIDY_TODAY` (`YYYY-MM-DD`) | + +Archive destination: `/Documents Archive/`. +Dupes destination: `/probably-dupes//`. + +`KEEP` is a visible list at the top of the script. These top-level items +never archive: `army`, `DD214.pdf`, `ssn.pdf`, `Andrew Rich 1989 GED.pdf`, +`Issue COE.pdf`, `1Password Emergency Kit A3-KCMDEC-my.pdf`. Plan counts +exclude them, so the counts in the notification are the real counts. + +## Cutoff + +The cutoff is 00:00 local on Jan 1 of `(today.year - 4)`. In Oct 2026 that is +2022-01-01; in Jan 2027 it is 2023-01-01. + +## Inventory + +1. List regular files only (`S_ISREG` on `lstat`). Sockets, FIFOs and + symlinks are reported as skipped and never planned. The 2026-10-21 run + failed on a git `fsmonitor--daemon.ipc` socket. +2. Skip `.DS_Store` and `.localized` everywhere. +3. MD5 source, in order: + 1. the DriveFS mirror DB (`mirror_item.local_md5_checksum`), when its size + and mtime match `lstat`; + 2. a local hash, when the file is not dataless (macOS `st_flags & + 0x40000000`, `SF_DATALESS`); + 3. otherwise unknown. Never download a file just to plan. + +## Plan sections (applied in this order) + +1. **archive.** Units are the top-level entries of Documents, except + dotfiles, `Documents - *`, `credit-reports`, `tax`, the pointer file and + `KEEP`. Each `tax/` is its own unit. A unit archives when its newest + regular-file mtime is before the cutoff. Every file in it maps to + `/Documents Archive/`. +2. **snapshot.** For each `Documents - ` folder, check every file + against the files that stay local after archive: + - If its MD5 matches a file outside the snapshots, stash it. + - If it is unique and `/` is absent, + merge it there (section `snapshot-merge`). + - If it is unique but that path exists with other content, stash it and + list it as "superseded". + - If its MD5 is unknown, list it under "needs review" and plan nothing. +3. **dupes.** Group the files that stay local (not archived, not in + snapshots) by MD5. Group only files of 1 KiB or larger. Exclude: + - any path with a `.git` or `node_modules` component, or a component + ending in `.app`, `.bundle`, `.photoslibrary`, `.rtfd`, `.pages`, + `.numbers`, `.key` or `.workflow`; + - any file inside a git working tree: a directory, at any depth, that + holds a `.git` entry (directory or file). Stashing one copy out of a + project breaks the project. + + Copies inside the same unit are deliberate (templates, renamed copies + such as `airship.png` and `dirigible.png`). A unit is a top-level entry + of Documents, or `tax/`. A group counts only when its copies span + two or more units. Keep one copy: anything in `credit-reports` wins, + then a `KEEP` item, then the earliest mtime, then the shortest path, + then lexical order. Stash the copies in other units. Copies in the + keeper's own unit stay, and `KEEP` items are never stashed. + + Why: on the 2026-10-21 dry run, the first rule set (no working-tree, + `node_modules` or unit rule) planned 245 stashes in 167 groups. 93 were + under `old-pi`, 51 under `Resume` (per-application template copies), 41 + under `Codex` and 33 in `node_modules`. With these rules the same data + gives 19 stashes in 19 groups. +4. **credit.** For files in `credit-reports` (not dotfiles) whose name does + not match `^\d{4}-\d{2} (Andrew|Jen) (Equifax|Experian|TransUnion)\.pdf$` + or the request-form names, propose a rename. Use the person and agency + rules from the 2026-10-21 `rename.py`: AR, AMR and Andrew mean Andrew; + JR, JMR, Jen and Jennifer mean Jen. + - The date comes from a `YYYYMMDD`, `YYYY-MM-DD` or `YYYY_MM_DD` in the + name, else the file's mtime. + - Unparseable names, or names that would collide, go to "needs manual". + Nothing is planned for them. + - Canonical names whose Finder tags differ from `[Person, Agency]` get a + retag-only entry. + +The text summary shows counts and bytes per section, every archive unit with +its newest mtime, every dupe group (kept path, then stashed paths), every +snapshot merge, every credit rename, and the skipped, needs-review and +needs-manual lists. + +## Apply + +Each guard below refuses the whole apply with a reason: + +- The plan is more than 14 days old. Fix: re-run `plan`. +- The Drive root is missing. +- Free space on the volume of Documents is below the bytes of the dataless + sources plus 2 GiB. + +Per file: copy, verify, delete. This is the pattern proven on 2026-10-21. + +1. Re-hash the source. If it differs from the planned MD5, record an error + and skip the file. +2. If the destination exists with the same MD5, delete the source. If it + exists with a different MD5, record an error. +3. Copy with `shutil.copy2`, re-hash the destination, and only then delete + the source. +4. A missing source with an existing destination counts as already done. + +For archive units, first re-check that the unit's newest mtime is still +before the cutoff. If not, skip the whole unit and report it. + +Before copying, call `brctl download` on each dataless source (macOS only). +On `ETIMEDOUT` (errno 60), call `brctl download` and retry, up to 3 times. +The 2026-10-21 run lost 9 files to this, and all 9 then copied in 5 s. + +Credit entries: `os.rename`, then write tags with `xattr -wx +com.apple.metadata:_kMDItemUserTags ` (macOS only; on other +systems, log that tags were skipped). Append one line per change to +`credit-reports/.rename-log-.tsv`. + +After the moves: + +- In each source tree that was moved, delete `.DS_Store` and `.localized`. + Then `rmdir` the empty directories, bottom-up. Never use `rm -rf`, and + never touch a directory that still holds other files. +- Append a dated section to `/ARCHIVED-TO-GOOGLE-DRIVE.txt` + (create it if missing). When tax years were archived, also append to + `/tax/ARCHIVED-YEARS.txt`. +- Write `apply-.tsv` (OK/ERR, section, md5, src, dst) to the state dir. + +Exit 0 if every entry succeeded, else exit 1 and print a summary of the +errors. diff --git a/tests/docs-tidy.bats b/tests/docs-tidy.bats new file mode 100755 index 0000000..b5bc82a --- /dev/null +++ b/tests/docs-tidy.bats @@ -0,0 +1,472 @@ +#!/usr/bin/env bats +# bats tests for docs-tidy.py (plan / show / apply) against fixture trees. +# +# Run from repo root: bats tests/docs-tidy.bats +# +# Every env override is set in setup(), so no test can fall back to the real +# ~/Documents, Drive mount, DriveFS DB or state dir. A test that needs a +# different date passes DOCS_TIDY_TODAY per command (`run env ...`). + +setup() { + REPO_ROOT="$(cd "$(dirname "${BATS_TEST_FILENAME:?must be run via bats}")/.." && pwd)" + SCRIPT="${REPO_ROOT}/docs-tidy.py" + DOC="${BATS_TEST_TMPDIR:?}/Documents" + DRIVE="${BATS_TEST_TMPDIR:?}/My Drive" + STATE="${BATS_TEST_TMPDIR:?}/state" + PLAN="${STATE}/plan-2026-10-21.json" + STASH="${DRIVE}/probably-dupes/2026-10-21" + ARCHIVE="${DRIVE}/Documents Archive" + mkdir -p "${DOC}" "${DRIVE}" + export DOCS_TIDY_DOC="${DOC}" + export DOCS_TIDY_DRIVE="${DRIVE}" + export DOCS_TIDY_STATE="${STATE}" + export DOCS_TIDY_DRIVEFS=none + export DOCS_TIDY_TODAY=2026-10-21 +} + +# mk PATH STAMP [CONTENT]: create a file under DOC with an mtime of STAMP +# (touch -t format, CCYYMMDDhhmm). +mk() { + local path="${DOC}/$1" stamp="$2" content="${3:-content of $1}" + mkdir -p "${path%/*}" + printf '%s' "${content}" >"${path}" + touch -t "${stamp}" "${path}" +} + +# mkbig PATH STAMP CHAR: like mk, with 2 KiB of CHAR as the content, enough +# to pass the 1 KiB dupe threshold. +mkbig() { + local pad + pad="$(printf '%2048s' '')" + mk "$1" "$2" "${pad// /$3}" +} + +# mksock DIR NAME: create a Unix socket. Bind a relative name after cd, since +# macOS caps socket paths at 104 bytes and BATS_TEST_TMPDIR is long there. +mksock() { + (cd "${DOC}/$1" && python3 -I -c 'import socket, sys; socket.socket(socket.AF_UNIX).bind(sys.argv[1])' "$2") +} + +# want_in PLANFILE EXPR EXPECTED: EXPR (Python, plan bound to `p`) must +# print EXPECTED. +want_in() { + local got + got="$(python3 -I -c 'import json, sys; p = json.load(open(sys.argv[1])); print(eval(sys.argv[2]))' "$1" "$2")" + if [ "${got}" != "$3" ]; then + printf 'expr: %s\n got: %s\nwant: %s\n' "$2" "${got}" "$3" >&2 + return 1 + fi +} + +want() { + want_in "${PLAN}" "$1" "$2" +} + +# want_srcs SECTION EXPECTED: the sorted rel paths planned for SECTION. +want_srcs() { + want "sorted(e['rel'] for e in p['entries'] if e['section'] == '$1')" "$2" +} + +# want_file PATH EXPECTED: PATH holds exactly EXPECTED. +want_file() { + local got + got="$(<"$1")" + if [ "${got}" != "$2" ]; then + printf 'file: %s\n got: %s\nwant: %s\n' "$1" "${got}" "$2" >&2 + return 1 + fi +} + +@test "cutoff is Jan 1 four years back: 2026-10-21 -> 2022-01-01" { + run "${SCRIPT}" plan + [ "${status}" -eq 0 ] + want "p['cutoff']" "2022-01-01" +} + +@test "cutoff is Jan 1 four years back: 2027-01-05 -> 2023-01-01" { + run env DOCS_TIDY_TODAY=2027-01-05 "${SCRIPT}" plan + [ "${status}" -eq 0 ] + want_in "${STATE}/plan-2027-01-05.json" "p['cutoff']" "2023-01-01" +} + +@test "--help describes the commands" { + run "${SCRIPT}" --help + [ "${status}" -eq 0 ] + [[ "${output}" == *plan* && "${output}" == *show* && "${output}" == *apply* ]] +} + +@test "an old unit archives and a recent unit stays" { + mk "old/a.txt" 201901010000 + mk "old/sub/b.txt" 202012311200 + mk "oldfile.pdf" 201805050000 + mk "new/a.txt" 201901010000 + mk "new/b.txt" 202603010000 + run "${SCRIPT}" plan + [ "${status}" -eq 0 ] + want_srcs archive "['old/a.txt', 'old/sub/b.txt', 'oldfile.pdf']" + want "sorted(u['rel'] for u in p['units'])" "['old', 'oldfile.pdf']" + want "[e['dst'] for e in p['entries'] if e['rel'] == 'old/a.txt'][0]" "${ARCHIVE}/old/a.txt" + [ -f "${STATE}/plan-2026-10-21.txt" ] + [[ "${output}" == *"Archive units (2)"* ]] +} + +@test "KEEP items never archive and are not counted" { + mk "army/dd.pdf" 201001010000 + mk "DD214.pdf" 201001010000 + mk "ssn.pdf" 201001010000 + run "${SCRIPT}" plan + [ "${status}" -eq 0 ] + want "len(p['entries'])" "0" + want "len(p['units'])" "0" +} + +@test "tax years split: an old year archives, a year with one recent file stays" { + mk "tax/2019/w2.pdf" 202002010000 + mk "tax/2021/w2.pdf" 202102010000 + mk "tax/2021/amended.pdf" 202203010000 + run "${SCRIPT}" plan + [ "${status}" -eq 0 ] + want_srcs archive "['tax/2019/w2.pdf']" + want "[u['rel'] for u in p['units']]" "['tax/2019']" +} + +@test "a socket and a symlink are skipped, not planned" { + mk "old/a.txt" 201901010000 + ln -s a.txt "${DOC}/old/link" + mksock old sock + [ -S "${DOC}/old/sock" ] + run "${SCRIPT}" plan + [ "${status}" -eq 0 ] + want_srcs archive "['old/a.txt']" + want "sorted(p['skipped'])" "[['old/link', 'symlink'], ['old/sock', 'socket']]" +} + +@test "snapshot: merge a unique file, stash a dupe, stash a superseded file" { + mk "keep.txt" 202501010000 "same bytes" + mk "conflict.txt" 202501010000 "local version" + mk "Documents - HOST/copy-of-keep.txt" 202001010000 "same bytes" + mk "Documents - HOST/only/here.txt" 202001010000 "unique" + mk "Documents - HOST/conflict.txt" 202001010000 "snapshot version" + run "${SCRIPT}" plan + [ "${status}" -eq 0 ] + want_srcs snapshot-merge "['Documents - HOST/only/here.txt']" + want "[e['dst'] for e in p['entries'] if e['section'] == 'snapshot-merge'][0]" "${DOC}/only/here.txt" + want_srcs snapshot "['Documents - HOST/conflict.txt', 'Documents - HOST/copy-of-keep.txt']" + want "p['snapshots']['Documents - HOST']['superseded']" "['Documents - HOST/conflict.txt']" + want "[e['dst'] for e in p['entries'] if e['rel'] == 'Documents - HOST/conflict.txt'][0]" \ + "${STASH}/Documents - HOST/conflict.txt" + # Snapshot folders are never archive units, however old. + want_srcs archive "[]" +} + +@test "dupes: credit-reports wins, then earliest mtime" { + mkbig "a/one.bin" 202405010000 x + mkbig "b/two.bin" 202305010000 x + mkbig "c/three.bin" 202305010000 x + mkbig "credit-reports/r.pdf" 202501010000 y + mkbig "other/r.pdf" 202301010000 y + run "${SCRIPT}" plan + [ "${status}" -eq 0 ] + want "len(p['dupe_groups'])" "2" + want "sorted(g['keep'] for g in p['dupe_groups'])" "['b/two.bin', 'credit-reports/r.pdf']" + want_srcs dupes "['a/one.bin', 'c/three.bin', 'other/r.pdf']" + want "[e['dst'] for e in p['entries'] if e['rel'] == 'a/one.bin'][0]" "${STASH}/a/one.bin" +} + +@test "dupes: .git paths, small files and bundles are excluded" { + mkbig "repo/.git/objects/x" 202501010000 g + mkbig "other/x" 202501010000 g + mkbig "Thing.app/Contents/x" 202501010000 h + mkbig "loose/x" 202501010000 h + mkbig "Doc.pages/x" 202501010000 i + mkbig "loose2/y" 202501010000 i + mkbig "Search.workflow/Contents/document.wflow" 202501010000 j + mkbig "loose3/document.wflow" 202501010000 j + mk "small1/s.txt" 202501010000 "tiny" + mk "small2/s.txt" 202501010000 "tiny" + run "${SCRIPT}" plan + [ "${status}" -eq 0 ] + want "len(p['dupe_groups'])" "0" + want_srcs dupes "[]" +} + +@test "dupes: files inside a git working tree are excluded, at any depth" { + # A .git directory at the top of one unit, and a .git file (a worktree or + # submodule) two levels down in another. + mkdir -p "${DOC}/proj/.git" + mkbig "proj/src/data.bin" 202501010000 w + mkbig "outside/data.bin" 202501010000 w + mk "notes/deep/sub/.git" 202501010000 "gitdir: elsewhere" + mkbig "notes/deep/sub/lib/z.bin" 202501010000 z + mkbig "elsewhere/z.bin" 202501010000 z + # Control: the same layout with no .git entry is still a dupe group. + mkbig "plain/src/data.bin" 202501010000 v + mkbig "plain2/data.bin" 202601010000 v + run "${SCRIPT}" plan + [ "${status}" -eq 0 ] + want_srcs dupes "['plain2/data.bin']" +} + +@test "dupes: node_modules paths are excluded" { + mkbig "site/node_modules/pkg/index.js" 202501010000 n + mkbig "other/index.js" 202501010000 n + run "${SCRIPT}" plan + [ "${status}" -eq 0 ] + want "len(p['dupe_groups'])" "0" +} + +@test "dupes: copies inside one unit are deliberate and stay" { + mkbig "Resume/_base/template.md" 202301010000 t + mkbig "Resume/_archived/acme/template.md" 202401010000 t + mkbig "icons/airship.png" 202301010000 p + mkbig "icons/dirigible.png" 202401010000 p + mkbig "tax/2024/form.pdf" 202401010000 f + mkbig "tax/2025/form.pdf" 202501010000 f + run "${SCRIPT}" plan + [ "${status}" -eq 0 ] + # Only the tax pair spans two units (tax/2024 and tax/2025). + want_srcs dupes "['tax/2025/form.pdf']" +} + +@test "dupes: copies in the keeper's unit stay; copies in other units are stashed" { + mkbig "a/one.bin" 202301010000 x + mkbig "a/one-copy.bin" 202401010000 x + mkbig "b/two.bin" 202402010000 x + run "${SCRIPT}" plan + [ "${status}" -eq 0 ] + want "[g['keep'] for g in p['dupe_groups']]" "['a/one.bin']" + want_srcs dupes "['b/two.bin']" +} + +@test "credit: names are parsed into renames; an unparseable name needs manual" { + mk "credit-reports/20240615 AMR Experian report.pdf" 202406150000 + mk "credit-reports/JR trans union.pdf" 202302100000 + mk "credit-reports/2024-10 Jen Equifax.pdf" 202410010000 + mk "credit-reports/mystery.pdf" 202401010000 + mk "credit-reports/.rename-log-2026-10-21.tsv" 202601010000 + run "${SCRIPT}" plan + [ "${status}" -eq 0 ] + want "sorted((e['rel'], e['dst'].rsplit('/', 1)[1], e['tags']) for e in p['entries'] if e['section'] == 'credit' and not e['retag_only'])" \ + "[('credit-reports/20240615 AMR Experian report.pdf', '2024-06 Andrew Experian.pdf', ['Andrew', 'Experian']), ('credit-reports/JR trans union.pdf', '2023-02 Jen TransUnion.pdf', ['Jen', 'TransUnion'])]" + want "p['needs_manual']" "[['credit-reports/mystery.pdf', 'no person']]" +} + +@test "credit: a rename that would collide needs manual" { + mk "credit-reports/2024-06 Andrew Experian.pdf" 202406010000 + mk "credit-reports/AMR experian.pdf" 202406200000 + run "${SCRIPT}" plan + [ "${status}" -eq 0 ] + want "[e['rel'] for e in p['entries'] if e['section'] == 'credit' and not e['retag_only']]" "[]" + want "p['needs_manual'][0][0]" "credit-reports/AMR experian.pdf" +} + +@test "apply moves, verifies, deletes the source, rmdirs empty dirs and appends the pointer" { + mk "old/a.txt" 201901010000 "alpha" + mk "old/sub/b.txt" 201901010000 "beta" + mk "old/sub/.DS_Store" 201901010000 "junk" + mk "tax/2018/w2.pdf" 201902010000 "w2" + mk "recent.txt" 202601010000 "stay" + printf 'earlier notes\n' >"${DOC}/ARCHIVED-TO-GOOGLE-DRIVE.txt" + run "${SCRIPT}" plan + [ "${status}" -eq 0 ] + run "${SCRIPT}" apply + [ "${status}" -eq 0 ] + want_file "${ARCHIVE}/old/a.txt" "alpha" + want_file "${ARCHIVE}/old/sub/b.txt" "beta" + want_file "${ARCHIVE}/tax/2018/w2.pdf" "w2" + [ ! -e "${DOC}/old" ] + [ ! -e "${DOC}/tax/2018" ] + [ -f "${DOC}/recent.txt" ] + run head -1 "${DOC}/ARCHIVED-TO-GOOGLE-DRIVE.txt" + [ "${output}" = "earlier notes" ] + grep -q "On 2026-10-21, docs-tidy" "${DOC}/ARCHIVED-TO-GOOGLE-DRIVE.txt" + grep -q "2018" "${DOC}/tax/ARCHIVED-YEARS.txt" + run grep -c '^OK' "${STATE}/apply-2026-10-21.tsv" + [ "${output}" = "3" ] + run grep -c '^ERR' "${STATE}/apply-2026-10-21.tsv" + [ "${output}" = "0" ] +} + +@test "apply keeps a directory that still holds other files" { + mk "old/a.txt" 201901010000 "alpha" + mksock old sock + run "${SCRIPT}" plan + [ "${status}" -eq 0 ] + run "${SCRIPT}" apply + [ "${status}" -eq 0 ] + [ ! -e "${DOC}/old/a.txt" ] + [ -S "${DOC}/old/sock" ] +} + +@test "apply merges and stashes a snapshot" { + mk "keep.txt" 202501010000 "same bytes" + mk "Documents - HOST/copy.txt" 202001010000 "same bytes" + mk "Documents - HOST/only/here.txt" 202001010000 "unique" + run "${SCRIPT}" plan + [ "${status}" -eq 0 ] + run "${SCRIPT}" apply + [ "${status}" -eq 0 ] + want_file "${DOC}/only/here.txt" "unique" + [ -f "${STASH}/Documents - HOST/copy.txt" ] + [ ! -e "${DOC}/Documents - HOST" ] +} + +@test "apply refuses a plan older than 14 days" { + mk "old/a.txt" 201901010000 + run env DOCS_TIDY_TODAY=2026-10-01 "${SCRIPT}" plan + [ "${status}" -eq 0 ] + run env DOCS_TIDY_TODAY=2026-10-20 "${SCRIPT}" apply "${STATE}/plan-2026-10-01.json" + [ "${status}" -eq 2 ] + [[ "${output}" == *"19 days old"* ]] + [ -f "${DOC}/old/a.txt" ] + [ ! -e "${ARCHIVE}" ] +} + +@test "apply refuses when the Drive root is missing" { + mk "old/a.txt" 201901010000 + run "${SCRIPT}" plan + [ "${status}" -eq 0 ] + rmdir "${DRIVE}" + run "${SCRIPT}" apply + [ "${status}" -eq 2 ] + [[ "${output}" == *"Drive root missing"* ]] + [ -f "${DOC}/old/a.txt" ] +} + +@test "plan --notify posts a FAILED notification when plan refuses" { + # A stub osascript records the call, so no real notification pops up. On + # Linux the script logs the notification instead; accept either. + local bin="${BATS_TEST_TMPDIR:?}/bin" + mkdir -p "${bin}" + printf '#!/bin/sh\nprintf "%%s\\n" "$*" >>"%s/osascript.log"\n' "${BATS_TEST_TMPDIR}" >"${bin}/osascript" + chmod +x "${bin}/osascript" + rmdir "${DOC}" + run env PATH="${bin}:${PATH}" "${SCRIPT}" plan --notify + [ "${status}" -eq 2 ] + local seen="${output}" + if [[ -f "${BATS_TEST_TMPDIR}/osascript.log" ]]; then + seen="$(<"${BATS_TEST_TMPDIR}/osascript.log")" + fi + [[ "${seen}" == *"docs-tidy plan FAILED"* ]] +} + +@test "apply records an error and keeps the source when it changed after planning" { + mkbig "a/keep.bin" 202301010000 x + mkbig "b/stash.bin" 202401010000 x + run "${SCRIPT}" plan + [ "${status}" -eq 0 ] + want_srcs dupes "['b/stash.bin']" + printf 'edited' >>"${DOC}/b/stash.bin" + run "${SCRIPT}" apply + [ "${status}" -eq 1 ] + [ -f "${DOC}/b/stash.bin" ] + [ ! -e "${STASH}/b/stash.bin" ] + grep -q '^ERR dupes' "${STATE}/apply-2026-10-21.tsv" + [[ "${output}" == *"source changed since plan"* ]] +} + +@test "apply skips an archive unit that changed after planning" { + mk "old/a.txt" 201901010000 + run "${SCRIPT}" plan + [ "${status}" -eq 0 ] + mk "old/new.txt" 202605010000 + run "${SCRIPT}" apply + [ "${status}" -eq 0 ] + [ -f "${DOC}/old/a.txt" ] + [[ "${output}" == *"unit changed since plan, skipped: old"* ]] +} + +@test "a missing source with an existing destination counts as done" { + mk "old/a.txt" 201901010000 "alpha" + run "${SCRIPT}" plan + [ "${status}" -eq 0 ] + mkdir -p "${ARCHIVE}/old" + cp -p "${DOC}/old/a.txt" "${ARCHIVE}/old/a.txt" + rm "${DOC}/old/a.txt" + run "${SCRIPT}" apply + [ "${status}" -eq 0 ] + grep -q "^OK archive .*already done" "${STATE}/apply-2026-10-21.tsv" +} + +@test "apply errors when the destination exists with other content" { + mk "old/a.txt" 201901010000 "alpha" + run "${SCRIPT}" plan + [ "${status}" -eq 0 ] + mkdir -p "${ARCHIVE}/old" + printf 'different' >"${ARCHIVE}/old/a.txt" + run "${SCRIPT}" apply + [ "${status}" -eq 1 ] + [ -f "${DOC}/old/a.txt" ] + want_file "${ARCHIVE}/old/a.txt" "different" +} + +@test "--skip dupes leaves dupes in place" { + mkbig "a/keep.bin" 202301010000 x + mkbig "b/stash.bin" 202401010000 x + mk "old/a.txt" 201901010000 + run "${SCRIPT}" plan + [ "${status}" -eq 0 ] + run "${SCRIPT}" apply --skip dupes + [ "${status}" -eq 0 ] + [ -f "${DOC}/a/keep.bin" ] + [ -f "${DOC}/b/stash.bin" ] + [ ! -e "${DRIVE}/probably-dupes" ] + [ ! -e "${DOC}/old" ] +} + +@test "apply renames credit reports and writes the rename log" { + mk "credit-reports/20240615 AMR Experian report.pdf" 202406150000 "pdf" + run "${SCRIPT}" plan + [ "${status}" -eq 0 ] + run "${SCRIPT}" apply + [ "${status}" -eq 0 ] + [ -f "${DOC}/credit-reports/2024-06 Andrew Experian.pdf" ] + [ ! -e "${DOC}/credit-reports/20240615 AMR Experian report.pdf" ] + grep -q "20240615 AMR Experian report.pdf 2024-06 Andrew Experian.pdf Andrew,Experian" \ + "${DOC}/credit-reports/.rename-log-2026-10-21.tsv" + # A credit-only apply moved nothing to Drive, so it writes no pointer. + [ ! -e "${DOC}/ARCHIVED-TO-GOOGLE-DRIVE.txt" ] +} + +@test "apply of dupes rmdirs the emptied parent but not an untouched sibling" { + mkbig "a/keep.bin" 202301010000 x + mkbig "b/c/stash.bin" 202401010000 x + mk "b/c/.DS_Store" 202401010000 "junk" + mk "b/sibling/.DS_Store" 202401010000 "junk" + run "${SCRIPT}" plan + [ "${status}" -eq 0 ] + run "${SCRIPT}" apply + [ "${status}" -eq 0 ] + [ -f "${STASH}/b/c/stash.bin" ] + [ ! -e "${DOC}/b/c" ] + [ -f "${DOC}/b/sibling/.DS_Store" ] + [ -f "${DOC}/a/keep.bin" ] +} + +@test "--skip repeats to skip more than one section" { + mkbig "a/keep.bin" 202301010000 x + mkbig "b/stash.bin" 202401010000 x + mk "old/a.txt" 201901010000 + run "${SCRIPT}" plan + [ "${status}" -eq 0 ] + run "${SCRIPT}" apply --skip dupes --skip archive + [ "${status}" -eq 0 ] + [ -f "${DOC}/b/stash.bin" ] + [ -f "${DOC}/old/a.txt" ] +} + +@test "--skip rejects an unknown section" { + run "${SCRIPT}" apply --skip bogus + [ "${status}" -ne 0 ] + [[ "${output}" == *"invalid choice"* ]] +} + +@test "show prints the newest plan" { + mk "old/a.txt" 201901010000 + run "${SCRIPT}" plan + [ "${status}" -eq 0 ] + run "${SCRIPT}" show + [ "${status}" -eq 0 ] + [[ "${output}" == *"docs-tidy plan 2026-10-21"* ]] + [[ "${output}" == *"old newest 2019-01-01"* ]] +} From 37d310a21229a50e01d655cda9bab9f52756b110 Mon Sep 17 00:00:00 2001 From: Claude Code Bot Date: Wed, 21 Oct 2026 14:50:52 -0700 Subject: [PATCH 2/4] fix(docs-tidy): address commit-review findings - Credit retag keeps the user's own Finder tags (and colours); it only adds a missing canonical tag, and plans a retag only in that case. - The rename log row is written at rename time; a tag-write failure is a note on an OK result, not an ERR. - A snapshot file whose target unit is archived in the same plan is stashed, not merged back into the archived unit. - Notifications pass title and message to osascript as argv, so no AppleScript escaping, and a nonzero exit is logged. - apply writes its TSV before the pointer and cleanup steps. - Unreadable directories are reported, and their unit never archives. - A failed or unverified copy removes its partial destination. --- docs-tidy.py | 144 ++++++++++++++------ docs/plans/2026-10-21-docs-tidy-design.md | 57 +++++--- tests/docs-tidy.bats | 155 ++++++++++++++++++++++ 3 files changed, 300 insertions(+), 56 deletions(-) diff --git a/docs-tidy.py b/docs-tidy.py index 7f58221..eecd62b 100755 --- a/docs-tidy.py +++ b/docs-tidy.py @@ -204,14 +204,22 @@ def rel(i): def inventory(doc, drivefs): - """Walk Documents. Return (files, skipped). + """Walk Documents. Return (files, skipped, unreadable). files: {rel: dict(size, mtime, dataless, md5, md5_src)}; regular files only. - skipped: [(rel, kind)] for symlinks, sockets, FIFOs and devices. + skipped: [(rel, kind)] for symlinks, sockets, FIFOs, devices and + unreadable directories. + unreadable: set of rel paths of directories os.walk could not list. """ - files, skipped = {}, [] + files, skipped, unreadable = {}, [], set() hashed = 0 - for root, dirs, names in os.walk(doc, followlinks=False): + + def walk_error(err): + rel = os.path.relpath(err.filename, doc) if err.filename else "?" + unreadable.add(rel) + skipped.append((rel, "unreadable directory")) + + for root, dirs, names in os.walk(doc, followlinks=False, onerror=walk_error): for d in list(dirs): p = os.path.join(root, d) if os.path.islink(p): @@ -244,7 +252,7 @@ def inventory(doc, drivefs): continue files[rel] = dict(size=st.st_size, mtime=st.st_mtime, dataless=dataless, md5=md5, md5_src=src) skipped.sort() - return files, skipped + return files, skipped, unreadable def file_kind(mode): @@ -279,7 +287,10 @@ def under(rel, prefix): def read_tags(path): - """Finder tag names (colour suffix stripped), or None when unreadable.""" + """Raw Finder tags, colour suffix kept (e.g. "Important\\n6"). + + None when tags cannot be read on this system; [] when the file has none. + """ if not (IS_MAC and shutil.which("xattr")): return None r = subprocess.run(["xattr", "-px", TAG_XATTR, path], capture_output=True, text=True) @@ -289,7 +300,17 @@ def read_tags(path): tags = plistlib.loads(bytes.fromhex("".join(r.stdout.split()))) except Exception: return [] - return [t.split("\n", 1)[0] for t in tags] + return [t for t in tags if isinstance(t, str)] + + +def tag_name(tag): + return tag.split("\n", 1)[0] + + +def merge_tags(have, canon): + """have, in order with colour suffixes, plus any canonical tag it lacks.""" + names = {tag_name(t) for t in have} + return list(have) + [t for t in canon if t not in names] def write_tags(path, tags): @@ -348,7 +369,7 @@ def parse_credit(name, mtime): def build_plan(doc, drive, drivefs_dirs, day): drivefs = load_drivefs(doc, drivefs_dirs) - files, skipped = inventory(doc, drivefs) + files, skipped, unreadable = inventory(doc, drivefs) cut = cutoff_ts(day) date = day.isoformat() arch_base = os.path.join(drive, "Documents Archive") @@ -382,6 +403,9 @@ def entry(section, rel, dst, **extra): members = [r for r in files if under(r, u)] if not members: continue + # A unit we cannot fully read may hold recent files we cannot see. + if any(under(d, u) for d in unreadable): + continue newest = max(files[r]["mtime"] for r in members) if newest >= cut: continue @@ -395,6 +419,7 @@ def in_snapshot(r): return top(r).startswith(SNAPSHOT_PREFIX) local = {r for r in files if r not in archived and not in_snapshot(r)} + archived_units = {u["rel"] for u in units} # 2. snapshots outside_md5 = {files[r]["md5"] for r in local if files[r]["md5"]} @@ -417,6 +442,13 @@ def in_snapshot(r): s["stash"] += 1 s["stash_bytes"] += f["size"] continue + if unit_of(inner) in archived_units: + # Merging into a unit that is leaving would strand the file + # in a half-archived folder. + entry("snapshot", r, stash_dst, why="unit archived") + s["stash"] += 1 + s["stash_bytes"] += f["size"] + continue if not os.path.lexists(os.path.join(doc, inner)) and inner not in merge_dst: entry("snapshot-merge", r, os.path.join(doc, inner)) merge_dst[inner] = f["md5"] @@ -491,16 +523,18 @@ def dupe_ok(r): if rel in stashed: continue tags = canonical_tags(n) + have = read_tags(os.path.join(cdir, n)) if tags is not None: - have = read_tags(os.path.join(cdir, n)) - if have is not None and sorted(have) != sorted(tags): + # Retag only when a canonical tag is missing; the user's own + # tags are kept. + if have is not None and merge_tags(have, tags) != have: proposals.append((n, n, tags, have)) continue new, why = parse_credit(n, files[rel]["mtime"]) if new is None: needs_manual.append((rel, why)) continue - proposals.append((n, new, why, None)) + proposals.append((n, new, why, have)) targets = {} for old, new, _tags, _have in proposals: targets.setdefault(new, []).append(old) @@ -619,9 +653,14 @@ def summary(plan): def post(title, msg): + """Post a macOS notification. Title and message go in as argv, so no + AppleScript string escaping is needed.""" if IS_MAC and shutil.which("osascript"): - script = f"display notification {json.dumps(msg)} with title {json.dumps(title)}" - subprocess.run(["osascript", "-e", script], check=False) + r = subprocess.run(["osascript", "-e", "on run argv", + "-e", "display notification (item 2 of argv) with title (item 1 of argv)", + "-e", "end run", title, msg], check=False, capture_output=True, text=True) + if r.returncode != 0: + log(f"notify failed (osascript exit {r.returncode}): {r.stderr.strip()}; {title}: {msg}") else: log(f"notify: {title}: {msg}") @@ -733,35 +772,42 @@ def move_file(e): os.remove(src) return "OK", h, "destination already had it" os.makedirs(os.path.dirname(dst), exist_ok=True) - for attempt in range(4): - try: - shutil.copy2(src, dst) - break - except OSError as ex: - if ex.errno not in (60, errno.ETIMEDOUT) or attempt == 3: - raise - brctl_download(src) - time.sleep(1) - if md5_file(dst) != h: - os.remove(dst) - return "ERR", h, "copy verification failed" + # dst did not exist (checked above), so any dst after a failure is this + # call's partial copy and is safe to remove. + try: + for attempt in range(4): + try: + shutil.copy2(src, dst) + break + except OSError as ex: + if ex.errno not in (60, errno.ETIMEDOUT) or attempt == 3: + raise + brctl_download(src) + time.sleep(1) + if md5_file(dst) != h: + raise RuntimeError("copy verification failed") + except BaseException: + if os.path.lexists(dst): + os.remove(dst) + raise os.remove(src) return "OK", h, "" def unit_newest(doc, unit): + """Return (newest regular-file mtime or None, whether a directory was unreadable).""" p = os.path.join(doc, unit) - ts = [] if os.path.isfile(p) and not os.path.islink(p): - return os.lstat(p).st_mtime - for r, _d, names in os.walk(p): + return os.lstat(p).st_mtime, False + ts, bad = [], [] + for r, _d, names in os.walk(p, onerror=bad.append): for n in names: if n in SKIPNAMES: continue st = os.lstat(os.path.join(r, n)) if stat.S_ISREG(st.st_mode): ts.append(st.st_mtime) - return max(ts) if ts else None + return (max(ts) if ts else None), bool(bad) def cmd_apply(args): @@ -804,10 +850,14 @@ def cmd_apply(args): bad_units = set() if section == "archive": for u in sorted({e["unit"] for e in ents}): - n = unit_newest(doc, u) - if n is not None and n >= cut: + n, unreadable = unit_newest(doc, u) + if unreadable: bad_units.add(u) - skipped_units.append((u, n)) + skipped_units.append((u, "has an unreadable directory")) + log(f" unit has an unreadable directory, skipped: {u}") + elif n is not None and n >= cut: + bad_units.add(u) + skipped_units.append((u, f"newest {fmt_ts(n)}")) log(f" unit changed since plan, skipped: {u} (newest {fmt_ts(n)})") for i, e in enumerate(ents): if section == "archive" and e["unit"] in bad_units: @@ -834,6 +884,14 @@ def cmd_apply(args): if (i + 1) % 500 == 0: log(f" {i + 1}/{len(ents)} {time.time() - t0:.0f}s") + # The record of what moved goes to disk first, so a failure in the + # pointer or cleanup steps below cannot lose it. + sd = state_dir() + os.makedirs(sd, exist_ok=True) + with open(os.path.join(sd, f"apply-{date}.tsv"), "a") as f: + for st, section, h, src, dst, note in results: + f.write(f"{st}\t{section}\t{h}\t{src}\t{dst}" + (f"\t{note}" if note else "") + "\n") + ok = [r for r in results if r[0] == "OK"] errs = [r for r in results if r[0] == "ERR"] # Credit entries rename in place, so they never belong in the pointer. @@ -852,11 +910,6 @@ def cmd_apply(args): f.write("old_name\tnew_name\ttags\n") for row in credit_log: f.write("\t".join(row) + "\n") - sd = state_dir() - os.makedirs(sd, exist_ok=True) - with open(os.path.join(sd, f"apply-{date}.tsv"), "a") as f: - for st, section, h, src, dst, note in results: - f.write(f"{st}\t{section}\t{h}\t{src}\t{dst}" + (f"\t{note}" if note else "") + "\n") by = {} for st, section, *_ in results: @@ -865,8 +918,8 @@ def cmd_apply(args): for s in SECTIONS: if s in by: log(f"{s:15} ok {by[s][0]:6} err {by[s][1]:6}") - for u, n in skipped_units: - log(f"skipped unit (changed since plan): {u} newest {fmt_ts(n)}") + for u, why in skipped_units: + log(f"skipped unit: {u} ({why})") log(f"done in {time.time() - t0:.0f}s; log: {os.path.join(sd, f'apply-{date}.tsv')}") if errs: log(f"\n{len(errs)} error(s):") @@ -889,10 +942,19 @@ def apply_credit(e, credit_log): if e.get("md5") and md5_file(src) != e["md5"]: return "ERR", "", "source changed since plan" os.rename(src, dst) + credit_log.append((old, new, ",".join(e["tags"]))) elif not os.path.exists(dst): return "ERR", "", "file missing" - tagged = write_tags(dst, e["tags"]) - credit_log.append((old, new, ",".join(e["tags"]))) + else: + credit_log.append((old, new, ",".join(e["tags"]))) + # The rename is done; a tag failure is a note on an OK result, not an ERR. + have = read_tags(dst) + if have is None: + have = e.get("old_tags") or [] + try: + tagged = write_tags(dst, merge_tags(have, e["tags"])) + except (OSError, subprocess.CalledProcessError) as ex: + return "OK", e.get("md5") or "", f"tags not written: {ex}" return "OK", e.get("md5") or "", "" if tagged else "tags skipped (not macOS)" diff --git a/docs/plans/2026-10-21-docs-tidy-design.md b/docs/plans/2026-10-21-docs-tidy-design.md index eaa6fb1..819b0fc 100644 --- a/docs/plans/2026-10-21-docs-tidy-design.md +++ b/docs/plans/2026-10-21-docs-tidy-design.md @@ -63,6 +63,8 @@ The cutoff is 00:00 local on Jan 1 of `(today.year - 4)`. In Oct 2026 that is 1. List regular files only (`S_ISREG` on `lstat`). Sockets, FIFOs and symlinks are reported as skipped and never planned. The 2026-10-21 run failed on a git `fsmonitor--daemon.ipc` socket. + A directory that cannot be listed (for example mode 000) is reported as + skipped ("unreadable directory"). 2. Skip `.DS_Store` and `.localized` everywhere. 3. MD5 source, in order: 1. the DriveFS mirror DB (`mirror_item.local_md5_checksum`), when its size @@ -76,11 +78,16 @@ The cutoff is 00:00 local on Jan 1 of `(today.year - 4)`. In Oct 2026 that is 1. **archive.** Units are the top-level entries of Documents, except dotfiles, `Documents - *`, `credit-reports`, `tax`, the pointer file and `KEEP`. Each `tax/` is its own unit. A unit archives when its newest - regular-file mtime is before the cutoff. Every file in it maps to + regular-file mtime is before the cutoff. A unit that contains an + unreadable directory never archives, because it may hold recent files + the plan cannot see. Every file in it maps to `/Documents Archive/`. 2. **snapshot.** For each `Documents - ` folder, check every file against the files that stay local after archive: - If its MD5 matches a file outside the snapshots, stash it. + - If its merge target falls in a unit that this plan archives, stash it + (`why` = "unit archived"). Merging it would leave the file behind in a + folder that is moving to Drive. - If it is unique and `/` is absent, merge it there (section `snapshot-merge`). - If it is unique but that path exists with other content, stash it and @@ -107,7 +114,7 @@ The cutoff is 00:00 local on Jan 1 of `(today.year - 4)`. In Oct 2026 that is `node_modules` or unit rule) planned 245 stashes in 167 groups. 93 were under `old-pi`, 51 under `Resume` (per-application template copies), 41 under `Codex` and 33 in `node_modules`. With these rules the same data - gives 19 stashes in 19 groups. + gives 18 stashes in 18 groups (with `.workflow` excluded). 4. **credit.** For files in `credit-reports` (not dotfiles) whose name does not match `^\d{4}-\d{2} (Andrew|Jen) (Equifax|Experian|TransUnion)\.pdf$` or the request-form names, propose a rename. Use the person and agency @@ -117,8 +124,10 @@ The cutoff is 00:00 local on Jan 1 of `(today.year - 4)`. In Oct 2026 that is name, else the file's mtime. - Unparseable names, or names that would collide, go to "needs manual". Nothing is planned for them. - - Canonical names whose Finder tags differ from `[Person, Agency]` get a - retag-only entry. + - A canonical name gets a retag-only entry only when one of its canonical + tags (`[Person, Agency]`) is missing. Tag names are compared with the + Finder colour suffix removed (`"Important\n6"` is `Important`). Extra + tags are the user's and never trigger a retag. The text summary shows counts and bytes per section, every archive unit with its newest mtime, every dupe group (kept path, then stashed paths), every @@ -141,30 +150,48 @@ Per file: copy, verify, delete. This is the pattern proven on 2026-10-21. 2. If the destination exists with the same MD5, delete the source. If it exists with a different MD5, record an error. 3. Copy with `shutil.copy2`, re-hash the destination, and only then delete - the source. + the source. If the copy raises or the verification fails, remove the + partial destination (it did not exist before this copy) and record an + error. The source stays. 4. A missing source with an existing destination counts as already done. For archive units, first re-check that the unit's newest mtime is still -before the cutoff. If not, skip the whole unit and report it. +before the cutoff and that every directory in it can be read. If not, skip +the whole unit and report it. Before copying, call `brctl download` on each dataless source (macOS only). On `ETIMEDOUT` (errno 60), call `brctl download` and retry, up to 3 times. The 2026-10-21 run lost 9 files to this, and all 9 then copied in 5 s. -Credit entries: `os.rename`, then write tags with `xattr -wx -com.apple.metadata:_kMDItemUserTags ` (macOS only; on other -systems, log that tags were skipped). Append one line per change to -`credit-reports/.rename-log-.tsv`. +Credit entries: `os.rename`, and record the rename-log row at once. Then +write tags with `xattr -wx com.apple.metadata:_kMDItemUserTags ` (macOS only; on other systems, log that tags were skipped). The tags +written are the file's current tags, in order and with colour suffixes, plus +any missing canonical tag. A tag-write failure is a note on an OK result, +not an error, because the rename already happened. Append one line per +change to `credit-reports/.rename-log-.tsv`. -After the moves: +After the moves, in this order: +- Write `apply-.tsv` (OK/ERR, section, md5, src, dst, note) to the + state dir first, so a failure in a later step cannot lose the record. +- Append a dated section to `/ARCHIVED-TO-GOOGLE-DRIVE.txt` + (create it if missing), unless only credit entries changed. When tax + years were archived, also append to `/tax/ARCHIVED-YEARS.txt`. + This runs before cleanup, so `tax/` is not removed as empty when its last + year moves. - In each source tree that was moved, delete `.DS_Store` and `.localized`. Then `rmdir` the empty directories, bottom-up. Never use `rm -rf`, and never touch a directory that still holds other files. -- Append a dated section to `/ARCHIVED-TO-GOOGLE-DRIVE.txt` - (create it if missing). When tax years were archived, also append to - `/tax/ARCHIVED-YEARS.txt`. -- Write `apply-.tsv` (OK/ERR, section, md5, src, dst) to the state dir. Exit 0 if every entry succeeded, else exit 1 and print a summary of the errors. + +## Notifications + +`post(title, message)` runs `osascript -e 'on run argv' -e 'display +notification (item 2 of argv) with title (item 1 of argv)' -e 'end run' +TITLE MESSAGE`. The text goes in as argv, so it needs no AppleScript +escaping. A nonzero `osascript` exit is logged. Off macOS, the text is +printed instead. A `plan --notify` that fails posts "docs-tidy plan FAILED" +with the reason, because nobody reads the launchd log. diff --git a/tests/docs-tidy.bats b/tests/docs-tidy.bats index b5bc82a..9224c0c 100755 --- a/tests/docs-tidy.bats +++ b/tests/docs-tidy.bats @@ -24,6 +24,36 @@ setup() { export DOCS_TIDY_TODAY=2026-10-21 } +# Restore permissions that a test removed (chmod 000 / 555), so bats can +# clean up its temp dir. +teardown() { + chmod -R u+rwx "${BATS_TEST_TMPDIR:?}" +} + +# is_mac: true on macOS, where Finder tags and osascript exist. +is_mac() { + [[ "${OSTYPE}" == darwin* ]] +} + +# settags FILE TAG...: write Finder tags (macOS only). +settags() { + local file="$1" hex + shift + hex="$(python3 -I -c 'import plistlib, sys; print(plistlib.dumps(sys.argv[1:], fmt=plistlib.FMT_BINARY).hex())' "$@")" + xattr -wx com.apple.metadata:_kMDItemUserTags "${hex}" "${file}" +} + +# want_tags FILE EXPECTED: the file's Finder tags as a Python list repr. +want_tags() { + local hex got + hex="$(xattr -px com.apple.metadata:_kMDItemUserTags "$1")" + got="$(python3 -I -c 'import plistlib, sys; print(plistlib.loads(bytes.fromhex("".join(sys.argv[1].split()))))' "${hex}")" + if [ "${got}" != "$2" ]; then + printf 'tags: %s\n got: %s\nwant: %s\n' "$1" "${got}" "$2" >&2 + return 1 + fi +} + # mk PATH STAMP [CONTENT]: create a file under DOC with an mtime of STAMP # (touch -t format, CCYYMMDDhhmm). mk() { @@ -470,3 +500,128 @@ want_file() { [[ "${output}" == *"docs-tidy plan 2026-10-21"* ]] [[ "${output}" == *"old newest 2019-01-01"* ]] } + +@test "credit retag: only a missing canonical tag triggers it, and the user's tags are kept" { + if ! is_mac; then skip "Finder tags need macOS"; fi + mk "credit-reports/2024-10 Jen Equifax.pdf" 202410010000 + mk "credit-reports/2024-06 Andrew Experian.pdf" 202406010000 + settags "${DOC}/credit-reports/2024-10 Jen Equifax.pdf" $'Important\n6' Jen + # Canonical tags present (one with a colour suffix) plus a user tag: no retag. + settags "${DOC}/credit-reports/2024-06 Andrew Experian.pdf" Andrew $'Experian\n4' Mine + run "${SCRIPT}" plan + [ "${status}" -eq 0 ] + want "[e['rel'] for e in p['entries'] if e['section'] == 'credit']" "['credit-reports/2024-10 Jen Equifax.pdf']" + run "${SCRIPT}" apply + [ "${status}" -eq 0 ] + want_tags "${DOC}/credit-reports/2024-10 Jen Equifax.pdf" "['Important\\n6', 'Jen', 'Equifax']" + want_tags "${DOC}/credit-reports/2024-06 Andrew Experian.pdf" "['Andrew', 'Experian\\n4', 'Mine']" +} + +@test "credit rename keeps the file's existing tags and adds the canonical ones" { + if ! is_mac; then skip "Finder tags need macOS"; fi + mk "credit-reports/AMR experian.pdf" 202406200000 + settags "${DOC}/credit-reports/AMR experian.pdf" $'Important\n6' + run "${SCRIPT}" plan + [ "${status}" -eq 0 ] + run "${SCRIPT}" apply + [ "${status}" -eq 0 ] + want_tags "${DOC}/credit-reports/2024-06 Andrew Experian.pdf" "['Important\\n6', 'Andrew', 'Experian']" +} + +@test "credit: a tag write failure is a note on an OK rename, and the rename is logged" { + mk "credit-reports/AMR experian.pdf" 202406200000 + run "${SCRIPT}" plan + [ "${status}" -eq 0 ] + # Read-only file: the rename still works (the directory is writable) but + # xattr cannot write the tags. + chmod 444 "${DOC}/credit-reports/AMR experian.pdf" + run "${SCRIPT}" apply + [ "${status}" -eq 0 ] + [ -f "${DOC}/credit-reports/2024-06 Andrew Experian.pdf" ] + grep -q "AMR experian.pdf 2024-06 Andrew Experian.pdf Andrew,Experian" \ + "${DOC}/credit-reports/.rename-log-2026-10-21.tsv" + grep -q "^OK credit" "${STATE}/apply-2026-10-21.tsv" + if is_mac; then + grep -q "^OK credit.*tags not written" "${STATE}/apply-2026-10-21.tsv" + fi +} + +@test "snapshot: a file whose unit is being archived is stashed, not merged" { + mk "old/a.txt" 201901010000 "alpha" + mk "Documents - HOST/old/extra.txt" 201801010000 "only in snapshot" + run "${SCRIPT}" plan + [ "${status}" -eq 0 ] + want_srcs archive "['old/a.txt']" + want_srcs snapshot-merge "[]" + want "[e['why'] for e in p['entries'] if e['section'] == 'snapshot']" "['unit archived']" +} + +@test "notify passes title and message to osascript as argv and logs a failure" { + mkdir -p "${BATS_TEST_TMPDIR}/bin" + cat >"${BATS_TEST_TMPDIR}/bin/osascript" <<'SH' +#!/usr/bin/env bash +printf '<%s>\n' "$@" >"${BATS_TEST_TMPDIR}/osascript.args" +exit 3 +SH + chmod +x "${BATS_TEST_TMPDIR}/bin/osascript" + run env PATH="${BATS_TEST_TMPDIR}/bin:${PATH}" "${SCRIPT}" plan --notify + [ "${status}" -eq 0 ] + if is_mac; then + grep -qx '' "${BATS_TEST_TMPDIR}/osascript.args" + grep -qx '' "${BATS_TEST_TMPDIR}/osascript.args" + grep -q '^.tsv before the pointer step, so a pointer failure keeps the record" { + mk "old/a.txt" 201901010000 "alpha" + run "${SCRIPT}" plan + [ "${status}" -eq 0 ] + # A directory where the pointer file should be makes write_pointer fail. + mkdir "${DOC}/ARCHIVED-TO-GOOGLE-DRIVE.txt" + run "${SCRIPT}" apply + [ "${status}" -ne 0 ] + grep -q "^OK archive .*old/a.txt" "${STATE}/apply-2026-10-21.tsv" +} + +@test "an unreadable directory is skipped and its unit never archives" { + if [[ "${EUID}" -eq 0 ]]; then skip "root can read a chmod 000 directory"; fi + mk "old/a.txt" 201901010000 + mk "old/locked/b.txt" 201901010000 + mk "old2/a.txt" 201901010000 + chmod 000 "${DOC}/old/locked" + run "${SCRIPT}" plan + [ "${status}" -eq 0 ] + want_srcs archive "['old2/a.txt']" + want "p['skipped']" "[['old/locked', 'unreadable directory']]" +} + +@test "apply skips a unit that gained an unreadable directory after planning" { + if [[ "${EUID}" -eq 0 ]]; then skip "root can read a chmod 000 directory"; fi + mk "old/a.txt" 201901010000 + mk "old/sub/b.txt" 201901010000 + run "${SCRIPT}" plan + [ "${status}" -eq 0 ] + chmod 000 "${DOC}/old/sub" + run "${SCRIPT}" apply + [ "${status}" -eq 0 ] + [ -f "${DOC}/old/a.txt" ] + [[ "${output}" == *"unit has an unreadable directory, skipped: old"* ]] +} + +@test "a failed copy records an error, keeps the source and leaves no destination" { + if [[ "${EUID}" -eq 0 ]]; then skip "root can write a read-only directory"; fi + mk "old/a.txt" 201901010000 "alpha" + run "${SCRIPT}" plan + [ "${status}" -eq 0 ] + mkdir -p "${ARCHIVE}/old" + chmod 555 "${ARCHIVE}/old" + run "${SCRIPT}" apply + [ "${status}" -eq 1 ] + want_file "${DOC}/old/a.txt" "alpha" + [ ! -e "${ARCHIVE}/old/a.txt" ] + grep -q "^ERR archive" "${STATE}/apply-2026-10-21.tsv" +} From 15f0810dc6314cdb915de0e498a93a4165634791 Mon Sep 17 00:00:00 2001 From: Claude Code Bot Date: Wed, 21 Oct 2026 14:53:23 -0700 Subject: [PATCH 3/4] fix(docs-tidy): write rename log early; tighten tag and unit reads - The credit rename log is written with the apply TSV, before the pointer and cleanup steps, so a failure there cannot lose it. - read_tags returns None (unknown) on a failed xattr read or a bad plist, so apply falls back to the planned tags instead of dropping the user's own tags. A missing xattr still means no tags. - unit_newest treats an archive unit that is already gone as moved, not unreadable; os.walk calls onerror for a missing top directory, which made a re-run skip finished units. --- docs-tidy.py | 24 ++++++++++++++---------- tests/docs-tidy.bats | 15 +++++++++++++++ 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/docs-tidy.py b/docs-tidy.py index eecd62b..06ef0f9 100755 --- a/docs-tidy.py +++ b/docs-tidy.py @@ -295,11 +295,12 @@ def read_tags(path): return None r = subprocess.run(["xattr", "-px", TAG_XATTR, path], capture_output=True, text=True) if r.returncode != 0: - return [] + # A missing xattr means no tags; any other failure means unknown. + return [] if "No such xattr" in r.stderr else None try: tags = plistlib.loads(bytes.fromhex("".join(r.stdout.split()))) except Exception: - return [] + return None return [t for t in tags if isinstance(t, str)] @@ -797,6 +798,9 @@ def move_file(e): def unit_newest(doc, unit): """Return (newest regular-file mtime or None, whether a directory was unreadable).""" p = os.path.join(doc, unit) + if not os.path.lexists(p): + # Already moved (a re-run): not unreadable; move_file handles it. + return None, False if os.path.isfile(p) and not os.path.islink(p): return os.lstat(p).st_mtime, False ts, bad = [], [] @@ -891,6 +895,14 @@ def cmd_apply(args): with open(os.path.join(sd, f"apply-{date}.tsv"), "a") as f: for st, section, h, src, dst, note in results: f.write(f"{st}\t{section}\t{h}\t{src}\t{dst}" + (f"\t{note}" if note else "") + "\n") + if credit_log: + lp = os.path.join(doc, CREDIT, f".rename-log-{date}.tsv") + new = not os.path.exists(lp) + with open(lp, "a") as f: + if new: + f.write("old_name\tnew_name\ttags\n") + for row in credit_log: + f.write("\t".join(row) + "\n") ok = [r for r in results if r[0] == "OK"] errs = [r for r in results if r[0] == "ERR"] @@ -902,14 +914,6 @@ def cmd_apply(args): write_pointer(plan, moved, date) for root in sorted(moved_roots, key=len, reverse=True): cleanup_tree(root, doc) - if credit_log: - lp = os.path.join(doc, CREDIT, f".rename-log-{date}.tsv") - new = not os.path.exists(lp) - with open(lp, "a") as f: - if new: - f.write("old_name\tnew_name\ttags\n") - for row in credit_log: - f.write("\t".join(row) + "\n") by = {} for st, section, *_ in results: diff --git a/tests/docs-tidy.bats b/tests/docs-tidy.bats index 9224c0c..938ffdf 100755 --- a/tests/docs-tidy.bats +++ b/tests/docs-tidy.bats @@ -578,6 +578,7 @@ SH @test "apply writes apply-.tsv before the pointer step, so a pointer failure keeps the record" { mk "old/a.txt" 201901010000 "alpha" + mk "credit-reports/20240615 AMR Experian report.pdf" 202406150000 "pdf" run "${SCRIPT}" plan [ "${status}" -eq 0 ] # A directory where the pointer file should be makes write_pointer fail. @@ -585,6 +586,20 @@ SH run "${SCRIPT}" apply [ "${status}" -ne 0 ] grep -q "^OK archive .*old/a.txt" "${STATE}/apply-2026-10-21.tsv" + # The credit rename log is written before the pointer step too. + grep -q "2024-06 Andrew Experian.pdf" "${DOC}/credit-reports/.rename-log-2026-10-21.tsv" +} + +@test "re-applying a finished plan counts moved archive units as done" { + mk "old/a.txt" 201901010000 "alpha" + run "${SCRIPT}" plan + [ "${status}" -eq 0 ] + run "${SCRIPT}" apply + [ "${status}" -eq 0 ] + [ ! -e "${DOC}/old" ] + run "${SCRIPT}" apply + [ "${status}" -eq 0 ] + [[ "${output}" != *"unreadable"* ]] } @test "an unreadable directory is skipped and its unit never archives" { From 69c9e94e649846e1e9522c0a67e6bcaa0739c31f Mon Sep 17 00:00:00 2001 From: Claude Code Bot Date: Wed, 21 Oct 2026 15:07:11 -0700 Subject: [PATCH 4/4] fix(docs-tidy): make cross-folder dupes report only The keep-one rule (earliest mtime) picked the wrong copy on real data: it would have kept a write-up in a scratch Codex output folder and stashed the copy in Beacon Biosignals, and pulled 7 files out of a deliberately assembled HELOC packet. No rule can tell which copy is the one in use, so the plan now lists every copy in each cross-folder group and apply never moves them. `dupes` is no longer an apply section, so `--skip dupes` is rejected. Machine-snapshot de-duplication is unchanged. --- docs-tidy.py | 43 ++++------- docs/plans/2026-10-21-docs-tidy-design.md | 29 ++++--- tests/docs-tidy.bats | 92 +++++++++-------------- 3 files changed, 65 insertions(+), 99 deletions(-) diff --git a/docs-tidy.py b/docs-tidy.py index 06ef0f9..4ced69e 100755 --- a/docs-tidy.py +++ b/docs-tidy.py @@ -36,7 +36,7 @@ CREDIT = "credit-reports" SNAPSHOT_PREFIX = "Documents - " SKIPNAMES = {".DS_Store", ".localized"} -SECTIONS = ["archive", "snapshot-merge", "snapshot", "dupes", "credit"] +SECTIONS = ["archive", "snapshot-merge", "snapshot", "credit"] MAX_PLAN_AGE_DAYS = 14 FREE_RESERVE = 2 * 1024**3 DUPE_MIN_SIZE = 1024 @@ -461,8 +461,8 @@ def in_snapshot(r): s["stash_bytes"] += f["size"] s["superseded"].append(r) - # 3. dupes - # Skip any file inside a git working tree: stashing it breaks the project. + # 3. dupes (report only) + # Skip any file inside a git working tree: project files repeat by design. git_cache = {} def in_work_tree(r): @@ -488,23 +488,14 @@ def dupe_ok(r): for r in local: if dupe_ok(r): groups.setdefault(files[r]["md5"], []).append(r) - dupe_groups, stashed = [], set() + # Report only, never moved: no rule can tell which copy is in use. + dupe_groups = [] for md5, members in sorted(groups.items(), key=lambda kv: min(kv[1])): # Copies inside one unit are deliberate (templates, renamed copies): # only copies in different units count as dupes. if len({unit_of(r) for r in members}) < 2: continue - ranked = sorted(members, key=lambda r: (top(r) != CREDIT, top(r) not in KEEP, files[r]["mtime"], len(r), r)) - keep = ranked[0] - # Copies in the keeper's own unit stay with it. KEEP items are - # protected: never stash them, even as a duplicate. - stash = [r for r in ranked[1:] if unit_of(r) != unit_of(keep) and top(r) not in KEEP] - if not stash: - continue - dupe_groups.append(dict(md5=md5, size=files[keep]["size"], keep=keep, stash=stash)) - for r in stash: - entry("dupes", r, os.path.join(dupe_base, r), keep=keep) - stashed.add(r) + dupe_groups.append(dict(md5=md5, size=files[members[0]]["size"], copies=sorted(members))) # 4. credit needs_manual, credit = [], [] @@ -521,8 +512,6 @@ def dupe_ok(r): if os.path.isdir(os.path.join(cdir, n)) and not os.path.islink(os.path.join(cdir, n)): needs_manual.append((rel, "directory")) continue - if rel in stashed: - continue tags = canonical_tags(n) have = read_tags(os.path.join(cdir, n)) if tags is not None: @@ -588,6 +577,8 @@ def fmt_ts(ts): def section_counts(plan): c = {s: [0, 0] for s in SECTIONS} for e in plan["entries"]: + if e["section"] not in c: + continue # a section this version no longer applies c[e["section"]][0] += 1 c[e["section"]][1] += e["size"] return c @@ -623,12 +614,11 @@ def summary(plan): for r in s["superseded"]: w(f" superseded {r}") w("") - w(f"Dupe groups ({len(plan['dupe_groups'])}):") + w(f"Duplicate files across folders, report only, never moved ({len(plan['dupe_groups'])} groups):") for g in plan["dupe_groups"]: - w(f" {human(g['size'])} x{1 + len(g['stash'])} {g['md5']}") - w(f" keep {g['keep']}") - for r in g["stash"]: - w(f" stash {r}") + w(f" {human(g['size'])} x{len(g['copies'])} {g['md5']}") + for r in g["copies"]: + w(f" {r}") w("") credit = [e for e in plan["entries"] if e["section"] == "credit"] w(f"Credit renames ({len(credit)}):") @@ -669,7 +659,7 @@ def post(title, msg): def notify(plan): c = section_counts(plan) msg = (f"archive {c['archive'][0]}, merge {c['snapshot-merge'][0]}, snapshot {c['snapshot'][0]}," - f" dupes {c['dupes'][0]}, credit {c['credit'][0]}. Run: docs-tidy show") + f" credit {c['credit'][0]}; {len(plan['dupe_groups'])} dupe groups to review. Run: docs-tidy show") post(f"docs-tidy plan {plan['date']}", msg) @@ -979,10 +969,9 @@ def write_pointer(plan, ok, date): if "snapshot-merge" in by: lines.append(f" snapshot-merge: {len(by['snapshot-merge'])} files that existed only in a machine snapshot," " merged into this folder at the same relative paths\n") - for s, label in (("snapshot", "machine-snapshot files"), ("dupes", "duplicate files")): - if s in by: - lines.append(f" {s}: {len(by[s])} {label} to\n" - f" {os.path.join(drive, 'probably-dupes', plan['date'])}\n") + if "snapshot" in by: + lines.append(f" snapshot: {len(by['snapshot'])} machine-snapshot files to\n" + f" {os.path.join(drive, 'probably-dupes', plan['date'])}\n") lines.append(" Every move was copy, verify checksum, then delete the original.\n") with open(os.path.join(doc, POINTER), "a") as f: f.writelines(lines) diff --git a/docs/plans/2026-10-21-docs-tidy-design.md b/docs/plans/2026-10-21-docs-tidy-design.md index 819b0fc..edc55f5 100644 --- a/docs/plans/2026-10-21-docs-tidy-design.md +++ b/docs/plans/2026-10-21-docs-tidy-design.md @@ -46,7 +46,7 @@ plists. Doing that in Bash would mean shelling out to Python anyway. | Today | local date | `DOCS_TIDY_TODAY` (`YYYY-MM-DD`) | Archive destination: `/Documents Archive/`. -Dupes destination: `/probably-dupes//`. +Snapshot stash destination: `/probably-dupes//`. `KEEP` is a visible list at the top of the script. These top-level items never archive: `army`, `DD214.pdf`, `ssn.pdf`, `Andrew Rich 1989 GED.pdf`, @@ -93,28 +93,27 @@ The cutoff is 00:00 local on Jan 1 of `(today.year - 4)`. In Oct 2026 that is - If it is unique but that path exists with other content, stash it and list it as "superseded". - If its MD5 is unknown, list it under "needs review" and plan nothing. -3. **dupes.** Group the files that stay local (not archived, not in +3. **dupes (report only).** Apply never moves these. Group the files that stay local (not archived, not in snapshots) by MD5. Group only files of 1 KiB or larger. Exclude: - any path with a `.git` or `node_modules` component, or a component ending in `.app`, `.bundle`, `.photoslibrary`, `.rtfd`, `.pages`, `.numbers`, `.key` or `.workflow`; - any file inside a git working tree: a directory, at any depth, that - holds a `.git` entry (directory or file). Stashing one copy out of a - project breaks the project. + holds a `.git` entry (directory or file). Project files repeat by + design. Copies inside the same unit are deliberate (templates, renamed copies such as `airship.png` and `dirigible.png`). A unit is a top-level entry of Documents, or `tax/`. A group counts only when its copies span - two or more units. Keep one copy: anything in `credit-reports` wins, - then a `KEEP` item, then the earliest mtime, then the shortest path, - then lexical order. Stash the copies in other units. Copies in the - keeper's own unit stay, and `KEEP` items are never stashed. - - Why: on the 2026-10-21 dry run, the first rule set (no working-tree, - `node_modules` or unit rule) planned 245 stashes in 167 groups. 93 were - under `old-pi`, 51 under `Resume` (per-application template copies), 41 - under `Codex` and 33 in `node_modules`. With these rules the same data - gives 18 stashes in 18 groups (with `.workflow` excluded). + two or more units. The summary lists every copy in each group. + + Why report only: no rule can tell which copy is the one in use. An + earlier version kept the earliest-mtime copy and stashed the rest. On + the 2026-10-21 dry run that would have kept a write-up in a scratch + `Codex` output folder and stashed the copy in `Beacon Biosignals`, and + taken 7 files out of a deliberately assembled HELOC packet. Real + duplicate clutter came from machine snapshots, which the snapshot + section handles. 4. **credit.** For files in `credit-reports` (not dotfiles) whose name does not match `^\d{4}-\d{2} (Andrew|Jen) (Equifax|Experian|TransUnion)\.pdf$` or the request-form names, propose a rename. Use the person and agency @@ -130,7 +129,7 @@ The cutoff is 00:00 local on Jan 1 of `(today.year - 4)`. In Oct 2026 that is tags are the user's and never trigger a retag. The text summary shows counts and bytes per section, every archive unit with -its newest mtime, every dupe group (kept path, then stashed paths), every +its newest mtime, every dupe group (all copies), every snapshot merge, every credit rename, and the skipped, needs-review and needs-manual lists. diff --git a/tests/docs-tidy.bats b/tests/docs-tidy.bats index 938ffdf..467eac1 100755 --- a/tests/docs-tidy.bats +++ b/tests/docs-tidy.bats @@ -189,7 +189,7 @@ want_file() { want_srcs archive "[]" } -@test "dupes: credit-reports wins, then earliest mtime" { +@test "dupes: cross-folder copies are reported with every copy, never planned to move" { mkbig "a/one.bin" 202405010000 x mkbig "b/two.bin" 202305010000 x mkbig "c/three.bin" 202305010000 x @@ -197,10 +197,23 @@ want_file() { mkbig "other/r.pdf" 202301010000 y run "${SCRIPT}" plan [ "${status}" -eq 0 ] - want "len(p['dupe_groups'])" "2" - want "sorted(g['keep'] for g in p['dupe_groups'])" "['b/two.bin', 'credit-reports/r.pdf']" - want_srcs dupes "['a/one.bin', 'c/three.bin', 'other/r.pdf']" - want "[e['dst'] for e in p['entries'] if e['rel'] == 'a/one.bin'][0]" "${STASH}/a/one.bin" + want "[g['copies'] for g in p['dupe_groups']]" \ + "[['a/one.bin', 'b/two.bin', 'c/three.bin'], ['credit-reports/r.pdf', 'other/r.pdf']]" + want "len(p['entries'])" "0" + run "${SCRIPT}" show + [[ "${output}" == *"report only, never moved (2 groups)"* ]] +} + +@test "apply never moves duplicate files" { + mkbig "a/one.bin" 202301010000 x + mkbig "b/c/two.bin" 202401010000 x + run "${SCRIPT}" plan + [ "${status}" -eq 0 ] + run "${SCRIPT}" apply + [ "${status}" -eq 0 ] + [ -f "${DOC}/a/one.bin" ] + [ -f "${DOC}/b/c/two.bin" ] + [ ! -e "${DRIVE}/probably-dupes" ] } @test "dupes: .git paths, small files and bundles are excluded" { @@ -217,7 +230,6 @@ want_file() { run "${SCRIPT}" plan [ "${status}" -eq 0 ] want "len(p['dupe_groups'])" "0" - want_srcs dupes "[]" } @test "dupes: files inside a git working tree are excluded, at any depth" { @@ -234,7 +246,7 @@ want_file() { mkbig "plain2/data.bin" 202601010000 v run "${SCRIPT}" plan [ "${status}" -eq 0 ] - want_srcs dupes "['plain2/data.bin']" + want "[g['copies'] for g in p['dupe_groups']]" "[['plain/src/data.bin', 'plain2/data.bin']]" } @test "dupes: node_modules paths are excluded" { @@ -255,17 +267,7 @@ want_file() { run "${SCRIPT}" plan [ "${status}" -eq 0 ] # Only the tax pair spans two units (tax/2024 and tax/2025). - want_srcs dupes "['tax/2025/form.pdf']" -} - -@test "dupes: copies in the keeper's unit stay; copies in other units are stashed" { - mkbig "a/one.bin" 202301010000 x - mkbig "a/one-copy.bin" 202401010000 x - mkbig "b/two.bin" 202402010000 x - run "${SCRIPT}" plan - [ "${status}" -eq 0 ] - want "[g['keep'] for g in p['dupe_groups']]" "['a/one.bin']" - want_srcs dupes "['b/two.bin']" + want "[g['copies'] for g in p['dupe_groups']]" "[['tax/2024/form.pdf', 'tax/2025/form.pdf']]" } @test "credit: names are parsed into renames; an unparseable name needs manual" { @@ -382,16 +384,16 @@ want_file() { @test "apply records an error and keeps the source when it changed after planning" { mkbig "a/keep.bin" 202301010000 x - mkbig "b/stash.bin" 202401010000 x + mkbig "Documents - HOST/a/keep.bin" 202401010000 x run "${SCRIPT}" plan [ "${status}" -eq 0 ] - want_srcs dupes "['b/stash.bin']" - printf 'edited' >>"${DOC}/b/stash.bin" + want_srcs snapshot "['Documents - HOST/a/keep.bin']" + printf 'edited' >>"${DOC}/Documents - HOST/a/keep.bin" run "${SCRIPT}" apply [ "${status}" -eq 1 ] - [ -f "${DOC}/b/stash.bin" ] - [ ! -e "${STASH}/b/stash.bin" ] - grep -q '^ERR dupes' "${STATE}/apply-2026-10-21.tsv" + [ -f "${DOC}/Documents - HOST/a/keep.bin" ] + [ ! -e "${STASH}/Documents - HOST/a/keep.bin" ] + grep -q '^ERR snapshot' "${STATE}/apply-2026-10-21.tsv" [[ "${output}" == *"source changed since plan"* ]] } @@ -430,20 +432,6 @@ want_file() { want_file "${ARCHIVE}/old/a.txt" "different" } -@test "--skip dupes leaves dupes in place" { - mkbig "a/keep.bin" 202301010000 x - mkbig "b/stash.bin" 202401010000 x - mk "old/a.txt" 201901010000 - run "${SCRIPT}" plan - [ "${status}" -eq 0 ] - run "${SCRIPT}" apply --skip dupes - [ "${status}" -eq 0 ] - [ -f "${DOC}/a/keep.bin" ] - [ -f "${DOC}/b/stash.bin" ] - [ ! -e "${DRIVE}/probably-dupes" ] - [ ! -e "${DOC}/old" ] -} - @test "apply renames credit reports and writes the rename log" { mk "credit-reports/20240615 AMR Experian report.pdf" 202406150000 "pdf" run "${SCRIPT}" plan @@ -458,31 +446,21 @@ want_file() { [ ! -e "${DOC}/ARCHIVED-TO-GOOGLE-DRIVE.txt" ] } -@test "apply of dupes rmdirs the emptied parent but not an untouched sibling" { - mkbig "a/keep.bin" 202301010000 x - mkbig "b/c/stash.bin" 202401010000 x - mk "b/c/.DS_Store" 202401010000 "junk" - mk "b/sibling/.DS_Store" 202401010000 "junk" - run "${SCRIPT}" plan - [ "${status}" -eq 0 ] - run "${SCRIPT}" apply - [ "${status}" -eq 0 ] - [ -f "${STASH}/b/c/stash.bin" ] - [ ! -e "${DOC}/b/c" ] - [ -f "${DOC}/b/sibling/.DS_Store" ] - [ -f "${DOC}/a/keep.bin" ] -} - @test "--skip repeats to skip more than one section" { - mkbig "a/keep.bin" 202301010000 x - mkbig "b/stash.bin" 202401010000 x mk "old/a.txt" 201901010000 + mk "credit-reports/20240615 AMR Experian report.pdf" 202406150000 "pdf" run "${SCRIPT}" plan [ "${status}" -eq 0 ] - run "${SCRIPT}" apply --skip dupes --skip archive + run "${SCRIPT}" apply --skip credit --skip archive [ "${status}" -eq 0 ] - [ -f "${DOC}/b/stash.bin" ] [ -f "${DOC}/old/a.txt" ] + [ -f "${DOC}/credit-reports/20240615 AMR Experian report.pdf" ] +} + +@test "--skip dupes is rejected: dupes are report only" { + run "${SCRIPT}" apply --skip dupes + [ "${status}" -ne 0 ] + [[ "${output}" == *"invalid choice"* ]] } @test "--skip rejects an unknown section" {