Coverage for plugin/scripts/detach.py: 100%
122 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-15 14:46 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-15 14:46 +0000
1#!/usr/bin/env python3
2"""Detach the repo from rhiza by removing every rhiza-managed file.
4A stdlib-only port of the `rhiza uninstall` command, bundled with this plugin so
5`/rhiza:detach` 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.
9This detaches the repo from its template; it does not uninstall the plugin.
11Usage:
12 uv run --python 3.12 --no-project python scripts/detach.py [TARGET] [--force|-y]
14 TARGET repository root to clean (default: current directory)
15 --force, -y skip the confirmation prompt and proceed with deletion
17This is destructive. Without --force it prompts for confirmation; if stdin is
18not a TTY (no way to answer) it treats that as "no" and cancels. Exits 0 on
19success or a clean no-op, 1 if any deletion failed or the lock is unreadable.
20"""
22from __future__ import annotations
24import argparse
25import stat
26import sys
27from pathlib import Path
29sys.path.insert(0, str(Path(__file__).resolve().parent))
30from _rhiza_yaml import load_yaml # noqa: E402
32LOCK_REL = Path(".rhiza") / "template.lock"
35def _info(message: str) -> None:
36 """Print an informational line to stderr."""
37 print(message, file=sys.stderr)
40def _error(message: str) -> None:
41 """Print an error line to stderr."""
42 print(f"error: {message}", file=sys.stderr)
45def _confirm(files_to_remove: list[Path], target: Path) -> bool:
46 """Show the deletion list and ask for confirmation; False cancels."""
47 _info("This will remove the following files from your repository:")
48 for file_path in sorted(files_to_remove):
49 if (target / file_path).exists():
50 _info(f" - {file_path}")
51 try:
52 response = input("\nAre you sure you want to proceed? [y/N]: ").strip().lower()
53 except (KeyboardInterrupt, EOFError):
54 _info("\nDetach cancelled")
55 return False
56 if response not in ("y", "yes"):
57 _info("Detach cancelled by user")
58 return False
59 return True
62def _remove_files(files_to_remove: list[Path], target: Path) -> tuple[int, int, int]:
63 """Delete the listed files; return (removed, skipped, errors)."""
64 _info("Removing files...")
65 removed = skipped = errors = 0
66 for file_path in sorted(files_to_remove):
67 full_path = target / file_path
68 if not full_path.exists():
69 skipped += 1
70 continue
71 try:
72 full_path.unlink()
73 _info(f"[DEL] {file_path}")
74 removed += 1
75 except PermissionError:
76 # A read-only file must be made writable before it can be deleted.
77 try:
78 full_path.chmod(full_path.stat().st_mode | stat.S_IWRITE)
79 full_path.unlink()
80 _info(f"[DEL] {file_path}")
81 removed += 1
82 except OSError as exc:
83 _error(f"Failed to delete {file_path}: {exc}")
84 errors += 1
85 except OSError as exc:
86 _error(f"Failed to delete {file_path}: {exc}")
87 errors += 1
88 return removed, skipped, errors
91def _cleanup_empty_directories(files_to_remove: list[Path], target: Path) -> int:
92 """Remove directories left empty by the deletions; return the count."""
93 removed = 0
94 for file_path in sorted(files_to_remove, reverse=True):
95 parent = (target / file_path).parent
96 while parent != target and parent.exists():
97 try:
98 if parent.is_dir() and not any(parent.iterdir()):
99 parent.rmdir()
100 removed += 1
101 parent = parent.parent
102 else:
103 break
104 except OSError:
105 break
106 return removed
109def _print_summary(removed: int, skipped: int, empty_dirs: int, errors: int) -> None:
110 """Print the deletion summary counts."""
111 _info("\nDetach summary:")
112 _info(f" Files removed: {removed}")
113 if skipped:
114 _info(f" Files skipped (already deleted): {skipped}")
115 if empty_dirs:
116 _info(f" Empty directories removed: {empty_dirs}")
117 if errors:
118 _error(f" Errors encountered: {errors}")
121def _drop_lock(lock_file: Path) -> tuple[int, int]:
122 """Delete the lock so the repo is no longer rhiza-managed; return ``(removed, errors)``.
124 Last, and deliberately so: while the lock is present the detach is resumable, because
125 it is the only record of which files were managed.
126 """
127 if not lock_file.exists():
128 return 0, 0
129 try:
130 lock_file.unlink()
131 except OSError as exc:
132 _error(f"Failed to delete {LOCK_REL}: {exc}")
133 return 0, 1
134 _info(f"[DEL] {LOCK_REL}")
135 return 1, 0
138def _report_outcome(errors: int) -> int:
139 """Print the closing guidance and return the process exit code."""
140 if errors:
141 _error(f"Detach completed with {errors} error(s)")
142 return 1
143 _info("Repository detached from rhiza successfully")
144 _info(
145 "\nNext steps:\n"
146 " Review changes: git status && git diff\n"
147 ' Commit: git add . && git commit -m "chore: remove rhiza templates"'
148 )
149 return 0
152def detach(target: Path, *, force: bool) -> int:
153 """Remove all rhiza-managed files; return a process exit code."""
154 target = target.resolve()
155 _info(f"Target repository: {target}")
157 lock_file = target / LOCK_REL
158 if not lock_file.exists():
159 _info(f"No lock file found at: {LOCK_REL}")
160 _info("Nothing to detach. This repository may not have Rhiza templates synced.")
161 return 0
163 try:
164 lock = load_yaml(lock_file)
165 except (OSError, ValueError) as exc:
166 _error(f"Failed to read template.lock: {exc}")
167 return 1
169 files_to_remove = [Path(str(f)) for f in (lock.get("files") or [])]
170 if not files_to_remove:
171 _info("No files found to detach. Nothing to do.")
172 return 0
174 _info(f"Found {len(files_to_remove)} file(s) to remove")
176 if not force and not _confirm(files_to_remove, target):
177 return 0
179 removed, skipped, errors = _remove_files(files_to_remove, target)
180 empty_dirs = _cleanup_empty_directories(files_to_remove, target)
181 lock_removed, lock_errors = _drop_lock(lock_file)
183 _print_summary(removed + lock_removed, skipped, empty_dirs, errors + lock_errors)
184 return _report_outcome(errors + lock_errors)
187def main(argv: list[str] | None = None) -> int:
188 """Entry point: parse args and run the detach."""
189 parser = argparse.ArgumentParser(
190 description="Detach the repo from rhiza, removing every file .rhiza/template.lock records.",
191 )
192 parser.add_argument(
193 "target",
194 nargs="?",
195 default=".",
196 help="Repository root to clean (default: current directory).",
197 )
198 parser.add_argument(
199 "--force",
200 "-y",
201 action="store_true",
202 help="Skip the confirmation prompt and proceed with deletion.",
203 )
204 args = parser.parse_args(argv)
205 return detach(Path(args.target), force=args.force)
208if __name__ == "__main__":
209 raise SystemExit(main())