Coverage for plugin/scripts/detach.py: 100%

122 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-15 14:46 +0000

1#!/usr/bin/env python3 

2"""Detach the repo from rhiza by removing every rhiza-managed file. 

3 

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

5`/rhiza:detach` 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 

9This detaches the repo from its template; it does not uninstall the plugin. 

10 

11Usage: 

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

13 

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

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

16 

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

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

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

20""" 

21 

22from __future__ import annotations 

23 

24import argparse 

25import stat 

26import sys 

27from pathlib import Path 

28 

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

30from _rhiza_yaml import load_yaml # noqa: E402 

31 

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

33 

34 

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

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

37 print(message, file=sys.stderr) 

38 

39 

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

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

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

43 

44 

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

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

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

48 for file_path in sorted(files_to_remove): 

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

50 _info(f" - {file_path}") 

51 try: 

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

53 except (KeyboardInterrupt, EOFError): 

54 _info("\nDetach cancelled") 

55 return False 

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

57 _info("Detach cancelled by user") 

58 return False 

59 return True 

60 

61 

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

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

64 _info("Removing files...") 

65 removed = skipped = errors = 0 

66 for file_path in sorted(files_to_remove): 

67 full_path = target / file_path 

68 if not full_path.exists(): 

69 skipped += 1 

70 continue 

71 try: 

72 full_path.unlink() 

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

74 removed += 1 

75 except PermissionError: 

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

77 try: 

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

79 full_path.unlink() 

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

81 removed += 1 

82 except OSError as exc: 

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

84 errors += 1 

85 except OSError as exc: 

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

87 errors += 1 

88 return removed, skipped, errors 

89 

90 

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

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

93 removed = 0 

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

95 parent = (target / file_path).parent 

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

97 try: 

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

99 parent.rmdir() 

100 removed += 1 

101 parent = parent.parent 

102 else: 

103 break 

104 except OSError: 

105 break 

106 return removed 

107 

108 

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

110 """Print the deletion summary counts.""" 

111 _info("\nDetach summary:") 

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

113 if skipped: 

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

115 if empty_dirs: 

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

117 if errors: 

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

119 

120 

121def _drop_lock(lock_file: Path) -> tuple[int, int]: 

122 """Delete the lock so the repo is no longer rhiza-managed; return ``(removed, errors)``. 

123 

124 Last, and deliberately so: while the lock is present the detach is resumable, because 

125 it is the only record of which files were managed. 

126 """ 

127 if not lock_file.exists(): 

128 return 0, 0 

129 try: 

130 lock_file.unlink() 

131 except OSError as exc: 

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

133 return 0, 1 

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

135 return 1, 0 

136 

137 

138def _report_outcome(errors: int) -> int: 

139 """Print the closing guidance and return the process exit code.""" 

140 if errors: 

141 _error(f"Detach completed with {errors} error(s)") 

142 return 1 

143 _info("Repository detached from rhiza successfully") 

144 _info( 

145 "\nNext steps:\n" 

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

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

148 ) 

149 return 0 

150 

151 

152def detach(target: Path, *, force: bool) -> int: 

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

154 target = target.resolve() 

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

156 

157 lock_file = target / LOCK_REL 

158 if not lock_file.exists(): 

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

160 _info("Nothing to detach. This repository may not have Rhiza templates synced.") 

161 return 0 

162 

163 try: 

164 lock = load_yaml(lock_file) 

165 except (OSError, ValueError) as exc: 

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

167 return 1 

168 

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

170 if not files_to_remove: 

171 _info("No files found to detach. Nothing to do.") 

172 return 0 

173 

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

175 

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

177 return 0 

178 

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

180 empty_dirs = _cleanup_empty_directories(files_to_remove, target) 

181 lock_removed, lock_errors = _drop_lock(lock_file) 

182 

183 _print_summary(removed + lock_removed, skipped, empty_dirs, errors + lock_errors) 

184 return _report_outcome(errors + lock_errors) 

185 

186 

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

188 """Entry point: parse args and run the detach.""" 

189 parser = argparse.ArgumentParser( 

190 description="Detach the repo from rhiza, removing every file .rhiza/template.lock records.", 

191 ) 

192 parser.add_argument( 

193 "target", 

194 nargs="?", 

195 default=".", 

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

197 ) 

198 parser.add_argument( 

199 "--force", 

200 "-y", 

201 action="store_true", 

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

203 ) 

204 args = parser.parse_args(argv) 

205 return detach(Path(args.target), force=args.force) 

206 

207 

208if __name__ == "__main__": 

209 raise SystemExit(main())