gac 0.15.1__py3-none-any.whl → 0.15.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/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.")