crackerjack 0.30.3__py3-none-any.whl → 0.31.7__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of crackerjack might be problematic. Click here for more details.

Files changed (156) hide show
  1. crackerjack/CLAUDE.md +1005 -0
  2. crackerjack/RULES.md +380 -0
  3. crackerjack/__init__.py +42 -13
  4. crackerjack/__main__.py +227 -299
  5. crackerjack/agents/__init__.py +41 -0
  6. crackerjack/agents/architect_agent.py +281 -0
  7. crackerjack/agents/base.py +170 -0
  8. crackerjack/agents/coordinator.py +512 -0
  9. crackerjack/agents/documentation_agent.py +498 -0
  10. crackerjack/agents/dry_agent.py +388 -0
  11. crackerjack/agents/formatting_agent.py +245 -0
  12. crackerjack/agents/import_optimization_agent.py +281 -0
  13. crackerjack/agents/performance_agent.py +669 -0
  14. crackerjack/agents/proactive_agent.py +104 -0
  15. crackerjack/agents/refactoring_agent.py +788 -0
  16. crackerjack/agents/security_agent.py +529 -0
  17. crackerjack/agents/test_creation_agent.py +657 -0
  18. crackerjack/agents/test_specialist_agent.py +486 -0
  19. crackerjack/agents/tracker.py +212 -0
  20. crackerjack/api.py +560 -0
  21. crackerjack/cli/__init__.py +24 -0
  22. crackerjack/cli/facade.py +104 -0
  23. crackerjack/cli/handlers.py +267 -0
  24. crackerjack/cli/interactive.py +471 -0
  25. crackerjack/cli/options.py +409 -0
  26. crackerjack/cli/utils.py +18 -0
  27. crackerjack/code_cleaner.py +618 -928
  28. crackerjack/config/__init__.py +19 -0
  29. crackerjack/config/hooks.py +218 -0
  30. crackerjack/core/__init__.py +0 -0
  31. crackerjack/core/async_workflow_orchestrator.py +406 -0
  32. crackerjack/core/autofix_coordinator.py +200 -0
  33. crackerjack/core/container.py +104 -0
  34. crackerjack/core/enhanced_container.py +542 -0
  35. crackerjack/core/performance.py +243 -0
  36. crackerjack/core/phase_coordinator.py +585 -0
  37. crackerjack/core/proactive_workflow.py +316 -0
  38. crackerjack/core/session_coordinator.py +289 -0
  39. crackerjack/core/workflow_orchestrator.py +826 -0
  40. crackerjack/dynamic_config.py +94 -103
  41. crackerjack/errors.py +263 -41
  42. crackerjack/executors/__init__.py +11 -0
  43. crackerjack/executors/async_hook_executor.py +431 -0
  44. crackerjack/executors/cached_hook_executor.py +242 -0
  45. crackerjack/executors/hook_executor.py +345 -0
  46. crackerjack/executors/individual_hook_executor.py +669 -0
  47. crackerjack/intelligence/__init__.py +44 -0
  48. crackerjack/intelligence/adaptive_learning.py +751 -0
  49. crackerjack/intelligence/agent_orchestrator.py +551 -0
  50. crackerjack/intelligence/agent_registry.py +414 -0
  51. crackerjack/intelligence/agent_selector.py +502 -0
  52. crackerjack/intelligence/integration.py +290 -0
  53. crackerjack/interactive.py +576 -315
  54. crackerjack/managers/__init__.py +11 -0
  55. crackerjack/managers/async_hook_manager.py +135 -0
  56. crackerjack/managers/hook_manager.py +137 -0
  57. crackerjack/managers/publish_manager.py +433 -0
  58. crackerjack/managers/test_command_builder.py +151 -0
  59. crackerjack/managers/test_executor.py +443 -0
  60. crackerjack/managers/test_manager.py +258 -0
  61. crackerjack/managers/test_manager_backup.py +1124 -0
  62. crackerjack/managers/test_progress.py +114 -0
  63. crackerjack/mcp/__init__.py +0 -0
  64. crackerjack/mcp/cache.py +336 -0
  65. crackerjack/mcp/client_runner.py +104 -0
  66. crackerjack/mcp/context.py +621 -0
  67. crackerjack/mcp/dashboard.py +636 -0
  68. crackerjack/mcp/enhanced_progress_monitor.py +479 -0
  69. crackerjack/mcp/file_monitor.py +336 -0
  70. crackerjack/mcp/progress_components.py +569 -0
  71. crackerjack/mcp/progress_monitor.py +949 -0
  72. crackerjack/mcp/rate_limiter.py +332 -0
  73. crackerjack/mcp/server.py +22 -0
  74. crackerjack/mcp/server_core.py +244 -0
  75. crackerjack/mcp/service_watchdog.py +501 -0
  76. crackerjack/mcp/state.py +395 -0
  77. crackerjack/mcp/task_manager.py +257 -0
  78. crackerjack/mcp/tools/__init__.py +17 -0
  79. crackerjack/mcp/tools/core_tools.py +249 -0
  80. crackerjack/mcp/tools/error_analyzer.py +308 -0
  81. crackerjack/mcp/tools/execution_tools.py +372 -0
  82. crackerjack/mcp/tools/execution_tools_backup.py +1097 -0
  83. crackerjack/mcp/tools/intelligence_tool_registry.py +80 -0
  84. crackerjack/mcp/tools/intelligence_tools.py +314 -0
  85. crackerjack/mcp/tools/monitoring_tools.py +502 -0
  86. crackerjack/mcp/tools/proactive_tools.py +384 -0
  87. crackerjack/mcp/tools/progress_tools.py +217 -0
  88. crackerjack/mcp/tools/utility_tools.py +341 -0
  89. crackerjack/mcp/tools/workflow_executor.py +565 -0
  90. crackerjack/mcp/websocket/__init__.py +14 -0
  91. crackerjack/mcp/websocket/app.py +39 -0
  92. crackerjack/mcp/websocket/endpoints.py +559 -0
  93. crackerjack/mcp/websocket/jobs.py +253 -0
  94. crackerjack/mcp/websocket/server.py +116 -0
  95. crackerjack/mcp/websocket/websocket_handler.py +78 -0
  96. crackerjack/mcp/websocket_server.py +10 -0
  97. crackerjack/models/__init__.py +31 -0
  98. crackerjack/models/config.py +93 -0
  99. crackerjack/models/config_adapter.py +230 -0
  100. crackerjack/models/protocols.py +118 -0
  101. crackerjack/models/task.py +154 -0
  102. crackerjack/monitoring/ai_agent_watchdog.py +450 -0
  103. crackerjack/monitoring/regression_prevention.py +638 -0
  104. crackerjack/orchestration/__init__.py +0 -0
  105. crackerjack/orchestration/advanced_orchestrator.py +970 -0
  106. crackerjack/orchestration/coverage_improvement.py +223 -0
  107. crackerjack/orchestration/execution_strategies.py +341 -0
  108. crackerjack/orchestration/test_progress_streamer.py +636 -0
  109. crackerjack/plugins/__init__.py +15 -0
  110. crackerjack/plugins/base.py +200 -0
  111. crackerjack/plugins/hooks.py +246 -0
  112. crackerjack/plugins/loader.py +335 -0
  113. crackerjack/plugins/managers.py +259 -0
  114. crackerjack/py313.py +8 -3
  115. crackerjack/services/__init__.py +22 -0
  116. crackerjack/services/cache.py +314 -0
  117. crackerjack/services/config.py +358 -0
  118. crackerjack/services/config_integrity.py +99 -0
  119. crackerjack/services/contextual_ai_assistant.py +516 -0
  120. crackerjack/services/coverage_ratchet.py +356 -0
  121. crackerjack/services/debug.py +736 -0
  122. crackerjack/services/dependency_monitor.py +617 -0
  123. crackerjack/services/enhanced_filesystem.py +439 -0
  124. crackerjack/services/file_hasher.py +151 -0
  125. crackerjack/services/filesystem.py +421 -0
  126. crackerjack/services/git.py +176 -0
  127. crackerjack/services/health_metrics.py +611 -0
  128. crackerjack/services/initialization.py +873 -0
  129. crackerjack/services/log_manager.py +286 -0
  130. crackerjack/services/logging.py +174 -0
  131. crackerjack/services/metrics.py +578 -0
  132. crackerjack/services/pattern_cache.py +362 -0
  133. crackerjack/services/pattern_detector.py +515 -0
  134. crackerjack/services/performance_benchmarks.py +653 -0
  135. crackerjack/services/security.py +163 -0
  136. crackerjack/services/server_manager.py +234 -0
  137. crackerjack/services/smart_scheduling.py +144 -0
  138. crackerjack/services/tool_version_service.py +61 -0
  139. crackerjack/services/unified_config.py +437 -0
  140. crackerjack/services/version_checker.py +248 -0
  141. crackerjack/slash_commands/__init__.py +14 -0
  142. crackerjack/slash_commands/init.md +122 -0
  143. crackerjack/slash_commands/run.md +163 -0
  144. crackerjack/slash_commands/status.md +127 -0
  145. crackerjack-0.31.7.dist-info/METADATA +742 -0
  146. crackerjack-0.31.7.dist-info/RECORD +149 -0
  147. crackerjack-0.31.7.dist-info/entry_points.txt +2 -0
  148. crackerjack/.gitignore +0 -34
  149. crackerjack/.libcst.codemod.yaml +0 -18
  150. crackerjack/.pdm.toml +0 -1
  151. crackerjack/crackerjack.py +0 -3805
  152. crackerjack/pyproject.toml +0 -286
  153. crackerjack-0.30.3.dist-info/METADATA +0 -1290
  154. crackerjack-0.30.3.dist-info/RECORD +0 -16
  155. {crackerjack-0.30.3.dist-info → crackerjack-0.31.7.dist-info}/WHEEL +0 -0
  156. {crackerjack-0.30.3.dist-info → crackerjack-0.31.7.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,421 @@
1
+ import shutil
2
+ from collections.abc import Iterator
3
+ from pathlib import Path
4
+
5
+ from crackerjack.errors import ErrorCode, FileError, ResourceError
6
+
7
+
8
+ class FileSystemService:
9
+ @staticmethod
10
+ def clean_trailing_whitespace_and_newlines(content: str) -> str:
11
+ """Clean trailing whitespace from all lines and ensure single trailing newline.
12
+
13
+ Args:
14
+ content: File content to clean
15
+
16
+ Returns:
17
+ Cleaned content with no trailing whitespace and single trailing newline
18
+ """
19
+ # Remove trailing whitespace from each line
20
+ lines = content.splitlines()
21
+ cleaned_lines = [line.rstrip() for line in lines]
22
+
23
+ # Join lines and ensure exactly one trailing newline
24
+ result = "\n".join(cleaned_lines)
25
+ if result and not result.endswith("\n"):
26
+ result += "\n"
27
+
28
+ return result
29
+
30
+ def read_file(self, path: str | Path) -> str:
31
+ try:
32
+ path_obj = Path(path) if isinstance(path, str) else path
33
+ if not path_obj.exists():
34
+ raise FileError(
35
+ message=f"File does not exist: {path_obj}",
36
+ details=f"Attempted to read file at {path_obj.absolute()}",
37
+ recovery="Check file path and ensure file exists",
38
+ )
39
+ return path_obj.read_text(encoding="utf-8")
40
+ except PermissionError as e:
41
+ raise FileError(
42
+ message=f"Permission denied reading file: {path}",
43
+ error_code=ErrorCode.PERMISSION_ERROR,
44
+ details=str(e),
45
+ recovery="Check file permissions and user access rights",
46
+ ) from e
47
+ except UnicodeDecodeError as e:
48
+ raise FileError(
49
+ message=f"Unable to decode file as UTF-8: {path}",
50
+ error_code=ErrorCode.FILE_READ_ERROR,
51
+ details=str(e),
52
+ recovery="Ensure file is text-based and UTF-8 encoded",
53
+ ) from e
54
+ except OSError as e:
55
+ raise FileError(
56
+ message=f"System error reading file: {path}",
57
+ error_code=ErrorCode.FILE_READ_ERROR,
58
+ details=str(e),
59
+ recovery="Check disk space and file system integrity",
60
+ ) from e
61
+
62
+ def write_file(self, path: str | Path, content: str) -> None:
63
+ try:
64
+ path_obj = Path(path) if isinstance(path, str) else path
65
+ try:
66
+ path_obj.parent.mkdir(parents=True, exist_ok=True)
67
+ except OSError as e:
68
+ raise FileError(
69
+ message=f"Cannot create parent directories for: {path}",
70
+ error_code=ErrorCode.FILE_WRITE_ERROR,
71
+ details=str(e),
72
+ recovery="Check disk space and directory permissions",
73
+ ) from e
74
+
75
+ # Auto-clean configuration files to prevent pre-commit hook failures
76
+ if path_obj.name in {".pre-commit-config.yaml", "pyproject.toml"}:
77
+ content = self.clean_trailing_whitespace_and_newlines(content)
78
+
79
+ path_obj.write_text(content, encoding="utf-8")
80
+ except PermissionError as e:
81
+ raise FileError(
82
+ message=f"Permission denied writing file: {path}",
83
+ error_code=ErrorCode.PERMISSION_ERROR,
84
+ details=str(e),
85
+ recovery="Check file and directory permissions",
86
+ ) from e
87
+ except OSError as e:
88
+ if "No space left on device" in str(e):
89
+ raise ResourceError(
90
+ message=f"Insufficient disk space to write file: {path}",
91
+ details=str(e),
92
+ recovery="Free up disk space and try again",
93
+ ) from e
94
+ raise FileError(
95
+ message=f"System error writing file: {path}",
96
+ error_code=ErrorCode.FILE_WRITE_ERROR,
97
+ details=str(e),
98
+ recovery="Check disk space and file system integrity",
99
+ ) from e
100
+
101
+ def exists(self, path: str | Path) -> bool:
102
+ try:
103
+ path_obj = Path(path) if isinstance(path, str) else path
104
+ return path_obj.exists()
105
+ except OSError:
106
+ return False
107
+
108
+ def mkdir(self, path: str | Path, parents: bool = False) -> None:
109
+ try:
110
+ path_obj = Path(path) if isinstance(path, str) else path
111
+ path_obj.mkdir(parents=parents, exist_ok=True)
112
+ except PermissionError as e:
113
+ raise FileError(
114
+ message=f"Permission denied creating directory: {path}",
115
+ error_code=ErrorCode.PERMISSION_ERROR,
116
+ details=str(e),
117
+ recovery="Check parent directory permissions",
118
+ ) from e
119
+ except FileExistsError as e:
120
+ if not parents:
121
+ raise FileError(
122
+ message=f"Directory already exists: {path}",
123
+ details=str(e),
124
+ recovery="Use exist_ok=True or check if directory exists first",
125
+ ) from e
126
+ except OSError as e:
127
+ if "No space left on device" in str(e):
128
+ raise ResourceError(
129
+ message=f"Insufficient disk space to create directory: {path}",
130
+ details=str(e),
131
+ recovery="Free up disk space and try again",
132
+ ) from e
133
+ raise FileError(
134
+ message=f"System error creating directory: {path}",
135
+ error_code=ErrorCode.FILE_WRITE_ERROR,
136
+ details=str(e),
137
+ recovery="Check disk space and file system integrity",
138
+ ) from e
139
+
140
+ def glob(self, pattern: str, path: str | Path | None = None) -> list[Path]:
141
+ base_path = Path(path) if path else Path.cwd()
142
+ try:
143
+ if not base_path.exists():
144
+ raise FileError(
145
+ message=f"Base path does not exist: {base_path}",
146
+ details=f"Attempted to glob in {base_path.absolute()}",
147
+ recovery="Check base path and ensure directory exists",
148
+ )
149
+ return list(base_path.glob(pattern))
150
+ except PermissionError as e:
151
+ raise FileError(
152
+ message=f"Permission denied accessing directory: {base_path}",
153
+ error_code=ErrorCode.PERMISSION_ERROR,
154
+ details=str(e),
155
+ recovery="Check directory permissions",
156
+ ) from e
157
+ except OSError as e:
158
+ raise FileError(
159
+ message=f"System error during glob operation: {pattern}",
160
+ error_code=ErrorCode.FILE_READ_ERROR,
161
+ details=str(e),
162
+ recovery="Check path validity and file system integrity",
163
+ ) from e
164
+
165
+ def rglob(self, pattern: str, path: str | Path | None = None) -> list[Path]:
166
+ base_path = Path(path) if path else Path.cwd()
167
+ try:
168
+ if not base_path.exists():
169
+ raise FileError(
170
+ message=f"Base path does not exist: {base_path}",
171
+ details=f"Attempted to rglob in {base_path.absolute()}",
172
+ recovery="Check base path and ensure directory exists",
173
+ )
174
+ return list(base_path.rglob(pattern))
175
+ except PermissionError as e:
176
+ raise FileError(
177
+ message=f"Permission denied accessing directory: {base_path}",
178
+ error_code=ErrorCode.PERMISSION_ERROR,
179
+ details=str(e),
180
+ recovery="Check directory permissions",
181
+ ) from e
182
+ except OSError as e:
183
+ raise FileError(
184
+ message=f"System error during recursive glob operation: {pattern}",
185
+ error_code=ErrorCode.FILE_READ_ERROR,
186
+ details=str(e),
187
+ recovery="Check path validity and file system integrity",
188
+ ) from e
189
+
190
+ def copy_file(self, src: str | Path, dst: str | Path) -> None:
191
+ src_path, dst_path = self._normalize_copy_paths(src, dst)
192
+ self._validate_copy_source(src_path)
193
+ self._prepare_copy_destination(dst_path)
194
+ self._perform_file_copy(src_path, dst_path, src, dst)
195
+
196
+ def _normalize_copy_paths(
197
+ self, src: str | Path, dst: str | Path
198
+ ) -> tuple[Path, Path]:
199
+ src_path = Path(src) if isinstance(src, str) else src
200
+ dst_path = Path(dst) if isinstance(dst, str) else dst
201
+ return src_path, dst_path
202
+
203
+ def _validate_copy_source(self, src_path: Path) -> None:
204
+ if not src_path.exists():
205
+ raise FileError(
206
+ message=f"Source file does not exist: {src_path}",
207
+ details=f"Attempted to copy from {src_path.absolute()}",
208
+ recovery="Check source file path and ensure file exists",
209
+ )
210
+ if not src_path.is_file():
211
+ raise FileError(
212
+ message=f"Source is not a file: {src_path}",
213
+ details=f"Source is a {src_path.stat().st_mode} type",
214
+ recovery="Ensure source path points to a regular file",
215
+ )
216
+
217
+ def _prepare_copy_destination(self, dst_path: Path) -> None:
218
+ try:
219
+ dst_path.parent.mkdir(parents=True, exist_ok=True)
220
+ except OSError as e:
221
+ raise FileError(
222
+ message=f"Cannot create destination parent directories: {dst_path.parent}",
223
+ error_code=ErrorCode.FILE_WRITE_ERROR,
224
+ details=str(e),
225
+ recovery="Check disk space and directory permissions",
226
+ ) from e
227
+
228
+ def _perform_file_copy(
229
+ self, src_path: Path, dst_path: Path, src: str | Path, dst: str | Path
230
+ ) -> None:
231
+ try:
232
+ shutil.copy2(src_path, dst_path)
233
+ except PermissionError as e:
234
+ raise FileError(
235
+ message=f"Permission denied copying file: {src} -> {dst}",
236
+ error_code=ErrorCode.PERMISSION_ERROR,
237
+ details=str(e),
238
+ recovery="Check file and directory permissions",
239
+ ) from e
240
+ except shutil.SameFileError as e:
241
+ raise FileError(
242
+ message=f"Source and destination are the same file: {src}",
243
+ error_code=ErrorCode.FILE_WRITE_ERROR,
244
+ details=str(e),
245
+ recovery="Ensure source and destination paths are different",
246
+ ) from e
247
+ except OSError as e:
248
+ if "No space left on device" in str(e):
249
+ raise ResourceError(
250
+ message=f"Insufficient disk space to copy file: {src} -> {dst}",
251
+ details=str(e),
252
+ recovery="Free up disk space and try again",
253
+ ) from e
254
+ raise FileError(
255
+ message=f"System error copying file: {src} -> {dst}",
256
+ error_code=ErrorCode.FILE_WRITE_ERROR,
257
+ details=str(e),
258
+ recovery="Check disk space and file system integrity",
259
+ ) from e
260
+
261
+ def remove_file(self, path: str | Path) -> None:
262
+ try:
263
+ path_obj = Path(path) if isinstance(path, str) else path
264
+ if path_obj.exists():
265
+ if not path_obj.is_file():
266
+ raise FileError(
267
+ message=f"Path is not a file: {path_obj}",
268
+ details=f"Path type: {path_obj.stat().st_mode}",
269
+ recovery="Use appropriate method for directory removal",
270
+ )
271
+ path_obj.unlink()
272
+ except PermissionError as e:
273
+ raise FileError(
274
+ message=f"Permission denied removing file: {path}",
275
+ error_code=ErrorCode.PERMISSION_ERROR,
276
+ details=str(e),
277
+ recovery="Check file permissions and ownership",
278
+ ) from e
279
+ except OSError as e:
280
+ raise FileError(
281
+ message=f"System error removing file: {path}",
282
+ error_code=ErrorCode.FILE_WRITE_ERROR,
283
+ details=str(e),
284
+ recovery="Check file system integrity and try again",
285
+ ) from e
286
+
287
+ def get_file_size(self, path: str | Path) -> int:
288
+ try:
289
+ path_obj = Path(path) if isinstance(path, str) else path
290
+ if not path_obj.exists():
291
+ raise FileError(
292
+ message=f"File does not exist: {path_obj}",
293
+ details=f"Attempted to get size of {path_obj.absolute()}",
294
+ recovery="Check file path and ensure file exists",
295
+ )
296
+ if not path_obj.is_file():
297
+ raise FileError(
298
+ message=f"Path is not a file: {path_obj}",
299
+ details=f"Path type: {path_obj.stat().st_mode}",
300
+ recovery="Ensure path points to a regular file",
301
+ )
302
+ return path_obj.stat().st_size
303
+ except PermissionError as e:
304
+ raise FileError(
305
+ message=f"Permission denied accessing file: {path}",
306
+ error_code=ErrorCode.PERMISSION_ERROR,
307
+ details=str(e),
308
+ recovery="Check file permissions",
309
+ ) from e
310
+ except OSError as e:
311
+ raise FileError(
312
+ message=f"System error getting file size: {path}",
313
+ error_code=ErrorCode.FILE_READ_ERROR,
314
+ details=str(e),
315
+ recovery="Check file system integrity",
316
+ ) from e
317
+
318
+ def get_file_mtime(self, path: str | Path) -> float:
319
+ try:
320
+ path_obj = Path(path) if isinstance(path, str) else path
321
+ if not path_obj.exists():
322
+ raise FileError(
323
+ message=f"File does not exist: {path_obj}",
324
+ details=f"Attempted to get mtime of {path_obj.absolute()}",
325
+ recovery="Check file path and ensure file exists",
326
+ )
327
+ if not path_obj.is_file():
328
+ raise FileError(
329
+ message=f"Path is not a file: {path_obj}",
330
+ details=f"Path type: {path_obj.stat().st_mode}",
331
+ recovery="Ensure path points to a regular file",
332
+ )
333
+ return path_obj.stat().st_mtime
334
+ except PermissionError as e:
335
+ raise FileError(
336
+ message=f"Permission denied accessing file: {path}",
337
+ error_code=ErrorCode.PERMISSION_ERROR,
338
+ details=str(e),
339
+ recovery="Check file permissions",
340
+ ) from e
341
+ except OSError as e:
342
+ raise FileError(
343
+ message=f"System error getting file modification time: {path}",
344
+ error_code=ErrorCode.FILE_READ_ERROR,
345
+ details=str(e),
346
+ recovery="Check file system integrity",
347
+ ) from e
348
+
349
+ def read_file_chunked(
350
+ self,
351
+ path: str | Path,
352
+ chunk_size: int = 8192,
353
+ ) -> Iterator[str]:
354
+ try:
355
+ path_obj = Path(path) if isinstance(path, str) else path
356
+ if not path_obj.exists():
357
+ raise FileError(
358
+ message=f"File does not exist: {path_obj}",
359
+ details=f"Attempted to read file at {path_obj.absolute()}",
360
+ recovery="Check file path and ensure file exists",
361
+ )
362
+
363
+ with path_obj.open(encoding="utf-8") as file:
364
+ while chunk := file.read(chunk_size):
365
+ yield chunk
366
+
367
+ except PermissionError as e:
368
+ raise FileError(
369
+ message=f"Permission denied reading file: {path}",
370
+ error_code=ErrorCode.PERMISSION_ERROR,
371
+ details=str(e),
372
+ recovery="Check file permissions",
373
+ ) from e
374
+ except UnicodeDecodeError as e:
375
+ raise FileError(
376
+ message=f"File encoding error: {path}",
377
+ error_code=ErrorCode.FILE_READ_ERROR,
378
+ details=str(e),
379
+ recovery="Ensure file is encoded in UTF-8",
380
+ ) from e
381
+ except OSError as e:
382
+ raise FileError(
383
+ message=f"System error reading file: {path}",
384
+ error_code=ErrorCode.FILE_READ_ERROR,
385
+ details=str(e),
386
+ recovery="Check file system integrity",
387
+ ) from e
388
+
389
+ def read_lines_streaming(self, path: str | Path) -> Iterator[str]:
390
+ try:
391
+ path_obj = Path(path) if isinstance(path, str) else path
392
+ if not path_obj.exists():
393
+ raise FileError(
394
+ message=f"File does not exist: {path_obj}",
395
+ details=f"Attempted to read file at {path_obj.absolute()}",
396
+ recovery="Check file path and ensure file exists",
397
+ )
398
+ with path_obj.open(encoding="utf-8") as file:
399
+ for line in file:
400
+ yield line.rstrip("\n\r")
401
+ except PermissionError as e:
402
+ raise FileError(
403
+ message=f"Permission denied reading file: {path}",
404
+ error_code=ErrorCode.PERMISSION_ERROR,
405
+ details=str(e),
406
+ recovery="Check file permissions",
407
+ ) from e
408
+ except UnicodeDecodeError as e:
409
+ raise FileError(
410
+ message=f"File encoding error: {path}",
411
+ error_code=ErrorCode.FILE_READ_ERROR,
412
+ details=str(e),
413
+ recovery="Ensure file is encoded in UTF-8",
414
+ ) from e
415
+ except OSError as e:
416
+ raise FileError(
417
+ message=f"System error reading file: {path}",
418
+ error_code=ErrorCode.FILE_READ_ERROR,
419
+ details=str(e),
420
+ recovery="Check file system integrity",
421
+ ) from e
@@ -0,0 +1,176 @@
1
+ import subprocess
2
+ from pathlib import Path
3
+
4
+ from rich.console import Console
5
+
6
+
7
+ class GitService:
8
+ def __init__(self, console: Console, pkg_path: Path | None = None) -> None:
9
+ self.console = console
10
+ self.pkg_path = pkg_path or Path.cwd()
11
+
12
+ def _run_git_command(self, args: list[str]) -> subprocess.CompletedProcess[str]:
13
+ cmd = ["git", *args]
14
+ return subprocess.run(
15
+ cmd,
16
+ check=False,
17
+ cwd=self.pkg_path,
18
+ capture_output=True,
19
+ text=True,
20
+ timeout=60,
21
+ )
22
+
23
+ def is_git_repo(self) -> bool:
24
+ try:
25
+ result = self._run_git_command(["rev-parse", "--git-dir"])
26
+ return result.returncode == 0
27
+ except (subprocess.SubprocessError, OSError, FileNotFoundError):
28
+ return False
29
+
30
+ def get_changed_files(self) -> list[str]:
31
+ try:
32
+ staged_result = self._run_git_command(["diff", "--cached", "--name-only"])
33
+ staged_files = (
34
+ staged_result.stdout.strip().split("\n")
35
+ if staged_result.stdout.strip()
36
+ else []
37
+ )
38
+ unstaged_result = self._run_git_command(["diff", "--name-only"])
39
+ unstaged_files = (
40
+ unstaged_result.stdout.strip().split("\n")
41
+ if unstaged_result.stdout.strip()
42
+ else []
43
+ )
44
+ untracked_result = self._run_git_command(
45
+ ["ls-files", "--others", "--exclude-standard"],
46
+ )
47
+ untracked_files = (
48
+ untracked_result.stdout.strip().split("\n")
49
+ if untracked_result.stdout.strip()
50
+ else []
51
+ )
52
+ all_files = set(staged_files + unstaged_files + untracked_files)
53
+ return [f for f in all_files if f]
54
+ except Exception as e:
55
+ self.console.print(f"[yellow]⚠️[/yellow] Error getting changed files: {e}")
56
+ return []
57
+
58
+ def get_staged_files(self) -> list[str]:
59
+ try:
60
+ result = self._run_git_command(["diff", "--cached", "--name-only"])
61
+ return result.stdout.strip().split("\n") if result.stdout.strip() else []
62
+ except Exception as e:
63
+ self.console.print(f"[yellow]⚠️[/yellow] Error getting staged files: {e}")
64
+ return []
65
+
66
+ def add_files(self, files: list[str]) -> bool:
67
+ try:
68
+ for file in files:
69
+ result = self._run_git_command(["add", file])
70
+ if result.returncode != 0:
71
+ self.console.print(
72
+ f"[red]❌[/red] Failed to add {file}: {result.stderr}",
73
+ )
74
+ return False
75
+ return True
76
+ except Exception as e:
77
+ self.console.print(f"[red]❌[/red] Error adding files: {e}")
78
+ return False
79
+
80
+ def commit(self, message: str) -> bool:
81
+ try:
82
+ result = self._run_git_command(["commit", "-m", message])
83
+ if result.returncode == 0:
84
+ self.console.print(f"[green]✅[/green] Committed: {message}")
85
+ return True
86
+
87
+ # When git commit fails due to pre-commit hooks, stderr contains hook output
88
+ # Use a more appropriate error message for commit failures
89
+ if "pre-commit" in result.stderr or "hook" in result.stderr.lower():
90
+ self.console.print("[red]❌[/red] Commit blocked by pre-commit hooks")
91
+ if result.stderr.strip():
92
+ # Show hook output in a more readable way
93
+ self.console.print(
94
+ f"[yellow]Hook output:[/yellow]\n{result.stderr.strip()}"
95
+ )
96
+ else:
97
+ self.console.print(f"[red]❌[/red] Commit failed: {result.stderr}")
98
+ return False
99
+ except Exception as e:
100
+ self.console.print(f"[red]❌[/red] Error committing: {e}")
101
+ return False
102
+
103
+ def push(self) -> bool:
104
+ try:
105
+ result = self._run_git_command(["push"])
106
+ if result.returncode == 0:
107
+ self.console.print("[green]✅[/green] Pushed to remote")
108
+ return True
109
+ self.console.print(f"[red]❌[/red] Push failed: {result.stderr}")
110
+ return False
111
+ except Exception as e:
112
+ self.console.print(f"[red]❌[/red] Error pushing: {e}")
113
+ return False
114
+
115
+ def get_current_branch(self) -> str | None:
116
+ try:
117
+ result = self._run_git_command(["branch", "--show-current"])
118
+ return result.stdout.strip() if result.returncode == 0 else None
119
+ except (subprocess.SubprocessError, OSError, FileNotFoundError):
120
+ return None
121
+
122
+ def get_commit_message_suggestions(self, files: list[str]) -> list[str]:
123
+ if not files:
124
+ return ["Update project files"]
125
+ file_categories = self._categorize_files(files)
126
+ messages = self._generate_category_messages(file_categories)
127
+ messages.extend(self._generate_specific_messages(files))
128
+
129
+ return messages[:5]
130
+
131
+ def _categorize_files(self, files: list[str]) -> set[str]:
132
+ categories = {
133
+ "docs": ["README", "CLAUDE", "docs/", ".md"],
134
+ "tests": ["test_", "tests/", "conftest.py"],
135
+ "config": ["pyproject.toml", ".yaml", ".yml", ".json", ".gitignore"],
136
+ "ci": [".github/", "ci/", ".pre-commit"],
137
+ "deps": ["requirements", "uv.lock", "Pipfile"],
138
+ }
139
+ file_categories: set[str] = set()
140
+ for file in files:
141
+ category = self._get_file_category(file, categories)
142
+ file_categories.add(category)
143
+
144
+ return file_categories
145
+
146
+ def _get_file_category(self, file: str, categories: dict[str, list[str]]) -> str:
147
+ for category, patterns in categories.items():
148
+ if any(pattern in file for pattern in patterns):
149
+ return category
150
+ return "core"
151
+
152
+ def _generate_category_messages(self, file_categories: set[str]) -> list[str]:
153
+ if len(file_categories) == 1:
154
+ return self._generate_single_category_message(next(iter(file_categories)))
155
+ return [f"Update {', '.join(sorted(file_categories))}"]
156
+
157
+ def _generate_single_category_message(self, category: str) -> list[str]:
158
+ category_messages = {
159
+ "docs": "Update documentation",
160
+ "tests": "Update tests",
161
+ "config": "Update configuration",
162
+ "ci": "Update CI/CD configuration",
163
+ "deps": "Update dependencies",
164
+ }
165
+ return [category_messages.get(category, "Update core functionality")]
166
+
167
+ def _generate_specific_messages(self, files: list[str]) -> list[str]:
168
+ messages: list[str] = []
169
+ if "pyproject.toml" in files:
170
+ messages.append("Update project configuration")
171
+ if any("test_" in f for f in files):
172
+ messages.append("Improve test coverage")
173
+ if "README.md" in files:
174
+ messages.append("Update README documentation")
175
+
176
+ return messages