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.
Files changed (76) hide show
  1. gac/__init__.py +4 -6
  2. gac/__version__.py +1 -1
  3. gac/ai_utils.py +18 -49
  4. gac/cli.py +14 -10
  5. gac/commit_executor.py +59 -0
  6. gac/config.py +28 -3
  7. gac/config_cli.py +19 -7
  8. gac/constants/__init__.py +34 -0
  9. gac/constants/commit.py +63 -0
  10. gac/constants/defaults.py +40 -0
  11. gac/constants/file_patterns.py +110 -0
  12. gac/constants/languages.py +119 -0
  13. gac/diff_cli.py +0 -22
  14. gac/errors.py +8 -2
  15. gac/git.py +6 -6
  16. gac/git_state_validator.py +193 -0
  17. gac/grouped_commit_workflow.py +458 -0
  18. gac/init_cli.py +2 -1
  19. gac/interactive_mode.py +179 -0
  20. gac/language_cli.py +0 -1
  21. gac/main.py +222 -959
  22. gac/model_cli.py +2 -1
  23. gac/model_identifier.py +70 -0
  24. gac/oauth/claude_code.py +2 -2
  25. gac/oauth/qwen_oauth.py +4 -0
  26. gac/oauth/token_store.py +2 -2
  27. gac/oauth_retry.py +161 -0
  28. gac/postprocess.py +155 -0
  29. gac/prompt.py +20 -490
  30. gac/prompt_builder.py +88 -0
  31. gac/providers/README.md +437 -0
  32. gac/providers/__init__.py +70 -81
  33. gac/providers/anthropic.py +12 -56
  34. gac/providers/azure_openai.py +48 -92
  35. gac/providers/base.py +329 -0
  36. gac/providers/cerebras.py +10 -43
  37. gac/providers/chutes.py +16 -72
  38. gac/providers/claude_code.py +64 -97
  39. gac/providers/custom_anthropic.py +51 -85
  40. gac/providers/custom_openai.py +29 -87
  41. gac/providers/deepseek.py +10 -43
  42. gac/providers/error_handler.py +139 -0
  43. gac/providers/fireworks.py +10 -43
  44. gac/providers/gemini.py +66 -73
  45. gac/providers/groq.py +10 -62
  46. gac/providers/kimi_coding.py +19 -59
  47. gac/providers/lmstudio.py +62 -52
  48. gac/providers/minimax.py +10 -43
  49. gac/providers/mistral.py +10 -43
  50. gac/providers/moonshot.py +10 -43
  51. gac/providers/ollama.py +54 -41
  52. gac/providers/openai.py +30 -46
  53. gac/providers/openrouter.py +15 -62
  54. gac/providers/protocol.py +71 -0
  55. gac/providers/qwen.py +55 -67
  56. gac/providers/registry.py +58 -0
  57. gac/providers/replicate.py +137 -91
  58. gac/providers/streamlake.py +26 -56
  59. gac/providers/synthetic.py +35 -47
  60. gac/providers/together.py +10 -43
  61. gac/providers/zai.py +21 -59
  62. gac/py.typed +0 -0
  63. gac/security.py +1 -1
  64. gac/templates/__init__.py +1 -0
  65. gac/templates/question_generation.txt +60 -0
  66. gac/templates/system_prompt.txt +224 -0
  67. gac/templates/user_prompt.txt +28 -0
  68. gac/utils.py +6 -5
  69. gac/workflow_context.py +162 -0
  70. {gac-3.8.1.dist-info → gac-3.10.10.dist-info}/METADATA +1 -1
  71. gac-3.10.10.dist-info/RECORD +79 -0
  72. gac/constants.py +0 -328
  73. gac-3.8.1.dist-info/RECORD +0 -56
  74. {gac-3.8.1.dist-info → gac-3.10.10.dist-info}/WHEEL +0 -0
  75. {gac-3.8.1.dist-info → gac-3.10.10.dist-info}/entry_points.txt +0 -0
  76. {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.config import load_config
16
- from gac.constants import EnvDefaults, Utility
17
- from gac.errors import AIError, ConfigError, GitError, handle_error
18
- from gac.git import (
19
- detect_rename_mappings,
20
- get_staged_files,
21
- get_staged_status,
22
- push_changes,
23
- run_git_command,
24
- run_lefthook_hooks,
25
- run_pre_commit_hooks,
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 _validate_grouped_files_or_feedback(staged: set[str], grouped_result: dict) -> tuple[bool, str, str]:
47
- from collections import Counter
31
+ def _execute_single_commit_workflow(ctx: WorkflowContext) -> int:
32
+ """Execute single commit workflow using extracted components.
48
33
 
49
- commits = grouped_result.get("commits", []) if isinstance(grouped_result, dict) else []
50
- all_files: list[str] = []
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
- _parse_model_identifier(model)
161
-
162
- # Generate interactive questions if enabled
163
- if interactive and not message_only:
164
- try:
165
- # Extract git data from the user prompt for question generation
166
- status_match = None
167
- diff_match = None
168
- diff_stat_match = None
169
-
170
- import re
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
- parsed: dict | None = None
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
- warning_limit_val = config.get("warning_limit_tokens", EnvDefaults.WARNING_LIMIT_TOKENS)
554
- if warning_limit_val is None:
555
- raise ConfigError("warning_limit_tokens configuration missing")
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
- sys.exit(0)
578
-
579
- display_commit_message(commit_message, prompt_tokens, model, quiet)
580
-
581
- if require_confirmation:
582
- decision, commit_message, conversation_messages = handle_confirmation_loop(
583
- commit_message, conversation_messages, quiet, model
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 == "no":
586
- console.print("[yellow]Prompt not accepted. Exiting...[/yellow]")
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
- if dry_run:
594
- console.print("[yellow]Dry run: Commit message generated but not applied[/yellow]")
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 push:
604
- try:
605
- if dry_run:
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
- sys.exit(0)
120
+ return 0
634
121
 
635
122
 
636
- def generate_contextual_questions(
637
- model: str,
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
- model: The model to use in provider:model_name format
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
- A list of contextual questions about the staged changes
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
- from gac.prompt import build_question_generation_prompt
667
-
668
- try:
669
- # Build prompts for question generation
670
- system_prompt, user_prompt = build_question_generation_prompt(
671
- status=status,
672
- processed_diff=processed_diff,
673
- diff_stat=diff_stat,
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
- if stage_all and (not dry_run):
793
- logger.info("Staging all changes")
794
- run_git_command(["add", "--all"])
795
-
796
- staged_files = get_staged_files(existing_only=False)
797
-
798
- if group:
799
- num_files = len(staged_files)
800
- multiplier = min(5, 2 + (num_files // 10))
801
- max_output_tokens *= multiplier
802
- logger.debug(f"Grouped mode: scaling max_output_tokens by {multiplier}x for {num_files} files")
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
- if not staged_files:
805
- console.print(
806
- "[yellow]No staged changes found. Stage your changes with git add first or use --add-all.[/yellow]"
807
- )
808
- sys.exit(0)
183
+ # No staged changes found
184
+ if git_state is None:
185
+ return 0
809
186
 
810
- if not no_verify and not dry_run:
811
- if not run_lefthook_hooks(hook_timeout):
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
- sys.exit(1)
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
- sys.exit(1)
820
-
821
- status = get_staged_status()
822
- diff = run_git_command(["diff", "--staged"])
823
- diff_stat = " " + run_git_command(["diff", "--stat", "--cached"])
824
-
825
- if not skip_secret_scan:
826
- logger.info("Scanning staged changes for potential secrets...")
827
- secrets = scan_staged_diff(diff)
828
- if secrets:
829
- if not quiet:
830
- console.print("\n[bold red]⚠️ SECURITY WARNING: Potential secrets detected![/bold red]")
831
- console.print("[red]The following sensitive information was found in your staged changes:[/red]\n")
832
-
833
- for secret in secrets:
834
- location = f"{secret.file_path}:{secret.line_number}" if secret.line_number else secret.file_path
835
- if not quiet:
836
- console.print(f" • [yellow]{secret.secret_type}[/yellow] in [cyan]{location}[/cyan]")
837
- console.print(f" Match: [dim]{secret.matched_text}[/dim]\n")
838
-
839
- if not quiet:
840
- console.print("\n[bold]Options:[/bold]")
841
- console.print(" \\[a] Abort commit (recommended)")
842
- console.print(" \\[c] [yellow]Continue anyway[/yellow] (not recommended)")
843
- console.print(" \\[r] Remove affected file(s) and continue")
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
- translate_prefixes_value = config.get("translate_prefixes")
905
- translate_prefixes: bool = bool(translate_prefixes_value) if isinstance(translate_prefixes_value, bool) else False
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
- system_prompt, user_prompt = build_prompt(
908
- status=status,
909
- processed_diff=processed_diff,
910
- diff_stat=diff_stat,
911
- one_liner=one_liner,
912
- hint=hint,
913
- infer_scope=infer_scope,
914
- verbose=verbose,
915
- system_template_path=system_template_path,
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 group:
921
- from gac.prompt import build_group_prompt
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
- try:
937
- execute_grouped_commits_workflow(
938
- system_prompt=system_prompt,
939
- user_prompt=user_prompt,
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
- hook_timeout=hook_timeout,
951
- interactive=interactive,
952
- message_only=message_only,
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
- except AIError as e:
955
- logger.error(str(e))
956
- console.print(f"[red]Failed to generate grouped commits: {str(e)}[/red]")
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
- except AIError as e:
978
- # Check if this is a Claude Code OAuth token expiration
979
- if (
980
- e.error_type == "authentication"
981
- and model.startswith("claude-code:")
982
- and ("expired" in str(e).lower() or "oauth" in str(e).lower())
983
- ):
984
- logger.error(str(e))
985
- console.print("[yellow]⚠ Claude Code OAuth token has expired[/yellow]")
986
- console.print("[cyan]🔐 Starting automatic re-authentication...[/cyan]")
987
-
988
- try:
989
- from gac.oauth.claude_code import authenticate_and_save
990
-
991
- if authenticate_and_save(quiet=quiet):
992
- console.print("[green]✓ Re-authentication successful![/green]")
993
- console.print("[cyan]Retrying commit...[/cyan]\n")
994
-
995
- # Retry the commit workflow
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
- # Retry the commit workflow
1036
- execute_single_commit_workflow(
1037
- system_prompt=system_prompt,
1038
- user_prompt=user_prompt,
1039
- model=model,
1040
- temperature=temperature,
1041
- max_output_tokens=max_output_tokens,
1042
- max_retries=max_retries,
1043
- require_confirmation=require_confirmation,
1044
- quiet=quiet,
1045
- no_verify=no_verify,
1046
- dry_run=dry_run,
1047
- message_only=message_only,
1048
- push=push,
1049
- show_prompt=show_prompt,
1050
- hook_timeout=hook_timeout,
1051
- interactive=interactive,
1052
- )
1053
- except Exception as auth_error:
1054
- console.print(f"[red]Re-authentication error: {auth_error}[/red]")
1055
- console.print("[yellow]Run 'gac auth qwen login' to re-authenticate manually.[/yellow]")
1056
- sys.exit(1)
1057
- else:
1058
- # Non-Claude Code/Qwen error or non-auth error
1059
- logger.error(str(e))
1060
- console.print(f"[red]Failed to generate commit message: {str(e)}[/red]")
1061
- sys.exit(1)
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()))