Coverage for src / rhiza / commands / uninstall.py: 100%

90 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2025-12-29 01:59 +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 uninstall(target: Path, force: bool) -> None: 

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

16 

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

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

19 

20 Args: 

21 target (Path): Path to the target repository. 

22 force (bool): If True, skip confirmation prompt and proceed with deletion. 

23 """ 

24 # Resolve to absolute path to avoid any ambiguity 

25 target = target.resolve() 

26 

27 logger.info(f"Target repository: {target}") 

28 

29 # Check for history file in new location only 

30 history_file = target / ".rhiza" / "history" 

31 

32 if not history_file.exists(): 

33 logger.warning(f"No history file found at: {history_file.relative_to(target)}") 

34 logger.info("Nothing to uninstall. This repository may not have Rhiza templates materialized.") 

35 logger.info("If you haven't migrated yet, run 'rhiza migrate' first.") 

36 return 

37 

38 # Read the history file 

39 logger.debug(f"Reading history file: {history_file.relative_to(target)}") 

40 files_to_remove: list[Path] = [] 

41 

42 with history_file.open("r", encoding="utf-8") as f: 

43 for line in f: 

44 line = line.strip() 

45 # Skip comments and empty lines 

46 if line and not line.startswith("#"): 

47 file_path = Path(line) 

48 files_to_remove.append(file_path) 

49 

50 if not files_to_remove: 

51 logger.warning("History file is empty (only contains comments)") 

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

53 return 

54 

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

56 

57 # Show confirmation prompt unless --force is used 

58 if not force: 

59 logger.warning("This will remove the following files from your repository:") 

60 for file_path in sorted(files_to_remove): 

61 full_path = target / file_path 

62 if full_path.exists(): 

63 logger.warning(f" - {file_path}") 

64 else: 

65 logger.debug(f" - {file_path} (already deleted)") 

66 

67 # Prompt for confirmation 

68 try: 

69 response = input("\nAre you sure you want to proceed? [y/N]: ").strip().lower() 

70 if response not in ("y", "yes"): 

71 logger.info("Uninstall cancelled by user") 

72 return 

73 except (KeyboardInterrupt, EOFError): 

74 logger.info("\nUninstall cancelled by user") 

75 return 

76 

77 # Remove files 

78 logger.info("Removing files...") 

79 removed_count = 0 

80 skipped_count = 0 

81 error_count = 0 

82 

83 for file_path in sorted(files_to_remove): 

84 full_path = target / file_path 

85 

86 if not full_path.exists(): 

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

88 skipped_count += 1 

89 continue 

90 

91 try: 

92 full_path.unlink() 

93 logger.success(f"[DEL] {file_path}") 

94 removed_count += 1 

95 except Exception as e: 

96 logger.error(f"Failed to delete {file_path}: {e}") 

97 error_count += 1 

98 

99 # Clean up empty directories 

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

101 empty_dirs_removed = 0 

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

103 full_path = target / file_path 

104 parent = full_path.parent 

105 

106 # Try to remove parent directories if they're empty 

107 # Walk up the directory tree 

108 while parent != target and parent.exists(): 

109 try: 

110 # Only remove if directory is empty 

111 if parent.is_dir() and not any(parent.iterdir()): 

112 parent.rmdir() 

113 logger.debug(f"[DEL] {parent.relative_to(target)}/ (empty directory)") 

114 empty_dirs_removed += 1 

115 parent = parent.parent 

116 else: 

117 break 

118 except Exception: 

119 # Directory not empty or other error, stop walking up 

120 break 

121 

122 # Remove history file itself 

123 try: 

124 history_file.unlink() 

125 logger.success(f"[DEL] {history_file.relative_to(target)}") 

126 removed_count += 1 

127 except Exception as e: 

128 logger.error(f"Failed to delete {history_file.relative_to(target)}: {e}") 

129 error_count += 1 

130 

131 # Summary 

132 logger.info("\nUninstall summary:") 

133 logger.info(f" Files removed: {removed_count}") 

134 if skipped_count > 0: 

135 logger.info(f" Files skipped (already deleted): {skipped_count}") 

136 if empty_dirs_removed > 0: 

137 logger.info(f" Empty directories removed: {empty_dirs_removed}") 

138 if error_count > 0: 

139 logger.error(f" Errors encountered: {error_count}") 

140 sys.exit(1) 

141 

142 logger.success("Rhiza templates uninstalled successfully") 

143 logger.info( 

144 "Next steps:\n" 

145 " Review changes:\n" 

146 " git status\n" 

147 " git diff\n\n" 

148 " Commit:\n" 

149 " git add .\n" 

150 ' git commit -m "chore: remove rhiza templates"' 

151 )