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

1"""Command for uninstalling Rhiza template files from a repository. 

2 

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""" 

8 

9from pathlib import Path 

10 

11from loguru import logger 

12 

13 

14def _confirm_uninstall(files_to_remove: list[Path], target: Path) -> bool: 

15 """Show confirmation prompt and get user response. 

16 

17 Args: 

18 files_to_remove: List of files to remove. 

19 target: Target repository path. 

20 

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)") 

31 

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 

40 

41 return True 

42 

43 

44def _remove_files(files_to_remove: list[Path], target: Path) -> tuple[int, int, int]: 

45 """Remove files from repository. 

46 

47 Args: 

48 files_to_remove: List of files to remove. 

49 target: Target repository path. 

50 

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 

58 

59 for file_path in sorted(files_to_remove): 

60 full_path = target / file_path 

61 

62 if not full_path.exists(): 

63 logger.debug(f"[SKIP] {file_path} (already deleted)") 

64 skipped_count += 1 

65 continue 

66 

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 

75 

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 

86 

87 return removed_count, skipped_count, error_count 

88 

89 

90def _cleanup_empty_directories(files_to_remove: list[Path], target: Path) -> int: 

91 """Clean up empty directories after file removal. 

92 

93 Args: 

94 files_to_remove: List of files that were removed. 

95 target: Target repository path. 

96 

97 Returns: 

98 Number of empty directories removed. 

99 """ 

100 logger.debug("Cleaning up empty directories...") 

101 empty_dirs_removed = 0 

102 

103 for file_path in sorted(files_to_remove, reverse=True): 

104 full_path = target / file_path 

105 parent = full_path.parent 

106 

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 

118 

119 return empty_dirs_removed 

120 

121 

122def _remove_history_file(history_file: Path, target: Path) -> tuple[int, int]: 

123 """Remove the history file itself. 

124 

125 Args: 

126 history_file: Path to history file. 

127 target: Target repository path. 

128 

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 

140 

141 

142def _print_summary(removed_count: int, skipped_count: int, empty_dirs_removed: int, error_count: int) -> None: 

143 """Print uninstall summary. 

144 

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 

160 

161 

162def uninstall(target: Path, force: bool) -> None: 

163 """Uninstall Rhiza templates from the target repository. 

164 

165 Reads `.rhiza/template.lock` and removes all files listed in it. 

166 This effectively removes all files that were materialized by Rhiza. 

167 

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}") 

174 

175 lock_file = target / ".rhiza" / "template.lock" 

176 

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 

181 

182 try: 

183 from rhiza.models import TemplateLock 

184 

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 

191 

192 if not files_to_remove: 

193 logger.warning("No files found to uninstall") 

194 logger.info("Nothing to uninstall.") 

195 return 

196 

197 logger.info(f"Found {len(files_to_remove)} file(s) to remove") 

198 

199 # Confirm uninstall unless force is used 

200 if not force and not _confirm_uninstall(files_to_remove, target): 

201 return 

202 

203 # Remove files 

204 removed_count, skipped_count, error_count = _remove_files(files_to_remove, target) 

205 

206 # Clean up empty directories 

207 empty_dirs_removed = _cleanup_empty_directories(files_to_remove, target) 

208 

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 

214 

215 # Print summary 

216 _print_summary(removed_count, skipped_count, empty_dirs_removed, error_count) 

217 

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 )