feat: add orphan mod cleanup tool with GUI integration and live-server tests
- Add arma_modlist_tools/cleaner.py: find_orphan_folders() detects @ModName folders no longer referenced in comparison.json; uses _normalize_name from fetcher for consistent three-level matching - Add clean_orphans.py: CLI with --dry-run and --yes/-y flags; junction-safe deletion via _is_junction() guard before shutil.rmtree - Add Clean Orphans tab to gui/views/tools.py: scrollable checkbox list, background scan/delete threads, pending-done-msg pattern for post-scan status, EN/VI localization strings in gui/locales.py - Add 23 unit tests (section 12), 6 E2E subprocess tests (section 13), 23 coverage-gap tests (section 14), 9 live-server fetcher tests (section 15) - Fix leaked builtins.open mock in _test_read_os_release_parses_file - Overall coverage: 84% → 93%; fetcher.py: 36% → 72%
This commit is contained in:
127
clean_orphans.py
Normal file
127
clean_orphans.py
Normal file
@@ -0,0 +1,127 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user