gac 2.2.0__py3-none-any.whl → 2.4.0__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/__version__.py +1 -1
- gac/ai.py +26 -0
- gac/ai_utils.py +28 -13
- gac/cli.py +7 -1
- gac/config.py +1 -0
- gac/constants.py +1 -1
- gac/git.py +107 -8
- gac/init_cli.py +305 -73
- gac/language_cli.py +177 -9
- gac/main.py +505 -169
- gac/prompt.py +101 -15
- gac/security.py +1 -1
- gac/utils.py +104 -3
- gac/workflow_utils.py +131 -0
- {gac-2.2.0.dist-info → gac-2.4.0.dist-info}/METADATA +36 -9
- {gac-2.2.0.dist-info → gac-2.4.0.dist-info}/RECORD +19 -18
- {gac-2.2.0.dist-info → gac-2.4.0.dist-info}/WHEEL +0 -0
- {gac-2.2.0.dist-info → gac-2.4.0.dist-info}/entry_points.txt +0 -0
- {gac-2.2.0.dist-info → gac-2.4.0.dist-info}/licenses/LICENSE +0 -0
gac/__version__.py
CHANGED
gac/ai.py
CHANGED
|
@@ -42,6 +42,8 @@ def generate_commit_message(
|
|
|
42
42
|
max_tokens: int = EnvDefaults.MAX_OUTPUT_TOKENS,
|
|
43
43
|
max_retries: int = EnvDefaults.MAX_RETRIES,
|
|
44
44
|
quiet: bool = False,
|
|
45
|
+
is_group: bool = False,
|
|
46
|
+
skip_success_message: bool = False,
|
|
45
47
|
) -> str:
|
|
46
48
|
"""Generate a commit message using direct API calls to AI providers.
|
|
47
49
|
|
|
@@ -116,6 +118,8 @@ def generate_commit_message(
|
|
|
116
118
|
max_tokens=max_tokens,
|
|
117
119
|
max_retries=max_retries,
|
|
118
120
|
quiet=quiet,
|
|
121
|
+
is_group=is_group,
|
|
122
|
+
skip_success_message=skip_success_message,
|
|
119
123
|
)
|
|
120
124
|
except AIError:
|
|
121
125
|
# Re-raise AIError exceptions as-is to preserve error classification
|
|
@@ -123,3 +127,25 @@ def generate_commit_message(
|
|
|
123
127
|
except Exception as e:
|
|
124
128
|
logger.error(f"Failed to generate commit message: {e}")
|
|
125
129
|
raise AIError.model_error(f"Failed to generate commit message: {e}") from e
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def generate_grouped_commits(
|
|
133
|
+
model: str,
|
|
134
|
+
prompt: list[dict[str, str]],
|
|
135
|
+
temperature: float,
|
|
136
|
+
max_tokens: int,
|
|
137
|
+
max_retries: int,
|
|
138
|
+
quiet: bool = False,
|
|
139
|
+
skip_success_message: bool = False,
|
|
140
|
+
) -> str:
|
|
141
|
+
"""Generate grouped commits JSON response."""
|
|
142
|
+
return generate_commit_message(
|
|
143
|
+
model=model,
|
|
144
|
+
prompt=prompt,
|
|
145
|
+
temperature=temperature,
|
|
146
|
+
max_tokens=max_tokens,
|
|
147
|
+
max_retries=max_retries,
|
|
148
|
+
quiet=quiet,
|
|
149
|
+
is_group=True,
|
|
150
|
+
skip_success_message=skip_success_message,
|
|
151
|
+
)
|
gac/ai_utils.py
CHANGED
|
@@ -83,6 +83,8 @@ def generate_with_retries(
|
|
|
83
83
|
max_tokens: int,
|
|
84
84
|
max_retries: int,
|
|
85
85
|
quiet: bool = False,
|
|
86
|
+
is_group: bool = False,
|
|
87
|
+
skip_success_message: bool = False,
|
|
86
88
|
) -> str:
|
|
87
89
|
"""Generate content with retry logic using direct API calls."""
|
|
88
90
|
# Parse model string to determine provider and actual model
|
|
@@ -121,10 +123,11 @@ def generate_with_retries(
|
|
|
121
123
|
raise AIError.model_error("No messages provided for AI generation")
|
|
122
124
|
|
|
123
125
|
# Set up spinner
|
|
126
|
+
message_type = "commit messages" if is_group else "commit message"
|
|
124
127
|
if quiet:
|
|
125
128
|
spinner = None
|
|
126
129
|
else:
|
|
127
|
-
spinner = Halo(text=f"Generating
|
|
130
|
+
spinner = Halo(text=f"Generating {message_type} with {provider} {model_name}...", spinner="dots")
|
|
128
131
|
spinner.start()
|
|
129
132
|
|
|
130
133
|
last_exception = None
|
|
@@ -132,7 +135,7 @@ def generate_with_retries(
|
|
|
132
135
|
|
|
133
136
|
for attempt in range(max_retries):
|
|
134
137
|
try:
|
|
135
|
-
if not quiet and attempt > 0:
|
|
138
|
+
if not quiet and not skip_success_message and attempt > 0:
|
|
136
139
|
if spinner:
|
|
137
140
|
spinner.text = f"Retry {attempt + 1}/{max_retries} with {provider} {model_name}..."
|
|
138
141
|
logger.info(f"Retry attempt {attempt + 1}/{max_retries}")
|
|
@@ -145,7 +148,10 @@ def generate_with_retries(
|
|
|
145
148
|
content = provider_func(model=model_name, messages=messages, temperature=temperature, max_tokens=max_tokens)
|
|
146
149
|
|
|
147
150
|
if spinner:
|
|
148
|
-
|
|
151
|
+
if skip_success_message:
|
|
152
|
+
spinner.stop() # Stop spinner without showing success/failure
|
|
153
|
+
else:
|
|
154
|
+
spinner.succeed(f"Generated {message_type} with {provider} {model_name}")
|
|
149
155
|
|
|
150
156
|
if content is not None and content.strip():
|
|
151
157
|
return content.strip() # type: ignore[no-any-return]
|
|
@@ -160,8 +166,8 @@ def generate_with_retries(
|
|
|
160
166
|
|
|
161
167
|
# For authentication and model errors, don't retry
|
|
162
168
|
if error_type in ["authentication", "model"]:
|
|
163
|
-
if spinner:
|
|
164
|
-
spinner.fail(f"Failed to generate
|
|
169
|
+
if spinner and not skip_success_message:
|
|
170
|
+
spinner.fail(f"Failed to generate {message_type} with {provider} {model_name}")
|
|
165
171
|
|
|
166
172
|
# Create the appropriate error type based on classification
|
|
167
173
|
if error_type == "authentication":
|
|
@@ -172,23 +178,32 @@ def generate_with_retries(
|
|
|
172
178
|
if attempt < max_retries - 1:
|
|
173
179
|
# Exponential backoff
|
|
174
180
|
wait_time = 2**attempt
|
|
175
|
-
if not quiet:
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
181
|
+
if not quiet and not skip_success_message:
|
|
182
|
+
if attempt == 0:
|
|
183
|
+
logger.warning(f"AI generation failed, retrying in {wait_time}s: {str(e)}")
|
|
184
|
+
else:
|
|
185
|
+
logger.warning(
|
|
186
|
+
f"AI generation failed (attempt {attempt + 1}), retrying in {wait_time}s: {str(e)}"
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
if spinner and not skip_success_message:
|
|
179
190
|
for i in range(wait_time, 0, -1):
|
|
180
191
|
spinner.text = f"Retry {attempt + 1}/{max_retries} in {i}s..."
|
|
181
192
|
time.sleep(1)
|
|
182
193
|
else:
|
|
183
194
|
time.sleep(wait_time)
|
|
184
195
|
else:
|
|
185
|
-
|
|
196
|
+
num_retries = max_retries
|
|
197
|
+
retry_word = "retry" if num_retries == 1 else "retries"
|
|
198
|
+
logger.error(f"AI generation failed after {num_retries} {retry_word}: {str(e)}")
|
|
186
199
|
|
|
187
|
-
if spinner:
|
|
188
|
-
spinner.fail(f"Failed to generate
|
|
200
|
+
if spinner and not skip_success_message:
|
|
201
|
+
spinner.fail(f"Failed to generate {message_type} with {provider} {model_name}")
|
|
189
202
|
|
|
190
203
|
# If we get here, all retries failed - use the last classified error type
|
|
191
|
-
|
|
204
|
+
num_retries = max_retries
|
|
205
|
+
retry_word = "retry" if num_retries == 1 else "retries"
|
|
206
|
+
error_message = f"Failed to generate {message_type} after {num_retries} {retry_word}"
|
|
192
207
|
if last_error_type == "authentication":
|
|
193
208
|
raise AIError.authentication_error(error_message) from last_exception
|
|
194
209
|
elif last_error_type == "rate_limit":
|
gac/cli.py
CHANGED
|
@@ -17,6 +17,7 @@ from gac.constants import Languages, Logging
|
|
|
17
17
|
from gac.diff_cli import diff as diff_cli
|
|
18
18
|
from gac.errors import handle_error
|
|
19
19
|
from gac.init_cli import init as init_cli
|
|
20
|
+
from gac.init_cli import model as model_cli
|
|
20
21
|
from gac.language_cli import language as language_cli
|
|
21
22
|
from gac.main import main
|
|
22
23
|
from gac.utils import setup_logging
|
|
@@ -28,6 +29,7 @@ logger = logging.getLogger(__name__)
|
|
|
28
29
|
@click.group(invoke_without_command=True, context_settings={"ignore_unknown_options": True})
|
|
29
30
|
# Git workflow options
|
|
30
31
|
@click.option("--add-all", "-a", is_flag=True, help="Stage all changes before committing")
|
|
32
|
+
@click.option("--group", "-g", is_flag=True, help="Group changes into multiple logical commits")
|
|
31
33
|
@click.option("--push", "-p", is_flag=True, help="Push changes to remote after committing")
|
|
32
34
|
@click.option("--dry-run", is_flag=True, help="Dry run the commit workflow")
|
|
33
35
|
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt")
|
|
@@ -70,6 +72,7 @@ logger = logging.getLogger(__name__)
|
|
|
70
72
|
def cli(
|
|
71
73
|
ctx: click.Context,
|
|
72
74
|
add_all: bool = False,
|
|
75
|
+
group: bool = False,
|
|
73
76
|
log_level: str = str(config["log_level"]),
|
|
74
77
|
one_liner: bool = False,
|
|
75
78
|
push: bool = False,
|
|
@@ -109,6 +112,7 @@ def cli(
|
|
|
109
112
|
try:
|
|
110
113
|
main(
|
|
111
114
|
stage_all=add_all,
|
|
115
|
+
group=group,
|
|
112
116
|
model=model,
|
|
113
117
|
hint=hint,
|
|
114
118
|
one_liner=one_liner,
|
|
@@ -131,6 +135,7 @@ def cli(
|
|
|
131
135
|
|
|
132
136
|
ctx.obj = {
|
|
133
137
|
"add_all": add_all,
|
|
138
|
+
"group": group,
|
|
134
139
|
"log_level": log_level,
|
|
135
140
|
"one_liner": one_liner,
|
|
136
141
|
"push": push,
|
|
@@ -150,9 +155,10 @@ def cli(
|
|
|
150
155
|
|
|
151
156
|
|
|
152
157
|
cli.add_command(config_cli)
|
|
158
|
+
cli.add_command(diff_cli)
|
|
153
159
|
cli.add_command(init_cli)
|
|
154
160
|
cli.add_command(language_cli)
|
|
155
|
-
cli.add_command(
|
|
161
|
+
cli.add_command(model_cli)
|
|
156
162
|
|
|
157
163
|
|
|
158
164
|
@click.command(context_settings=language_cli.context_settings)
|
gac/config.py
CHANGED
|
@@ -41,6 +41,7 @@ def load_config() -> dict[str, str | int | float | bool | None]:
|
|
|
41
41
|
"system_prompt_path": os.getenv("GAC_SYSTEM_PROMPT_PATH"),
|
|
42
42
|
"language": os.getenv("GAC_LANGUAGE"),
|
|
43
43
|
"translate_prefixes": os.getenv("GAC_TRANSLATE_PREFIXES", "false").lower() in ("true", "1", "yes", "on"),
|
|
44
|
+
"rtl_confirmed": os.getenv("GAC_RTL_CONFIRMED", "false").lower() in ("true", "1", "yes", "on"),
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
return config
|
gac/constants.py
CHANGED
|
@@ -20,7 +20,7 @@ class EnvDefaults:
|
|
|
20
20
|
|
|
21
21
|
MAX_RETRIES: int = 3
|
|
22
22
|
TEMPERATURE: float = 1
|
|
23
|
-
MAX_OUTPUT_TOKENS: int =
|
|
23
|
+
MAX_OUTPUT_TOKENS: int = 4096 # includes reasoning tokens
|
|
24
24
|
WARNING_LIMIT_TOKENS: int = 32768
|
|
25
25
|
ALWAYS_INCLUDE_SCOPE: bool = False
|
|
26
26
|
SKIP_SECRET_SCAN: bool = False
|
gac/git.py
CHANGED
|
@@ -14,6 +14,63 @@ from gac.utils import run_subprocess
|
|
|
14
14
|
logger = logging.getLogger(__name__)
|
|
15
15
|
|
|
16
16
|
|
|
17
|
+
def run_subprocess_with_encoding_fallback(
|
|
18
|
+
command: list[str], silent: bool = False, timeout: int = 60
|
|
19
|
+
) -> subprocess.CompletedProcess:
|
|
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 = 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 Exception 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
|
+
|
|
17
74
|
def run_git_command(args: list[str], silent: bool = False, timeout: int = 30) -> str:
|
|
18
75
|
"""Run a git command and return the output."""
|
|
19
76
|
command = ["git"] + args
|
|
@@ -50,6 +107,48 @@ def get_staged_files(file_type: str | None = None, existing_only: bool = False)
|
|
|
50
107
|
return []
|
|
51
108
|
|
|
52
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
|
+
|
|
53
152
|
def get_diff(staged: bool = True, color: bool = True, commit1: str | None = None, commit2: str | None = None) -> str:
|
|
54
153
|
"""Get the diff between commits or working tree.
|
|
55
154
|
|
|
@@ -90,20 +189,20 @@ def get_diff(staged: bool = True, color: bool = True, commit1: str | None = None
|
|
|
90
189
|
|
|
91
190
|
def get_repo_root() -> str:
|
|
92
191
|
"""Get absolute path of repository root."""
|
|
93
|
-
result =
|
|
94
|
-
return result
|
|
192
|
+
result = run_git_command(["rev-parse", "--show-toplevel"])
|
|
193
|
+
return result
|
|
95
194
|
|
|
96
195
|
|
|
97
196
|
def get_current_branch() -> str:
|
|
98
197
|
"""Get name of current git branch."""
|
|
99
|
-
result =
|
|
100
|
-
return result
|
|
198
|
+
result = run_git_command(["rev-parse", "--abbrev-ref", "HEAD"])
|
|
199
|
+
return result
|
|
101
200
|
|
|
102
201
|
|
|
103
202
|
def get_commit_hash() -> str:
|
|
104
203
|
"""Get SHA-1 hash of current commit."""
|
|
105
|
-
result =
|
|
106
|
-
return result
|
|
204
|
+
result = run_git_command(["rev-parse", "HEAD"])
|
|
205
|
+
return result
|
|
107
206
|
|
|
108
207
|
|
|
109
208
|
def run_pre_commit_hooks() -> bool:
|
|
@@ -128,7 +227,7 @@ def run_pre_commit_hooks() -> bool:
|
|
|
128
227
|
# Run pre-commit hooks on staged files
|
|
129
228
|
logger.info("Running pre-commit hooks...")
|
|
130
229
|
# Run pre-commit and capture both stdout and stderr
|
|
131
|
-
result =
|
|
230
|
+
result = run_subprocess_with_encoding_fallback(["pre-commit", "run"])
|
|
132
231
|
|
|
133
232
|
if result.returncode == 0:
|
|
134
233
|
# All hooks passed
|
|
@@ -178,7 +277,7 @@ def run_lefthook_hooks() -> bool:
|
|
|
178
277
|
# Run lefthook hooks on staged files
|
|
179
278
|
logger.info("Running Lefthook hooks...")
|
|
180
279
|
# Run lefthook and capture both stdout and stderr
|
|
181
|
-
result =
|
|
280
|
+
result = run_subprocess_with_encoding_fallback(["lefthook", "run", "pre-commit"])
|
|
182
281
|
|
|
183
282
|
if result.returncode == 0:
|
|
184
283
|
# All hooks passed
|