gac 3.8.1__py3-none-any.whl → 3.10.10__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.
- gac/__init__.py +4 -6
- gac/__version__.py +1 -1
- gac/ai_utils.py +18 -49
- gac/cli.py +14 -10
- gac/commit_executor.py +59 -0
- gac/config.py +28 -3
- gac/config_cli.py +19 -7
- gac/constants/__init__.py +34 -0
- gac/constants/commit.py +63 -0
- gac/constants/defaults.py +40 -0
- gac/constants/file_patterns.py +110 -0
- gac/constants/languages.py +119 -0
- gac/diff_cli.py +0 -22
- gac/errors.py +8 -2
- gac/git.py +6 -6
- gac/git_state_validator.py +193 -0
- gac/grouped_commit_workflow.py +458 -0
- gac/init_cli.py +2 -1
- gac/interactive_mode.py +179 -0
- gac/language_cli.py +0 -1
- gac/main.py +222 -959
- gac/model_cli.py +2 -1
- gac/model_identifier.py +70 -0
- gac/oauth/claude_code.py +2 -2
- gac/oauth/qwen_oauth.py +4 -0
- gac/oauth/token_store.py +2 -2
- gac/oauth_retry.py +161 -0
- gac/postprocess.py +155 -0
- gac/prompt.py +20 -490
- gac/prompt_builder.py +88 -0
- gac/providers/README.md +437 -0
- gac/providers/__init__.py +70 -81
- gac/providers/anthropic.py +12 -56
- gac/providers/azure_openai.py +48 -92
- gac/providers/base.py +329 -0
- gac/providers/cerebras.py +10 -43
- gac/providers/chutes.py +16 -72
- gac/providers/claude_code.py +64 -97
- gac/providers/custom_anthropic.py +51 -85
- gac/providers/custom_openai.py +29 -87
- gac/providers/deepseek.py +10 -43
- gac/providers/error_handler.py +139 -0
- gac/providers/fireworks.py +10 -43
- gac/providers/gemini.py +66 -73
- gac/providers/groq.py +10 -62
- gac/providers/kimi_coding.py +19 -59
- gac/providers/lmstudio.py +62 -52
- gac/providers/minimax.py +10 -43
- gac/providers/mistral.py +10 -43
- gac/providers/moonshot.py +10 -43
- gac/providers/ollama.py +54 -41
- gac/providers/openai.py +30 -46
- gac/providers/openrouter.py +15 -62
- gac/providers/protocol.py +71 -0
- gac/providers/qwen.py +55 -67
- gac/providers/registry.py +58 -0
- gac/providers/replicate.py +137 -91
- gac/providers/streamlake.py +26 -56
- gac/providers/synthetic.py +35 -47
- gac/providers/together.py +10 -43
- gac/providers/zai.py +21 -59
- gac/py.typed +0 -0
- gac/security.py +1 -1
- gac/templates/__init__.py +1 -0
- gac/templates/question_generation.txt +60 -0
- gac/templates/system_prompt.txt +224 -0
- gac/templates/user_prompt.txt +28 -0
- gac/utils.py +6 -5
- gac/workflow_context.py +162 -0
- {gac-3.8.1.dist-info → gac-3.10.10.dist-info}/METADATA +1 -1
- gac-3.10.10.dist-info/RECORD +79 -0
- gac/constants.py +0 -328
- gac-3.8.1.dist-info/RECORD +0 -56
- {gac-3.8.1.dist-info → gac-3.10.10.dist-info}/WHEEL +0 -0
- {gac-3.8.1.dist-info → gac-3.10.10.dist-info}/entry_points.txt +0 -0
- {gac-3.8.1.dist-info → gac-3.10.10.dist-info}/licenses/LICENSE +0 -0
gac/main.py
CHANGED
|
@@ -4,765 +4,142 @@ prompt building, AI generation, and commit/push operations. This module contains
|
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
6
|
import logging
|
|
7
|
-
import sys
|
|
8
7
|
|
|
9
|
-
import click
|
|
10
8
|
from rich.console import Console
|
|
11
|
-
from rich.panel import Panel
|
|
12
9
|
|
|
13
10
|
from gac.ai import generate_commit_message
|
|
14
11
|
from gac.ai_utils import count_tokens
|
|
15
|
-
from gac.
|
|
16
|
-
from gac.
|
|
17
|
-
from gac.errors import AIError, ConfigError,
|
|
18
|
-
from gac.git import
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
from gac.preprocess import preprocess_diff
|
|
28
|
-
from gac.prompt import build_prompt, clean_commit_message
|
|
29
|
-
from gac.security import get_affected_files, scan_staged_diff
|
|
30
|
-
from gac.workflow_utils import (
|
|
31
|
-
check_token_warning,
|
|
32
|
-
collect_interactive_answers,
|
|
33
|
-
display_commit_message,
|
|
34
|
-
execute_commit,
|
|
35
|
-
format_answers_for_prompt,
|
|
36
|
-
handle_confirmation_loop,
|
|
37
|
-
restore_staging,
|
|
38
|
-
)
|
|
12
|
+
from gac.commit_executor import CommitExecutor
|
|
13
|
+
from gac.config import GACConfig, load_config
|
|
14
|
+
from gac.errors import AIError, ConfigError, handle_error
|
|
15
|
+
from gac.git import run_lefthook_hooks, run_pre_commit_hooks
|
|
16
|
+
from gac.git_state_validator import GitStateValidator
|
|
17
|
+
from gac.grouped_commit_workflow import GroupedCommitWorkflow
|
|
18
|
+
from gac.interactive_mode import InteractiveMode
|
|
19
|
+
from gac.oauth_retry import handle_oauth_retry
|
|
20
|
+
from gac.postprocess import clean_commit_message
|
|
21
|
+
from gac.prompt_builder import PromptBuilder
|
|
22
|
+
from gac.workflow_context import CLIOptions, GenerationConfig, WorkflowContext, WorkflowFlags, WorkflowState
|
|
23
|
+
from gac.workflow_utils import check_token_warning, display_commit_message
|
|
39
24
|
|
|
40
25
|
logger = logging.getLogger(__name__)
|
|
41
26
|
|
|
42
|
-
config = load_config()
|
|
27
|
+
config: GACConfig = load_config()
|
|
43
28
|
console = Console() # Initialize console globally to prevent undefined access
|
|
44
29
|
|
|
45
30
|
|
|
46
|
-
def
|
|
47
|
-
|
|
31
|
+
def _execute_single_commit_workflow(ctx: WorkflowContext) -> int:
|
|
32
|
+
"""Execute single commit workflow using extracted components.
|
|
48
33
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
for commit in commits:
|
|
52
|
-
files = commit.get("files", []) if isinstance(commit, dict) else []
|
|
53
|
-
all_files.extend([str(p) for p in files])
|
|
54
|
-
|
|
55
|
-
counts = Counter(all_files)
|
|
56
|
-
union_set = set(all_files)
|
|
57
|
-
|
|
58
|
-
duplicates = sorted([f for f, c in counts.items() if c > 1])
|
|
59
|
-
missing = sorted(staged - union_set)
|
|
60
|
-
unexpected = sorted(union_set - staged)
|
|
61
|
-
|
|
62
|
-
if not duplicates and not missing and not unexpected:
|
|
63
|
-
return True, "", ""
|
|
64
|
-
|
|
65
|
-
problems: list[str] = []
|
|
66
|
-
if missing:
|
|
67
|
-
problems.append(f"Missing: {', '.join(missing)}")
|
|
68
|
-
if unexpected:
|
|
69
|
-
problems.append(f"Not staged: {', '.join(unexpected)}")
|
|
70
|
-
if duplicates:
|
|
71
|
-
problems.append(f"Duplicates: {', '.join(duplicates)}")
|
|
72
|
-
|
|
73
|
-
feedback = f"{'; '.join(problems)}. Required files: {', '.join(sorted(staged))}. Respond with ONLY valid JSON."
|
|
74
|
-
return False, feedback, "; ".join(problems)
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
def _parse_model_identifier(model: str) -> tuple[str, str]:
|
|
78
|
-
"""Validate and split model identifier into provider and model name."""
|
|
79
|
-
normalized = model.strip()
|
|
80
|
-
if ":" not in normalized:
|
|
81
|
-
message = (
|
|
82
|
-
f"Invalid model format: '{model}'. Expected 'provider:model', e.g. 'openai:gpt-4o-mini'. "
|
|
83
|
-
"Use 'gac config set model <provider:model>' to update your configuration."
|
|
84
|
-
)
|
|
85
|
-
logger.error(message)
|
|
86
|
-
console.print(f"[red]{message}[/red]")
|
|
87
|
-
sys.exit(1)
|
|
88
|
-
|
|
89
|
-
provider, model_name = normalized.split(":", 1)
|
|
90
|
-
if not provider or not model_name:
|
|
91
|
-
message = (
|
|
92
|
-
f"Invalid model format: '{model}'. Both provider and model name are required "
|
|
93
|
-
"(example: 'anthropic:claude-haiku-4-5')."
|
|
94
|
-
)
|
|
95
|
-
logger.error(message)
|
|
96
|
-
console.print(f"[red]{message}[/red]")
|
|
97
|
-
sys.exit(1)
|
|
98
|
-
|
|
99
|
-
return provider, model_name
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
def _handle_validation_retry(
|
|
103
|
-
attempts: int,
|
|
104
|
-
content_retry_budget: int,
|
|
105
|
-
raw_response: str,
|
|
106
|
-
feedback_message: str,
|
|
107
|
-
error_message: str,
|
|
108
|
-
conversation_messages: list[dict[str, str]],
|
|
109
|
-
quiet: bool,
|
|
110
|
-
retry_context: str,
|
|
111
|
-
) -> bool:
|
|
112
|
-
"""Handle validation retry logic. Returns True if should exit, False if should retry."""
|
|
113
|
-
conversation_messages.append({"role": "assistant", "content": raw_response})
|
|
114
|
-
conversation_messages.append({"role": "user", "content": feedback_message})
|
|
115
|
-
if attempts >= content_retry_budget:
|
|
116
|
-
logger.error(error_message)
|
|
117
|
-
console.print(f"\n[red]{error_message}[/red]")
|
|
118
|
-
console.print("\n[yellow]Raw model output:[/yellow]")
|
|
119
|
-
console.print(Panel(raw_response, title="Model Output", border_style="yellow"))
|
|
120
|
-
return True
|
|
121
|
-
if not quiet:
|
|
122
|
-
console.print(f"[yellow]Retry {attempts} of {content_retry_budget - 1}: {retry_context}[/yellow]")
|
|
123
|
-
return False
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
def execute_grouped_commits_workflow(
|
|
127
|
-
*,
|
|
128
|
-
system_prompt: str,
|
|
129
|
-
user_prompt: str,
|
|
130
|
-
model: str,
|
|
131
|
-
temperature: float,
|
|
132
|
-
max_output_tokens: int,
|
|
133
|
-
max_retries: int,
|
|
134
|
-
require_confirmation: bool,
|
|
135
|
-
quiet: bool,
|
|
136
|
-
no_verify: bool,
|
|
137
|
-
dry_run: bool,
|
|
138
|
-
push: bool,
|
|
139
|
-
show_prompt: bool,
|
|
140
|
-
interactive: bool,
|
|
141
|
-
message_only: bool,
|
|
142
|
-
hook_timeout: int = 120,
|
|
143
|
-
) -> None:
|
|
144
|
-
"""Execute the grouped commits workflow."""
|
|
145
|
-
import json
|
|
146
|
-
|
|
147
|
-
from gac.ai import generate_grouped_commits
|
|
148
|
-
|
|
149
|
-
provider, model_name = _parse_model_identifier(model)
|
|
150
|
-
|
|
151
|
-
if show_prompt:
|
|
152
|
-
full_prompt = f"SYSTEM PROMPT:\n{system_prompt}\n\nUSER PROMPT:\n{user_prompt}"
|
|
153
|
-
console.print(Panel(full_prompt, title="Prompt for LLM", border_style="bright_blue"))
|
|
34
|
+
Args:
|
|
35
|
+
ctx: WorkflowContext containing all configuration, flags, and state
|
|
154
36
|
|
|
37
|
+
Returns:
|
|
38
|
+
Exit code: 0 for success, non-zero for failure/abort
|
|
39
|
+
"""
|
|
155
40
|
conversation_messages: list[dict[str, str]] = []
|
|
156
|
-
if system_prompt:
|
|
157
|
-
conversation_messages.append({"role": "system", "content": system_prompt})
|
|
158
|
-
conversation_messages.append({"role": "user", "content": user_prompt})
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
status_match = re.search(r"<git_status>\n(.*?)\n</git_status>", user_prompt, re.DOTALL)
|
|
173
|
-
diff_match = re.search(r"<git_diff>\n(.*?)\n</git_diff>", user_prompt, re.DOTALL)
|
|
174
|
-
diff_stat_match = re.search(r"<git_diff_stat>\n(.*?)\n</git_diff_stat>", user_prompt, re.DOTALL)
|
|
175
|
-
|
|
176
|
-
status = status_match.group(1) if status_match else ""
|
|
177
|
-
diff = diff_match.group(1) if diff_match else ""
|
|
178
|
-
diff_stat = diff_stat_match.group(1) if diff_stat_match else ""
|
|
179
|
-
|
|
180
|
-
# Extract hint text if present
|
|
181
|
-
hint_match = re.search(r"<hint_text>(.*?)</hint_text>", user_prompt, re.DOTALL)
|
|
182
|
-
hint = hint_match.group(1) if hint_match else ""
|
|
183
|
-
|
|
184
|
-
questions = generate_contextual_questions(
|
|
185
|
-
model=model,
|
|
186
|
-
status=status,
|
|
187
|
-
processed_diff=diff,
|
|
188
|
-
diff_stat=diff_stat,
|
|
189
|
-
hint=hint,
|
|
190
|
-
temperature=temperature,
|
|
191
|
-
max_tokens=max_output_tokens,
|
|
192
|
-
max_retries=max_retries,
|
|
193
|
-
quiet=quiet,
|
|
194
|
-
)
|
|
195
|
-
|
|
196
|
-
if questions:
|
|
197
|
-
# Collect answers interactively
|
|
198
|
-
answers = collect_interactive_answers(questions)
|
|
199
|
-
|
|
200
|
-
if answers is None:
|
|
201
|
-
# User aborted interactive mode
|
|
202
|
-
if not quiet:
|
|
203
|
-
console.print("[yellow]Proceeding with commit without additional context[/yellow]\n")
|
|
204
|
-
elif answers:
|
|
205
|
-
# User provided some answers, format them for the prompt
|
|
206
|
-
answers_context = format_answers_for_prompt(answers)
|
|
207
|
-
enhanced_user_prompt = user_prompt + answers_context
|
|
208
|
-
|
|
209
|
-
# Update the conversation messages with the enhanced prompt
|
|
210
|
-
if conversation_messages and conversation_messages[-1]["role"] == "user":
|
|
211
|
-
conversation_messages[-1]["content"] = enhanced_user_prompt
|
|
212
|
-
|
|
213
|
-
logger.info(f"Collected answers for {len(answers)} questions")
|
|
214
|
-
else:
|
|
215
|
-
# User skipped all questions
|
|
216
|
-
if not quiet:
|
|
217
|
-
console.print("[dim]No answers provided, proceeding with original context[/dim]\n")
|
|
218
|
-
|
|
219
|
-
except Exception as e:
|
|
220
|
-
logger.warning(f"Failed to generate contextual questions, proceeding without them: {e}")
|
|
221
|
-
if not quiet:
|
|
222
|
-
console.print("[yellow]⚠️ Could not generate contextual questions, proceeding normally[/yellow]\n")
|
|
223
|
-
|
|
224
|
-
first_iteration = True
|
|
225
|
-
content_retry_budget = max(3, int(max_retries))
|
|
226
|
-
attempts = 0
|
|
227
|
-
|
|
228
|
-
grouped_result: dict | None = None
|
|
229
|
-
raw_response: str = ""
|
|
230
|
-
|
|
231
|
-
while True:
|
|
232
|
-
prompt_tokens = count_tokens(conversation_messages, model)
|
|
233
|
-
|
|
234
|
-
if first_iteration:
|
|
235
|
-
warning_limit_val = config.get("warning_limit_tokens", EnvDefaults.WARNING_LIMIT_TOKENS)
|
|
236
|
-
if warning_limit_val is None:
|
|
237
|
-
raise ConfigError("warning_limit_tokens configuration missing")
|
|
238
|
-
warning_limit = int(warning_limit_val)
|
|
239
|
-
if not check_token_warning(prompt_tokens, warning_limit, require_confirmation):
|
|
240
|
-
sys.exit(0)
|
|
241
|
-
first_iteration = False
|
|
242
|
-
|
|
243
|
-
raw_response = generate_grouped_commits(
|
|
244
|
-
model=model,
|
|
245
|
-
prompt=conversation_messages,
|
|
246
|
-
temperature=temperature,
|
|
247
|
-
max_tokens=max_output_tokens,
|
|
248
|
-
max_retries=max_retries,
|
|
249
|
-
quiet=quiet,
|
|
250
|
-
skip_success_message=True,
|
|
41
|
+
if ctx.system_prompt:
|
|
42
|
+
conversation_messages.append({"role": "system", "content": ctx.system_prompt})
|
|
43
|
+
conversation_messages.append({"role": "user", "content": ctx.user_prompt})
|
|
44
|
+
|
|
45
|
+
# Handle interactive questions if enabled
|
|
46
|
+
if ctx.interactive and not ctx.message_only:
|
|
47
|
+
ctx.state.interactive_mode.handle_interactive_flow(
|
|
48
|
+
model=ctx.model,
|
|
49
|
+
user_prompt=ctx.user_prompt,
|
|
50
|
+
git_state=ctx.git_state,
|
|
51
|
+
hint=ctx.hint,
|
|
52
|
+
conversation_messages=conversation_messages,
|
|
53
|
+
temperature=ctx.temperature,
|
|
54
|
+
max_tokens=ctx.max_output_tokens,
|
|
55
|
+
max_retries=ctx.max_retries,
|
|
56
|
+
quiet=ctx.quiet,
|
|
251
57
|
)
|
|
252
58
|
|
|
253
|
-
|
|
254
|
-
extract = raw_response
|
|
255
|
-
first_brace = raw_response.find("{")
|
|
256
|
-
last_brace = raw_response.rfind("}")
|
|
257
|
-
if first_brace != -1 and last_brace != -1 and first_brace < last_brace:
|
|
258
|
-
extract = raw_response[first_brace : last_brace + 1]
|
|
259
|
-
|
|
260
|
-
try:
|
|
261
|
-
parsed = json.loads(extract)
|
|
262
|
-
except json.JSONDecodeError as e:
|
|
263
|
-
parsed = None
|
|
264
|
-
logger.debug(
|
|
265
|
-
f"JSON parsing failed: {e}. Extract length: {len(extract)}, Response length: {len(raw_response)}"
|
|
266
|
-
)
|
|
267
|
-
|
|
268
|
-
if parsed is None:
|
|
269
|
-
attempts += 1
|
|
270
|
-
feedback = "Your response was not valid JSON. Respond with ONLY valid JSON matching the expected schema. Do not include any commentary or code fences."
|
|
271
|
-
error_msg = f"Failed to parse LLM response as JSON after {attempts} retries."
|
|
272
|
-
if _handle_validation_retry(
|
|
273
|
-
attempts,
|
|
274
|
-
content_retry_budget,
|
|
275
|
-
raw_response,
|
|
276
|
-
feedback,
|
|
277
|
-
error_msg,
|
|
278
|
-
conversation_messages,
|
|
279
|
-
quiet,
|
|
280
|
-
"JSON parsing failed, asking model to fix...",
|
|
281
|
-
):
|
|
282
|
-
sys.exit(1)
|
|
283
|
-
continue
|
|
284
|
-
|
|
285
|
-
try:
|
|
286
|
-
if "commits" not in parsed or not isinstance(parsed["commits"], list):
|
|
287
|
-
raise ValueError("Response missing 'commits' array")
|
|
288
|
-
if len(parsed["commits"]) == 0:
|
|
289
|
-
raise ValueError("No commits in response")
|
|
290
|
-
for idx, commit in enumerate(parsed["commits"]):
|
|
291
|
-
if "files" not in commit or not isinstance(commit["files"], list):
|
|
292
|
-
raise ValueError(f"Commit {idx + 1} missing 'files' array")
|
|
293
|
-
if "message" not in commit or not isinstance(commit["message"], str):
|
|
294
|
-
raise ValueError(f"Commit {idx + 1} missing 'message' string")
|
|
295
|
-
if len(commit["files"]) == 0:
|
|
296
|
-
raise ValueError(f"Commit {idx + 1} has empty files list")
|
|
297
|
-
if not commit["message"].strip():
|
|
298
|
-
raise ValueError(f"Commit {idx + 1} has empty message")
|
|
299
|
-
except (ValueError, TypeError) as e:
|
|
300
|
-
attempts += 1
|
|
301
|
-
feedback = f"Invalid response structure: {e}. Please return ONLY valid JSON following the schema with a non-empty 'commits' array of objects containing 'files' and 'message'."
|
|
302
|
-
error_msg = f"Invalid grouped commits structure after {attempts} retries: {e}"
|
|
303
|
-
if _handle_validation_retry(
|
|
304
|
-
attempts,
|
|
305
|
-
content_retry_budget,
|
|
306
|
-
raw_response,
|
|
307
|
-
feedback,
|
|
308
|
-
error_msg,
|
|
309
|
-
conversation_messages,
|
|
310
|
-
quiet,
|
|
311
|
-
"Structure validation failed, asking model to fix...",
|
|
312
|
-
):
|
|
313
|
-
sys.exit(1)
|
|
314
|
-
continue
|
|
315
|
-
|
|
316
|
-
staged_set = set(get_staged_files(existing_only=False))
|
|
317
|
-
ok, feedback, detail_msg = _validate_grouped_files_or_feedback(staged_set, parsed)
|
|
318
|
-
if not ok:
|
|
319
|
-
attempts += 1
|
|
320
|
-
error_msg = (
|
|
321
|
-
f"Grouped commits file set mismatch after {attempts} retries{': ' + detail_msg if detail_msg else ''}"
|
|
322
|
-
)
|
|
323
|
-
if _handle_validation_retry(
|
|
324
|
-
attempts,
|
|
325
|
-
content_retry_budget,
|
|
326
|
-
raw_response,
|
|
327
|
-
feedback,
|
|
328
|
-
error_msg,
|
|
329
|
-
conversation_messages,
|
|
330
|
-
quiet,
|
|
331
|
-
"File coverage mismatch, asking model to fix...",
|
|
332
|
-
):
|
|
333
|
-
sys.exit(1)
|
|
334
|
-
continue
|
|
335
|
-
|
|
336
|
-
grouped_result = parsed
|
|
337
|
-
conversation_messages.append({"role": "assistant", "content": raw_response})
|
|
338
|
-
|
|
339
|
-
if not quiet:
|
|
340
|
-
console.print(f"[green]✔ Generated commit messages with {provider} {model_name}[/green]")
|
|
341
|
-
num_commits = len(grouped_result["commits"])
|
|
342
|
-
console.print(f"[bold green]Proposed Commits ({num_commits}):[/bold green]\n")
|
|
343
|
-
for idx, commit in enumerate(grouped_result["commits"], 1):
|
|
344
|
-
files = commit["files"]
|
|
345
|
-
files_display = ", ".join(files)
|
|
346
|
-
console.print(f"[dim]{files_display}[/dim]")
|
|
347
|
-
commit_msg = commit["message"].strip()
|
|
348
|
-
console.print(Panel(commit_msg, title=f"Commit Message {idx}/{num_commits}", border_style="cyan"))
|
|
349
|
-
console.print()
|
|
350
|
-
|
|
351
|
-
completion_tokens = count_tokens(raw_response, model)
|
|
352
|
-
total_tokens = prompt_tokens + completion_tokens
|
|
353
|
-
console.print(
|
|
354
|
-
f"[dim]Token usage: {prompt_tokens} prompt + {completion_tokens} completion = {total_tokens} total[/dim]"
|
|
355
|
-
)
|
|
356
|
-
|
|
357
|
-
if require_confirmation:
|
|
358
|
-
accepted = False
|
|
359
|
-
num_commits = len(grouped_result["commits"]) if grouped_result else 0
|
|
360
|
-
while True:
|
|
361
|
-
response = click.prompt(
|
|
362
|
-
f"Proceed with {num_commits} commits above? [y/n/r/<feedback>]",
|
|
363
|
-
type=str,
|
|
364
|
-
show_default=False,
|
|
365
|
-
).strip()
|
|
366
|
-
response_lower = response.lower()
|
|
367
|
-
|
|
368
|
-
if response_lower in ["y", "yes"]:
|
|
369
|
-
accepted = True
|
|
370
|
-
break
|
|
371
|
-
if response_lower in ["n", "no"]:
|
|
372
|
-
console.print("[yellow]Commits not accepted. Exiting...[/yellow]")
|
|
373
|
-
sys.exit(0)
|
|
374
|
-
if response == "":
|
|
375
|
-
continue
|
|
376
|
-
if response_lower in ["r", "reroll"]:
|
|
377
|
-
feedback_message = "Please provide alternative commit groupings using the same repository context."
|
|
378
|
-
console.print("[cyan]Regenerating commit groups...[/cyan]")
|
|
379
|
-
conversation_messages.append({"role": "user", "content": feedback_message})
|
|
380
|
-
console.print()
|
|
381
|
-
attempts = 0
|
|
382
|
-
break
|
|
383
|
-
|
|
384
|
-
feedback_message = f"Please revise the commit groupings based on this feedback: {response}"
|
|
385
|
-
console.print(f"[cyan]Regenerating commit groups with feedback: {response}[/cyan]")
|
|
386
|
-
conversation_messages.append({"role": "user", "content": feedback_message})
|
|
387
|
-
console.print()
|
|
388
|
-
attempts = 0
|
|
389
|
-
break
|
|
390
|
-
|
|
391
|
-
if not accepted:
|
|
392
|
-
continue
|
|
393
|
-
|
|
394
|
-
num_commits = len(grouped_result["commits"]) if grouped_result else 0
|
|
395
|
-
if dry_run:
|
|
396
|
-
console.print(f"[yellow]Dry run: Would create {num_commits} commits[/yellow]")
|
|
397
|
-
for idx, commit in enumerate(grouped_result["commits"], 1):
|
|
398
|
-
console.print(f"\n[cyan]Commit {idx}/{num_commits}:[/cyan]")
|
|
399
|
-
console.print(f" Files: {', '.join(commit['files'])}")
|
|
400
|
-
console.print(f" Message: {commit['message'].strip()[:50]}...")
|
|
401
|
-
else:
|
|
402
|
-
original_staged_files = get_staged_files(existing_only=False)
|
|
403
|
-
original_staged_diff = run_git_command(["diff", "--cached", "--binary"], silent=True)
|
|
404
|
-
run_git_command(["reset", "HEAD"])
|
|
405
|
-
|
|
406
|
-
try:
|
|
407
|
-
# Detect file renames to handle them properly
|
|
408
|
-
rename_mappings = detect_rename_mappings(original_staged_diff)
|
|
409
|
-
|
|
410
|
-
for idx, commit in enumerate(grouped_result["commits"], 1):
|
|
411
|
-
try:
|
|
412
|
-
for file_path in commit["files"]:
|
|
413
|
-
# Check if this file is the destination of a rename
|
|
414
|
-
if file_path in rename_mappings:
|
|
415
|
-
old_file = rename_mappings[file_path]
|
|
416
|
-
# For renames, stage both the old file (for deletion) and new file
|
|
417
|
-
# This ensures the complete rename operation is preserved
|
|
418
|
-
run_git_command(["add", "-A", old_file])
|
|
419
|
-
run_git_command(["add", "-A", file_path])
|
|
420
|
-
else:
|
|
421
|
-
run_git_command(["add", "-A", file_path])
|
|
422
|
-
execute_commit(commit["message"].strip(), no_verify, hook_timeout)
|
|
423
|
-
console.print(f"[green]✓ Commit {idx}/{num_commits} created[/green]")
|
|
424
|
-
except Exception as e:
|
|
425
|
-
console.print(f"[red]✗ Failed at commit {idx}/{num_commits}: {e}[/red]")
|
|
426
|
-
console.print(f"[yellow]Completed {idx - 1}/{num_commits} commits.[/yellow]")
|
|
427
|
-
if idx == 1:
|
|
428
|
-
console.print("[yellow]Restoring original staging area...[/yellow]")
|
|
429
|
-
restore_staging(original_staged_files, original_staged_diff)
|
|
430
|
-
console.print("[green]Original staging area restored.[/green]")
|
|
431
|
-
sys.exit(1)
|
|
432
|
-
except KeyboardInterrupt:
|
|
433
|
-
console.print("\n[yellow]Interrupted by user. Restoring original staging area...[/yellow]")
|
|
434
|
-
restore_staging(original_staged_files, original_staged_diff)
|
|
435
|
-
console.print("[green]Original staging area restored.[/green]")
|
|
436
|
-
sys.exit(1)
|
|
437
|
-
|
|
438
|
-
if push:
|
|
439
|
-
try:
|
|
440
|
-
if dry_run:
|
|
441
|
-
console.print("[yellow]Dry run: Would push changes[/yellow]")
|
|
442
|
-
sys.exit(0)
|
|
443
|
-
if push_changes():
|
|
444
|
-
logger.info("Changes pushed successfully")
|
|
445
|
-
console.print("[green]Changes pushed successfully[/green]")
|
|
446
|
-
else:
|
|
447
|
-
console.print(
|
|
448
|
-
"[red]Failed to push changes. Check your remote configuration and network connection.[/red]"
|
|
449
|
-
)
|
|
450
|
-
sys.exit(1)
|
|
451
|
-
except Exception as e:
|
|
452
|
-
console.print(f"[red]Error pushing changes: {e}[/red]")
|
|
453
|
-
sys.exit(1)
|
|
454
|
-
|
|
455
|
-
sys.exit(0)
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
def execute_single_commit_workflow(
|
|
459
|
-
*,
|
|
460
|
-
system_prompt: str,
|
|
461
|
-
user_prompt: str,
|
|
462
|
-
model: str,
|
|
463
|
-
temperature: float,
|
|
464
|
-
max_output_tokens: int,
|
|
465
|
-
max_retries: int,
|
|
466
|
-
require_confirmation: bool,
|
|
467
|
-
quiet: bool,
|
|
468
|
-
no_verify: bool,
|
|
469
|
-
dry_run: bool,
|
|
470
|
-
message_only: bool = False,
|
|
471
|
-
push: bool,
|
|
472
|
-
show_prompt: bool,
|
|
473
|
-
hook_timeout: int = 120,
|
|
474
|
-
interactive: bool = False,
|
|
475
|
-
) -> None:
|
|
476
|
-
if show_prompt:
|
|
477
|
-
full_prompt = f"SYSTEM PROMPT:\n{system_prompt}\n\nUSER PROMPT:\n{user_prompt}"
|
|
478
|
-
console.print(Panel(full_prompt, title="Prompt for LLM", border_style="bright_blue"))
|
|
479
|
-
|
|
480
|
-
conversation_messages: list[dict[str, str]] = []
|
|
481
|
-
if system_prompt:
|
|
482
|
-
conversation_messages.append({"role": "system", "content": system_prompt})
|
|
483
|
-
conversation_messages.append({"role": "user", "content": user_prompt})
|
|
484
|
-
|
|
485
|
-
_parse_model_identifier(model)
|
|
486
|
-
|
|
487
|
-
# Generate interactive questions if enabled
|
|
488
|
-
if interactive and not message_only:
|
|
489
|
-
try:
|
|
490
|
-
# Extract git data from the user prompt for question generation
|
|
491
|
-
status_match = None
|
|
492
|
-
diff_match = None
|
|
493
|
-
diff_stat_match = None
|
|
494
|
-
|
|
495
|
-
import re
|
|
496
|
-
|
|
497
|
-
status_match = re.search(r"<git_status>\n(.*?)\n</git_status>", user_prompt, re.DOTALL)
|
|
498
|
-
diff_match = re.search(r"<git_diff>\n(.*?)\n</git_diff>", user_prompt, re.DOTALL)
|
|
499
|
-
diff_stat_match = re.search(r"<git_diff_stat>\n(.*?)\n</git_diff_stat>", user_prompt, re.DOTALL)
|
|
500
|
-
|
|
501
|
-
status = status_match.group(1) if status_match else ""
|
|
502
|
-
diff = diff_match.group(1) if diff_match else ""
|
|
503
|
-
diff_stat = diff_stat_match.group(1) if diff_stat_match else ""
|
|
504
|
-
|
|
505
|
-
# Extract hint text if present
|
|
506
|
-
hint_match = re.search(r"<hint_text>(.*?)</hint_text>", user_prompt, re.DOTALL)
|
|
507
|
-
hint = hint_match.group(1) if hint_match else ""
|
|
508
|
-
|
|
509
|
-
questions = generate_contextual_questions(
|
|
510
|
-
model=model,
|
|
511
|
-
status=status,
|
|
512
|
-
processed_diff=diff,
|
|
513
|
-
diff_stat=diff_stat,
|
|
514
|
-
hint=hint,
|
|
515
|
-
temperature=temperature,
|
|
516
|
-
max_tokens=max_output_tokens,
|
|
517
|
-
max_retries=max_retries,
|
|
518
|
-
quiet=quiet,
|
|
519
|
-
)
|
|
520
|
-
|
|
521
|
-
if questions:
|
|
522
|
-
# Collect answers interactively
|
|
523
|
-
answers = collect_interactive_answers(questions)
|
|
524
|
-
|
|
525
|
-
if answers is None:
|
|
526
|
-
# User aborted interactive mode
|
|
527
|
-
if not quiet:
|
|
528
|
-
console.print("[yellow]Proceeding with commit without additional context[/yellow]\n")
|
|
529
|
-
elif answers:
|
|
530
|
-
# User provided some answers, format them for the prompt
|
|
531
|
-
answers_context = format_answers_for_prompt(answers)
|
|
532
|
-
enhanced_user_prompt = user_prompt + answers_context
|
|
533
|
-
|
|
534
|
-
# Update the conversation messages with the enhanced prompt
|
|
535
|
-
if conversation_messages and conversation_messages[-1]["role"] == "user":
|
|
536
|
-
conversation_messages[-1]["content"] = enhanced_user_prompt
|
|
537
|
-
|
|
538
|
-
logger.info(f"Collected answers for {len(answers)} questions")
|
|
539
|
-
else:
|
|
540
|
-
# User skipped all questions
|
|
541
|
-
if not quiet:
|
|
542
|
-
console.print("[dim]No answers provided, proceeding with original context[/dim]\n")
|
|
543
|
-
|
|
544
|
-
except Exception as e:
|
|
545
|
-
logger.warning(f"Failed to generate contextual questions, proceeding without them: {e}")
|
|
546
|
-
if not quiet:
|
|
547
|
-
console.print("[yellow]⚠️ Could not generate contextual questions, proceeding normally[/yellow]\n")
|
|
548
|
-
|
|
59
|
+
# Generate commit message
|
|
549
60
|
first_iteration = True
|
|
550
61
|
while True:
|
|
551
|
-
prompt_tokens = count_tokens(conversation_messages, model)
|
|
62
|
+
prompt_tokens = count_tokens(conversation_messages, ctx.model)
|
|
552
63
|
if first_iteration:
|
|
553
|
-
|
|
554
|
-
if
|
|
555
|
-
|
|
556
|
-
warning_limit = int(warning_limit_val)
|
|
557
|
-
if not check_token_warning(prompt_tokens, warning_limit, require_confirmation):
|
|
558
|
-
sys.exit(0)
|
|
64
|
+
warning_limit = config["warning_limit_tokens"]
|
|
65
|
+
if not check_token_warning(prompt_tokens, warning_limit, ctx.flags.require_confirmation):
|
|
66
|
+
return 0 # User declined due to token warning
|
|
559
67
|
first_iteration = False
|
|
560
68
|
|
|
561
69
|
raw_commit_message = generate_commit_message(
|
|
562
|
-
model=model,
|
|
70
|
+
model=ctx.model,
|
|
563
71
|
prompt=conversation_messages,
|
|
564
|
-
temperature=temperature,
|
|
565
|
-
max_tokens=max_output_tokens,
|
|
566
|
-
max_retries=max_retries,
|
|
567
|
-
quiet=quiet or message_only,
|
|
72
|
+
temperature=ctx.temperature,
|
|
73
|
+
max_tokens=ctx.max_output_tokens,
|
|
74
|
+
max_retries=ctx.max_retries,
|
|
75
|
+
quiet=ctx.quiet or ctx.message_only,
|
|
568
76
|
)
|
|
569
77
|
commit_message = clean_commit_message(raw_commit_message)
|
|
570
78
|
logger.info("Generated commit message:")
|
|
571
79
|
logger.info(commit_message)
|
|
572
80
|
conversation_messages.append({"role": "assistant", "content": commit_message})
|
|
573
81
|
|
|
574
|
-
if message_only:
|
|
575
|
-
# Output only the commit message without any formatting
|
|
82
|
+
if ctx.message_only:
|
|
576
83
|
print(commit_message)
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
84
|
+
return 0
|
|
85
|
+
|
|
86
|
+
# Display commit message panel (always show, regardless of confirmation mode)
|
|
87
|
+
if not ctx.quiet:
|
|
88
|
+
display_commit_message(commit_message, prompt_tokens, ctx.model, ctx.quiet)
|
|
89
|
+
|
|
90
|
+
# Handle confirmation
|
|
91
|
+
if ctx.flags.require_confirmation:
|
|
92
|
+
final_message, decision = ctx.state.interactive_mode.handle_single_commit_confirmation(
|
|
93
|
+
model=ctx.model,
|
|
94
|
+
commit_message=commit_message,
|
|
95
|
+
conversation_messages=conversation_messages,
|
|
96
|
+
quiet=ctx.quiet,
|
|
584
97
|
)
|
|
585
|
-
if decision == "
|
|
586
|
-
|
|
587
|
-
sys.exit(0)
|
|
588
|
-
elif decision == "yes":
|
|
98
|
+
if decision == "yes":
|
|
99
|
+
commit_message = final_message
|
|
589
100
|
break
|
|
101
|
+
elif decision == "no":
|
|
102
|
+
console.print("[yellow]Commit aborted.[/yellow]")
|
|
103
|
+
return 0 # User aborted
|
|
104
|
+
# decision == "regenerate": continue the loop
|
|
590
105
|
else:
|
|
591
106
|
break
|
|
592
107
|
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
console.print("Would commit with message:")
|
|
596
|
-
console.print(Panel(commit_message, title="Commit Message", border_style="cyan"))
|
|
597
|
-
staged_files = get_staged_files(existing_only=False)
|
|
598
|
-
console.print(f"Would commit {len(staged_files)} files")
|
|
599
|
-
logger.info(f"Would commit {len(staged_files)} files")
|
|
600
|
-
else:
|
|
601
|
-
execute_commit(commit_message, no_verify, hook_timeout)
|
|
108
|
+
# Execute the commit
|
|
109
|
+
ctx.state.commit_executor.create_commit(commit_message)
|
|
602
110
|
|
|
603
|
-
if
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
staged_files = get_staged_files(existing_only=False)
|
|
607
|
-
logger.info("Dry run: Would push changes")
|
|
608
|
-
logger.info("Would push with message:")
|
|
609
|
-
logger.info(commit_message)
|
|
610
|
-
logger.info(f"Would push {len(staged_files)} files")
|
|
611
|
-
console.print("[yellow]Dry run: Would push changes[/yellow]")
|
|
612
|
-
console.print("Would push with message:")
|
|
613
|
-
console.print(Panel(commit_message, title="Commit Message", border_style="cyan"))
|
|
614
|
-
console.print(f"Would push {len(staged_files)} files")
|
|
615
|
-
sys.exit(0)
|
|
616
|
-
if push_changes():
|
|
617
|
-
logger.info("Changes pushed successfully")
|
|
618
|
-
console.print("[green]Changes pushed successfully[/green]")
|
|
619
|
-
else:
|
|
620
|
-
console.print(
|
|
621
|
-
"[red]Failed to push changes. Check your remote configuration and network connection.[/red]"
|
|
622
|
-
)
|
|
623
|
-
sys.exit(1)
|
|
624
|
-
except Exception as e:
|
|
625
|
-
console.print(f"[red]Error pushing changes: {e}[/red]")
|
|
626
|
-
sys.exit(1)
|
|
111
|
+
# Push if requested
|
|
112
|
+
if ctx.flags.push:
|
|
113
|
+
ctx.state.commit_executor.push_to_remote()
|
|
627
114
|
|
|
628
|
-
if not quiet:
|
|
115
|
+
if not ctx.quiet:
|
|
629
116
|
logger.info("Successfully committed changes with message:")
|
|
630
117
|
logger.info(commit_message)
|
|
631
|
-
if push:
|
|
118
|
+
if ctx.flags.push:
|
|
632
119
|
logger.info("Changes pushed to remote.")
|
|
633
|
-
|
|
120
|
+
return 0
|
|
634
121
|
|
|
635
122
|
|
|
636
|
-
def
|
|
637
|
-
|
|
638
|
-
status: str,
|
|
639
|
-
processed_diff: str,
|
|
640
|
-
diff_stat: str = "",
|
|
641
|
-
hint: str = "",
|
|
642
|
-
temperature: float = EnvDefaults.TEMPERATURE,
|
|
643
|
-
max_tokens: int = EnvDefaults.MAX_OUTPUT_TOKENS,
|
|
644
|
-
max_retries: int = EnvDefaults.MAX_RETRIES,
|
|
645
|
-
quiet: bool = False,
|
|
646
|
-
) -> list[str]:
|
|
647
|
-
"""Generate contextual questions about staged changes when interactive mode is enabled.
|
|
123
|
+
def main(opts: CLIOptions) -> int:
|
|
124
|
+
"""Main application logic for gac.
|
|
648
125
|
|
|
649
126
|
Args:
|
|
650
|
-
|
|
651
|
-
status: Git status output
|
|
652
|
-
processed_diff: Git diff output, already preprocessed
|
|
653
|
-
diff_stat: Git diff stat output showing file changes summary
|
|
654
|
-
hint: Optional hint to guide the question generation
|
|
655
|
-
temperature: Controls randomness for generation
|
|
656
|
-
max_tokens: Maximum tokens in the response
|
|
657
|
-
max_retries: Number of retry attempts if generation fails
|
|
658
|
-
quiet: If True, suppress progress indicators
|
|
127
|
+
opts: CLI options bundled in a dataclass
|
|
659
128
|
|
|
660
129
|
Returns:
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
Raises:
|
|
664
|
-
AIError: If question generation fails after max_retries attempts
|
|
130
|
+
Exit code: 0 for success, non-zero for failure
|
|
665
131
|
"""
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
hint=hint,
|
|
675
|
-
)
|
|
676
|
-
|
|
677
|
-
# Generate questions using existing infrastructure
|
|
678
|
-
logger.info("Generating contextual questions about staged changes...")
|
|
679
|
-
questions_text = generate_commit_message(
|
|
680
|
-
model=model,
|
|
681
|
-
prompt=(system_prompt, user_prompt),
|
|
682
|
-
temperature=temperature,
|
|
683
|
-
max_tokens=max_tokens,
|
|
684
|
-
max_retries=max_retries,
|
|
685
|
-
quiet=quiet,
|
|
686
|
-
skip_success_message=True, # Don't show "Generated commit message" for questions
|
|
687
|
-
task_description="contextual questions",
|
|
688
|
-
)
|
|
689
|
-
|
|
690
|
-
# Parse the response to extract individual questions
|
|
691
|
-
questions = _parse_questions_from_response(questions_text)
|
|
692
|
-
|
|
693
|
-
logger.info(f"Generated {len(questions)} contextual questions")
|
|
694
|
-
return questions
|
|
695
|
-
|
|
696
|
-
except Exception as e:
|
|
697
|
-
logger.error(f"Failed to generate contextual questions: {e}")
|
|
698
|
-
raise AIError.model_error(f"Failed to generate contextual questions: {e}") from e
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
def _parse_questions_from_response(response: str) -> list[str]:
|
|
702
|
-
"""Parse the AI response to extract individual questions from a numbered list.
|
|
703
|
-
|
|
704
|
-
Args:
|
|
705
|
-
response: The raw response from the AI model
|
|
706
|
-
|
|
707
|
-
Returns:
|
|
708
|
-
A list of cleaned questions
|
|
709
|
-
"""
|
|
710
|
-
import re
|
|
711
|
-
|
|
712
|
-
questions = []
|
|
713
|
-
lines = response.strip().split("\n")
|
|
714
|
-
|
|
715
|
-
for line in lines:
|
|
716
|
-
line = line.strip()
|
|
717
|
-
if not line:
|
|
718
|
-
continue
|
|
719
|
-
|
|
720
|
-
# Match numbered list format (e.g., "1. Question text?" or "1) Question text?")
|
|
721
|
-
match = re.match(r"^\d+\.\s+(.+)$", line)
|
|
722
|
-
if not match:
|
|
723
|
-
match = re.match(r"^\d+\)\s+(.+)$", line)
|
|
724
|
-
|
|
725
|
-
if match:
|
|
726
|
-
question = match.group(1).strip()
|
|
727
|
-
# Remove any leading symbols like •, -, *
|
|
728
|
-
question = re.sub(r"^[•\-*]\s+", "", question)
|
|
729
|
-
if question and question.endswith("?"):
|
|
730
|
-
questions.append(question)
|
|
731
|
-
elif line.endswith("?") and len(line) > 5: # Fallback for non-numbered questions
|
|
732
|
-
questions.append(line)
|
|
733
|
-
|
|
734
|
-
return questions
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
def main(
|
|
738
|
-
stage_all: bool = False,
|
|
739
|
-
group: bool = False,
|
|
740
|
-
interactive: bool = False,
|
|
741
|
-
model: str | None = None,
|
|
742
|
-
hint: str = "",
|
|
743
|
-
one_liner: bool = False,
|
|
744
|
-
show_prompt: bool = False,
|
|
745
|
-
infer_scope: bool = False,
|
|
746
|
-
require_confirmation: bool = True,
|
|
747
|
-
push: bool = False,
|
|
748
|
-
quiet: bool = False,
|
|
749
|
-
dry_run: bool = False,
|
|
750
|
-
message_only: bool = False,
|
|
751
|
-
verbose: bool = False,
|
|
752
|
-
no_verify: bool = False,
|
|
753
|
-
skip_secret_scan: bool = False,
|
|
754
|
-
language: str | None = None,
|
|
755
|
-
hook_timeout: int = 120,
|
|
756
|
-
) -> None:
|
|
757
|
-
"""Main application logic for gac."""
|
|
758
|
-
try:
|
|
759
|
-
git_dir = run_git_command(["rev-parse", "--show-toplevel"])
|
|
760
|
-
if not git_dir:
|
|
761
|
-
raise GitError("Not in a git repository")
|
|
762
|
-
except Exception as e:
|
|
763
|
-
logger.error(f"Error checking git repository: {e}")
|
|
764
|
-
handle_error(GitError("Not in a git repository"), exit_program=True)
|
|
132
|
+
# Initialize components
|
|
133
|
+
git_validator = GitStateValidator(config)
|
|
134
|
+
prompt_builder = PromptBuilder(config)
|
|
135
|
+
commit_executor = CommitExecutor(
|
|
136
|
+
dry_run=opts.dry_run, quiet=opts.quiet, no_verify=opts.no_verify, hook_timeout=opts.hook_timeout
|
|
137
|
+
)
|
|
138
|
+
interactive_mode = InteractiveMode(config)
|
|
139
|
+
grouped_workflow = GroupedCommitWorkflow(config)
|
|
765
140
|
|
|
141
|
+
# Validate and get model configuration
|
|
142
|
+
model = opts.model
|
|
766
143
|
if model is None:
|
|
767
144
|
model_from_config = config["model"]
|
|
768
145
|
if model_from_config is None:
|
|
@@ -789,277 +166,163 @@ def main(
|
|
|
789
166
|
raise ConfigError("max_retries configuration missing")
|
|
790
167
|
max_retries = int(max_retries_val)
|
|
791
168
|
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
169
|
+
# Get git state and handle hooks
|
|
170
|
+
git_state = git_validator.get_git_state(
|
|
171
|
+
stage_all=opts.stage_all,
|
|
172
|
+
dry_run=opts.dry_run,
|
|
173
|
+
skip_secret_scan=opts.skip_secret_scan,
|
|
174
|
+
quiet=opts.quiet,
|
|
175
|
+
model=model,
|
|
176
|
+
hint=opts.hint,
|
|
177
|
+
one_liner=opts.one_liner,
|
|
178
|
+
infer_scope=opts.infer_scope,
|
|
179
|
+
verbose=opts.verbose,
|
|
180
|
+
language=opts.language,
|
|
181
|
+
)
|
|
803
182
|
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
)
|
|
808
|
-
sys.exit(0)
|
|
183
|
+
# No staged changes found
|
|
184
|
+
if git_state is None:
|
|
185
|
+
return 0
|
|
809
186
|
|
|
810
|
-
|
|
811
|
-
|
|
187
|
+
# Run pre-commit hooks
|
|
188
|
+
if not opts.no_verify and not opts.dry_run:
|
|
189
|
+
if not run_lefthook_hooks(opts.hook_timeout):
|
|
812
190
|
console.print("[red]Lefthook hooks failed. Please fix the issues and try again.[/red]")
|
|
813
191
|
console.print("[yellow]You can use --no-verify to skip pre-commit and lefthook hooks.[/yellow]")
|
|
814
|
-
|
|
192
|
+
return 1
|
|
815
193
|
|
|
816
|
-
if not run_pre_commit_hooks(hook_timeout):
|
|
194
|
+
if not run_pre_commit_hooks(opts.hook_timeout):
|
|
817
195
|
console.print("[red]Pre-commit hooks failed. Please fix the issues and try again.[/red]")
|
|
818
196
|
console.print("[yellow]You can use --no-verify to skip pre-commit and lefthook hooks.[/yellow]")
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
try:
|
|
846
|
-
choice = (
|
|
847
|
-
click.prompt(
|
|
848
|
-
"\nChoose an option",
|
|
849
|
-
type=click.Choice(["a", "c", "r"], case_sensitive=False),
|
|
850
|
-
default="a",
|
|
851
|
-
show_choices=True,
|
|
852
|
-
show_default=True,
|
|
853
|
-
)
|
|
854
|
-
.strip()
|
|
855
|
-
.lower()
|
|
856
|
-
)
|
|
857
|
-
except (EOFError, KeyboardInterrupt):
|
|
858
|
-
console.print("\n[red]Aborted by user.[/red]")
|
|
859
|
-
sys.exit(0)
|
|
860
|
-
|
|
861
|
-
if choice == "a":
|
|
862
|
-
console.print("[yellow]Commit aborted.[/yellow]")
|
|
863
|
-
sys.exit(0)
|
|
864
|
-
elif choice == "c":
|
|
865
|
-
console.print("[bold yellow]⚠️ Continuing with potential secrets in commit...[/bold yellow]")
|
|
866
|
-
logger.warning("User chose to continue despite detected secrets")
|
|
867
|
-
elif choice == "r":
|
|
868
|
-
affected_files = get_affected_files(secrets)
|
|
869
|
-
for file_path in affected_files:
|
|
870
|
-
try:
|
|
871
|
-
run_git_command(["reset", "HEAD", file_path])
|
|
872
|
-
console.print(f"[green]Unstaged: {file_path}[/green]")
|
|
873
|
-
except GitError as e:
|
|
874
|
-
console.print(f"[red]Failed to unstage {file_path}: {e}[/red]")
|
|
875
|
-
|
|
876
|
-
# Check if there are still staged files
|
|
877
|
-
remaining_staged = get_staged_files(existing_only=False)
|
|
878
|
-
if not remaining_staged:
|
|
879
|
-
console.print("[yellow]No files remain staged. Commit aborted.[/yellow]")
|
|
880
|
-
sys.exit(0)
|
|
881
|
-
|
|
882
|
-
console.print(f"[green]Continuing with {len(remaining_staged)} staged file(s)...[/green]")
|
|
883
|
-
status = get_staged_status()
|
|
884
|
-
diff = run_git_command(["diff", "--staged"])
|
|
885
|
-
diff_stat = " " + run_git_command(["diff", "--stat", "--cached"])
|
|
886
|
-
else:
|
|
887
|
-
logger.info("No secrets detected in staged changes")
|
|
888
|
-
|
|
889
|
-
logger.debug(f"Preprocessing diff ({len(diff)} characters)")
|
|
890
|
-
if model is None:
|
|
891
|
-
raise ConfigError("Model must be specified via GAC_MODEL environment variable or --model flag")
|
|
892
|
-
processed_diff = preprocess_diff(diff, token_limit=Utility.DEFAULT_DIFF_TOKEN_LIMIT, model=model)
|
|
893
|
-
logger.debug(f"Processed diff ({len(processed_diff)} characters)")
|
|
894
|
-
|
|
895
|
-
system_template_path_value = config.get("system_prompt_path")
|
|
896
|
-
system_template_path: str | None = (
|
|
897
|
-
system_template_path_value if isinstance(system_template_path_value, str) else None
|
|
898
|
-
)
|
|
899
|
-
|
|
900
|
-
if language is None:
|
|
901
|
-
language_value = config.get("language")
|
|
902
|
-
language = language_value if isinstance(language_value, str) else None
|
|
197
|
+
return 1
|
|
198
|
+
|
|
199
|
+
# Handle secret detection
|
|
200
|
+
if git_state.has_secrets:
|
|
201
|
+
secret_decision = git_validator.handle_secret_detection(git_state.secrets, opts.quiet)
|
|
202
|
+
if secret_decision is None:
|
|
203
|
+
# User chose to abort
|
|
204
|
+
return 0
|
|
205
|
+
if not secret_decision:
|
|
206
|
+
# Secrets were removed, we need to refresh the git state
|
|
207
|
+
git_state = git_validator.get_git_state(
|
|
208
|
+
stage_all=False,
|
|
209
|
+
dry_run=opts.dry_run,
|
|
210
|
+
skip_secret_scan=True, # Skip secret scan this time
|
|
211
|
+
quiet=opts.quiet,
|
|
212
|
+
model=model,
|
|
213
|
+
hint=opts.hint,
|
|
214
|
+
one_liner=opts.one_liner,
|
|
215
|
+
infer_scope=opts.infer_scope,
|
|
216
|
+
verbose=opts.verbose,
|
|
217
|
+
language=opts.language,
|
|
218
|
+
)
|
|
219
|
+
# After removing secret files, no staged changes may remain
|
|
220
|
+
if git_state is None:
|
|
221
|
+
return 0
|
|
903
222
|
|
|
904
|
-
|
|
905
|
-
|
|
223
|
+
# Adjust max_output_tokens for grouped mode
|
|
224
|
+
if opts.group:
|
|
225
|
+
num_files = len(git_state.staged_files)
|
|
226
|
+
multiplier = min(5, 2 + (num_files // 10))
|
|
227
|
+
max_output_tokens *= multiplier
|
|
228
|
+
logger.debug(f"Grouped mode: scaling max_output_tokens by {multiplier}x for {num_files} files")
|
|
906
229
|
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
one_liner=one_liner,
|
|
912
|
-
hint=hint,
|
|
913
|
-
infer_scope=infer_scope,
|
|
914
|
-
verbose=verbose,
|
|
915
|
-
|
|
916
|
-
language=language,
|
|
917
|
-
translate_prefixes=translate_prefixes,
|
|
230
|
+
# Build prompts
|
|
231
|
+
prompts = prompt_builder.build_prompts(
|
|
232
|
+
git_state=git_state,
|
|
233
|
+
group=opts.group,
|
|
234
|
+
one_liner=opts.one_liner,
|
|
235
|
+
hint=opts.hint,
|
|
236
|
+
infer_scope=opts.infer_scope,
|
|
237
|
+
verbose=opts.verbose,
|
|
238
|
+
language=opts.language,
|
|
918
239
|
)
|
|
919
240
|
|
|
920
|
-
if
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
system_prompt, user_prompt = build_group_prompt(
|
|
924
|
-
status=status,
|
|
925
|
-
processed_diff=processed_diff,
|
|
926
|
-
diff_stat=diff_stat,
|
|
927
|
-
one_liner=one_liner,
|
|
928
|
-
hint=hint,
|
|
929
|
-
infer_scope=infer_scope,
|
|
930
|
-
verbose=verbose,
|
|
931
|
-
system_template_path=system_template_path,
|
|
932
|
-
language=language,
|
|
933
|
-
translate_prefixes=translate_prefixes,
|
|
934
|
-
)
|
|
241
|
+
# Display prompts if requested
|
|
242
|
+
if opts.show_prompt:
|
|
243
|
+
prompt_builder.display_prompts(prompts.system_prompt, prompts.user_prompt)
|
|
935
244
|
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
245
|
+
try:
|
|
246
|
+
if opts.group:
|
|
247
|
+
# Execute grouped workflow
|
|
248
|
+
return grouped_workflow.execute_workflow(
|
|
249
|
+
system_prompt=prompts.system_prompt,
|
|
250
|
+
user_prompt=prompts.user_prompt,
|
|
940
251
|
model=model,
|
|
941
252
|
temperature=temperature,
|
|
942
253
|
max_output_tokens=max_output_tokens,
|
|
943
254
|
max_retries=max_retries,
|
|
944
|
-
require_confirmation=require_confirmation,
|
|
945
|
-
quiet=quiet,
|
|
946
|
-
no_verify=no_verify,
|
|
947
|
-
dry_run=dry_run,
|
|
948
|
-
push=push,
|
|
949
|
-
show_prompt=show_prompt,
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
255
|
+
require_confirmation=opts.require_confirmation,
|
|
256
|
+
quiet=opts.quiet,
|
|
257
|
+
no_verify=opts.no_verify,
|
|
258
|
+
dry_run=opts.dry_run,
|
|
259
|
+
push=opts.push,
|
|
260
|
+
show_prompt=opts.show_prompt,
|
|
261
|
+
interactive=opts.interactive,
|
|
262
|
+
message_only=opts.message_only,
|
|
263
|
+
hook_timeout=opts.hook_timeout,
|
|
264
|
+
git_state=git_state,
|
|
265
|
+
hint=opts.hint,
|
|
953
266
|
)
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
sys.exit(1)
|
|
958
|
-
else:
|
|
959
|
-
try:
|
|
960
|
-
execute_single_commit_workflow(
|
|
961
|
-
system_prompt=system_prompt,
|
|
962
|
-
user_prompt=user_prompt,
|
|
267
|
+
else:
|
|
268
|
+
# Build workflow context
|
|
269
|
+
gen_config = GenerationConfig(
|
|
963
270
|
model=model,
|
|
964
271
|
temperature=temperature,
|
|
965
272
|
max_output_tokens=max_output_tokens,
|
|
966
273
|
max_retries=max_retries,
|
|
967
|
-
require_confirmation=require_confirmation,
|
|
968
|
-
quiet=quiet,
|
|
969
|
-
no_verify=no_verify,
|
|
970
|
-
dry_run=dry_run,
|
|
971
|
-
message_only=message_only,
|
|
972
|
-
push=push,
|
|
973
|
-
show_prompt=show_prompt,
|
|
974
|
-
hook_timeout=hook_timeout,
|
|
975
|
-
interactive=interactive,
|
|
976
274
|
)
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
execute_single_commit_workflow(
|
|
997
|
-
system_prompt=system_prompt,
|
|
998
|
-
user_prompt=user_prompt,
|
|
999
|
-
model=model,
|
|
1000
|
-
temperature=temperature,
|
|
1001
|
-
max_output_tokens=max_output_tokens,
|
|
1002
|
-
max_retries=max_retries,
|
|
1003
|
-
require_confirmation=require_confirmation,
|
|
1004
|
-
quiet=quiet,
|
|
1005
|
-
no_verify=no_verify,
|
|
1006
|
-
dry_run=dry_run,
|
|
1007
|
-
message_only=message_only,
|
|
1008
|
-
push=push,
|
|
1009
|
-
show_prompt=show_prompt,
|
|
1010
|
-
hook_timeout=hook_timeout,
|
|
1011
|
-
interactive=interactive,
|
|
1012
|
-
)
|
|
1013
|
-
else:
|
|
1014
|
-
console.print("[red]Re-authentication failed.[/red]")
|
|
1015
|
-
console.print("[yellow]Run 'gac model' to re-authenticate manually.[/yellow]")
|
|
1016
|
-
sys.exit(1)
|
|
1017
|
-
except Exception as auth_error:
|
|
1018
|
-
console.print(f"[red]Re-authentication error: {auth_error}[/red]")
|
|
1019
|
-
console.print("[yellow]Run 'gac model' to re-authenticate manually.[/yellow]")
|
|
1020
|
-
sys.exit(1)
|
|
1021
|
-
# Check if this is a Qwen OAuth token expiration
|
|
1022
|
-
elif e.error_type == "authentication" and model.startswith("qwen:"):
|
|
1023
|
-
logger.error(str(e))
|
|
1024
|
-
console.print("[yellow]⚠ Qwen authentication failed[/yellow]")
|
|
1025
|
-
console.print("[cyan]🔐 Starting automatic re-authentication...[/cyan]")
|
|
1026
|
-
|
|
1027
|
-
try:
|
|
1028
|
-
from gac.oauth import QwenOAuthProvider, TokenStore
|
|
1029
|
-
|
|
1030
|
-
oauth_provider = QwenOAuthProvider(TokenStore())
|
|
1031
|
-
oauth_provider.initiate_auth(open_browser=True)
|
|
1032
|
-
console.print("[green]✓ Re-authentication successful![/green]")
|
|
1033
|
-
console.print("[cyan]Retrying commit...[/cyan]\n")
|
|
275
|
+
flags = WorkflowFlags(
|
|
276
|
+
require_confirmation=opts.require_confirmation,
|
|
277
|
+
quiet=opts.quiet,
|
|
278
|
+
no_verify=opts.no_verify,
|
|
279
|
+
dry_run=opts.dry_run,
|
|
280
|
+
message_only=opts.message_only,
|
|
281
|
+
push=opts.push,
|
|
282
|
+
show_prompt=opts.show_prompt,
|
|
283
|
+
interactive=opts.interactive,
|
|
284
|
+
hook_timeout=opts.hook_timeout,
|
|
285
|
+
)
|
|
286
|
+
state = WorkflowState(
|
|
287
|
+
prompts=prompts,
|
|
288
|
+
git_state=git_state,
|
|
289
|
+
hint=opts.hint,
|
|
290
|
+
commit_executor=commit_executor,
|
|
291
|
+
interactive_mode=interactive_mode,
|
|
292
|
+
)
|
|
293
|
+
ctx = WorkflowContext(config=gen_config, flags=flags, state=state)
|
|
1034
294
|
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
295
|
+
# Execute single commit workflow
|
|
296
|
+
return _execute_single_commit_workflow(ctx)
|
|
297
|
+
except AIError as e:
|
|
298
|
+
# Build context for retry
|
|
299
|
+
gen_config = GenerationConfig(
|
|
300
|
+
model=model,
|
|
301
|
+
temperature=temperature,
|
|
302
|
+
max_output_tokens=max_output_tokens,
|
|
303
|
+
max_retries=max_retries,
|
|
304
|
+
)
|
|
305
|
+
flags = WorkflowFlags(
|
|
306
|
+
require_confirmation=opts.require_confirmation,
|
|
307
|
+
quiet=opts.quiet,
|
|
308
|
+
no_verify=opts.no_verify,
|
|
309
|
+
dry_run=opts.dry_run,
|
|
310
|
+
message_only=opts.message_only,
|
|
311
|
+
push=opts.push,
|
|
312
|
+
show_prompt=opts.show_prompt,
|
|
313
|
+
interactive=opts.interactive,
|
|
314
|
+
hook_timeout=opts.hook_timeout,
|
|
315
|
+
)
|
|
316
|
+
state = WorkflowState(
|
|
317
|
+
prompts=prompts,
|
|
318
|
+
git_state=git_state,
|
|
319
|
+
hint=opts.hint,
|
|
320
|
+
commit_executor=commit_executor,
|
|
321
|
+
interactive_mode=interactive_mode,
|
|
322
|
+
)
|
|
323
|
+
ctx = WorkflowContext(config=gen_config, flags=flags, state=state)
|
|
324
|
+
return handle_oauth_retry(e=e, ctx=ctx)
|
|
1062
325
|
|
|
1063
326
|
|
|
1064
327
|
if __name__ == "__main__":
|
|
1065
|
-
main()
|
|
328
|
+
raise SystemExit(main(CLIOptions()))
|