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