#!/usr/bin/env python3 """Remove every trace of a project, and nothing else. office delete # say what would go, change nothing office delete --yes # do it What it removes, in this order: 1. the TUIOS session for the project, and any -git helper session 2. the Paperclip project, with its workspace records 3. the registry line in ~/.config/paperclip-harness/projects.toml 4. the folder's own machinery: .git, .gitignore, .harness, .paperclip, .pi What it keeps unless you also pass --delete-folder: the folder and every ordinary file in it, so the work survives and can be adopted again. This is the destructive one. `project-reset` is the gentle one: it clears the panes and the worktrees and keeps the Paperclip history. """ import json import os import shutil import subprocess import sys HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, os.path.join(HERE, "lib")) import harness as h # noqa: E402 import project as pj # noqa: E402 import tuios # noqa: E402 MACHINERY = [".git", ".gitignore", ".harness", ".paperclip", ".pi"] def parse(argv): opts = {"folder": "", "yes": False, "delete_folder": False} for a in argv: if a == "--yes" or a == "-y": opts["yes"] = True elif a == "--delete-folder": opts["delete_folder"] = True elif not a.startswith("-"): opts["folder"] = a return opts def sessions_for(session): """The project's session and the helper session office may have made.""" have = tuios.sessions() out = [] for name in (session, session + "-git"): if name in have: out.append(name) return out def drop_registry(project_id): """Remove one project's block from the registry, and nothing else.""" path = pj.REGISTRY if not os.path.exists(path) or not project_id: return False keep, skip = [], False with open(path) as fh: for line in fh: if line.startswith("[projects."): skip = project_id in line if not skip: keep.append(line) with open(path, "w") as fh: fh.writelines(keep) return True def main(): opts = parse(sys.argv[1:]) if not opts["folder"]: h.say(__doc__.strip()) return 2 folder = os.path.abspath(os.path.expanduser(opts["folder"])) if not os.path.isdir(folder): raise h.Refused(2, "no folder at %s" % folder) # A project folder is a leaf, not a place where other things live. Refusing # these is the difference between a mistake and a disaster. forbidden = {"/", "/home", "/tmp", "/var", "/etc", "/usr", "/nix", os.path.expanduser("~")} if folder.rstrip("/") in forbidden: raise h.Refused(2, "%s is not a project folder; give the folder of one project" % folder) cfg = {} if pj.exists(folder): try: cfg = pj.load(folder) except Exception: # A half-written project file must not stop the removal: it is one # of the things being removed. cfg = {} session = cfg.get("session") or os.path.basename(folder) project_id = (cfg.get("paperclip") or {}).get("project", "") project_name = (cfg.get("paperclip") or {}).get("name", "") # Every project that claims this folder, not just the one the project file # remembers. Adopting the same folder twice leaves more than one behind, and # deleting only the last one leaves the rest pointing at a folder that has # been cleared out. projects = [] if project_id: projects.append((project_id, project_name)) try: for p in h.api("GET", "/api/companies/%s/projects" % h.COMPANY): for w in (p.get("workspaces") or []): if (w.get("cwd") or "").rstrip("/") == folder.rstrip("/"): pair = (p.get("id"), p.get("name")) if pair[0] and pair[0] not in [x[0] for x in projects]: projects.append(pair) break except Exception: pass doomed_sessions = sessions_for(session) dotfiles = [d for d in MACHINERY if os.path.exists(os.path.join(folder, d))] h.say("folder %s" % folder) if projects: for pid, pname in projects: h.say("project %s %s" % (pname or "(no name)", pid)) else: h.say("project (none found)") h.say("sessions %s" % (", ".join(doomed_sessions) if doomed_sessions else "(none)")) h.say("files %s" % (", ".join(dotfiles) if dotfiles else "(none)")) h.say("keeps every ordinary file in the folder%s" % ("" if not opts["delete_folder"] else " — EXCEPT the folder itself, --delete-folder")) if not (doomed_sessions or projects or dotfiles or opts["delete_folder"]): h.say("") h.say("nothing to remove.") return 0 if not opts["yes"]: h.say("") h.say("nothing was changed. Add --yes to do it.") return 0 # 1. sessions for name in doomed_sessions: out = subprocess.run(["tuios", "kill-session", name], capture_output=True, text=True, timeout=120) h.say(" killed session %s%s" % (name, "" if out.returncode == 0 else " (refused)")) # 2. the Paperclip projects for pid, pname in projects: try: h.api("DELETE", "/api/projects/%s" % pid) h.say(" deleted the Paperclip project %s" % (pname or pid)) except h.Refused as exc: h.say(" could not delete %s: %s" % (pname or pid, exc)) # 3. the registry for pid, _ in projects: if drop_registry(pid): h.say(" removed %s from %s" % (pid, pj.REGISTRY)) # 4. the folder's machinery for name in dotfiles: path = os.path.join(folder, name) if os.path.isdir(path) and not os.path.islink(path): shutil.rmtree(path, ignore_errors=True) else: try: os.remove(path) except OSError: pass h.say(" removed %s" % os.path.join(folder, name)) # 5. the folder itself, only when asked if opts["delete_folder"]: h.say("") h.say("This removes the folder and every file in it: %s" % folder) if sys.stdin.isatty(): typed = input(" type the folder name to confirm: ").strip() if typed != os.path.basename(folder): raise h.Refused(2, "not confirmed, nothing further was removed") shutil.rmtree(folder, ignore_errors=True) h.say(" removed the folder") h.say("") h.say("done.") return 0 if __name__ == "__main__": h.main_guard(main)