tankslab.com/recall/source

bin/leftovers.py

219 lines, exactly as they ship in the download.

""" leftovers.py - offer to set aside what the old tool left behind. THIS IS THE MOST DANGEROUS THING THIS PACKAGE DOES. It touches files on a stranger's machine. Get it wrong once and we are worse than the thing we replaced. Every rule below exists because the failure it prevents is unrecoverable on someone else's disk. 1. NEVER BULK. One item at a time, each shown before it is asked about. There is no "select all", deliberately. A person should have to look at each thing. 2. THE DEFAULT IS KEEP. Enter means keep. The destructive answer must be typed in full. Nobody loses work by pressing return too fast. 3. QUARANTINE, NOT DELETE. Things are MOVED, to a dated folder, and the person is told where it went and how to put it back. Deleting for good is a separate act they can perform themselves once they are satisfied. We do not need to be the ones who make it permanent. 4. POSITIVE IDENTIFICATION ONLY. Nothing is offered unless we can say what it IS. No wildcards, no "clean up this folder", no heuristics on a directory name. A false positive here destroys real work and there is no undo. 5. NOTHING IS OFFERED UNTIL THE IMPORT IS VERIFIED - rows counted and read back from the new store. "Import, delete, then discover the import failed" is the one sequence that must be impossible. 6. THE RAW TRANSCRIPTS ARE NEVER TOUCHED. They are Claude Code's, not ours and not the old tool's, and they are the universal source everything can be rebuilt from. They are not in the candidate list at all, at any privilege. 7. EVERY GUARD IS PROVEN ABLE TO REFUSE. See tests/test_leftovers.py: each rule has a case that FAILS when the rule is removed. A safety rail nobody has watched fail is not a safety rail. """ from __future__ import annotations import shutil import sys from datetime import datetime from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) # Absolutely never offered, whatever else happens. Checked by prefix on the resolved # path, so a symlink or a relative route cannot walk into them. def _sibling_names() -> dict: """Read from the source table; never spelled out here. Empty on failure, which means nothing extra is offered - the safe direction.""" try: import sources return dict(sources.LEGACY_SIBLINGS) except Exception: return {} def _dedicated_dirs() -> tuple[Path, ...]: """Directories a legacy store is known to occupy alone. Resolved at call time. Fails CLOSED: if the source table cannot be consulted, nothing is dedicated, so no sibling is ever offered. An empty answer costs the user one manual delete; a wrong answer costs them a directory. """ try: import sources return tuple(sources.dedicated_store_dirs()) except Exception: return () def never_paths() -> tuple[Path, ...]: """Resolved at CALL time, never at import time. A module-level tuple freezes whatever HOME was when the module happened to load. If anything changes HOME afterwards - a test harness, a service manager, a different user's session - the protections would still point at the OLD home and silently protect nothing. The list that decides what may never be touched must not depend on import order. Found by the safety test itself. """ h = Path.home() return ( h / ".claude" / "projects", # rule 6: Claude Code's transcripts h / ".claude" / "settings.json", h / ".ssh", h / ".gnupg", h / ".config", ) def _protected(p: Path) -> bool: try: rp = p.resolve() except Exception: return True # cannot resolve it -> will not touch it for n in never_paths(): try: nn = n.resolve() except Exception: nn = n if rp == nn or nn in rp.parents: return True return False def candidates(store_path: Path) -> list[dict]: """Only things we can positively identify as belonging to the imported store. Derived from the store's OWN location, never from a directory-name guess. """ out: list[dict] = [] store_path = Path(store_path) if not store_path.exists(): return out home = store_path.parent # The database itself, plus SQLite's own sidecars for that exact file. for p in (store_path, Path(str(store_path) + "-wal"), Path(str(store_path) + "-shm")): if p.is_file(): out.append({"path": p, "what": "the database that was imported", "size": p.stat().st_size}) # Siblings inside the store's own directory. # # RULE 3 IS THE HARD ONE HERE. These are matched by DIRECTORY NAME, and a name # is not an identification. "archive", "backups" and "logs" are ordinary names # that ordinary people use for their own folders. So the name match is only # allowed to speak at all when the store sits in a directory that belongs to # the old tool ALONE - which is a fact from the source table, not a guess. # # Without this gate, `recall import --path ~/old.db` would enumerate ~/archive, # ~/backups and ~/logs - the user's own directories - and offer to move them # while ASSERTING they belong to a tool that never owned them. The quarantine # and the typed confirmation would have been the only things standing between # a stranger and their own backups. That is too thin, and it is exactly the # failure this project already had once: a name match that ate innocent files. try: home_is_dedicated = home.resolve() in _dedicated_dirs() except Exception: home_is_dedicated = False # unresolvable -> not dedicated -> offer nothing if home_is_dedicated: for name, what in _sibling_names().items(): p = home / name if p.is_dir(): size = sum(f.stat().st_size for f in p.rglob("*") if f.is_file()) out.append({"path": p, "what": what, "size": size}) # rule 4 + rule 6: drop anything protected or unresolvable, at the last moment return [c for c in out if not _protected(c["path"])] def quarantine_dir() -> Path: stamp = datetime.now().strftime("%Y%m%d-%H%M%S") return Path.home() / f"recall-setaside-{stamp}" def _human(n: float) -> str: for u in ("B", "KB", "MB", "GB"): if n < 1024 or u == "GB": return f"{n:.1f}{u}" n /= 1024 return f"{n:.1f}GB" def offer(store_path: Path, verified: bool, assume_no: bool = False, input_fn=input) -> dict: """Walk the candidates one at a time. Returns what was moved.""" result = {"moved": [], "kept": [], "refused": None} # RULE 5 - the hard gate. if not verified: result["refused"] = ("import not verified - nothing will be offered. " "Rows must be counted and read back first.") return result items = candidates(store_path) if not items: return result total = sum(i["size"] for i in items) print(f"\n The old tool is gone. Its copy of your work is not:" f" {len(items)} item(s), {_human(total)}.") print(" Each is shown one at a time. The default is to KEEP.") print(" Nothing is deleted - things are moved aside where you can put them back.\n") qdir = quarantine_dir() for i in items: print(f" {i['what']}") print(f" {i['path']}") print(f" {_human(i['size'])}") if assume_no: result["kept"].append(i["path"]) print(" kept.\n") continue try: ans = input_fn(" Move this aside? type MOVE to confirm, Enter to keep: ") except (EOFError, KeyboardInterrupt): print("\n kept (interrupted).") result["kept"].append(i["path"]) break if (ans or "").strip() != "MOVE": result["kept"].append(i["path"]) print(" kept.\n") continue if _protected(i["path"]): # re-checked at the moment of action result["kept"].append(i["path"]) print(" refused - protected path.\n") continue qdir.mkdir(parents=True, exist_ok=True) dest = qdir / i["path"].name try: shutil.move(str(i["path"]), str(dest)) result["moved"].append((i["path"], dest)) print(f" moved to {dest}\n") except Exception as e: result["kept"].append(i["path"]) print(f" could not move it: {e}\n") if result["moved"]: print(f" Moved {len(result['moved'])} item(s) to {qdir}") print(" Nothing was deleted. To put it all back:") for src, dest in result["moved"]: print(f" mv {dest} {src}") print(" When you are satisfied, you can delete that folder yourself.") return result