Coverage for scripts/uninstall.py: 100%

117 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-31 06:18 +0000

1#!/usr/bin/env python3 

2"""Remove all rhiza-managed files from the repo. 

3 

4A stdlib-only port of the `rhiza uninstall` command, bundled with this plugin so 

5`/rhiza:uninstall` works without the `rhiza` CLI (or PyYAML) installed. It reads 

6the `files` recorded in `.rhiza/template.lock`, deletes each one, prunes the 

7now-empty directories, and finally removes the lock file itself. 

8 

9Usage: 

10 uv run --python 3.12 --no-project python scripts/uninstall.py [TARGET] [--force|-y] 

11 

12 TARGET repository root to clean (default: current directory) 

13 --force, -y skip the confirmation prompt and proceed with deletion 

14 

15This is destructive. Without --force it prompts for confirmation; if stdin is 

16not a TTY (no way to answer) it treats that as "no" and cancels. Exits 0 on 

17success or a clean no-op, 1 if any deletion failed or the lock is unreadable. 

18""" 

19 

20from __future__ import annotations 

21 

22import argparse 

23import stat 

24import sys 

25from pathlib import Path 

26 

27sys.path.insert(0, str(Path(__file__).resolve().parent)) 

28from _rhiza_yaml import load_yaml # noqa: E402 

29 

30LOCK_REL = Path(".rhiza") / "template.lock" 

31 

32 

33def _info(message: str) -> None: 

34 """Print an informational line to stderr.""" 

35 print(message, file=sys.stderr) 

36 

37 

38def _error(message: str) -> None: 

39 """Print an error line to stderr.""" 

40 print(f"error: {message}", file=sys.stderr) 

41 

42 

43def _confirm(files_to_remove: list[Path], target: Path) -> bool: 

44 """Show the deletion list and ask for confirmation; False cancels.""" 

45 _info("This will remove the following files from your repository:") 

46 for file_path in sorted(files_to_remove): 

47 if (target / file_path).exists(): 

48 _info(f" - {file_path}") 

49 try: 

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

51 except (KeyboardInterrupt, EOFError): 

52 _info("\nUninstall cancelled") 

53 return False 

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

55 _info("Uninstall cancelled by user") 

56 return False 

57 return True 

58 

59 

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

61 """Delete the listed files; return (removed, skipped, errors).""" 

62 _info("Removing files...") 

63 removed = skipped = errors = 0 

64 for file_path in sorted(files_to_remove): 

65 full_path = target / file_path 

66 if not full_path.exists(): 

67 skipped += 1 

68 continue 

69 try: 

70 full_path.unlink() 

71 _info(f"[DEL] {file_path}") 

72 removed += 1 

73 except PermissionError: 

74 # A read-only file must be made writable before it can be deleted. 

75 try: 

76 full_path.chmod(full_path.stat().st_mode | stat.S_IWRITE) 

77 full_path.unlink() 

78 _info(f"[DEL] {file_path}") 

79 removed += 1 

80 except OSError as exc: 

81 _error(f"Failed to delete {file_path}: {exc}") 

82 errors += 1 

83 except OSError as exc: 

84 _error(f"Failed to delete {file_path}: {exc}") 

85 errors += 1 

86 return removed, skipped, errors 

87 

88 

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

90 """Remove directories left empty by the deletions; return the count.""" 

91 removed = 0 

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

93 parent = (target / file_path).parent 

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

95 try: 

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

97 parent.rmdir() 

98 removed += 1 

99 parent = parent.parent 

100 else: 

101 break 

102 except OSError: 

103 break 

104 return removed 

105 

106 

107def _print_summary(removed: int, skipped: int, empty_dirs: int, errors: int) -> None: 

108 """Print the deletion summary counts.""" 

109 _info("\nUninstall summary:") 

110 _info(f" Files removed: {removed}") 

111 if skipped: 

112 _info(f" Files skipped (already deleted): {skipped}") 

113 if empty_dirs: 

114 _info(f" Empty directories removed: {empty_dirs}") 

115 if errors: 

116 _error(f" Errors encountered: {errors}") 

117 

118 

119def uninstall(target: Path, *, force: bool) -> int: 

120 """Remove all rhiza-managed files; return a process exit code.""" 

121 target = target.resolve() 

122 _info(f"Target repository: {target}") 

123 

124 lock_file = target / LOCK_REL 

125 if not lock_file.exists(): 

126 _info(f"No lock file found at: {LOCK_REL}") 

127 _info("Nothing to uninstall. This repository may not have Rhiza templates synced.") 

128 return 0 

129 

130 try: 

131 lock = load_yaml(lock_file) 

132 except (OSError, ValueError) as exc: 

133 _error(f"Failed to read template.lock: {exc}") 

134 return 1 

135 

136 files_to_remove = [Path(str(f)) for f in (lock.get("files") or [])] 

137 if not files_to_remove: 

138 _info("No files found to uninstall. Nothing to do.") 

139 return 0 

140 

141 _info(f"Found {len(files_to_remove)} file(s) to remove") 

142 

143 if not force and not _confirm(files_to_remove, target): 

144 return 0 

145 

146 removed, skipped, errors = _remove_files(files_to_remove, target) 

147 empty_dirs = _cleanup_empty_directories(files_to_remove, target) 

148 

149 # Finally drop the lock file itself so the repo is no longer rhiza-managed. 

150 if lock_file.exists(): 

151 try: 

152 lock_file.unlink() 

153 _info(f"[DEL] {LOCK_REL}") 

154 removed += 1 

155 except OSError as exc: 

156 _error(f"Failed to delete {LOCK_REL}: {exc}") 

157 errors += 1 

158 

159 _print_summary(removed, skipped, empty_dirs, errors) 

160 if errors: 

161 _error(f"Uninstall completed with {errors} error(s)") 

162 return 1 

163 

164 _info("Rhiza templates uninstalled successfully") 

165 _info( 

166 "\nNext steps:\n" 

167 " Review changes: git status && git diff\n" 

168 ' Commit: git add . && git commit -m "chore: remove rhiza templates"' 

169 ) 

170 return 0 

171 

172 

173def main(argv: list[str] | None = None) -> int: 

174 """Entry point: parse args and run the uninstall.""" 

175 parser = argparse.ArgumentParser( 

176 description="Remove all rhiza-managed files (from .rhiza/template.lock) from the repo.", 

177 ) 

178 parser.add_argument( 

179 "target", 

180 nargs="?", 

181 default=".", 

182 help="Repository root to clean (default: current directory).", 

183 ) 

184 parser.add_argument( 

185 "--force", 

186 "-y", 

187 action="store_true", 

188 help="Skip the confirmation prompt and proceed with deletion.", 

189 ) 

190 args = parser.parse_args(argv) 

191 return uninstall(Path(args.target), force=args.force) 

192 

193 

194if __name__ == "__main__": 

195 raise SystemExit(main())