Coverage for scripts/uninstall.py: 100%
117 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-31 06:18 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-31 06:18 +0000
1#!/usr/bin/env python3
2"""Remove all rhiza-managed files from the repo.
4A stdlib-only port of the `rhiza uninstall` command, bundled with this plugin so
5`/rhiza:uninstall` works without the `rhiza` CLI (or PyYAML) installed. It reads
6the `files` recorded in `.rhiza/template.lock`, deletes each one, prunes the
7now-empty directories, and finally removes the lock file itself.
9Usage:
10 uv run --python 3.12 --no-project python scripts/uninstall.py [TARGET] [--force|-y]
12 TARGET repository root to clean (default: current directory)
13 --force, -y skip the confirmation prompt and proceed with deletion
15This is destructive. Without --force it prompts for confirmation; if stdin is
16not a TTY (no way to answer) it treats that as "no" and cancels. Exits 0 on
17success or a clean no-op, 1 if any deletion failed or the lock is unreadable.
18"""
20from __future__ import annotations
22import argparse
23import stat
24import sys
25from pathlib import Path
27sys.path.insert(0, str(Path(__file__).resolve().parent))
28from _rhiza_yaml import load_yaml # noqa: E402
30LOCK_REL = Path(".rhiza") / "template.lock"
33def _info(message: str) -> None:
34 """Print an informational line to stderr."""
35 print(message, file=sys.stderr)
38def _error(message: str) -> None:
39 """Print an error line to stderr."""
40 print(f"error: {message}", file=sys.stderr)
43def _confirm(files_to_remove: list[Path], target: Path) -> bool:
44 """Show the deletion list and ask for confirmation; False cancels."""
45 _info("This will remove the following files from your repository:")
46 for file_path in sorted(files_to_remove):
47 if (target / file_path).exists():
48 _info(f" - {file_path}")
49 try:
50 response = input("\nAre you sure you want to proceed? [y/N]: ").strip().lower()
51 except (KeyboardInterrupt, EOFError):
52 _info("\nUninstall cancelled")
53 return False
54 if response not in ("y", "yes"):
55 _info("Uninstall cancelled by user")
56 return False
57 return True
60def _remove_files(files_to_remove: list[Path], target: Path) -> tuple[int, int, int]:
61 """Delete the listed files; return (removed, skipped, errors)."""
62 _info("Removing files...")
63 removed = skipped = errors = 0
64 for file_path in sorted(files_to_remove):
65 full_path = target / file_path
66 if not full_path.exists():
67 skipped += 1
68 continue
69 try:
70 full_path.unlink()
71 _info(f"[DEL] {file_path}")
72 removed += 1
73 except PermissionError:
74 # A read-only file must be made writable before it can be deleted.
75 try:
76 full_path.chmod(full_path.stat().st_mode | stat.S_IWRITE)
77 full_path.unlink()
78 _info(f"[DEL] {file_path}")
79 removed += 1
80 except OSError as exc:
81 _error(f"Failed to delete {file_path}: {exc}")
82 errors += 1
83 except OSError as exc:
84 _error(f"Failed to delete {file_path}: {exc}")
85 errors += 1
86 return removed, skipped, errors
89def _cleanup_empty_directories(files_to_remove: list[Path], target: Path) -> int:
90 """Remove directories left empty by the deletions; return the count."""
91 removed = 0
92 for file_path in sorted(files_to_remove, reverse=True):
93 parent = (target / file_path).parent
94 while parent != target and parent.exists():
95 try:
96 if parent.is_dir() and not any(parent.iterdir()):
97 parent.rmdir()
98 removed += 1
99 parent = parent.parent
100 else:
101 break
102 except OSError:
103 break
104 return removed
107def _print_summary(removed: int, skipped: int, empty_dirs: int, errors: int) -> None:
108 """Print the deletion summary counts."""
109 _info("\nUninstall summary:")
110 _info(f" Files removed: {removed}")
111 if skipped:
112 _info(f" Files skipped (already deleted): {skipped}")
113 if empty_dirs:
114 _info(f" Empty directories removed: {empty_dirs}")
115 if errors:
116 _error(f" Errors encountered: {errors}")
119def uninstall(target: Path, *, force: bool) -> int:
120 """Remove all rhiza-managed files; return a process exit code."""
121 target = target.resolve()
122 _info(f"Target repository: {target}")
124 lock_file = target / LOCK_REL
125 if not lock_file.exists():
126 _info(f"No lock file found at: {LOCK_REL}")
127 _info("Nothing to uninstall. This repository may not have Rhiza templates synced.")
128 return 0
130 try:
131 lock = load_yaml(lock_file)
132 except (OSError, ValueError) as exc:
133 _error(f"Failed to read template.lock: {exc}")
134 return 1
136 files_to_remove = [Path(str(f)) for f in (lock.get("files") or [])]
137 if not files_to_remove:
138 _info("No files found to uninstall. Nothing to do.")
139 return 0
141 _info(f"Found {len(files_to_remove)} file(s) to remove")
143 if not force and not _confirm(files_to_remove, target):
144 return 0
146 removed, skipped, errors = _remove_files(files_to_remove, target)
147 empty_dirs = _cleanup_empty_directories(files_to_remove, target)
149 # Finally drop the lock file itself so the repo is no longer rhiza-managed.
150 if lock_file.exists():
151 try:
152 lock_file.unlink()
153 _info(f"[DEL] {LOCK_REL}")
154 removed += 1
155 except OSError as exc:
156 _error(f"Failed to delete {LOCK_REL}: {exc}")
157 errors += 1
159 _print_summary(removed, skipped, empty_dirs, errors)
160 if errors:
161 _error(f"Uninstall completed with {errors} error(s)")
162 return 1
164 _info("Rhiza templates uninstalled successfully")
165 _info(
166 "\nNext steps:\n"
167 " Review changes: git status && git diff\n"
168 ' Commit: git add . && git commit -m "chore: remove rhiza templates"'
169 )
170 return 0
173def main(argv: list[str] | None = None) -> int:
174 """Entry point: parse args and run the uninstall."""
175 parser = argparse.ArgumentParser(
176 description="Remove all rhiza-managed files (from .rhiza/template.lock) from the repo.",
177 )
178 parser.add_argument(
179 "target",
180 nargs="?",
181 default=".",
182 help="Repository root to clean (default: current directory).",
183 )
184 parser.add_argument(
185 "--force",
186 "-y",
187 action="store_true",
188 help="Skip the confirmation prompt and proceed with deletion.",
189 )
190 args = parser.parse_args(argv)
191 return uninstall(Path(args.target), force=args.force)
194if __name__ == "__main__":
195 raise SystemExit(main())