gac 3.10.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of gac might be problematic. Click here for more details.

Files changed (67) hide show
  1. gac/__init__.py +15 -0
  2. gac/__version__.py +3 -0
  3. gac/ai.py +109 -0
  4. gac/ai_utils.py +246 -0
  5. gac/auth_cli.py +214 -0
  6. gac/cli.py +218 -0
  7. gac/commit_executor.py +62 -0
  8. gac/config.py +125 -0
  9. gac/config_cli.py +95 -0
  10. gac/constants.py +328 -0
  11. gac/diff_cli.py +159 -0
  12. gac/errors.py +231 -0
  13. gac/git.py +372 -0
  14. gac/git_state_validator.py +184 -0
  15. gac/grouped_commit_workflow.py +423 -0
  16. gac/init_cli.py +70 -0
  17. gac/interactive_mode.py +182 -0
  18. gac/language_cli.py +377 -0
  19. gac/main.py +476 -0
  20. gac/model_cli.py +430 -0
  21. gac/oauth/__init__.py +27 -0
  22. gac/oauth/claude_code.py +464 -0
  23. gac/oauth/qwen_oauth.py +327 -0
  24. gac/oauth/token_store.py +81 -0
  25. gac/preprocess.py +511 -0
  26. gac/prompt.py +878 -0
  27. gac/prompt_builder.py +88 -0
  28. gac/providers/README.md +437 -0
  29. gac/providers/__init__.py +80 -0
  30. gac/providers/anthropic.py +17 -0
  31. gac/providers/azure_openai.py +57 -0
  32. gac/providers/base.py +329 -0
  33. gac/providers/cerebras.py +15 -0
  34. gac/providers/chutes.py +25 -0
  35. gac/providers/claude_code.py +79 -0
  36. gac/providers/custom_anthropic.py +103 -0
  37. gac/providers/custom_openai.py +44 -0
  38. gac/providers/deepseek.py +15 -0
  39. gac/providers/error_handler.py +139 -0
  40. gac/providers/fireworks.py +15 -0
  41. gac/providers/gemini.py +90 -0
  42. gac/providers/groq.py +15 -0
  43. gac/providers/kimi_coding.py +27 -0
  44. gac/providers/lmstudio.py +80 -0
  45. gac/providers/minimax.py +15 -0
  46. gac/providers/mistral.py +15 -0
  47. gac/providers/moonshot.py +15 -0
  48. gac/providers/ollama.py +73 -0
  49. gac/providers/openai.py +32 -0
  50. gac/providers/openrouter.py +21 -0
  51. gac/providers/protocol.py +71 -0
  52. gac/providers/qwen.py +64 -0
  53. gac/providers/registry.py +58 -0
  54. gac/providers/replicate.py +156 -0
  55. gac/providers/streamlake.py +31 -0
  56. gac/providers/synthetic.py +40 -0
  57. gac/providers/together.py +15 -0
  58. gac/providers/zai.py +31 -0
  59. gac/py.typed +0 -0
  60. gac/security.py +293 -0
  61. gac/utils.py +401 -0
  62. gac/workflow_utils.py +217 -0
  63. gac-3.10.3.dist-info/METADATA +283 -0
  64. gac-3.10.3.dist-info/RECORD +67 -0
  65. gac-3.10.3.dist-info/WHEEL +4 -0
  66. gac-3.10.3.dist-info/entry_points.txt +2 -0
  67. gac-3.10.3.dist-info/licenses/LICENSE +16 -0
@@ -0,0 +1,184 @@
1
+ #!/usr/bin/env python3
2
+ """Git state validation and management for gac."""
3
+
4
+ import logging
5
+ import subprocess
6
+ import sys
7
+ from typing import Any, NamedTuple
8
+
9
+ from rich.console import Console
10
+
11
+ from gac.config import GACConfig
12
+ from gac.errors import ConfigError, GitError, handle_error
13
+ from gac.git import get_staged_files, get_staged_status, run_git_command
14
+ from gac.preprocess import preprocess_diff
15
+ from gac.security import get_affected_files, scan_staged_diff
16
+
17
+ logger = logging.getLogger(__name__)
18
+ console = Console()
19
+
20
+
21
+ class GitState(NamedTuple):
22
+ """Structured representation of git repository state."""
23
+
24
+ repo_root: str
25
+ staged_files: list[str]
26
+ status: str
27
+ diff: str
28
+ diff_stat: str
29
+ processed_diff: str
30
+ has_secrets: bool
31
+ secrets: list[Any]
32
+
33
+
34
+ class GitStateValidator:
35
+ """Validates and manages git repository state."""
36
+
37
+ def __init__(self, config: GACConfig):
38
+ self.config = config
39
+
40
+ def validate_repository(self) -> str:
41
+ """Validate that we're in a git repository and return the repo root."""
42
+ try:
43
+ git_dir = run_git_command(["rev-parse", "--show-toplevel"])
44
+ if not git_dir:
45
+ raise GitError("Not in a git repository")
46
+ return git_dir
47
+ except (subprocess.SubprocessError, GitError, OSError) as e:
48
+ logger.error(f"Error checking git repository: {e}")
49
+ handle_error(GitError("Not in a git repository"), exit_program=True)
50
+ return "" # Never reached, but required for type safety
51
+
52
+ def stage_all_if_requested(self, stage_all: bool, dry_run: bool) -> None:
53
+ """Stage all changes if requested and not in dry run mode."""
54
+ if stage_all and (not dry_run):
55
+ logger.info("Staging all changes")
56
+ run_git_command(["add", "--all"])
57
+
58
+ def get_git_state(
59
+ self,
60
+ stage_all: bool = False,
61
+ dry_run: bool = False,
62
+ skip_secret_scan: bool = False,
63
+ quiet: bool = False,
64
+ model: str | None = None,
65
+ hint: str = "",
66
+ one_liner: bool = False,
67
+ infer_scope: bool = False,
68
+ verbose: bool = False,
69
+ language: str | None = None,
70
+ ) -> GitState:
71
+ """Get complete git state including validation and processing."""
72
+ from gac.constants import Utility
73
+
74
+ # Validate repository
75
+ repo_root = self.validate_repository()
76
+
77
+ # Stage files if requested
78
+ self.stage_all_if_requested(stage_all, dry_run)
79
+
80
+ # Get staged files
81
+ staged_files = get_staged_files(existing_only=False)
82
+
83
+ if not staged_files:
84
+ console.print(
85
+ "[yellow]No staged changes found. Stage your changes with git add first or use --add-all.[/yellow]"
86
+ )
87
+ sys.exit(0)
88
+
89
+ # Get git status and diffs
90
+ status = get_staged_status()
91
+ diff = run_git_command(["diff", "--staged"])
92
+ diff_stat = " " + run_git_command(["diff", "--stat", "--cached"])
93
+
94
+ # Scan for secrets
95
+ has_secrets = False
96
+ secrets = []
97
+ if not skip_secret_scan:
98
+ logger.info("Scanning staged changes for potential secrets...")
99
+ secrets = scan_staged_diff(diff)
100
+ has_secrets = bool(secrets)
101
+
102
+ # Process diff for AI consumption
103
+ logger.debug(f"Preprocessing diff ({len(diff)} characters)")
104
+ if model is None:
105
+ raise ConfigError("Model must be specified via GAC_MODEL environment variable or --model flag")
106
+ processed_diff = preprocess_diff(diff, token_limit=Utility.DEFAULT_DIFF_TOKEN_LIMIT, model=model)
107
+ logger.debug(f"Processed diff ({len(processed_diff)} characters)")
108
+
109
+ return GitState(
110
+ repo_root=repo_root,
111
+ staged_files=staged_files,
112
+ status=status,
113
+ diff=diff,
114
+ diff_stat=diff_stat,
115
+ processed_diff=processed_diff,
116
+ has_secrets=has_secrets,
117
+ secrets=secrets,
118
+ )
119
+
120
+ def handle_secret_detection(self, secrets: list[Any], quiet: bool = False) -> bool:
121
+ """Handle secret detection and user interaction. Returns True if commit should continue."""
122
+ if not secrets:
123
+ return True
124
+
125
+ if not quiet:
126
+ console.print("\n[bold red]⚠️ SECURITY WARNING: Potential secrets detected![/bold red]")
127
+ console.print("[red]The following sensitive information was found in your staged changes:[/red]\n")
128
+
129
+ for secret in secrets:
130
+ location = f"{secret.file_path}:{secret.line_number}" if secret.line_number else secret.file_path
131
+ if not quiet:
132
+ console.print(f" • [yellow]{secret.secret_type}[/yellow] in [cyan]{location}[/cyan]")
133
+ console.print(f" Match: [dim]{secret.matched_text}[/dim]\n")
134
+
135
+ if not quiet:
136
+ console.print("\n[bold]Options:[/bold]")
137
+ console.print(" \\[a] Abort commit (recommended)")
138
+ console.print(" \\[c] [yellow]Continue anyway[/yellow] (not recommended)")
139
+ console.print(" \\[r] Remove affected file(s) and continue")
140
+
141
+ try:
142
+ import click
143
+
144
+ choice = (
145
+ click.prompt(
146
+ "\nChoose an option",
147
+ type=click.Choice(["a", "c", "r"], case_sensitive=False),
148
+ default="a",
149
+ show_choices=True,
150
+ show_default=True,
151
+ )
152
+ .strip()
153
+ .lower()
154
+ )
155
+ except (EOFError, KeyboardInterrupt):
156
+ console.print("\n[red]Aborted by user.[/red]")
157
+ sys.exit(0)
158
+
159
+ if choice == "a":
160
+ console.print("[yellow]Commit aborted.[/yellow]")
161
+ sys.exit(0)
162
+ elif choice == "c":
163
+ console.print("[bold yellow]⚠️ Continuing with potential secrets in commit...[/bold yellow]")
164
+ logger.warning("User chose to continue despite detected secrets")
165
+ return True
166
+ elif choice == "r":
167
+ affected_files = get_affected_files(secrets)
168
+ for file_path in affected_files:
169
+ try:
170
+ run_git_command(["reset", "HEAD", file_path])
171
+ console.print(f"[green]Unstaged: {file_path}[/green]")
172
+ except GitError as e:
173
+ console.print(f"[red]Failed to unstage {file_path}: {e}[/red]")
174
+
175
+ # Check if there are still staged files
176
+ remaining_staged = get_staged_files(existing_only=False)
177
+ if not remaining_staged:
178
+ console.print("[yellow]No files remain staged. Commit aborted.[/yellow]")
179
+ sys.exit(0)
180
+
181
+ console.print(f"[green]Continuing with {len(remaining_staged)} staged file(s)...[/green]")
182
+ return False
183
+
184
+ return True
@@ -0,0 +1,423 @@
1
+ #!/usr/bin/env python3
2
+ """Grouped commit workflow handling for gac."""
3
+
4
+ import json
5
+ import logging
6
+ import subprocess
7
+ import sys
8
+ from collections import Counter
9
+ from typing import Any, NamedTuple
10
+
11
+ import click
12
+ from rich.console import Console
13
+ from rich.panel import Panel
14
+
15
+ from gac.ai import generate_grouped_commits
16
+ from gac.ai_utils import count_tokens
17
+ from gac.config import GACConfig
18
+ from gac.constants import EnvDefaults
19
+ from gac.errors import AIError, ConfigError, GitError
20
+ from gac.git import detect_rename_mappings, get_staged_files, run_git_command
21
+ from gac.git_state_validator import GitState
22
+ from gac.workflow_utils import check_token_warning, execute_commit, restore_staging
23
+
24
+ logger = logging.getLogger(__name__)
25
+ console = Console()
26
+
27
+
28
+ class GroupedCommitResult(NamedTuple):
29
+ """Result of grouped commit generation."""
30
+
31
+ commits: list[dict[str, Any]]
32
+ raw_response: str
33
+
34
+
35
+ class GroupedCommitWorkflow:
36
+ """Handles multi-file grouping logic and per-group AI calls."""
37
+
38
+ def __init__(self, config: GACConfig):
39
+ self.config = config
40
+
41
+ def validate_grouped_files_or_feedback(
42
+ self, staged: set[str], grouped_result: dict[str, Any]
43
+ ) -> tuple[bool, str, str]:
44
+ """Validate that grouped commits cover all staged files correctly."""
45
+ commits = grouped_result.get("commits", []) if isinstance(grouped_result, dict) else []
46
+ all_files: list[str] = []
47
+ for commit in commits:
48
+ files = commit.get("files", []) if isinstance(commit, dict) else []
49
+ all_files.extend([str(p) for p in files])
50
+
51
+ counts = Counter(all_files)
52
+ union_set = set(all_files)
53
+
54
+ duplicates = sorted([f for f, c in counts.items() if c > 1])
55
+ missing = sorted(staged - union_set)
56
+ unexpected = sorted(union_set - staged)
57
+
58
+ if not duplicates and not missing and not unexpected:
59
+ return True, "", ""
60
+
61
+ problems: list[str] = []
62
+ if missing:
63
+ problems.append(f"Missing: {', '.join(missing)}")
64
+ if unexpected:
65
+ problems.append(f"Not staged: {', '.join(unexpected)}")
66
+ if duplicates:
67
+ problems.append(f"Duplicates: {', '.join(duplicates)}")
68
+
69
+ feedback = f"{'; '.join(problems)}. Required files: {', '.join(sorted(staged))}. Respond with ONLY valid JSON."
70
+ return False, feedback, "; ".join(problems)
71
+
72
+ def handle_validation_retry(
73
+ self,
74
+ attempts: int,
75
+ content_retry_budget: int,
76
+ raw_response: str,
77
+ feedback_message: str,
78
+ error_message: str,
79
+ conversation_messages: list[dict[str, str]],
80
+ quiet: bool,
81
+ retry_context: str,
82
+ ) -> bool:
83
+ """Handle validation retry logic. Returns True if should exit, False if should retry."""
84
+ conversation_messages.append({"role": "assistant", "content": raw_response})
85
+ conversation_messages.append({"role": "user", "content": feedback_message})
86
+ if attempts >= content_retry_budget:
87
+ logger.error(error_message)
88
+ console.print(f"\n[red]{error_message}[/red]")
89
+ console.print("\n[yellow]Raw model output:[/yellow]")
90
+ console.print(Panel(raw_response, title="Model Output", border_style="yellow"))
91
+ return True
92
+ if not quiet:
93
+ console.print(f"[yellow]Retry {attempts} of {content_retry_budget - 1}: {retry_context}[/yellow]")
94
+ return False
95
+
96
+ def parse_and_validate_json_response(self, raw_response: str) -> dict[str, Any] | None:
97
+ """Parse and validate JSON response from AI."""
98
+ parsed: dict[str, Any] | None = None
99
+ extract = raw_response
100
+ first_brace = raw_response.find("{")
101
+ last_brace = raw_response.rfind("}")
102
+ if first_brace != -1 and last_brace != -1 and first_brace < last_brace:
103
+ extract = raw_response[first_brace : last_brace + 1]
104
+
105
+ try:
106
+ parsed = json.loads(extract)
107
+ except json.JSONDecodeError as e:
108
+ parsed = None
109
+ logger.debug(
110
+ f"JSON parsing failed: {e}. Extract length: {len(extract)}, Response length: {len(raw_response)}"
111
+ )
112
+
113
+ if parsed is None:
114
+ raise ValueError("Invalid JSON response")
115
+
116
+ # Validate structure
117
+ if "commits" not in parsed or not isinstance(parsed["commits"], list):
118
+ raise ValueError("Response missing 'commits' array")
119
+ if len(parsed["commits"]) == 0:
120
+ raise ValueError("No commits in response")
121
+ for idx, commit in enumerate(parsed["commits"]):
122
+ if "files" not in commit or not isinstance(commit["files"], list):
123
+ raise ValueError(f"Commit {idx + 1} missing 'files' array")
124
+ if "message" not in commit or not isinstance(commit["message"], str):
125
+ raise ValueError(f"Commit {idx + 1} missing 'message' string")
126
+ if len(commit["files"]) == 0:
127
+ raise ValueError(f"Commit {idx + 1} has empty files list")
128
+ if not commit["message"].strip():
129
+ raise ValueError(f"Commit {idx + 1} has empty message")
130
+
131
+ return parsed
132
+
133
+ def generate_grouped_commits_with_retry(
134
+ self,
135
+ model: str,
136
+ conversation_messages: list[dict[str, str]],
137
+ temperature: float,
138
+ max_output_tokens: int,
139
+ max_retries: int,
140
+ quiet: bool,
141
+ staged_files_set: set[str],
142
+ require_confirmation: bool = True,
143
+ ) -> GroupedCommitResult:
144
+ """Generate grouped commits with validation and retry logic."""
145
+ first_iteration = True
146
+ content_retry_budget = max(3, int(max_retries))
147
+ attempts = 0
148
+
149
+ warning_limit_val = self.config.get("warning_limit_tokens", EnvDefaults.WARNING_LIMIT_TOKENS)
150
+ if warning_limit_val is None:
151
+ raise ConfigError("warning_limit_tokens configuration missing")
152
+ warning_limit = int(warning_limit_val)
153
+
154
+ while True:
155
+ prompt_tokens = count_tokens(conversation_messages, model)
156
+
157
+ if first_iteration:
158
+ if not check_token_warning(prompt_tokens, warning_limit, require_confirmation):
159
+ sys.exit(0)
160
+ first_iteration = False
161
+
162
+ raw_response = generate_grouped_commits(
163
+ model=model,
164
+ prompt=conversation_messages,
165
+ temperature=temperature,
166
+ max_tokens=max_output_tokens,
167
+ max_retries=max_retries,
168
+ quiet=quiet,
169
+ skip_success_message=True,
170
+ )
171
+
172
+ try:
173
+ parsed = self.parse_and_validate_json_response(raw_response)
174
+ except ValueError as e:
175
+ attempts += 1
176
+ 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'."
177
+ error_msg = f"Invalid grouped commits structure after {attempts} retries: {e}"
178
+ if self.handle_validation_retry(
179
+ attempts,
180
+ content_retry_budget,
181
+ raw_response,
182
+ feedback,
183
+ error_msg,
184
+ conversation_messages,
185
+ quiet,
186
+ "Structure validation failed, asking model to fix...",
187
+ ):
188
+ sys.exit(1)
189
+ continue
190
+
191
+ # Assert parsed is not None for mypy - ValueError would have been raised earlier
192
+ assert parsed is not None
193
+ ok, feedback, detail_msg = self.validate_grouped_files_or_feedback(staged_files_set, parsed)
194
+ if not ok:
195
+ attempts += 1
196
+ error_msg = f"Grouped commits file set mismatch after {attempts} retries{': ' + detail_msg if detail_msg else ''}"
197
+ if self.handle_validation_retry(
198
+ attempts,
199
+ content_retry_budget,
200
+ raw_response,
201
+ feedback,
202
+ error_msg,
203
+ conversation_messages,
204
+ quiet,
205
+ "File coverage mismatch, asking model to fix...",
206
+ ):
207
+ sys.exit(1)
208
+ continue
209
+
210
+ conversation_messages.append({"role": "assistant", "content": raw_response})
211
+ # Assert parsed is not None for mypy - ValueError would have been raised earlier
212
+ assert parsed is not None
213
+ return GroupedCommitResult(commits=parsed["commits"], raw_response=raw_response)
214
+
215
+ def display_grouped_commits(self, result: GroupedCommitResult, model: str, prompt_tokens: int, quiet: bool) -> None:
216
+ """Display the generated grouped commits to the user."""
217
+ provider, model_name = model.split(":", 1)
218
+
219
+ if not quiet:
220
+ console.print(f"[green]✔ Generated commit messages with {provider} {model_name}[/green]")
221
+ num_commits = len(result.commits)
222
+ console.print(f"[bold green]Proposed Commits ({num_commits}):[/bold green]\n")
223
+ for idx, commit in enumerate(result.commits, 1):
224
+ files = commit["files"]
225
+ files_display = ", ".join(files)
226
+ console.print(f"[dim]{files_display}[/dim]")
227
+ commit_msg = commit["message"].strip()
228
+ console.print(Panel(commit_msg, title=f"Commit Message {idx}/{num_commits}", border_style="cyan"))
229
+ console.print()
230
+
231
+ completion_tokens = count_tokens(result.raw_response, model)
232
+ total_tokens = prompt_tokens + completion_tokens
233
+ console.print(
234
+ f"[dim]Token usage: {prompt_tokens} prompt + {completion_tokens} completion = {total_tokens} total[/dim]"
235
+ )
236
+
237
+ def handle_grouped_commit_confirmation(self, result: GroupedCommitResult) -> bool:
238
+ """Handle user confirmation for grouped commits. Returns True if accepted."""
239
+ num_commits = len(result.commits)
240
+ while True:
241
+ response = click.prompt(
242
+ f"Proceed with {num_commits} commits above? [y/n/r/<feedback>]",
243
+ type=str,
244
+ show_default=False,
245
+ ).strip()
246
+ response_lower = response.lower()
247
+
248
+ if response_lower in ["y", "yes"]:
249
+ return True
250
+ if response_lower in ["n", "no"]:
251
+ console.print("[yellow]Commits not accepted. Exiting...[/yellow]")
252
+ sys.exit(0)
253
+ if response == "":
254
+ continue
255
+ if response_lower in ["r", "reroll"]:
256
+ console.print("[cyan]Regenerating commit groups...[/cyan]")
257
+ return False # Signal to regenerate
258
+
259
+ def execute_grouped_commits(
260
+ self,
261
+ result: GroupedCommitResult,
262
+ dry_run: bool,
263
+ push: bool,
264
+ no_verify: bool,
265
+ hook_timeout: int,
266
+ ) -> None:
267
+ """Execute the grouped commits by creating multiple individual commits."""
268
+ num_commits = len(result.commits)
269
+
270
+ if dry_run:
271
+ console.print(f"[yellow]Dry run: Would create {num_commits} commits[/yellow]")
272
+ for idx, commit in enumerate(result.commits, 1):
273
+ console.print(f"\n[cyan]Commit {idx}/{num_commits}:[/cyan]")
274
+ console.print(f" Files: {', '.join(commit['files'])}")
275
+ console.print(f" Message: {commit['message'].strip()[:50]}...")
276
+ else:
277
+ original_staged_files = get_staged_files(existing_only=False)
278
+ original_staged_diff = run_git_command(["diff", "--cached", "--binary"], silent=True)
279
+ run_git_command(["reset", "HEAD"])
280
+
281
+ try:
282
+ # Detect file renames to handle them properly
283
+ rename_mappings = detect_rename_mappings(original_staged_diff)
284
+
285
+ for idx, commit in enumerate(result.commits, 1):
286
+ try:
287
+ for file_path in commit["files"]:
288
+ # Check if this file is the destination of a rename
289
+ if file_path in rename_mappings:
290
+ old_file = rename_mappings[file_path]
291
+ # For renames, stage both the old file (for deletion) and new file
292
+ # This ensures the complete rename operation is preserved
293
+ run_git_command(["add", "-A", old_file])
294
+ run_git_command(["add", "-A", file_path])
295
+ else:
296
+ run_git_command(["add", "-A", file_path])
297
+ execute_commit(commit["message"].strip(), no_verify, hook_timeout)
298
+ console.print(f"[green]✓ Commit {idx}/{num_commits} created[/green]")
299
+ except (AIError, ConfigError, GitError, subprocess.SubprocessError, OSError) as e:
300
+ console.print(f"[red]✗ Failed at commit {idx}/{num_commits}: {e}[/red]")
301
+ console.print(f"[yellow]Completed {idx - 1}/{num_commits} commits.[/yellow]")
302
+ if idx == 1:
303
+ console.print("[yellow]Restoring original staging area...[/yellow]")
304
+ restore_staging(original_staged_files, original_staged_diff)
305
+ console.print("[green]Original staging area restored.[/green]")
306
+ sys.exit(1)
307
+ except KeyboardInterrupt:
308
+ console.print("\n[yellow]Interrupted by user. Restoring original staging area...[/yellow]")
309
+ restore_staging(original_staged_files, original_staged_diff)
310
+ console.print("[green]Original staging area restored.[/green]")
311
+ sys.exit(1)
312
+
313
+ if push:
314
+ try:
315
+ if dry_run:
316
+ console.print("[yellow]Dry run: Would push changes[/yellow]")
317
+ sys.exit(0)
318
+ from gac.git import push_changes
319
+
320
+ if push_changes():
321
+ logger.info("Changes pushed successfully")
322
+ console.print("[green]Changes pushed successfully[/green]")
323
+ else:
324
+ console.print(
325
+ "[red]Failed to push changes. Check your remote configuration and network connection.[/red]"
326
+ )
327
+ sys.exit(1)
328
+ except (GitError, OSError) as e:
329
+ console.print(f"[red]Error pushing changes: {e}[/red]")
330
+ sys.exit(1)
331
+
332
+ sys.exit(0)
333
+
334
+ def execute_workflow(
335
+ self,
336
+ system_prompt: str,
337
+ user_prompt: str,
338
+ model: str,
339
+ temperature: float,
340
+ max_output_tokens: int,
341
+ max_retries: int,
342
+ require_confirmation: bool,
343
+ quiet: bool,
344
+ no_verify: bool,
345
+ dry_run: bool,
346
+ push: bool,
347
+ show_prompt: bool,
348
+ interactive: bool,
349
+ message_only: bool,
350
+ git_state: GitState,
351
+ hint: str,
352
+ hook_timeout: int = 120,
353
+ ) -> None:
354
+ """Execute the complete grouped commit workflow."""
355
+ if show_prompt:
356
+ full_prompt = f"SYSTEM PROMPT:\n{system_prompt}\n\nUSER PROMPT:\n{user_prompt}"
357
+ console.print(Panel(full_prompt, title="Prompt for LLM", border_style="bright_blue"))
358
+
359
+ conversation_messages: list[dict[str, str]] = []
360
+ if system_prompt:
361
+ conversation_messages.append({"role": "system", "content": system_prompt})
362
+ conversation_messages.append({"role": "user", "content": user_prompt})
363
+
364
+ # Get staged files for validation
365
+ staged_files_set = set(get_staged_files(existing_only=False))
366
+
367
+ # Handle interactive questions if enabled
368
+ if interactive and not message_only:
369
+ from gac.interactive_mode import InteractiveMode
370
+
371
+ interactive_mode = InteractiveMode(self.config)
372
+ interactive_mode.handle_interactive_flow(
373
+ model=model,
374
+ user_prompt=user_prompt,
375
+ git_state=git_state,
376
+ hint=hint,
377
+ conversation_messages=conversation_messages,
378
+ temperature=temperature,
379
+ max_tokens=max_output_tokens,
380
+ max_retries=max_retries,
381
+ quiet=quiet,
382
+ )
383
+
384
+ while True:
385
+ # Generate grouped commits
386
+ result = self.generate_grouped_commits_with_retry(
387
+ model=model,
388
+ conversation_messages=conversation_messages,
389
+ temperature=temperature,
390
+ max_output_tokens=max_output_tokens,
391
+ max_retries=max_retries,
392
+ quiet=quiet,
393
+ staged_files_set=staged_files_set,
394
+ require_confirmation=require_confirmation,
395
+ )
396
+
397
+ # Display results
398
+ prompt_tokens = count_tokens(conversation_messages, model)
399
+ self.display_grouped_commits(result, model, prompt_tokens, quiet)
400
+
401
+ # Handle confirmation
402
+ if require_confirmation:
403
+ if self.handle_grouped_commit_confirmation(result):
404
+ # User accepted, execute commits
405
+ self.execute_grouped_commits(
406
+ result=result,
407
+ dry_run=dry_run,
408
+ push=push,
409
+ no_verify=no_verify,
410
+ hook_timeout=hook_timeout,
411
+ )
412
+ else:
413
+ # User wants to regenerate, continue loop
414
+ continue
415
+ else:
416
+ # No confirmation required, execute directly
417
+ self.execute_grouped_commits(
418
+ result=result,
419
+ dry_run=dry_run,
420
+ push=push,
421
+ no_verify=no_verify,
422
+ hook_timeout=hook_timeout,
423
+ )
gac/init_cli.py ADDED
@@ -0,0 +1,70 @@
1
+ """CLI for initializing gac configuration interactively."""
2
+
3
+ from pathlib import Path
4
+ from typing import cast
5
+
6
+ import click
7
+ import questionary
8
+ from dotenv import dotenv_values
9
+
10
+ from gac.language_cli import configure_language_init_workflow
11
+ from gac.model_cli import _configure_model
12
+
13
+ GAC_ENV_PATH = Path.home() / ".gac.env"
14
+
15
+
16
+ def _prompt_required_text(prompt: str) -> str | None:
17
+ """Prompt until a non-empty string is provided or the user cancels."""
18
+ while True:
19
+ response = questionary.text(prompt).ask()
20
+ if response is None:
21
+ return None
22
+ value = response.strip()
23
+ if value:
24
+ return cast(str, value)
25
+ click.echo("A value is required. Please try again.")
26
+
27
+
28
+ def _load_existing_env() -> dict[str, str]:
29
+ """Ensure the env file exists and return its current values."""
30
+ existing_env: dict[str, str] = {}
31
+ if GAC_ENV_PATH.exists():
32
+ click.echo(f"$HOME/.gac.env already exists at {GAC_ENV_PATH}. Values will be updated.")
33
+ existing_env = {k: v for k, v in dotenv_values(str(GAC_ENV_PATH)).items() if v is not None}
34
+ else:
35
+ GAC_ENV_PATH.touch()
36
+ click.echo(f"Created $HOME/.gac.env at {GAC_ENV_PATH}.")
37
+ return existing_env
38
+
39
+
40
+ def _configure_language(existing_env: dict[str, str]) -> None:
41
+ """Run the language configuration flow using consolidated logic."""
42
+ click.echo("\n")
43
+
44
+ # Use the consolidated language configuration from language_cli
45
+ success = configure_language_init_workflow(GAC_ENV_PATH)
46
+
47
+ if not success:
48
+ click.echo("Language configuration cancelled or failed.")
49
+ else:
50
+ click.echo("Language configuration completed.")
51
+
52
+
53
+ @click.command()
54
+ def init() -> None:
55
+ """Interactively set up $HOME/.gac.env for gac."""
56
+ click.echo("Welcome to gac initialization!\n")
57
+
58
+ existing_env = _load_existing_env()
59
+
60
+ if not _configure_model(existing_env):
61
+ click.echo("Model configuration cancelled. Exiting.")
62
+ return
63
+
64
+ _configure_language(existing_env)
65
+
66
+ click.echo("\ngac environment setup complete 🎉")
67
+ click.echo("Configuration saved to:")
68
+ click.echo(f" {GAC_ENV_PATH}")
69
+ click.echo("\nYou can now run 'gac' in any Git repository to generate commit messages.")
70
+ click.echo("Run 'gac --help' to see available options.")