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