gac 0.17.2__py3-none-any.whl → 3.6.0__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/__version__.py +1 -1
- gac/ai.py +69 -123
- gac/ai_utils.py +227 -0
- gac/auth_cli.py +69 -0
- gac/cli.py +87 -19
- gac/config.py +13 -7
- gac/config_cli.py +26 -5
- gac/constants.py +176 -5
- gac/errors.py +14 -0
- gac/git.py +207 -11
- gac/init_cli.py +52 -29
- gac/language_cli.py +378 -0
- gac/main.py +922 -189
- gac/model_cli.py +374 -0
- gac/oauth/__init__.py +1 -0
- gac/oauth/claude_code.py +397 -0
- gac/preprocess.py +5 -5
- gac/prompt.py +656 -219
- gac/providers/__init__.py +88 -0
- gac/providers/anthropic.py +51 -0
- gac/providers/azure_openai.py +97 -0
- gac/providers/cerebras.py +38 -0
- gac/providers/chutes.py +71 -0
- gac/providers/claude_code.py +102 -0
- gac/providers/custom_anthropic.py +133 -0
- gac/providers/custom_openai.py +98 -0
- gac/providers/deepseek.py +38 -0
- gac/providers/fireworks.py +38 -0
- gac/providers/gemini.py +87 -0
- gac/providers/groq.py +63 -0
- gac/providers/kimi_coding.py +63 -0
- gac/providers/lmstudio.py +59 -0
- gac/providers/minimax.py +38 -0
- gac/providers/mistral.py +38 -0
- gac/providers/moonshot.py +38 -0
- gac/providers/ollama.py +50 -0
- gac/providers/openai.py +38 -0
- gac/providers/openrouter.py +58 -0
- gac/providers/replicate.py +98 -0
- gac/providers/streamlake.py +51 -0
- gac/providers/synthetic.py +42 -0
- gac/providers/together.py +38 -0
- gac/providers/zai.py +59 -0
- gac/security.py +293 -0
- gac/utils.py +243 -4
- gac/workflow_utils.py +222 -0
- gac-3.6.0.dist-info/METADATA +281 -0
- gac-3.6.0.dist-info/RECORD +53 -0
- {gac-0.17.2.dist-info → gac-3.6.0.dist-info}/WHEEL +1 -1
- {gac-0.17.2.dist-info → gac-3.6.0.dist-info}/licenses/LICENSE +1 -1
- gac-0.17.2.dist-info/METADATA +0 -221
- gac-0.17.2.dist-info/RECORD +0 -20
- {gac-0.17.2.dist-info → gac-3.6.0.dist-info}/entry_points.txt +0 -0
gac/git.py
CHANGED
|
@@ -14,6 +14,63 @@ from gac.utils import run_subprocess
|
|
|
14
14
|
logger = logging.getLogger(__name__)
|
|
15
15
|
|
|
16
16
|
|
|
17
|
+
def run_subprocess_with_encoding_fallback(
|
|
18
|
+
command: list[str], silent: bool = False, timeout: int = 60
|
|
19
|
+
) -> subprocess.CompletedProcess:
|
|
20
|
+
"""Run subprocess with encoding fallback, returning full CompletedProcess object.
|
|
21
|
+
|
|
22
|
+
This is used for cases where we need both stdout and stderr separately,
|
|
23
|
+
like pre-commit and lefthook hook execution.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
command: List of command arguments
|
|
27
|
+
silent: If True, suppress debug logging
|
|
28
|
+
timeout: Command timeout in seconds
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
CompletedProcess object with stdout, stderr, and returncode
|
|
32
|
+
"""
|
|
33
|
+
from gac.utils import get_safe_encodings
|
|
34
|
+
|
|
35
|
+
encodings = get_safe_encodings()
|
|
36
|
+
last_exception: Exception | None = None
|
|
37
|
+
|
|
38
|
+
for encoding in encodings:
|
|
39
|
+
try:
|
|
40
|
+
if not silent:
|
|
41
|
+
logger.debug(f"Running command: {' '.join(command)} (encoding: {encoding})")
|
|
42
|
+
|
|
43
|
+
result = subprocess.run(
|
|
44
|
+
command,
|
|
45
|
+
capture_output=True,
|
|
46
|
+
text=True,
|
|
47
|
+
check=False,
|
|
48
|
+
timeout=timeout,
|
|
49
|
+
encoding=encoding,
|
|
50
|
+
errors="replace",
|
|
51
|
+
)
|
|
52
|
+
return result
|
|
53
|
+
except UnicodeError as e:
|
|
54
|
+
last_exception = e
|
|
55
|
+
if not silent:
|
|
56
|
+
logger.debug(f"Failed to decode with {encoding}: {e}")
|
|
57
|
+
continue
|
|
58
|
+
except subprocess.TimeoutExpired:
|
|
59
|
+
raise
|
|
60
|
+
except Exception as e:
|
|
61
|
+
if not silent:
|
|
62
|
+
logger.debug(f"Command error: {e}")
|
|
63
|
+
# Try next encoding for non-timeout errors
|
|
64
|
+
last_exception = e
|
|
65
|
+
continue
|
|
66
|
+
|
|
67
|
+
# If we get here, all encodings failed
|
|
68
|
+
if last_exception:
|
|
69
|
+
raise subprocess.CalledProcessError(1, command, "", f"Encoding error: {last_exception}") from last_exception
|
|
70
|
+
else:
|
|
71
|
+
raise subprocess.CalledProcessError(1, command, "", "All encoding attempts failed")
|
|
72
|
+
|
|
73
|
+
|
|
17
74
|
def run_git_command(args: list[str], silent: bool = False, timeout: int = 30) -> str:
|
|
18
75
|
"""Run a git command and return the output."""
|
|
19
76
|
command = ["git"] + args
|
|
@@ -50,6 +107,48 @@ def get_staged_files(file_type: str | None = None, existing_only: bool = False)
|
|
|
50
107
|
return []
|
|
51
108
|
|
|
52
109
|
|
|
110
|
+
def get_staged_status() -> str:
|
|
111
|
+
"""Get formatted status of staged files only, excluding unstaged/untracked files.
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
Formatted status string with M/A/D/R indicators
|
|
115
|
+
"""
|
|
116
|
+
try:
|
|
117
|
+
output = run_git_command(["diff", "--name-status", "--staged"])
|
|
118
|
+
if not output:
|
|
119
|
+
return "No changes staged for commit."
|
|
120
|
+
|
|
121
|
+
status_map = {
|
|
122
|
+
"M": "modified",
|
|
123
|
+
"A": "new file",
|
|
124
|
+
"D": "deleted",
|
|
125
|
+
"R": "renamed",
|
|
126
|
+
"C": "copied",
|
|
127
|
+
"T": "typechange",
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
status_lines = ["Changes to be committed:"]
|
|
131
|
+
for line in output.splitlines():
|
|
132
|
+
line = line.strip()
|
|
133
|
+
if not line:
|
|
134
|
+
continue
|
|
135
|
+
|
|
136
|
+
# Parse status line (e.g., "M\tfile.py" or "R100\told.py\tnew.py")
|
|
137
|
+
parts = line.split("\t")
|
|
138
|
+
if len(parts) < 2:
|
|
139
|
+
continue
|
|
140
|
+
|
|
141
|
+
change_type = parts[0][0] # First char is the status (M, A, D, R, etc.)
|
|
142
|
+
file_path = parts[-1] # Last part is the new/current file path
|
|
143
|
+
|
|
144
|
+
status_label = status_map.get(change_type, "modified")
|
|
145
|
+
status_lines.append(f"\t{status_label}: {file_path}")
|
|
146
|
+
|
|
147
|
+
return "\n".join(status_lines)
|
|
148
|
+
except GitError:
|
|
149
|
+
return "No changes staged for commit."
|
|
150
|
+
|
|
151
|
+
|
|
53
152
|
def get_diff(staged: bool = True, color: bool = True, commit1: str | None = None, commit2: str | None = None) -> str:
|
|
54
153
|
"""Get the diff between commits or working tree.
|
|
55
154
|
|
|
@@ -90,23 +189,23 @@ def get_diff(staged: bool = True, color: bool = True, commit1: str | None = None
|
|
|
90
189
|
|
|
91
190
|
def get_repo_root() -> str:
|
|
92
191
|
"""Get absolute path of repository root."""
|
|
93
|
-
result =
|
|
94
|
-
return result
|
|
192
|
+
result = run_git_command(["rev-parse", "--show-toplevel"])
|
|
193
|
+
return result
|
|
95
194
|
|
|
96
195
|
|
|
97
196
|
def get_current_branch() -> str:
|
|
98
197
|
"""Get name of current git branch."""
|
|
99
|
-
result =
|
|
100
|
-
return result
|
|
198
|
+
result = run_git_command(["rev-parse", "--abbrev-ref", "HEAD"])
|
|
199
|
+
return result
|
|
101
200
|
|
|
102
201
|
|
|
103
202
|
def get_commit_hash() -> str:
|
|
104
203
|
"""Get SHA-1 hash of current commit."""
|
|
105
|
-
result =
|
|
106
|
-
return result
|
|
204
|
+
result = run_git_command(["rev-parse", "HEAD"])
|
|
205
|
+
return result
|
|
107
206
|
|
|
108
207
|
|
|
109
|
-
def run_pre_commit_hooks() -> bool:
|
|
208
|
+
def run_pre_commit_hooks(hook_timeout: int = 120) -> bool:
|
|
110
209
|
"""Run pre-commit hooks if they exist.
|
|
111
210
|
|
|
112
211
|
Returns:
|
|
@@ -120,15 +219,15 @@ def run_pre_commit_hooks() -> bool:
|
|
|
120
219
|
# Check if pre-commit is installed and configured
|
|
121
220
|
try:
|
|
122
221
|
# First check if pre-commit is installed
|
|
123
|
-
|
|
124
|
-
if not
|
|
222
|
+
version_check = run_subprocess(["pre-commit", "--version"], silent=True, raise_on_error=False)
|
|
223
|
+
if not version_check:
|
|
125
224
|
logger.debug("pre-commit not installed, skipping hooks")
|
|
126
225
|
return True
|
|
127
226
|
|
|
128
227
|
# Run pre-commit hooks on staged files
|
|
129
|
-
logger.info("Running pre-commit hooks...")
|
|
228
|
+
logger.info(f"Running pre-commit hooks with {hook_timeout}s timeout...")
|
|
130
229
|
# Run pre-commit and capture both stdout and stderr
|
|
131
|
-
result =
|
|
230
|
+
result = run_subprocess_with_encoding_fallback(["pre-commit", "run"], timeout=hook_timeout)
|
|
132
231
|
|
|
133
232
|
if result.returncode == 0:
|
|
134
233
|
# All hooks passed
|
|
@@ -153,6 +252,56 @@ def run_pre_commit_hooks() -> bool:
|
|
|
153
252
|
return True
|
|
154
253
|
|
|
155
254
|
|
|
255
|
+
def run_lefthook_hooks(hook_timeout: int = 120) -> bool:
|
|
256
|
+
"""Run Lefthook hooks if they exist.
|
|
257
|
+
|
|
258
|
+
Returns:
|
|
259
|
+
True if Lefthook hooks passed or don't exist, False if they failed.
|
|
260
|
+
"""
|
|
261
|
+
# Check for common Lefthook configuration files
|
|
262
|
+
lefthook_configs = [".lefthook.yml", "lefthook.yml", ".lefthook.yaml", "lefthook.yaml"]
|
|
263
|
+
config_exists = any(os.path.exists(config) for config in lefthook_configs)
|
|
264
|
+
|
|
265
|
+
if not config_exists:
|
|
266
|
+
logger.debug("No Lefthook configuration found, skipping Lefthook hooks")
|
|
267
|
+
return True
|
|
268
|
+
|
|
269
|
+
# Check if lefthook is installed and configured
|
|
270
|
+
try:
|
|
271
|
+
# First check if lefthook is installed
|
|
272
|
+
version_check = run_subprocess(["lefthook", "--version"], silent=True, raise_on_error=False)
|
|
273
|
+
if not version_check:
|
|
274
|
+
logger.debug("Lefthook not installed, skipping hooks")
|
|
275
|
+
return True
|
|
276
|
+
|
|
277
|
+
# Run lefthook hooks on staged files
|
|
278
|
+
logger.info(f"Running Lefthook hooks with {hook_timeout}s timeout...")
|
|
279
|
+
# Run lefthook and capture both stdout and stderr
|
|
280
|
+
result = run_subprocess_with_encoding_fallback(["lefthook", "run", "pre-commit"], timeout=hook_timeout)
|
|
281
|
+
|
|
282
|
+
if result.returncode == 0:
|
|
283
|
+
# All hooks passed
|
|
284
|
+
return True
|
|
285
|
+
else:
|
|
286
|
+
# Lefthook hooks failed - show the output
|
|
287
|
+
output = result.stdout if result.stdout else ""
|
|
288
|
+
error = result.stderr if result.stderr else ""
|
|
289
|
+
|
|
290
|
+
# Combine outputs (lefthook usually outputs to stdout)
|
|
291
|
+
full_output = output + ("\n" + error if error else "")
|
|
292
|
+
|
|
293
|
+
if full_output.strip():
|
|
294
|
+
# Show which hooks failed and why
|
|
295
|
+
logger.error(f"Lefthook hooks failed:\n{full_output}")
|
|
296
|
+
else:
|
|
297
|
+
logger.error(f"Lefthook hooks failed with exit code {result.returncode}")
|
|
298
|
+
return False
|
|
299
|
+
except Exception as e:
|
|
300
|
+
logger.debug(f"Error running Lefthook: {e}")
|
|
301
|
+
# If lefthook isn't available, don't block the commit
|
|
302
|
+
return True
|
|
303
|
+
|
|
304
|
+
|
|
156
305
|
def push_changes() -> bool:
|
|
157
306
|
"""Push committed changes to the remote repository."""
|
|
158
307
|
remote_exists = run_git_command(["remote"])
|
|
@@ -174,3 +323,50 @@ def push_changes() -> bool:
|
|
|
174
323
|
except Exception as e:
|
|
175
324
|
logger.error(f"Failed to push changes: {e}")
|
|
176
325
|
return False
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def detect_rename_mappings(staged_diff: str) -> dict[str, str]:
|
|
329
|
+
"""Detect file rename mappings from a staged diff.
|
|
330
|
+
|
|
331
|
+
Args:
|
|
332
|
+
staged_diff: The output of 'git diff --cached --binary'
|
|
333
|
+
|
|
334
|
+
Returns:
|
|
335
|
+
Dictionary mapping new_file_path -> old_file_path for rename operations
|
|
336
|
+
"""
|
|
337
|
+
rename_mappings = {}
|
|
338
|
+
lines = staged_diff.split("\n")
|
|
339
|
+
|
|
340
|
+
i = 0
|
|
341
|
+
while i < len(lines):
|
|
342
|
+
line = lines[i]
|
|
343
|
+
|
|
344
|
+
if line.startswith("diff --git a/"):
|
|
345
|
+
# Extract old and new file paths from diff header
|
|
346
|
+
if " b/" in line:
|
|
347
|
+
parts = line.split(" a/")
|
|
348
|
+
if len(parts) >= 2:
|
|
349
|
+
old_path_part = parts[1]
|
|
350
|
+
old_path = old_path_part.split(" b/")[0] if " b/" in old_path_part else old_path_part
|
|
351
|
+
|
|
352
|
+
new_path = line.split(" b/")[-1] if " b/" in line else None
|
|
353
|
+
|
|
354
|
+
# Check if this diff represents a rename by looking at following lines
|
|
355
|
+
j = i + 1
|
|
356
|
+
is_rename = False
|
|
357
|
+
|
|
358
|
+
while j < len(lines) and not lines[j].startswith("diff --git"):
|
|
359
|
+
if lines[j].startswith("similarity index "):
|
|
360
|
+
is_rename = True
|
|
361
|
+
break
|
|
362
|
+
elif lines[j].startswith("rename from "):
|
|
363
|
+
is_rename = True
|
|
364
|
+
break
|
|
365
|
+
j += 1
|
|
366
|
+
|
|
367
|
+
if is_rename and old_path and new_path and old_path != new_path:
|
|
368
|
+
rename_mappings[new_path] = old_path
|
|
369
|
+
|
|
370
|
+
i += 1
|
|
371
|
+
|
|
372
|
+
return rename_mappings
|
gac/init_cli.py
CHANGED
|
@@ -4,43 +4,66 @@ from pathlib import Path
|
|
|
4
4
|
|
|
5
5
|
import click
|
|
6
6
|
import questionary
|
|
7
|
-
from dotenv import
|
|
7
|
+
from dotenv import dotenv_values
|
|
8
|
+
|
|
9
|
+
from gac.language_cli import configure_language_init_workflow
|
|
10
|
+
from gac.model_cli import _configure_model
|
|
8
11
|
|
|
9
12
|
GAC_ENV_PATH = Path.home() / ".gac.env"
|
|
10
13
|
|
|
11
14
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
15
|
+
def _prompt_required_text(prompt: str) -> str | None:
|
|
16
|
+
"""Prompt until a non-empty string is provided or the user cancels."""
|
|
17
|
+
while True:
|
|
18
|
+
response = questionary.text(prompt).ask()
|
|
19
|
+
if response is None:
|
|
20
|
+
return None
|
|
21
|
+
value = response.strip()
|
|
22
|
+
if value:
|
|
23
|
+
return value # type: ignore[no-any-return]
|
|
24
|
+
click.echo("A value is required. Please try again.")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _load_existing_env() -> dict[str, str]:
|
|
28
|
+
"""Ensure the env file exists and return its current values."""
|
|
29
|
+
existing_env: dict[str, str] = {}
|
|
16
30
|
if GAC_ENV_PATH.exists():
|
|
17
31
|
click.echo(f"$HOME/.gac.env already exists at {GAC_ENV_PATH}. Values will be updated.")
|
|
32
|
+
existing_env = {k: v for k, v in dotenv_values(str(GAC_ENV_PATH)).items() if v is not None}
|
|
18
33
|
else:
|
|
19
34
|
GAC_ENV_PATH.touch()
|
|
20
35
|
click.echo(f"Created $HOME/.gac.env at {GAC_ENV_PATH}.")
|
|
36
|
+
return existing_env
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _configure_language(existing_env: dict[str, str]) -> None:
|
|
40
|
+
"""Run the language configuration flow using consolidated logic."""
|
|
41
|
+
click.echo("\n")
|
|
21
42
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
("
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
43
|
+
# Use the consolidated language configuration from language_cli
|
|
44
|
+
success = configure_language_init_workflow(GAC_ENV_PATH)
|
|
45
|
+
|
|
46
|
+
if not success:
|
|
47
|
+
click.echo("Language configuration cancelled or failed.")
|
|
48
|
+
else:
|
|
49
|
+
click.echo("Language configuration completed.")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@click.command()
|
|
53
|
+
def init() -> None:
|
|
54
|
+
"""Interactively set up $HOME/.gac.env for gac."""
|
|
55
|
+
click.echo("Welcome to gac initialization!\n")
|
|
56
|
+
|
|
57
|
+
existing_env = _load_existing_env()
|
|
58
|
+
|
|
59
|
+
if not _configure_model(existing_env):
|
|
60
|
+
click.echo("Model configuration cancelled. Exiting.")
|
|
33
61
|
return
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
click.echo(f"
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
if api_key:
|
|
43
|
-
set_key(str(GAC_ENV_PATH), f"{provider_key.upper()}_API_KEY", api_key)
|
|
44
|
-
click.echo(f"Set {provider_key.upper()}_API_KEY (hidden)")
|
|
45
|
-
|
|
46
|
-
click.echo(f"\ngac environment setup complete. You can edit {GAC_ENV_PATH} to update values later.")
|
|
62
|
+
|
|
63
|
+
_configure_language(existing_env)
|
|
64
|
+
|
|
65
|
+
click.echo("\ngac environment setup complete 🎉")
|
|
66
|
+
click.echo("Configuration saved to:")
|
|
67
|
+
click.echo(f" {GAC_ENV_PATH}")
|
|
68
|
+
click.echo("\nYou can now run 'gac' in any Git repository to generate commit messages.")
|
|
69
|
+
click.echo("Run 'gac --help' to see available options.")
|