Files
paperclip-harness/bin/project-delete

193 lines
6.6 KiB
Python
Executable File

#!/usr/bin/env python3
"""Remove every trace of a project, and nothing else.
office delete <folder> # say what would go, change nothing
office delete <folder> --yes # do it
What it removes, in this order:
1. the TUIOS session for the project, and any <session>-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)