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.
- gac/__init__.py +15 -0
- gac/__version__.py +3 -0
- gac/ai.py +109 -0
- gac/ai_utils.py +246 -0
- gac/auth_cli.py +214 -0
- gac/cli.py +218 -0
- gac/commit_executor.py +62 -0
- gac/config.py +125 -0
- gac/config_cli.py +95 -0
- gac/constants.py +328 -0
- gac/diff_cli.py +159 -0
- gac/errors.py +231 -0
- gac/git.py +372 -0
- gac/git_state_validator.py +184 -0
- gac/grouped_commit_workflow.py +423 -0
- gac/init_cli.py +70 -0
- gac/interactive_mode.py +182 -0
- gac/language_cli.py +377 -0
- gac/main.py +476 -0
- gac/model_cli.py +430 -0
- gac/oauth/__init__.py +27 -0
- gac/oauth/claude_code.py +464 -0
- gac/oauth/qwen_oauth.py +327 -0
- gac/oauth/token_store.py +81 -0
- gac/preprocess.py +511 -0
- gac/prompt.py +878 -0
- gac/prompt_builder.py +88 -0
- gac/providers/README.md +437 -0
- gac/providers/__init__.py +80 -0
- gac/providers/anthropic.py +17 -0
- gac/providers/azure_openai.py +57 -0
- gac/providers/base.py +329 -0
- gac/providers/cerebras.py +15 -0
- gac/providers/chutes.py +25 -0
- gac/providers/claude_code.py +79 -0
- gac/providers/custom_anthropic.py +103 -0
- gac/providers/custom_openai.py +44 -0
- gac/providers/deepseek.py +15 -0
- gac/providers/error_handler.py +139 -0
- gac/providers/fireworks.py +15 -0
- gac/providers/gemini.py +90 -0
- gac/providers/groq.py +15 -0
- gac/providers/kimi_coding.py +27 -0
- gac/providers/lmstudio.py +80 -0
- gac/providers/minimax.py +15 -0
- gac/providers/mistral.py +15 -0
- gac/providers/moonshot.py +15 -0
- gac/providers/ollama.py +73 -0
- gac/providers/openai.py +32 -0
- gac/providers/openrouter.py +21 -0
- gac/providers/protocol.py +71 -0
- gac/providers/qwen.py +64 -0
- gac/providers/registry.py +58 -0
- gac/providers/replicate.py +156 -0
- gac/providers/streamlake.py +31 -0
- gac/providers/synthetic.py +40 -0
- gac/providers/together.py +15 -0
- gac/providers/zai.py +31 -0
- gac/py.typed +0 -0
- gac/security.py +293 -0
- gac/utils.py +401 -0
- gac/workflow_utils.py +217 -0
- gac-3.10.3.dist-info/METADATA +283 -0
- gac-3.10.3.dist-info/RECORD +67 -0
- gac-3.10.3.dist-info/WHEEL +4 -0
- gac-3.10.3.dist-info/entry_points.txt +2 -0
- 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.")
|