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.
- crackerjack/CLAUDE.md +1005 -0
- crackerjack/RULES.md +380 -0
- crackerjack/__init__.py +42 -13
- crackerjack/__main__.py +227 -299
- crackerjack/agents/__init__.py +41 -0
- crackerjack/agents/architect_agent.py +281 -0
- crackerjack/agents/base.py +170 -0
- crackerjack/agents/coordinator.py +512 -0
- crackerjack/agents/documentation_agent.py +498 -0
- crackerjack/agents/dry_agent.py +388 -0
- crackerjack/agents/formatting_agent.py +245 -0
- crackerjack/agents/import_optimization_agent.py +281 -0
- crackerjack/agents/performance_agent.py +669 -0
- crackerjack/agents/proactive_agent.py +104 -0
- crackerjack/agents/refactoring_agent.py +788 -0
- crackerjack/agents/security_agent.py +529 -0
- crackerjack/agents/test_creation_agent.py +657 -0
- crackerjack/agents/test_specialist_agent.py +486 -0
- crackerjack/agents/tracker.py +212 -0
- crackerjack/api.py +560 -0
- crackerjack/cli/__init__.py +24 -0
- crackerjack/cli/facade.py +104 -0
- crackerjack/cli/handlers.py +267 -0
- crackerjack/cli/interactive.py +471 -0
- crackerjack/cli/options.py +409 -0
- crackerjack/cli/utils.py +18 -0
- crackerjack/code_cleaner.py +618 -928
- crackerjack/config/__init__.py +19 -0
- crackerjack/config/hooks.py +218 -0
- crackerjack/core/__init__.py +0 -0
- crackerjack/core/async_workflow_orchestrator.py +406 -0
- crackerjack/core/autofix_coordinator.py +200 -0
- crackerjack/core/container.py +104 -0
- crackerjack/core/enhanced_container.py +542 -0
- crackerjack/core/performance.py +243 -0
- crackerjack/core/phase_coordinator.py +585 -0
- crackerjack/core/proactive_workflow.py +316 -0
- crackerjack/core/session_coordinator.py +289 -0
- crackerjack/core/workflow_orchestrator.py +826 -0
- crackerjack/dynamic_config.py +94 -103
- crackerjack/errors.py +263 -41
- crackerjack/executors/__init__.py +11 -0
- crackerjack/executors/async_hook_executor.py +431 -0
- crackerjack/executors/cached_hook_executor.py +242 -0
- crackerjack/executors/hook_executor.py +345 -0
- crackerjack/executors/individual_hook_executor.py +669 -0
- crackerjack/intelligence/__init__.py +44 -0
- crackerjack/intelligence/adaptive_learning.py +751 -0
- crackerjack/intelligence/agent_orchestrator.py +551 -0
- crackerjack/intelligence/agent_registry.py +414 -0
- crackerjack/intelligence/agent_selector.py +502 -0
- crackerjack/intelligence/integration.py +290 -0
- crackerjack/interactive.py +576 -315
- crackerjack/managers/__init__.py +11 -0
- crackerjack/managers/async_hook_manager.py +135 -0
- crackerjack/managers/hook_manager.py +137 -0
- crackerjack/managers/publish_manager.py +433 -0
- crackerjack/managers/test_command_builder.py +151 -0
- crackerjack/managers/test_executor.py +443 -0
- crackerjack/managers/test_manager.py +258 -0
- crackerjack/managers/test_manager_backup.py +1124 -0
- crackerjack/managers/test_progress.py +114 -0
- crackerjack/mcp/__init__.py +0 -0
- crackerjack/mcp/cache.py +336 -0
- crackerjack/mcp/client_runner.py +104 -0
- crackerjack/mcp/context.py +621 -0
- crackerjack/mcp/dashboard.py +636 -0
- crackerjack/mcp/enhanced_progress_monitor.py +479 -0
- crackerjack/mcp/file_monitor.py +336 -0
- crackerjack/mcp/progress_components.py +569 -0
- crackerjack/mcp/progress_monitor.py +949 -0
- crackerjack/mcp/rate_limiter.py +332 -0
- crackerjack/mcp/server.py +22 -0
- crackerjack/mcp/server_core.py +244 -0
- crackerjack/mcp/service_watchdog.py +501 -0
- crackerjack/mcp/state.py +395 -0
- crackerjack/mcp/task_manager.py +257 -0
- crackerjack/mcp/tools/__init__.py +17 -0
- crackerjack/mcp/tools/core_tools.py +249 -0
- crackerjack/mcp/tools/error_analyzer.py +308 -0
- crackerjack/mcp/tools/execution_tools.py +372 -0
- crackerjack/mcp/tools/execution_tools_backup.py +1097 -0
- crackerjack/mcp/tools/intelligence_tool_registry.py +80 -0
- crackerjack/mcp/tools/intelligence_tools.py +314 -0
- crackerjack/mcp/tools/monitoring_tools.py +502 -0
- crackerjack/mcp/tools/proactive_tools.py +384 -0
- crackerjack/mcp/tools/progress_tools.py +217 -0
- crackerjack/mcp/tools/utility_tools.py +341 -0
- crackerjack/mcp/tools/workflow_executor.py +565 -0
- crackerjack/mcp/websocket/__init__.py +14 -0
- crackerjack/mcp/websocket/app.py +39 -0
- crackerjack/mcp/websocket/endpoints.py +559 -0
- crackerjack/mcp/websocket/jobs.py +253 -0
- crackerjack/mcp/websocket/server.py +116 -0
- crackerjack/mcp/websocket/websocket_handler.py +78 -0
- crackerjack/mcp/websocket_server.py +10 -0
- crackerjack/models/__init__.py +31 -0
- crackerjack/models/config.py +93 -0
- crackerjack/models/config_adapter.py +230 -0
- crackerjack/models/protocols.py +118 -0
- crackerjack/models/task.py +154 -0
- crackerjack/monitoring/ai_agent_watchdog.py +450 -0
- crackerjack/monitoring/regression_prevention.py +638 -0
- crackerjack/orchestration/__init__.py +0 -0
- crackerjack/orchestration/advanced_orchestrator.py +970 -0
- crackerjack/orchestration/coverage_improvement.py +223 -0
- crackerjack/orchestration/execution_strategies.py +341 -0
- crackerjack/orchestration/test_progress_streamer.py +636 -0
- crackerjack/plugins/__init__.py +15 -0
- crackerjack/plugins/base.py +200 -0
- crackerjack/plugins/hooks.py +246 -0
- crackerjack/plugins/loader.py +335 -0
- crackerjack/plugins/managers.py +259 -0
- crackerjack/py313.py +8 -3
- crackerjack/services/__init__.py +22 -0
- crackerjack/services/cache.py +314 -0
- crackerjack/services/config.py +358 -0
- crackerjack/services/config_integrity.py +99 -0
- crackerjack/services/contextual_ai_assistant.py +516 -0
- crackerjack/services/coverage_ratchet.py +356 -0
- crackerjack/services/debug.py +736 -0
- crackerjack/services/dependency_monitor.py +617 -0
- crackerjack/services/enhanced_filesystem.py +439 -0
- crackerjack/services/file_hasher.py +151 -0
- crackerjack/services/filesystem.py +421 -0
- crackerjack/services/git.py +176 -0
- crackerjack/services/health_metrics.py +611 -0
- crackerjack/services/initialization.py +873 -0
- crackerjack/services/log_manager.py +286 -0
- crackerjack/services/logging.py +174 -0
- crackerjack/services/metrics.py +578 -0
- crackerjack/services/pattern_cache.py +362 -0
- crackerjack/services/pattern_detector.py +515 -0
- crackerjack/services/performance_benchmarks.py +653 -0
- crackerjack/services/security.py +163 -0
- crackerjack/services/server_manager.py +234 -0
- crackerjack/services/smart_scheduling.py +144 -0
- crackerjack/services/tool_version_service.py +61 -0
- crackerjack/services/unified_config.py +437 -0
- crackerjack/services/version_checker.py +248 -0
- crackerjack/slash_commands/__init__.py +14 -0
- crackerjack/slash_commands/init.md +122 -0
- crackerjack/slash_commands/run.md +163 -0
- crackerjack/slash_commands/status.md +127 -0
- crackerjack-0.31.7.dist-info/METADATA +742 -0
- crackerjack-0.31.7.dist-info/RECORD +149 -0
- crackerjack-0.31.7.dist-info/entry_points.txt +2 -0
- crackerjack/.gitignore +0 -34
- crackerjack/.libcst.codemod.yaml +0 -18
- crackerjack/.pdm.toml +0 -1
- crackerjack/crackerjack.py +0 -3805
- crackerjack/pyproject.toml +0 -286
- crackerjack-0.30.3.dist-info/METADATA +0 -1290
- crackerjack-0.30.3.dist-info/RECORD +0 -16
- {crackerjack-0.30.3.dist-info → crackerjack-0.31.7.dist-info}/WHEEL +0 -0
- {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
|