#!/usr/bin/env python3 """ CLI entry point: find and remove orphaned mod folders from downloads/. An orphan is a downloads/{group}/@ModName folder that is no longer referenced by any group in comparison.json. These accumulate when presets change and the pipeline is re-run without cleaning up old folders. Usage: python clean_orphans.py # list orphans, ask for confirmation python clean_orphans.py --dry-run # list orphans, do not delete python clean_orphans.py --yes # list and delete without prompting """ from __future__ import annotations import argparse import json import os import shutil import sys from arma_modlist_tools.cleaner import find_orphan_folders from arma_modlist_tools.compat import fix_console_encoding from arma_modlist_tools.config import load_config from arma_modlist_tools.linker import _is_junction, remove_junction fix_console_encoding() _UNITS = ("B", "KB", "MB", "GB", "TB") def _fmt_size(n: int) -> str: for unit in _UNITS: if n < 1024: return f"{n:.1f} {unit}" n /= 1024 return f"{n:.1f} PB" def main() -> None: parser = argparse.ArgumentParser( description="Find and remove orphaned mod folders from downloads/." ) parser.add_argument( "--dry-run", action="store_true", help="List orphans but do not delete anything.", ) parser.add_argument( "--yes", "-y", action="store_true", help="Delete without prompting for confirmation.", ) args = parser.parse_args() cfg = load_config() if not cfg.comparison.exists(): print(f"ERROR: {cfg.comparison} not found. Run compare_modlists.py first.") sys.exit(1) comparison = json.loads(cfg.comparison.read_text(encoding="utf-8")) print(f"\nScanning {cfg.downloads} for orphaned mod folders...\n") orphans = find_orphan_folders(cfg.downloads, comparison) if not orphans: print(" No orphans found. Your downloads folder is clean.") print() return total_size = sum(o["size"] for o in orphans) print(f" {'Group':<28} {'Folder':<32} Size") print(f" {'-'*28} {'-'*32} {'-'*10}") for o in orphans: print(f" {o['group']:<28} {o['name']:<32} {_fmt_size(o['size'])}") print() print(f" {len(orphans)} orphan(s) found — {_fmt_size(total_size)} total") print() if args.dry_run: print(" --dry-run: nothing deleted.") print() return if not args.yes: answer = input(" Delete all orphans? [y/N] ").strip().lower() if answer not in ("y", "yes"): print(" Aborted.") print() return deleted = 0 freed = 0 errors = 0 for o in orphans: p = o["path"] try: if _is_junction(p): # Safety: never rmtree a junction — use remove_junction() which # calls os.rmdir() and removes only the pointer, not the target. ok, err = remove_junction(p) if not ok: print(f" ERROR: could not remove junction {p.name}: {err}") errors += 1 continue else: shutil.rmtree(p) deleted += 1 freed += o["size"] print(f" Deleted: {o['group']}/{o['name']}") except Exception as e: print(f" ERROR: {p.name}: {e}") errors += 1 print() print(f" Done: {deleted} deleted, freed {_fmt_size(freed)}" + (f", {errors} error(s)" if errors else "")) print() if errors: sys.exit(1) if __name__ == "__main__": main()