gac 0.15.1__py3-none-any.whl → 0.15.2__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 +166 -0
- gac/cli.py +130 -0
- gac/config.py +32 -0
- gac/config_cli.py +62 -0
- gac/constants.py +149 -0
- gac/diff_cli.py +177 -0
- gac/errors.py +217 -0
- gac/git.py +158 -0
- gac/init_cli.py +45 -0
- gac/main.py +254 -0
- gac/preprocess.py +506 -0
- gac/prompt.py +355 -0
- gac/utils.py +133 -0
- {gac-0.15.1.dist-info → gac-0.15.2.dist-info}/METADATA +1 -1
- gac-0.15.2.dist-info/RECORD +20 -0
- gac-0.15.1.dist-info/RECORD +0 -5
- {gac-0.15.1.dist-info → gac-0.15.2.dist-info}/WHEEL +0 -0
- {gac-0.15.1.dist-info → gac-0.15.2.dist-info}/entry_points.txt +0 -0
- {gac-0.15.1.dist-info → gac-0.15.2.dist-info}/licenses/LICENSE +0 -0
gac/diff_cli.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# flake8: noqa: E304
|
|
2
|
+
|
|
3
|
+
"""Git diff display command for gac.
|
|
4
|
+
|
|
5
|
+
This module implements the 'gac diff' subcommand which displays git diffs with various
|
|
6
|
+
filtering and formatting options. It provides a convenient way to view staged or unstaged
|
|
7
|
+
changes, compare commits, and apply smart filtering to focus on meaningful code changes.
|
|
8
|
+
|
|
9
|
+
Key features:
|
|
10
|
+
- Display staged or unstaged changes
|
|
11
|
+
- Compare specific commits or branches
|
|
12
|
+
- Filter out binary files, minified files, and lockfiles
|
|
13
|
+
- Smart truncation of large diffs based on token limits
|
|
14
|
+
- Colored output support for better readability
|
|
15
|
+
- Integration with gac's preprocessing logic for cleaner diffs
|
|
16
|
+
|
|
17
|
+
The diff command is particularly useful for:
|
|
18
|
+
- Previewing what changes will be included in the commit message
|
|
19
|
+
- Reviewing filtered diffs before committing
|
|
20
|
+
- Comparing code changes between branches or commits
|
|
21
|
+
- Understanding what files have been modified in the staging area
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import logging
|
|
25
|
+
import sys
|
|
26
|
+
|
|
27
|
+
import click
|
|
28
|
+
|
|
29
|
+
from gac.errors import GitError, with_error_handling
|
|
30
|
+
from gac.git import get_diff, get_staged_files
|
|
31
|
+
from gac.preprocess import filter_binary_and_minified, smart_truncate_diff, split_diff_into_sections
|
|
32
|
+
from gac.utils import print_message, setup_logging
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _diff_implementation(
|
|
36
|
+
filter: bool,
|
|
37
|
+
truncate: bool,
|
|
38
|
+
max_tokens: int | None,
|
|
39
|
+
staged: bool,
|
|
40
|
+
color: bool,
|
|
41
|
+
commit1: str | None = None,
|
|
42
|
+
commit2: str | None = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
"""Implementation of the diff command logic for easier testing."""
|
|
45
|
+
setup_logging()
|
|
46
|
+
# Get a logger for this module instead of using the return value of setup_logging
|
|
47
|
+
logger = logging.getLogger(__name__)
|
|
48
|
+
logger.debug("Running diff command")
|
|
49
|
+
|
|
50
|
+
# If we're comparing specific commits, don't need to check for staged changes
|
|
51
|
+
if not (commit1 or commit2):
|
|
52
|
+
# Check if there are staged changes
|
|
53
|
+
staged_files = get_staged_files()
|
|
54
|
+
if not staged_files and staged:
|
|
55
|
+
print_message("No staged changes found. Use 'git add' to stage changes.", level="error")
|
|
56
|
+
sys.exit(1)
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
diff_text = get_diff(staged=staged, color=color, commit1=commit1, commit2=commit2)
|
|
60
|
+
if not diff_text:
|
|
61
|
+
print_message("No changes to display.", level="error")
|
|
62
|
+
sys.exit(1)
|
|
63
|
+
except GitError as e:
|
|
64
|
+
print_message(f"Error getting diff: {str(e)}", level="error")
|
|
65
|
+
sys.exit(1)
|
|
66
|
+
|
|
67
|
+
if filter:
|
|
68
|
+
diff_text = filter_binary_and_minified(diff_text)
|
|
69
|
+
if not diff_text:
|
|
70
|
+
print_message("No changes to display after filtering.", level="error")
|
|
71
|
+
sys.exit(1)
|
|
72
|
+
|
|
73
|
+
if truncate:
|
|
74
|
+
# Convert the diff text to the format expected by smart_truncate_diff
|
|
75
|
+
# (list of tuples with (section, score))
|
|
76
|
+
if isinstance(diff_text, str):
|
|
77
|
+
sections = split_diff_into_sections(diff_text)
|
|
78
|
+
scored_sections = [(section, 1.0) for section in sections]
|
|
79
|
+
diff_text = smart_truncate_diff(scored_sections, max_tokens or 1000, "anthropic:claude-3-haiku-latest")
|
|
80
|
+
|
|
81
|
+
if color:
|
|
82
|
+
# Use git's colored diff output
|
|
83
|
+
print(diff_text)
|
|
84
|
+
else:
|
|
85
|
+
# Strip ANSI color codes if color is disabled
|
|
86
|
+
# This is a simple approach - a more robust solution would use a library like 'strip-ansi'
|
|
87
|
+
import re
|
|
88
|
+
|
|
89
|
+
ansi_escape = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
|
|
90
|
+
print(ansi_escape.sub("", diff_text))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@click.command(name="diff")
|
|
94
|
+
# Content filtering options
|
|
95
|
+
@click.option(
|
|
96
|
+
"--filter/--no-filter",
|
|
97
|
+
default=True,
|
|
98
|
+
help="Filter out binary files, minified files, and lockfiles",
|
|
99
|
+
)
|
|
100
|
+
# Display options
|
|
101
|
+
@click.option(
|
|
102
|
+
"--color/--no-color",
|
|
103
|
+
default=True,
|
|
104
|
+
help="Show colored diff output",
|
|
105
|
+
)
|
|
106
|
+
# Diff source options
|
|
107
|
+
@click.option(
|
|
108
|
+
"--staged/--unstaged",
|
|
109
|
+
default=True,
|
|
110
|
+
help="Show staged changes (default) or unstaged changes",
|
|
111
|
+
)
|
|
112
|
+
# Size control options
|
|
113
|
+
@click.option(
|
|
114
|
+
"--truncate/--no-truncate",
|
|
115
|
+
default=True,
|
|
116
|
+
help="Truncate large diffs to a reasonable size",
|
|
117
|
+
)
|
|
118
|
+
@click.option(
|
|
119
|
+
"--max-tokens",
|
|
120
|
+
default=None,
|
|
121
|
+
type=int,
|
|
122
|
+
help="Maximum number of tokens to include in the diff",
|
|
123
|
+
)
|
|
124
|
+
@click.argument("commit1", required=False)
|
|
125
|
+
@click.argument("commit2", required=False)
|
|
126
|
+
@with_error_handling(GitError, "Failed to display diff")
|
|
127
|
+
def diff(
|
|
128
|
+
filter: bool,
|
|
129
|
+
truncate: bool,
|
|
130
|
+
max_tokens: int | None,
|
|
131
|
+
staged: bool,
|
|
132
|
+
color: bool,
|
|
133
|
+
commit1: str | None = None,
|
|
134
|
+
commit2: str | None = None,
|
|
135
|
+
) -> None:
|
|
136
|
+
"""
|
|
137
|
+
Display the diff of staged or unstaged changes.
|
|
138
|
+
|
|
139
|
+
This command shows the raw diff without generating a commit message.
|
|
140
|
+
|
|
141
|
+
You can also compare specific commits or branches by providing one or two arguments:
|
|
142
|
+
gac diff <commit1> - Shows diff between working tree and <commit1>
|
|
143
|
+
gac diff <commit1> <commit2> - Shows diff between <commit1> and <commit2>
|
|
144
|
+
|
|
145
|
+
Commit references can be commit hashes, branch names, or other Git references.
|
|
146
|
+
"""
|
|
147
|
+
_diff_implementation(
|
|
148
|
+
filter=filter,
|
|
149
|
+
truncate=truncate,
|
|
150
|
+
max_tokens=max_tokens,
|
|
151
|
+
staged=staged,
|
|
152
|
+
color=color,
|
|
153
|
+
commit1=commit1,
|
|
154
|
+
commit2=commit2,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# Function for testing only
|
|
159
|
+
def _callback_for_testing(
|
|
160
|
+
filter: bool,
|
|
161
|
+
truncate: bool,
|
|
162
|
+
max_tokens: int | None,
|
|
163
|
+
staged: bool,
|
|
164
|
+
color: bool,
|
|
165
|
+
commit1: str | None = None,
|
|
166
|
+
commit2: str | None = None,
|
|
167
|
+
) -> None:
|
|
168
|
+
"""A version of the diff command callback that can be called directly from tests."""
|
|
169
|
+
_diff_implementation(
|
|
170
|
+
filter=filter,
|
|
171
|
+
truncate=truncate,
|
|
172
|
+
max_tokens=max_tokens,
|
|
173
|
+
staged=staged,
|
|
174
|
+
color=color,
|
|
175
|
+
commit1=commit1,
|
|
176
|
+
commit2=commit2,
|
|
177
|
+
)
|
gac/errors.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Error handling module for gac."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import sys
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from typing import 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
|
+
|
|
99
|
+
class FormattingError(GacError):
|
|
100
|
+
"""Error related to code formatting."""
|
|
101
|
+
|
|
102
|
+
exit_code = 5
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# Simplified error hierarchy - we use a single AIError class with error codes
|
|
106
|
+
# instead of multiple subclasses for better maintainability
|
|
107
|
+
|
|
108
|
+
# Error codes for AI errors
|
|
109
|
+
AI_ERROR_CODES = {
|
|
110
|
+
"authentication": 401, # Authentication failures
|
|
111
|
+
"connection": 503, # Connection issues
|
|
112
|
+
"rate_limit": 429, # Rate limits
|
|
113
|
+
"timeout": 408, # Timeouts
|
|
114
|
+
"model": 400, # Model-related errors
|
|
115
|
+
"unknown": 500, # Unknown errors
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def handle_error(error: Exception, exit_program: bool = False, quiet: bool = False) -> None:
|
|
120
|
+
"""Handle an error with proper logging and user feedback.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
error: The error to handle
|
|
124
|
+
exit_program: If True, exit the program after handling the error
|
|
125
|
+
quiet: If True, suppress non-error output
|
|
126
|
+
"""
|
|
127
|
+
logger.error(f"Error: {str(error)}")
|
|
128
|
+
|
|
129
|
+
if isinstance(error, GitError):
|
|
130
|
+
logger.error("Git operation failed. Please check your repository status.")
|
|
131
|
+
elif isinstance(error, AIError):
|
|
132
|
+
logger.error("AI operation failed. Please check your configuration and API keys.")
|
|
133
|
+
else:
|
|
134
|
+
logger.error("An unexpected error occurred.")
|
|
135
|
+
|
|
136
|
+
if exit_program:
|
|
137
|
+
logger.error("Exiting program due to error.")
|
|
138
|
+
sys.exit(error.exit_code if hasattr(error, "exit_code") else 1)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def format_error_for_user(error: Exception) -> str:
|
|
142
|
+
"""
|
|
143
|
+
Format an error message for display to the user.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
error: The exception to format
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
A user-friendly error message with remediation steps if applicable
|
|
150
|
+
"""
|
|
151
|
+
base_message = str(error)
|
|
152
|
+
|
|
153
|
+
# More specific remediation for AI errors based on error type
|
|
154
|
+
if isinstance(error, AIError):
|
|
155
|
+
if hasattr(error, "error_type"):
|
|
156
|
+
if error.error_type == "authentication":
|
|
157
|
+
return f"{base_message}\n\nPlease check your API key and ensure it is valid."
|
|
158
|
+
elif error.error_type == "connection":
|
|
159
|
+
return f"{base_message}\n\nPlease check your internet connection and try again."
|
|
160
|
+
elif error.error_type == "rate_limit":
|
|
161
|
+
return f"{base_message}\n\nYou've hit the rate limit for this AI provider. Please wait and try again later." # noqa: E501
|
|
162
|
+
elif error.error_type == "timeout":
|
|
163
|
+
return f"{base_message}\n\nThe request timed out. Please try again or use a different model."
|
|
164
|
+
elif error.error_type == "model":
|
|
165
|
+
return f"{base_message}\n\nPlease check that the specified model exists and is available to you."
|
|
166
|
+
return f"{base_message}\n\nPlease check your API key, model name, and internet connection."
|
|
167
|
+
|
|
168
|
+
# Mapping of error types to remediation steps
|
|
169
|
+
remediation_steps = {
|
|
170
|
+
ConfigError: "Please check your configuration settings.",
|
|
171
|
+
GitError: "Please ensure Git is installed and you're in a valid Git repository.",
|
|
172
|
+
FormattingError: "Please check that required formatters are installed.",
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
# Generic remediation for unexpected errors
|
|
176
|
+
if not any(isinstance(error, t) for t in remediation_steps.keys()):
|
|
177
|
+
return f"{base_message}\n\nIf this issue persists, please report it as a bug."
|
|
178
|
+
|
|
179
|
+
# Get remediation steps for the specific error type
|
|
180
|
+
for error_class, steps in remediation_steps.items():
|
|
181
|
+
if isinstance(error, error_class):
|
|
182
|
+
return f"{base_message}\n\n{steps}"
|
|
183
|
+
|
|
184
|
+
# Fallback (though we should never reach this)
|
|
185
|
+
return base_message
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def with_error_handling(
|
|
189
|
+
error_type: type[GacError], error_message: str, quiet: bool = False, exit_on_error: bool = True
|
|
190
|
+
) -> Callable[[Callable[..., T]], Callable[..., T | None]]:
|
|
191
|
+
"""
|
|
192
|
+
A decorator that wraps a function with standardized error handling.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
error_type: The specific error type to raise if an exception occurs
|
|
196
|
+
error_message: The error message to use
|
|
197
|
+
quiet: If True, suppress non-error output
|
|
198
|
+
exit_on_error: If True, exit the program on error
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
A decorator function that handles errors for the wrapped function
|
|
202
|
+
"""
|
|
203
|
+
|
|
204
|
+
def decorator(func: Callable[..., T]) -> Callable[..., T | None]:
|
|
205
|
+
def wrapper(*args, **kwargs) -> T | None:
|
|
206
|
+
try:
|
|
207
|
+
return func(*args, **kwargs)
|
|
208
|
+
except Exception as e:
|
|
209
|
+
# Create a specific error with our message and the original error
|
|
210
|
+
specific_error = error_type(f"{error_message}: {e}")
|
|
211
|
+
# Handle the error using our standardized handler
|
|
212
|
+
handle_error(specific_error, quiet=quiet, exit_program=exit_on_error)
|
|
213
|
+
return None
|
|
214
|
+
|
|
215
|
+
return wrapper
|
|
216
|
+
|
|
217
|
+
return decorator
|
gac/git.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
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_git_command(args: list[str], silent: bool = False, timeout: int = 30) -> str:
|
|
18
|
+
"""Run a git command and return the output."""
|
|
19
|
+
command = ["git"] + args
|
|
20
|
+
return run_subprocess(command, silent=silent, timeout=timeout, raise_on_error=False, strip_output=True)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_staged_files(file_type: str | None = None, existing_only: bool = False) -> list[str]:
|
|
24
|
+
"""Get list of staged files with optional filtering.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
file_type: Optional file extension to filter by
|
|
28
|
+
existing_only: If True, only include files that exist on disk
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
List of staged file paths
|
|
32
|
+
"""
|
|
33
|
+
try:
|
|
34
|
+
output = run_git_command(["diff", "--name-only", "--cached"])
|
|
35
|
+
if not output:
|
|
36
|
+
return []
|
|
37
|
+
|
|
38
|
+
# Parse and filter the file list
|
|
39
|
+
files = [line.strip() for line in output.splitlines() if line.strip()]
|
|
40
|
+
|
|
41
|
+
if file_type:
|
|
42
|
+
files = [f for f in files if f.endswith(file_type)]
|
|
43
|
+
|
|
44
|
+
if existing_only:
|
|
45
|
+
files = [f for f in files if os.path.isfile(f)]
|
|
46
|
+
|
|
47
|
+
return files
|
|
48
|
+
except GitError:
|
|
49
|
+
# If git command fails, return empty list as a fallback
|
|
50
|
+
return []
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def get_diff(staged: bool = True, color: bool = True, commit1: str | None = None, commit2: str | None = None) -> str:
|
|
54
|
+
"""Get the diff between commits or working tree.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
staged: If True, show staged changes. If False, show unstaged changes.
|
|
58
|
+
This is ignored if commit1 and commit2 are provided.
|
|
59
|
+
color: If True, include ANSI color codes in the output.
|
|
60
|
+
commit1: First commit hash, branch name, or reference to compare from.
|
|
61
|
+
commit2: Second commit hash, branch name, or reference to compare to.
|
|
62
|
+
If only commit1 is provided, compares working tree to commit1.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
String containing the diff output
|
|
66
|
+
|
|
67
|
+
Raises:
|
|
68
|
+
GitError: If the git command fails
|
|
69
|
+
"""
|
|
70
|
+
try:
|
|
71
|
+
args = ["diff"]
|
|
72
|
+
|
|
73
|
+
if color:
|
|
74
|
+
args.append("--color")
|
|
75
|
+
|
|
76
|
+
# If specific commits are provided, use them for comparison
|
|
77
|
+
if commit1 and commit2:
|
|
78
|
+
args.extend([commit1, commit2])
|
|
79
|
+
elif commit1:
|
|
80
|
+
args.append(commit1)
|
|
81
|
+
elif staged:
|
|
82
|
+
args.append("--cached")
|
|
83
|
+
|
|
84
|
+
output = run_git_command(args)
|
|
85
|
+
return output
|
|
86
|
+
except Exception as e:
|
|
87
|
+
logger.error(f"Failed to get diff: {str(e)}")
|
|
88
|
+
raise GitError(f"Failed to get diff: {str(e)}") from e
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def get_repo_root() -> str:
|
|
92
|
+
"""Get absolute path of repository root."""
|
|
93
|
+
result = subprocess.check_output(["git", "rev-parse", "--show-toplevel"])
|
|
94
|
+
return result.decode().strip()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def get_current_branch() -> str:
|
|
98
|
+
"""Get name of current git branch."""
|
|
99
|
+
result = subprocess.check_output(["git", "rev-parse", "--abbrev-ref", "HEAD"])
|
|
100
|
+
return result.decode().strip()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def get_commit_hash() -> str:
|
|
104
|
+
"""Get SHA-1 hash of current commit."""
|
|
105
|
+
result = subprocess.check_output(["git", "rev-parse", "HEAD"])
|
|
106
|
+
return result.decode().strip()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def run_pre_commit_hooks() -> bool:
|
|
110
|
+
"""Run pre-commit hooks if they exist.
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
True if pre-commit hooks passed or don't exist, False if they failed.
|
|
114
|
+
"""
|
|
115
|
+
# Check if .pre-commit-config.yaml exists
|
|
116
|
+
if not os.path.exists(".pre-commit-config.yaml"):
|
|
117
|
+
logger.debug("No .pre-commit-config.yaml found, skipping pre-commit hooks")
|
|
118
|
+
return True
|
|
119
|
+
|
|
120
|
+
# Check if pre-commit is installed and configured
|
|
121
|
+
try:
|
|
122
|
+
# First check if pre-commit is installed
|
|
123
|
+
result = run_subprocess(["pre-commit", "--version"], silent=True, raise_on_error=False)
|
|
124
|
+
if not result:
|
|
125
|
+
logger.debug("pre-commit not installed, skipping hooks")
|
|
126
|
+
return True
|
|
127
|
+
|
|
128
|
+
# Run pre-commit hooks on staged files
|
|
129
|
+
logger.info("Running pre-commit hooks...")
|
|
130
|
+
try:
|
|
131
|
+
run_subprocess(["pre-commit", "run"], silent=False, raise_on_error=True)
|
|
132
|
+
# If we get here, all hooks passed
|
|
133
|
+
return True
|
|
134
|
+
except subprocess.CalledProcessError as e:
|
|
135
|
+
logger.error(f"Pre-commit hooks failed with exit code {e.returncode}")
|
|
136
|
+
return False
|
|
137
|
+
except Exception as e:
|
|
138
|
+
logger.debug(f"Error running pre-commit: {e}")
|
|
139
|
+
# If pre-commit isn't available, don't block the commit
|
|
140
|
+
return True
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def push_changes() -> bool:
|
|
144
|
+
"""Push committed changes to the remote repository."""
|
|
145
|
+
remote_exists = run_git_command(["remote"])
|
|
146
|
+
if not remote_exists:
|
|
147
|
+
logger.error("No configured remote repository.")
|
|
148
|
+
return False
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
run_git_command(["push"])
|
|
152
|
+
return True
|
|
153
|
+
except GitError as e:
|
|
154
|
+
if "fatal: No configured push destination" in str(e):
|
|
155
|
+
logger.error("No configured push destination.")
|
|
156
|
+
else:
|
|
157
|
+
logger.error(f"Failed to push changes: {e}")
|
|
158
|
+
return False
|
gac/init_cli.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""CLI for initializing gac configuration interactively."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
import questionary
|
|
7
|
+
from dotenv import set_key
|
|
8
|
+
|
|
9
|
+
GAC_ENV_PATH = Path.home() / ".gac.env"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@click.command()
|
|
13
|
+
def init() -> None:
|
|
14
|
+
"""Interactively set up $HOME/.gac.env for gac."""
|
|
15
|
+
click.echo("Welcome to gac initialization!\n")
|
|
16
|
+
if GAC_ENV_PATH.exists():
|
|
17
|
+
click.echo(f"$HOME/.gac.env already exists at {GAC_ENV_PATH}. Values will be updated.")
|
|
18
|
+
else:
|
|
19
|
+
GAC_ENV_PATH.touch()
|
|
20
|
+
click.echo(f"Created $HOME/.gac.env at {GAC_ENV_PATH}.")
|
|
21
|
+
|
|
22
|
+
providers = [
|
|
23
|
+
("Anthropic", "claude-3-5-haiku-latest"),
|
|
24
|
+
("Groq", "meta-llama/llama-4-scout-17b-16e-instruct"),
|
|
25
|
+
("Ollama", "gemma3"),
|
|
26
|
+
("OpenAI", "gpt-4.1-mini"),
|
|
27
|
+
]
|
|
28
|
+
provider_names = [p[0] for p in providers]
|
|
29
|
+
provider = questionary.select("Select your provider:", choices=provider_names).ask()
|
|
30
|
+
if not provider:
|
|
31
|
+
click.echo("Provider selection cancelled. Exiting.")
|
|
32
|
+
return
|
|
33
|
+
provider_key = provider.lower()
|
|
34
|
+
model_suggestion = dict(providers)[provider]
|
|
35
|
+
model = questionary.text(f"Enter the model (default: {model_suggestion}):", default=model_suggestion).ask()
|
|
36
|
+
model_to_save = model.strip() if model.strip() else model_suggestion
|
|
37
|
+
set_key(str(GAC_ENV_PATH), "GAC_MODEL", f"{provider_key}:{model_to_save}")
|
|
38
|
+
click.echo(f"Set GAC_MODEL={provider_key}:{model_to_save}")
|
|
39
|
+
|
|
40
|
+
api_key = questionary.password("Enter your API key (input hidden, can be set later):").ask()
|
|
41
|
+
if api_key:
|
|
42
|
+
set_key(str(GAC_ENV_PATH), f"{provider_key.upper()}_API_KEY", api_key)
|
|
43
|
+
click.echo(f"Set {provider_key.upper()}_API_KEY (hidden)")
|
|
44
|
+
|
|
45
|
+
click.echo(f"\ngac environment setup complete. You can edit {GAC_ENV_PATH} to update values later.")
|