Coverage for src / rhiza / commands / uninstall.py: 96%
114 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-02 07:04 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-02 07:04 +0000
1"""Command for uninstalling Rhiza template files from a repository.
3This module implements the `uninstall` command. It reads the
4`.rhiza/template.lock` file and removes all files that were previously
5materialized by Rhiza templates. This provides a clean way to remove all
6template-managed files from a project.
7"""
9from pathlib import Path
11from loguru import logger
14def _confirm_uninstall(files_to_remove: list[Path], target: Path) -> bool:
15 """Show confirmation prompt and get user response.
17 Args:
18 files_to_remove: List of files to remove.
19 target: Target repository path.
21 Returns:
22 True if user confirmed, False otherwise.
23 """
24 logger.warning("This will remove the following files from your repository:")
25 for file_path in sorted(files_to_remove):
26 full_path = target / file_path
27 if full_path.exists():
28 logger.warning(f" - {file_path}")
29 else:
30 logger.debug(f" - {file_path} (already deleted)")
32 try:
33 response = input("\nAre you sure you want to proceed? [y/N]: ").strip().lower()
34 if response not in ("y", "yes"):
35 logger.info("Uninstall cancelled by user")
36 return False
37 except (KeyboardInterrupt, EOFError):
38 logger.info("\nUninstall cancelled by user")
39 return False
41 return True
44def _remove_files(files_to_remove: list[Path], target: Path) -> tuple[int, int, int]:
45 """Remove files from repository.
47 Args:
48 files_to_remove: List of files to remove.
49 target: Target repository path.
51 Returns:
52 Tuple of (removed_count, skipped_count, error_count).
53 """
54 logger.info("Removing files...")
55 removed_count = 0
56 skipped_count = 0
57 error_count = 0
59 for file_path in sorted(files_to_remove):
60 full_path = target / file_path
62 if not full_path.exists():
63 logger.debug(f"[SKIP] {file_path} (already deleted)")
64 skipped_count += 1
65 continue
67 try:
68 full_path.unlink()
69 logger.success(f"[DEL] {file_path}")
70 removed_count += 1
71 except PermissionError:
72 # On Windows, read-only files must be made writable before deletion
73 try:
74 import stat
76 full_path.chmod(full_path.stat().st_mode | stat.S_IWRITE)
77 full_path.unlink()
78 logger.success(f"[DEL] {file_path}")
79 removed_count += 1
80 except Exception as e:
81 logger.error(f"Failed to delete {file_path}: {e}")
82 error_count += 1
83 except Exception as e:
84 logger.error(f"Failed to delete {file_path}: {e}")
85 error_count += 1
87 return removed_count, skipped_count, error_count
90def _cleanup_empty_directories(files_to_remove: list[Path], target: Path) -> int:
91 """Clean up empty directories after file removal.
93 Args:
94 files_to_remove: List of files that were removed.
95 target: Target repository path.
97 Returns:
98 Number of empty directories removed.
99 """
100 logger.debug("Cleaning up empty directories...")
101 empty_dirs_removed = 0
103 for file_path in sorted(files_to_remove, reverse=True):
104 full_path = target / file_path
105 parent = full_path.parent
107 while parent != target and parent.exists():
108 try:
109 if parent.is_dir() and not any(parent.iterdir()):
110 parent.rmdir()
111 logger.debug(f"[DEL] {parent.relative_to(target)}/ (empty directory)")
112 empty_dirs_removed += 1
113 parent = parent.parent
114 else:
115 break
116 except Exception:
117 break
119 return empty_dirs_removed
122def _remove_history_file(history_file: Path, target: Path) -> tuple[int, int]:
123 """Remove the history file itself.
125 Args:
126 history_file: Path to history file.
127 target: Target repository path.
129 Returns:
130 Tuple of (removed_count, error_count).
131 """
132 try:
133 history_file.unlink()
134 logger.success(f"[DEL] {history_file.relative_to(target)}")
135 except Exception as e:
136 logger.error(f"Failed to delete {history_file.relative_to(target)}: {e}")
137 return 0, 1
138 else:
139 return 1, 0
142def _print_summary(removed_count: int, skipped_count: int, empty_dirs_removed: int, error_count: int) -> None:
143 """Print uninstall summary.
145 Args:
146 removed_count: Number of files removed.
147 skipped_count: Number of files skipped.
148 empty_dirs_removed: Number of empty directories removed.
149 error_count: Number of errors encountered.
150 """
151 logger.info("\nUninstall summary:")
152 logger.info(f" Files removed: {removed_count}")
153 if skipped_count > 0:
154 logger.info(f" Files skipped (already deleted): {skipped_count}")
155 if empty_dirs_removed > 0:
156 logger.info(f" Empty directories removed: {empty_dirs_removed}")
157 if error_count > 0:
158 logger.error(f" Errors encountered: {error_count}")
159 raise RuntimeError(f"Uninstall completed with {error_count} error(s)") # noqa: TRY003
162def uninstall(target: Path, force: bool) -> None:
163 """Uninstall Rhiza templates from the target repository.
165 Reads `.rhiza/template.lock` and removes all files listed in it.
166 This effectively removes all files that were materialized by Rhiza.
168 Args:
169 target (Path): Path to the target repository.
170 force (bool): If True, skip confirmation prompt and proceed with deletion.
171 """
172 target = target.resolve()
173 logger.info(f"Target repository: {target}")
175 lock_file = target / ".rhiza" / "template.lock"
177 if not lock_file.exists():
178 logger.warning(f"No lock file found at: {(target / '.rhiza' / 'template.lock').relative_to(target)}")
179 logger.info("Nothing to uninstall. This repository may not have Rhiza templates materialized.")
180 return
182 try:
183 from rhiza.models import TemplateLock
185 lock = TemplateLock.from_yaml(lock_file)
186 files_to_remove = [Path(f) for f in lock.files] if lock.files else []
187 logger.debug(f"Reading file list from template.lock ({len(files_to_remove)} files)")
188 except Exception as e:
189 logger.error(f"Failed to read template.lock: {e}")
190 return
192 if not files_to_remove:
193 logger.warning("No files found to uninstall")
194 logger.info("Nothing to uninstall.")
195 return
197 logger.info(f"Found {len(files_to_remove)} file(s) to remove")
199 # Confirm uninstall unless force is used
200 if not force and not _confirm_uninstall(files_to_remove, target):
201 return
203 # Remove files
204 removed_count, skipped_count, error_count = _remove_files(files_to_remove, target)
206 # Clean up empty directories
207 empty_dirs_removed = _cleanup_empty_directories(files_to_remove, target)
209 # Remove tracking file
210 if lock_file.exists():
211 r, e = _remove_history_file(lock_file, target)
212 removed_count += r
213 error_count += e
215 # Print summary
216 _print_summary(removed_count, skipped_count, empty_dirs_removed, error_count)
218 logger.success("Rhiza templates uninstalled successfully")
219 logger.info(
220 "Next steps:\n"
221 " Review changes:\n"
222 " git status\n"
223 " git diff\n\n"
224 " Commit:\n"
225 " git add .\n"
226 ' git commit -m "chore: remove rhiza templates"'
227 )