feat(media): sweep removes the folders a completed run emptied
An apply leaves every release folder it emptied standing where it was: the live library run left 1675 husks at the root. sweep takes the same journal and deletes only folders it can prove this run emptied - a journalled move out of the folder or something under it, nothing but empty directories left, no journalled destination beneath it, not a folder the run created. A folder nobody moved cannot be selected at all, so a hand-made placeholder survives, and a symlink anywhere inside disqualifies the subtree. Every rmdir is journalled, so rollback recreates what a sweep removed.
This commit is contained in:
1 parent
272e06daf6
commit
1cb28095a7
3 files changed
+256
-17
No files matched your search
+94
-6
@@ -10,7 +10,7 @@ leaves either an intent whose move happened or an intent whose move never ran,
|
||||
and both are recognised on the next run. A file that moved with no journal line
|
||||
cannot have been moved by this tool, and saying so is better than guessing.
|
||||
|
||||
Three subcommands:
|
||||
Four subcommands:
|
||||
|
||||
apply --plan PLAN [--root ROOT] [--journal J] [--apply]
|
||||
no --apply => report only and write NOTHING, not even folders
|
||||
@@ -19,6 +19,10 @@ Three subcommands:
|
||||
removed only when empty, never anything else
|
||||
verify --plan PLAN --journal J [--root ROOT]
|
||||
every planned file is at its target with the journalled byte count
|
||||
sweep --journal J [--root ROOT] [--apply]
|
||||
removes the folders THIS RUN emptied — the journal has to show a
|
||||
file that moved out of one, so a folder someone made by hand can
|
||||
never be removed by this tool, and rollback puts them back
|
||||
|
||||
Refusals (exit 2, before the first rename, with nothing touched): a target
|
||||
already occupied by a foreign file, a source that has gone, a duplicate target,
|
||||
@@ -228,10 +232,87 @@ def do_verify(root, journal, moves, keeps):
|
||||
return 0
|
||||
|
||||
|
||||
def sweep_targets(root, journal):
|
||||
"""The folders this run emptied, deepest first — never one it did not touch.
|
||||
|
||||
Eligibility is evidence, not appearance. A folder is a target only when the
|
||||
journal shows a file moved out of it or out of something beneath it, it holds
|
||||
nothing but empty directories now, it is not a folder this run created, and no
|
||||
journalled destination lives under it. A symlink anywhere inside disqualifies
|
||||
the whole subtree: this tool never follows a link, and deleting the folder
|
||||
that holds one would delete someone's mount point.
|
||||
"""
|
||||
moves = [r for r in journal.records if r.get("op") == "move"]
|
||||
made = {r["dst"] for r in journal.records if r.get("op") == "mkdir"}
|
||||
gone = {r["path"] for r in journal.records if r.get("op") == "rmdir"}
|
||||
dsts = [r["dst"] for r in moves]
|
||||
marked = set()
|
||||
for r in moves:
|
||||
d = os.path.dirname(r["src"])
|
||||
while d.startswith(root + os.sep):
|
||||
marked.add(d)
|
||||
d = os.path.dirname(d)
|
||||
|
||||
def removable(d):
|
||||
if d in made or d in gone or os.path.islink(d):
|
||||
return False
|
||||
if any(p == d or p.startswith(d + os.sep) for p in dsts):
|
||||
return False
|
||||
try:
|
||||
entries = os.listdir(d)
|
||||
except OSError:
|
||||
return False
|
||||
for e in entries:
|
||||
c = os.path.join(d, e)
|
||||
if os.path.islink(c) or not os.path.isdir(c) or not removable(c):
|
||||
return False
|
||||
return True
|
||||
|
||||
return sorted((d for d in marked if removable(d)),
|
||||
key=lambda q: (-q.count(os.sep), q))
|
||||
|
||||
|
||||
def do_sweep(args, root, journal):
|
||||
"""Remove the empty husks an apply left standing at the library root."""
|
||||
moves = [r for r in journal.records if r.get("op") == "move"]
|
||||
if not moves:
|
||||
raise Refuse("the journal holds no moves — a sweep follows an apply, it"
|
||||
" does not guess at emptiness")
|
||||
outside = sorted({p for r in moves for p in (r["src"], r["dst"])
|
||||
if not (p == root or p.startswith(root + os.sep))})
|
||||
if outside and not args.force:
|
||||
raise Refuse(f"{len(outside)} journalled paths are outside --root {root}"
|
||||
f" (first: {outside[0]}) — this journal belongs to another"
|
||||
" library")
|
||||
targets = sweep_targets(root, journal)
|
||||
if not targets:
|
||||
print("swept: 0 folders — nothing this run emptied is still standing empty")
|
||||
return 0
|
||||
if not args.apply:
|
||||
print(f"dry run: {len(targets)} folders this run emptied would be removed"
|
||||
" (pass --apply to run)")
|
||||
for d in targets[:5]:
|
||||
print(f" would remove {d}")
|
||||
return 0
|
||||
removed = 0
|
||||
for d in targets:
|
||||
journal.record(op="rmdir", path=d)
|
||||
try:
|
||||
os.rmdir(d)
|
||||
except OSError as e:
|
||||
print(f"left in place: {d} ({e})")
|
||||
continue
|
||||
removed += 1
|
||||
print(f"swept: {removed} folders removed, {len(targets) - removed} left in place")
|
||||
print(f"journal: {journal.path} — rollback recreates them with their files")
|
||||
return 0
|
||||
|
||||
|
||||
def do_rollback(args, root, journal):
|
||||
"""Undo every journalled move, newest first, after a full preflight."""
|
||||
moves = [r for r in journal.records if r.get("op") == "move"]
|
||||
made = [r["dst"] for r in journal.records if r.get("op") == "mkdir"]
|
||||
swept = [r["path"] for r in journal.records if r.get("op") == "rmdir"]
|
||||
problems = []
|
||||
for r in reversed(moves):
|
||||
src, dst = r["src"], r["dst"]
|
||||
@@ -255,9 +336,14 @@ def do_rollback(args, root, journal):
|
||||
return 2
|
||||
if not args.apply:
|
||||
print(f"dry run: would move {len(moves)} files back to their census paths"
|
||||
" (pass --apply)")
|
||||
f" and recreate {len(set(swept))} swept folders (pass --apply)")
|
||||
return 0
|
||||
undone = 0
|
||||
recreated = 0
|
||||
for d in sorted(set(swept), key=lambda q: q.count(os.sep)):
|
||||
if not os.path.isdir(d):
|
||||
os.makedirs(d, exist_ok=True)
|
||||
recreated += 1
|
||||
for r in reversed(moves):
|
||||
if os.path.exists(r["dst"]) and not os.path.exists(r["src"]):
|
||||
os.makedirs(os.path.dirname(r["src"]), exist_ok=True)
|
||||
@@ -275,13 +361,13 @@ def do_rollback(args, root, journal):
|
||||
journal.record(op="rollback", undone=undone, dirs_removed=dirs_gone,
|
||||
t=datetime.datetime.now(datetime.timezone.utc).isoformat())
|
||||
print(f"rolled back: {undone} files back where the census found them,"
|
||||
f" {dirs_gone} empty folders removed")
|
||||
f" {dirs_gone} empty folders removed, {recreated} swept folders recreated")
|
||||
return 0
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="apply or undo a media relayout plan")
|
||||
ap.add_argument("cmd", choices=["apply", "rollback", "verify"])
|
||||
ap.add_argument("cmd", choices=["apply", "rollback", "verify", "sweep"])
|
||||
ap.add_argument("--plan")
|
||||
ap.add_argument("--root", default=DEFAULT_ROOT)
|
||||
ap.add_argument("--journal")
|
||||
@@ -295,9 +381,9 @@ def main():
|
||||
journal_path = args.journal or journal_default(root)
|
||||
journal = Journal(journal_path, args.apply)
|
||||
try:
|
||||
if args.cmd in ("rollback", "verify") and not os.path.exists(journal_path):
|
||||
if args.cmd in ("rollback", "verify", "sweep") and not os.path.exists(journal_path):
|
||||
raise Refuse(f"no journal at {journal_path} — nothing to {args.cmd}")
|
||||
if args.cmd == "rollback" and not args.force and journal.rolled_back:
|
||||
if args.cmd in ("rollback", "sweep") and not args.force and journal.rolled_back:
|
||||
raise Refuse("this journal has already been rolled back"
|
||||
" (--force to replay it)")
|
||||
if args.cmd in ("apply", "verify") and not args.plan:
|
||||
@@ -308,6 +394,8 @@ def main():
|
||||
return do_apply(args, root, journal, mkdirs, keeps, moves)
|
||||
if args.cmd == "verify":
|
||||
return do_verify(root, journal, moves, keeps)
|
||||
if args.cmd == "sweep":
|
||||
return do_sweep(args, root, journal)
|
||||
return do_rollback(args, root, journal)
|
||||
finally:
|
||||
journal.close()
|
||||
|
||||
Reference in new issue
Block a user