Coverage for src / rhiza / commands / uninstall.py: 100%
109 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-02-12 20:13 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-02-12 20:13 +0000
1"""Command for uninstalling Rhiza template files from a repository.
3This module implements the `uninstall` command. It reads the `.rhiza/history`
4file and removes all files that were previously materialized by Rhiza templates.
5This provides a clean way to remove all template-managed files from a project.
6"""
8import sys
9from pathlib import Path
11from loguru import logger
14def _read_history_file(history_file: Path, target: Path) -> list[Path]:
15 """Read history file and return list of files to remove.
17 Args:
18 history_file: Path to history file.
19 target: Target repository path.
21 Returns:
22 List of file paths to remove.
23 """
24 logger.debug(f"Reading history file: {history_file.relative_to(target)}")
25 files_to_remove: list[Path] = []
27 with history_file.open("r", encoding="utf-8") as f:
28 for line in f:
29 line = line.strip()
30 if line and not line.startswith("#"):
31 file_path = Path(line)
32 files_to_remove.append(file_path)
34 return files_to_remove
37def _confirm_uninstall(files_to_remove: list[Path], target: Path) -> bool:
38 """Show confirmation prompt and get user response.
40 Args:
41 files_to_remove: List of files to remove.
42 target: Target repository path.
44 Returns:
45 True if user confirmed, False otherwise.
46 """
47 logger.warning("This will remove the following files from your repository:")
48 for file_path in sorted(files_to_remove):
49 full_path = target / file_path
50 if full_path.exists():
51 logger.warning(f" - {file_path}")
52 else:
53 logger.debug(f" - {file_path} (already deleted)")
55 try:
56 response = input("\nAre you sure you want to proceed? [y/N]: ").strip().lower()
57 if response not in ("y", "yes"):
58 logger.info("Uninstall cancelled by user")
59 return False
60 except (KeyboardInterrupt, EOFError):
61 logger.info("\nUninstall cancelled by user")
62 return False
64 return True
67def _remove_files(files_to_remove: list[Path], target: Path) -> tuple[int, int, int]:
68 """Remove files from repository.
70 Args:
71 files_to_remove: List of files to remove.
72 target: Target repository path.
74 Returns:
75 Tuple of (removed_count, skipped_count, error_count).
76 """
77 logger.info("Removing files...")
78 removed_count = 0
79 skipped_count = 0
80 error_count = 0
82 for file_path in sorted(files_to_remove):
83 full_path = target / file_path
85 if not full_path.exists():
86 logger.debug(f"[SKIP] {file_path} (already deleted)")
87 skipped_count += 1
88 continue
90 try:
91 full_path.unlink()
92 logger.success(f"[DEL] {file_path}")
93 removed_count += 1
94 except Exception as e:
95 logger.error(f"Failed to delete {file_path}: {e}")
96 error_count += 1
98 return removed_count, skipped_count, error_count
101def _cleanup_empty_directories(files_to_remove: list[Path], target: Path) -> int:
102 """Clean up empty directories after file removal.
104 Args:
105 files_to_remove: List of files that were removed.
106 target: Target repository path.
108 Returns:
109 Number of empty directories removed.
110 """
111 logger.debug("Cleaning up empty directories...")
112 empty_dirs_removed = 0
114 for file_path in sorted(files_to_remove, reverse=True):
115 full_path = target / file_path
116 parent = full_path.parent
118 while parent != target and parent.exists():
119 try:
120 if parent.is_dir() and not any(parent.iterdir()):
121 parent.rmdir()
122 logger.debug(f"[DEL] {parent.relative_to(target)}/ (empty directory)")
123 empty_dirs_removed += 1
124 parent = parent.parent
125 else:
126 break
127 except Exception:
128 break
130 return empty_dirs_removed
133def _remove_history_file(history_file: Path, target: Path) -> tuple[int, int]:
134 """Remove the history file itself.
136 Args:
137 history_file: Path to history file.
138 target: Target repository path.
140 Returns:
141 Tuple of (removed_count, error_count).
142 """
143 try:
144 history_file.unlink()
145 logger.success(f"[DEL] {history_file.relative_to(target)}")
146 except Exception as e:
147 logger.error(f"Failed to delete {history_file.relative_to(target)}: {e}")
148 return 0, 1
149 else:
150 return 1, 0
153def _print_summary(removed_count: int, skipped_count: int, empty_dirs_removed: int, error_count: int) -> None:
154 """Print uninstall summary.
156 Args:
157 removed_count: Number of files removed.
158 skipped_count: Number of files skipped.
159 empty_dirs_removed: Number of empty directories removed.
160 error_count: Number of errors encountered.
161 """
162 logger.info("\nUninstall summary:")
163 logger.info(f" Files removed: {removed_count}")
164 if skipped_count > 0:
165 logger.info(f" Files skipped (already deleted): {skipped_count}")
166 if empty_dirs_removed > 0:
167 logger.info(f" Empty directories removed: {empty_dirs_removed}")
168 if error_count > 0:
169 logger.error(f" Errors encountered: {error_count}")
170 sys.exit(1)
173def uninstall(target: Path, force: bool) -> None:
174 """Uninstall Rhiza templates from the target repository.
176 Reads the `.rhiza/history` file and removes all files listed in it.
177 This effectively removes all files that were materialized by Rhiza.
179 Args:
180 target (Path): Path to the target repository.
181 force (bool): If True, skip confirmation prompt and proceed with deletion.
182 """
183 target = target.resolve()
184 logger.info(f"Target repository: {target}")
186 # Check for history file
187 history_file = target / ".rhiza" / "history"
188 if not history_file.exists():
189 logger.warning(f"No history file found at: {history_file.relative_to(target)}")
190 logger.info("Nothing to uninstall. This repository may not have Rhiza templates materialized.")
191 logger.info("If you haven't migrated yet, run 'rhiza migrate' first.")
192 return
194 # Read history file
195 files_to_remove = _read_history_file(history_file, target)
196 if not files_to_remove:
197 logger.warning("History file is empty (only contains comments)")
198 logger.info("Nothing to uninstall.")
199 return
201 logger.info(f"Found {len(files_to_remove)} file(s) to remove")
203 # Confirm uninstall unless force is used
204 if not force:
205 if not _confirm_uninstall(files_to_remove, target):
206 return
208 # Remove files
209 removed_count, skipped_count, error_count = _remove_files(files_to_remove, target)
211 # Clean up empty directories
212 empty_dirs_removed = _cleanup_empty_directories(files_to_remove, target)
214 # Remove history file
215 history_removed, history_error = _remove_history_file(history_file, target)
216 removed_count += history_removed
217 error_count += history_error
219 # Print summary
220 _print_summary(removed_count, skipped_count, empty_dirs_removed, error_count)
222 logger.success("Rhiza templates uninstalled successfully")
223 logger.info(
224 "Next steps:\n"
225 " Review changes:\n"
226 " git status\n"
227 " git diff\n\n"
228 " Commit:\n"
229 " git add .\n"
230 ' git commit -m "chore: remove rhiza templates"'
231 )