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

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

2 

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

7 

8import sys 

9from pathlib import Path 

10 

11from loguru import logger 

12 

13 

14def _read_history_file(history_file: Path, target: Path) -> list[Path]: 

15 """Read history file and return list of files to remove. 

16 

17 Args: 

18 history_file: Path to history file. 

19 target: Target repository path. 

20 

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] = [] 

26 

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) 

33 

34 return files_to_remove 

35 

36 

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

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

39 

40 Args: 

41 files_to_remove: List of files to remove. 

42 target: Target repository path. 

43 

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

54 

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 

63 

64 return True 

65 

66 

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

68 """Remove files from repository. 

69 

70 Args: 

71 files_to_remove: List of files to remove. 

72 target: Target repository path. 

73 

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 

81 

82 for file_path in sorted(files_to_remove): 

83 full_path = target / file_path 

84 

85 if not full_path.exists(): 

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

87 skipped_count += 1 

88 continue 

89 

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 

97 

98 return removed_count, skipped_count, error_count 

99 

100 

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

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

103 

104 Args: 

105 files_to_remove: List of files that were removed. 

106 target: Target repository path. 

107 

108 Returns: 

109 Number of empty directories removed. 

110 """ 

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

112 empty_dirs_removed = 0 

113 

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

115 full_path = target / file_path 

116 parent = full_path.parent 

117 

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 

129 

130 return empty_dirs_removed 

131 

132 

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

134 """Remove the history file itself. 

135 

136 Args: 

137 history_file: Path to history file. 

138 target: Target repository path. 

139 

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 

151 

152 

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

154 """Print uninstall summary. 

155 

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) 

171 

172 

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

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

175 

176 Reads the `.rhiza/history` file and removes all files listed in it. 

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

178 

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

185 

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 

193 

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 

200 

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

202 

203 # Confirm uninstall unless force is used 

204 if not force: 

205 if not _confirm_uninstall(files_to_remove, target): 

206 return 

207 

208 # Remove files 

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

210 

211 # Clean up empty directories 

212 empty_dirs_removed = _cleanup_empty_directories(files_to_remove, target) 

213 

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 

218 

219 # Print summary 

220 _print_summary(removed_count, skipped_count, empty_dirs_removed, error_count) 

221 

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 )