193 lines
6.6 KiB
Python
Executable File
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)
|