gac 3.10.3__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 gac might be problematic. Click here for more details.
- gac/__init__.py +15 -0
- gac/__version__.py +3 -0
- gac/ai.py +109 -0
- gac/ai_utils.py +246 -0
- gac/auth_cli.py +214 -0
- gac/cli.py +218 -0
- gac/commit_executor.py +62 -0
- gac/config.py +125 -0
- gac/config_cli.py +95 -0
- gac/constants.py +328 -0
- gac/diff_cli.py +159 -0
- gac/errors.py +231 -0
- gac/git.py +372 -0
- gac/git_state_validator.py +184 -0
- gac/grouped_commit_workflow.py +423 -0
- gac/init_cli.py +70 -0
- gac/interactive_mode.py +182 -0
- gac/language_cli.py +377 -0
- gac/main.py +476 -0
- gac/model_cli.py +430 -0
- gac/oauth/__init__.py +27 -0
- gac/oauth/claude_code.py +464 -0
- gac/oauth/qwen_oauth.py +327 -0
- gac/oauth/token_store.py +81 -0
- gac/preprocess.py +511 -0
- gac/prompt.py +878 -0
- gac/prompt_builder.py +88 -0
- gac/providers/README.md +437 -0
- gac/providers/__init__.py +80 -0
- gac/providers/anthropic.py +17 -0
- gac/providers/azure_openai.py +57 -0
- gac/providers/base.py +329 -0
- gac/providers/cerebras.py +15 -0
- gac/providers/chutes.py +25 -0
- gac/providers/claude_code.py +79 -0
- gac/providers/custom_anthropic.py +103 -0
- gac/providers/custom_openai.py +44 -0
- gac/providers/deepseek.py +15 -0
- gac/providers/error_handler.py +139 -0
- gac/providers/fireworks.py +15 -0
- gac/providers/gemini.py +90 -0
- gac/providers/groq.py +15 -0
- gac/providers/kimi_coding.py +27 -0
- gac/providers/lmstudio.py +80 -0
- gac/providers/minimax.py +15 -0
- gac/providers/mistral.py +15 -0
- gac/providers/moonshot.py +15 -0
- gac/providers/ollama.py +73 -0
- gac/providers/openai.py +32 -0
- gac/providers/openrouter.py +21 -0
- gac/providers/protocol.py +71 -0
- gac/providers/qwen.py +64 -0
- gac/providers/registry.py +58 -0
- gac/providers/replicate.py +156 -0
- gac/providers/streamlake.py +31 -0
- gac/providers/synthetic.py +40 -0
- gac/providers/together.py +15 -0
- gac/providers/zai.py +31 -0
- gac/py.typed +0 -0
- gac/security.py +293 -0
- gac/utils.py +401 -0
- gac/workflow_utils.py +217 -0
- gac-3.10.3.dist-info/METADATA +283 -0
- gac-3.10.3.dist-info/RECORD +67 -0
- gac-3.10.3.dist-info/WHEEL +4 -0
- gac-3.10.3.dist-info/entry_points.txt +2 -0
- gac-3.10.3.dist-info/licenses/LICENSE +16 -0
gac/errors.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Error handling module for gac."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import sys
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from typing import Any, TypeVar
|
|
7
|
+
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
console = Console()
|
|
12
|
+
T = TypeVar("T")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class GacError(Exception):
|
|
16
|
+
"""Base exception class for all gac errors."""
|
|
17
|
+
|
|
18
|
+
exit_code = 1 # Default exit code
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
message: str,
|
|
23
|
+
details: str | None = None,
|
|
24
|
+
suggestion: str | None = None,
|
|
25
|
+
exit_code: int | None = None,
|
|
26
|
+
):
|
|
27
|
+
"""
|
|
28
|
+
Initialize a new GacError.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
message: The error message
|
|
32
|
+
details: Optional details about the error
|
|
33
|
+
suggestion: Optional suggestion for the user
|
|
34
|
+
exit_code: Optional exit code to override the class default
|
|
35
|
+
"""
|
|
36
|
+
super().__init__(message)
|
|
37
|
+
self.message = message
|
|
38
|
+
self.details = details
|
|
39
|
+
self.suggestion = suggestion
|
|
40
|
+
if exit_code is not None:
|
|
41
|
+
self.exit_code = exit_code
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ConfigError(GacError):
|
|
45
|
+
"""Error related to configuration issues."""
|
|
46
|
+
|
|
47
|
+
exit_code = 2
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class GitError(GacError):
|
|
51
|
+
"""Error related to Git operations."""
|
|
52
|
+
|
|
53
|
+
exit_code = 3
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class AIError(GacError):
|
|
57
|
+
"""Error related to AI provider or models."""
|
|
58
|
+
|
|
59
|
+
exit_code = 4
|
|
60
|
+
|
|
61
|
+
def __init__(self, message: str, error_type: str = "unknown", exit_code: int | None = None):
|
|
62
|
+
"""Initialize an AIError with a specific error type.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
message: The error message
|
|
66
|
+
error_type: The type of AI error (from AI_ERROR_CODES keys)
|
|
67
|
+
exit_code: Optional exit code to override the default
|
|
68
|
+
"""
|
|
69
|
+
super().__init__(message, exit_code=exit_code)
|
|
70
|
+
self.error_type = error_type
|
|
71
|
+
self.error_code = AI_ERROR_CODES.get(error_type, AI_ERROR_CODES["unknown"])
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
def authentication_error(cls, message: str) -> "AIError":
|
|
75
|
+
"""Create an authentication error."""
|
|
76
|
+
return cls(message, error_type="authentication")
|
|
77
|
+
|
|
78
|
+
@classmethod
|
|
79
|
+
def connection_error(cls, message: str) -> "AIError":
|
|
80
|
+
"""Create a connection error."""
|
|
81
|
+
return cls(message, error_type="connection")
|
|
82
|
+
|
|
83
|
+
@classmethod
|
|
84
|
+
def rate_limit_error(cls, message: str) -> "AIError":
|
|
85
|
+
"""Create a rate limit error."""
|
|
86
|
+
return cls(message, error_type="rate_limit")
|
|
87
|
+
|
|
88
|
+
@classmethod
|
|
89
|
+
def timeout_error(cls, message: str) -> "AIError":
|
|
90
|
+
"""Create a timeout error."""
|
|
91
|
+
return cls(message, error_type="timeout")
|
|
92
|
+
|
|
93
|
+
@classmethod
|
|
94
|
+
def model_error(cls, message: str) -> "AIError":
|
|
95
|
+
"""Create a model error."""
|
|
96
|
+
return cls(message, error_type="model")
|
|
97
|
+
|
|
98
|
+
@classmethod
|
|
99
|
+
def unknown_error(cls, message: str) -> "AIError":
|
|
100
|
+
"""Create an unknown error."""
|
|
101
|
+
return cls(message, error_type="unknown")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class FormattingError(GacError):
|
|
105
|
+
"""Error related to code formatting."""
|
|
106
|
+
|
|
107
|
+
exit_code = 5
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class SecurityError(GacError):
|
|
111
|
+
"""Error related to security issues (e.g., detected secrets)."""
|
|
112
|
+
|
|
113
|
+
exit_code = 6
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# Simplified error hierarchy - we use a single AIError class with error codes
|
|
117
|
+
# instead of multiple subclasses for better maintainability
|
|
118
|
+
|
|
119
|
+
# Error codes for AI errors
|
|
120
|
+
AI_ERROR_CODES = {
|
|
121
|
+
"authentication": 401, # Authentication failures
|
|
122
|
+
"connection": 503, # Connection issues
|
|
123
|
+
"rate_limit": 429, # Rate limits
|
|
124
|
+
"timeout": 408, # Timeouts
|
|
125
|
+
"model": 400, # Model-related errors
|
|
126
|
+
"unknown": 500, # Unknown errors
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def handle_error(error: Exception, exit_program: bool = False, quiet: bool = False) -> None:
|
|
131
|
+
"""Handle an error with proper logging and user feedback.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
error: The error to handle
|
|
135
|
+
exit_program: If True, exit the program after handling the error
|
|
136
|
+
quiet: If True, suppress non-error output
|
|
137
|
+
"""
|
|
138
|
+
logger.error(f"Error: {str(error)}")
|
|
139
|
+
|
|
140
|
+
if isinstance(error, GitError):
|
|
141
|
+
logger.error("Git operation failed. Please check your repository status.")
|
|
142
|
+
elif isinstance(error, AIError):
|
|
143
|
+
logger.error("AI operation failed. Please check your configuration and API keys.")
|
|
144
|
+
elif isinstance(error, SecurityError):
|
|
145
|
+
logger.error("Security scan detected potential secrets in staged changes.")
|
|
146
|
+
else:
|
|
147
|
+
logger.error("An unexpected error occurred.")
|
|
148
|
+
|
|
149
|
+
if exit_program:
|
|
150
|
+
logger.error("Exiting program due to error.")
|
|
151
|
+
sys.exit(error.exit_code if hasattr(error, "exit_code") else 1)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def format_error_for_user(error: Exception) -> str:
|
|
155
|
+
"""
|
|
156
|
+
Format an error message for display to the user.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
error: The exception to format
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
A user-friendly error message with remediation steps if applicable
|
|
163
|
+
"""
|
|
164
|
+
base_message = str(error)
|
|
165
|
+
|
|
166
|
+
# More specific remediation for AI errors based on error type
|
|
167
|
+
if isinstance(error, AIError):
|
|
168
|
+
if hasattr(error, "error_type"):
|
|
169
|
+
if error.error_type == "authentication":
|
|
170
|
+
return f"{base_message}\n\nPlease check your API key and ensure it is valid."
|
|
171
|
+
elif error.error_type == "connection":
|
|
172
|
+
return f"{base_message}\n\nPlease check your internet connection and try again."
|
|
173
|
+
elif error.error_type == "rate_limit":
|
|
174
|
+
return f"{base_message}\n\nYou've hit the rate limit for this AI provider. Please wait and try again later." # noqa: E501
|
|
175
|
+
elif error.error_type == "timeout":
|
|
176
|
+
return f"{base_message}\n\nThe request timed out. Please try again or use a different model."
|
|
177
|
+
elif error.error_type == "model":
|
|
178
|
+
return f"{base_message}\n\nPlease check that the specified model exists and is available to you."
|
|
179
|
+
return f"{base_message}\n\nPlease check your API key, model name, and internet connection."
|
|
180
|
+
|
|
181
|
+
# Mapping of error types to remediation steps
|
|
182
|
+
remediation_steps = {
|
|
183
|
+
ConfigError: "Please check your configuration settings.",
|
|
184
|
+
GitError: "Please ensure Git is installed and you're in a valid Git repository.",
|
|
185
|
+
FormattingError: "Please check that required formatters are installed.",
|
|
186
|
+
SecurityError: "Please remove or secure any detected secrets before committing.",
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
# Generic remediation for unexpected errors
|
|
190
|
+
if not any(isinstance(error, t) for t in remediation_steps.keys()):
|
|
191
|
+
return f"{base_message}\n\nIf this issue persists, please report it as a bug."
|
|
192
|
+
|
|
193
|
+
# Get remediation steps for the specific error type
|
|
194
|
+
for error_class, steps in remediation_steps.items():
|
|
195
|
+
if isinstance(error, error_class):
|
|
196
|
+
return f"{base_message}\n\n{steps}"
|
|
197
|
+
|
|
198
|
+
# Fallback (though we should never reach this)
|
|
199
|
+
return base_message
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def with_error_handling(
|
|
203
|
+
error_type: type[GacError], error_message: str, quiet: bool = False, exit_on_error: bool = True
|
|
204
|
+
) -> Callable[[Callable[..., T]], Callable[..., T | None]]:
|
|
205
|
+
"""
|
|
206
|
+
A decorator that wraps a function with standardized error handling.
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
error_type: The specific error type to raise if an exception occurs
|
|
210
|
+
error_message: The error message to use
|
|
211
|
+
quiet: If True, suppress non-error output
|
|
212
|
+
exit_on_error: If True, exit the program on error
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
A decorator function that handles errors for the wrapped function
|
|
216
|
+
"""
|
|
217
|
+
|
|
218
|
+
def decorator(func: Callable[..., T]) -> Callable[..., T | None]:
|
|
219
|
+
def wrapper(*args: Any, **kwargs: Any) -> T | None:
|
|
220
|
+
try:
|
|
221
|
+
return func(*args, **kwargs)
|
|
222
|
+
except Exception as e:
|
|
223
|
+
# Create a specific error with our message and the original error
|
|
224
|
+
specific_error = error_type(f"{error_message}: {e}")
|
|
225
|
+
# Handle the error using our standardized handler
|
|
226
|
+
handle_error(specific_error, quiet=quiet, exit_program=exit_on_error)
|
|
227
|
+
return None
|
|
228
|
+
|
|
229
|
+
return wrapper
|
|
230
|
+
|
|
231
|
+
return decorator
|
gac/git.py
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
"""Git operations for gac.
|
|
2
|
+
|
|
3
|
+
This module provides a simplified interface to Git commands.
|
|
4
|
+
It focuses on the core operations needed for commit generation.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import subprocess
|
|
10
|
+
|
|
11
|
+
from gac.errors import GitError
|
|
12
|
+
from gac.utils import run_subprocess
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def run_subprocess_with_encoding_fallback(
|
|
18
|
+
command: list[str], silent: bool = False, timeout: int = 60
|
|
19
|
+
) -> subprocess.CompletedProcess[str]:
|
|
20
|
+
"""Run subprocess with encoding fallback, returning full CompletedProcess object.
|
|
21
|
+
|
|
22
|
+
This is used for cases where we need both stdout and stderr separately,
|
|
23
|
+
like pre-commit and lefthook hook execution.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
command: List of command arguments
|
|
27
|
+
silent: If True, suppress debug logging
|
|
28
|
+
timeout: Command timeout in seconds
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
CompletedProcess object with stdout, stderr, and returncode
|
|
32
|
+
"""
|
|
33
|
+
from gac.utils import get_safe_encodings
|
|
34
|
+
|
|
35
|
+
encodings = get_safe_encodings()
|
|
36
|
+
last_exception: Exception | None = None
|
|
37
|
+
|
|
38
|
+
for encoding in encodings:
|
|
39
|
+
try:
|
|
40
|
+
if not silent:
|
|
41
|
+
logger.debug(f"Running command: {' '.join(command)} (encoding: {encoding})")
|
|
42
|
+
|
|
43
|
+
result = subprocess.run(
|
|
44
|
+
command,
|
|
45
|
+
capture_output=True,
|
|
46
|
+
text=True,
|
|
47
|
+
check=False,
|
|
48
|
+
timeout=timeout,
|
|
49
|
+
encoding=encoding,
|
|
50
|
+
errors="replace",
|
|
51
|
+
)
|
|
52
|
+
return result
|
|
53
|
+
except UnicodeError as e:
|
|
54
|
+
last_exception = e
|
|
55
|
+
if not silent:
|
|
56
|
+
logger.debug(f"Failed to decode with {encoding}: {e}")
|
|
57
|
+
continue
|
|
58
|
+
except subprocess.TimeoutExpired:
|
|
59
|
+
raise
|
|
60
|
+
except (subprocess.SubprocessError, OSError, FileNotFoundError) as e:
|
|
61
|
+
if not silent:
|
|
62
|
+
logger.debug(f"Command error: {e}")
|
|
63
|
+
# Try next encoding for non-timeout errors
|
|
64
|
+
last_exception = e
|
|
65
|
+
continue
|
|
66
|
+
|
|
67
|
+
# If we get here, all encodings failed
|
|
68
|
+
if last_exception:
|
|
69
|
+
raise subprocess.CalledProcessError(1, command, "", f"Encoding error: {last_exception}") from last_exception
|
|
70
|
+
else:
|
|
71
|
+
raise subprocess.CalledProcessError(1, command, "", "All encoding attempts failed")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def run_git_command(args: list[str], silent: bool = False, timeout: int = 30) -> str:
|
|
75
|
+
"""Run a git command and return the output."""
|
|
76
|
+
command = ["git"] + args
|
|
77
|
+
return run_subprocess(command, silent=silent, timeout=timeout, raise_on_error=False, strip_output=True)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def get_staged_files(file_type: str | None = None, existing_only: bool = False) -> list[str]:
|
|
81
|
+
"""Get list of staged files with optional filtering.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
file_type: Optional file extension to filter by
|
|
85
|
+
existing_only: If True, only include files that exist on disk
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
List of staged file paths
|
|
89
|
+
"""
|
|
90
|
+
try:
|
|
91
|
+
output = run_git_command(["diff", "--name-only", "--cached"])
|
|
92
|
+
if not output:
|
|
93
|
+
return []
|
|
94
|
+
|
|
95
|
+
# Parse and filter the file list
|
|
96
|
+
files = [line.strip() for line in output.splitlines() if line.strip()]
|
|
97
|
+
|
|
98
|
+
if file_type:
|
|
99
|
+
files = [f for f in files if f.endswith(file_type)]
|
|
100
|
+
|
|
101
|
+
if existing_only:
|
|
102
|
+
files = [f for f in files if os.path.isfile(f)]
|
|
103
|
+
|
|
104
|
+
return files
|
|
105
|
+
except GitError:
|
|
106
|
+
# If git command fails, return empty list as a fallback
|
|
107
|
+
return []
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def get_staged_status() -> str:
|
|
111
|
+
"""Get formatted status of staged files only, excluding unstaged/untracked files.
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
Formatted status string with M/A/D/R indicators
|
|
115
|
+
"""
|
|
116
|
+
try:
|
|
117
|
+
output = run_git_command(["diff", "--name-status", "--staged"])
|
|
118
|
+
if not output:
|
|
119
|
+
return "No changes staged for commit."
|
|
120
|
+
|
|
121
|
+
status_map = {
|
|
122
|
+
"M": "modified",
|
|
123
|
+
"A": "new file",
|
|
124
|
+
"D": "deleted",
|
|
125
|
+
"R": "renamed",
|
|
126
|
+
"C": "copied",
|
|
127
|
+
"T": "typechange",
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
status_lines = ["Changes to be committed:"]
|
|
131
|
+
for line in output.splitlines():
|
|
132
|
+
line = line.strip()
|
|
133
|
+
if not line:
|
|
134
|
+
continue
|
|
135
|
+
|
|
136
|
+
# Parse status line (e.g., "M\tfile.py" or "R100\told.py\tnew.py")
|
|
137
|
+
parts = line.split("\t")
|
|
138
|
+
if len(parts) < 2:
|
|
139
|
+
continue
|
|
140
|
+
|
|
141
|
+
change_type = parts[0][0] # First char is the status (M, A, D, R, etc.)
|
|
142
|
+
file_path = parts[-1] # Last part is the new/current file path
|
|
143
|
+
|
|
144
|
+
status_label = status_map.get(change_type, "modified")
|
|
145
|
+
status_lines.append(f"\t{status_label}: {file_path}")
|
|
146
|
+
|
|
147
|
+
return "\n".join(status_lines)
|
|
148
|
+
except GitError:
|
|
149
|
+
return "No changes staged for commit."
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def get_diff(staged: bool = True, color: bool = True, commit1: str | None = None, commit2: str | None = None) -> str:
|
|
153
|
+
"""Get the diff between commits or working tree.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
staged: If True, show staged changes. If False, show unstaged changes.
|
|
157
|
+
This is ignored if commit1 and commit2 are provided.
|
|
158
|
+
color: If True, include ANSI color codes in the output.
|
|
159
|
+
commit1: First commit hash, branch name, or reference to compare from.
|
|
160
|
+
commit2: Second commit hash, branch name, or reference to compare to.
|
|
161
|
+
If only commit1 is provided, compares working tree to commit1.
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
String containing the diff output
|
|
165
|
+
|
|
166
|
+
Raises:
|
|
167
|
+
GitError: If the git command fails
|
|
168
|
+
"""
|
|
169
|
+
try:
|
|
170
|
+
args = ["diff"]
|
|
171
|
+
|
|
172
|
+
if color:
|
|
173
|
+
args.append("--color")
|
|
174
|
+
|
|
175
|
+
# If specific commits are provided, use them for comparison
|
|
176
|
+
if commit1 and commit2:
|
|
177
|
+
args.extend([commit1, commit2])
|
|
178
|
+
elif commit1:
|
|
179
|
+
args.append(commit1)
|
|
180
|
+
elif staged:
|
|
181
|
+
args.append("--cached")
|
|
182
|
+
|
|
183
|
+
output = run_git_command(args)
|
|
184
|
+
return output
|
|
185
|
+
except (subprocess.SubprocessError, OSError, FileNotFoundError) as e:
|
|
186
|
+
logger.error(f"Failed to get diff: {str(e)}")
|
|
187
|
+
raise GitError(f"Failed to get diff: {str(e)}") from e
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def get_repo_root() -> str:
|
|
191
|
+
"""Get absolute path of repository root."""
|
|
192
|
+
result = run_git_command(["rev-parse", "--show-toplevel"])
|
|
193
|
+
return result
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def get_current_branch() -> str:
|
|
197
|
+
"""Get name of current git branch."""
|
|
198
|
+
result = run_git_command(["rev-parse", "--abbrev-ref", "HEAD"])
|
|
199
|
+
return result
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def get_commit_hash() -> str:
|
|
203
|
+
"""Get SHA-1 hash of current commit."""
|
|
204
|
+
result = run_git_command(["rev-parse", "HEAD"])
|
|
205
|
+
return result
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def run_pre_commit_hooks(hook_timeout: int = 120) -> bool:
|
|
209
|
+
"""Run pre-commit hooks if they exist.
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
True if pre-commit hooks passed or don't exist, False if they failed.
|
|
213
|
+
"""
|
|
214
|
+
# Check if .pre-commit-config.yaml exists
|
|
215
|
+
if not os.path.exists(".pre-commit-config.yaml"):
|
|
216
|
+
logger.debug("No .pre-commit-config.yaml found, skipping pre-commit hooks")
|
|
217
|
+
return True
|
|
218
|
+
|
|
219
|
+
# Check if pre-commit is installed and configured
|
|
220
|
+
try:
|
|
221
|
+
# First check if pre-commit is installed
|
|
222
|
+
version_check = run_subprocess(["pre-commit", "--version"], silent=True, raise_on_error=False)
|
|
223
|
+
if not version_check:
|
|
224
|
+
logger.debug("pre-commit not installed, skipping hooks")
|
|
225
|
+
return True
|
|
226
|
+
|
|
227
|
+
# Run pre-commit hooks on staged files
|
|
228
|
+
logger.info(f"Running pre-commit hooks with {hook_timeout}s timeout...")
|
|
229
|
+
# Run pre-commit and capture both stdout and stderr
|
|
230
|
+
result = run_subprocess_with_encoding_fallback(["pre-commit", "run"], timeout=hook_timeout)
|
|
231
|
+
|
|
232
|
+
if result.returncode == 0:
|
|
233
|
+
# All hooks passed
|
|
234
|
+
return True
|
|
235
|
+
else:
|
|
236
|
+
# Pre-commit hooks failed - show the output
|
|
237
|
+
output = result.stdout if result.stdout else ""
|
|
238
|
+
error = result.stderr if result.stderr else ""
|
|
239
|
+
|
|
240
|
+
# Combine outputs (pre-commit usually outputs to stdout)
|
|
241
|
+
full_output = output + ("\n" + error if error else "")
|
|
242
|
+
|
|
243
|
+
if full_output.strip():
|
|
244
|
+
# Show which hooks failed and why
|
|
245
|
+
logger.error(f"Pre-commit hooks failed:\n{full_output}")
|
|
246
|
+
else:
|
|
247
|
+
logger.error(f"Pre-commit hooks failed with exit code {result.returncode}")
|
|
248
|
+
return False
|
|
249
|
+
except (subprocess.SubprocessError, OSError, FileNotFoundError, PermissionError) as e:
|
|
250
|
+
logger.debug(f"Error running pre-commit: {e}")
|
|
251
|
+
# If pre-commit isn't available, don't block the commit
|
|
252
|
+
return True
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def run_lefthook_hooks(hook_timeout: int = 120) -> bool:
|
|
256
|
+
"""Run Lefthook hooks if they exist.
|
|
257
|
+
|
|
258
|
+
Returns:
|
|
259
|
+
True if Lefthook hooks passed or don't exist, False if they failed.
|
|
260
|
+
"""
|
|
261
|
+
# Check for common Lefthook configuration files
|
|
262
|
+
lefthook_configs = [".lefthook.yml", "lefthook.yml", ".lefthook.yaml", "lefthook.yaml"]
|
|
263
|
+
config_exists = any(os.path.exists(config) for config in lefthook_configs)
|
|
264
|
+
|
|
265
|
+
if not config_exists:
|
|
266
|
+
logger.debug("No Lefthook configuration found, skipping Lefthook hooks")
|
|
267
|
+
return True
|
|
268
|
+
|
|
269
|
+
# Check if lefthook is installed and configured
|
|
270
|
+
try:
|
|
271
|
+
# First check if lefthook is installed
|
|
272
|
+
version_check = run_subprocess(["lefthook", "--version"], silent=True, raise_on_error=False)
|
|
273
|
+
if not version_check:
|
|
274
|
+
logger.debug("Lefthook not installed, skipping hooks")
|
|
275
|
+
return True
|
|
276
|
+
|
|
277
|
+
# Run lefthook hooks on staged files
|
|
278
|
+
logger.info(f"Running Lefthook hooks with {hook_timeout}s timeout...")
|
|
279
|
+
# Run lefthook and capture both stdout and stderr
|
|
280
|
+
result = run_subprocess_with_encoding_fallback(["lefthook", "run", "pre-commit"], timeout=hook_timeout)
|
|
281
|
+
|
|
282
|
+
if result.returncode == 0:
|
|
283
|
+
# All hooks passed
|
|
284
|
+
return True
|
|
285
|
+
else:
|
|
286
|
+
# Lefthook hooks failed - show the output
|
|
287
|
+
output = result.stdout if result.stdout else ""
|
|
288
|
+
error = result.stderr if result.stderr else ""
|
|
289
|
+
|
|
290
|
+
# Combine outputs (lefthook usually outputs to stdout)
|
|
291
|
+
full_output = output + ("\n" + error if error else "")
|
|
292
|
+
|
|
293
|
+
if full_output.strip():
|
|
294
|
+
# Show which hooks failed and why
|
|
295
|
+
logger.error(f"Lefthook hooks failed:\n{full_output}")
|
|
296
|
+
else:
|
|
297
|
+
logger.error(f"Lefthook hooks failed with exit code {result.returncode}")
|
|
298
|
+
return False
|
|
299
|
+
except (subprocess.SubprocessError, OSError, FileNotFoundError, PermissionError) as e:
|
|
300
|
+
logger.debug(f"Error running Lefthook: {e}")
|
|
301
|
+
# If lefthook isn't available, don't block the commit
|
|
302
|
+
return True
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def push_changes() -> bool:
|
|
306
|
+
"""Push committed changes to the remote repository."""
|
|
307
|
+
remote_exists = run_git_command(["remote"])
|
|
308
|
+
if not remote_exists:
|
|
309
|
+
logger.error("No configured remote repository.")
|
|
310
|
+
return False
|
|
311
|
+
|
|
312
|
+
try:
|
|
313
|
+
# Use raise_on_error=True to properly catch push failures
|
|
314
|
+
run_subprocess(["git", "push"], raise_on_error=True, strip_output=True)
|
|
315
|
+
return True
|
|
316
|
+
except subprocess.CalledProcessError as e:
|
|
317
|
+
error_msg = e.stderr.strip() if e.stderr else str(e)
|
|
318
|
+
if "fatal: No configured push destination" in error_msg:
|
|
319
|
+
logger.error("No configured push destination.")
|
|
320
|
+
else:
|
|
321
|
+
logger.error(f"Failed to push changes: {error_msg}")
|
|
322
|
+
return False
|
|
323
|
+
except (subprocess.SubprocessError, OSError, ConnectionError) as e:
|
|
324
|
+
logger.error(f"Failed to push changes: {e}")
|
|
325
|
+
return False
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def detect_rename_mappings(staged_diff: str) -> dict[str, str]:
|
|
329
|
+
"""Detect file rename mappings from a staged diff.
|
|
330
|
+
|
|
331
|
+
Args:
|
|
332
|
+
staged_diff: The output of 'git diff --cached --binary'
|
|
333
|
+
|
|
334
|
+
Returns:
|
|
335
|
+
Dictionary mapping new_file_path -> old_file_path for rename operations
|
|
336
|
+
"""
|
|
337
|
+
rename_mappings = {}
|
|
338
|
+
lines = staged_diff.split("\n")
|
|
339
|
+
|
|
340
|
+
i = 0
|
|
341
|
+
while i < len(lines):
|
|
342
|
+
line = lines[i]
|
|
343
|
+
|
|
344
|
+
if line.startswith("diff --git a/"):
|
|
345
|
+
# Extract old and new file paths from diff header
|
|
346
|
+
if " b/" in line:
|
|
347
|
+
parts = line.split(" a/")
|
|
348
|
+
if len(parts) >= 2:
|
|
349
|
+
old_path_part = parts[1]
|
|
350
|
+
old_path = old_path_part.split(" b/")[0] if " b/" in old_path_part else old_path_part
|
|
351
|
+
|
|
352
|
+
new_path = line.split(" b/")[-1] if " b/" in line else None
|
|
353
|
+
|
|
354
|
+
# Check if this diff represents a rename by looking at following lines
|
|
355
|
+
j = i + 1
|
|
356
|
+
is_rename = False
|
|
357
|
+
|
|
358
|
+
while j < len(lines) and not lines[j].startswith("diff --git"):
|
|
359
|
+
if lines[j].startswith("similarity index "):
|
|
360
|
+
is_rename = True
|
|
361
|
+
break
|
|
362
|
+
elif lines[j].startswith("rename from "):
|
|
363
|
+
is_rename = True
|
|
364
|
+
break
|
|
365
|
+
j += 1
|
|
366
|
+
|
|
367
|
+
if is_rename and old_path and new_path and old_path != new_path:
|
|
368
|
+
rename_mappings[new_path] = old_path
|
|
369
|
+
|
|
370
|
+
i += 1
|
|
371
|
+
|
|
372
|
+
return rename_mappings
|