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.
Files changed (53) hide show
  1. gac/__version__.py +1 -1
  2. gac/ai.py +69 -123
  3. gac/ai_utils.py +227 -0
  4. gac/auth_cli.py +69 -0
  5. gac/cli.py +87 -19
  6. gac/config.py +13 -7
  7. gac/config_cli.py +26 -5
  8. gac/constants.py +176 -5
  9. gac/errors.py +14 -0
  10. gac/git.py +207 -11
  11. gac/init_cli.py +52 -29
  12. gac/language_cli.py +378 -0
  13. gac/main.py +922 -189
  14. gac/model_cli.py +374 -0
  15. gac/oauth/__init__.py +1 -0
  16. gac/oauth/claude_code.py +397 -0
  17. gac/preprocess.py +5 -5
  18. gac/prompt.py +656 -219
  19. gac/providers/__init__.py +88 -0
  20. gac/providers/anthropic.py +51 -0
  21. gac/providers/azure_openai.py +97 -0
  22. gac/providers/cerebras.py +38 -0
  23. gac/providers/chutes.py +71 -0
  24. gac/providers/claude_code.py +102 -0
  25. gac/providers/custom_anthropic.py +133 -0
  26. gac/providers/custom_openai.py +98 -0
  27. gac/providers/deepseek.py +38 -0
  28. gac/providers/fireworks.py +38 -0
  29. gac/providers/gemini.py +87 -0
  30. gac/providers/groq.py +63 -0
  31. gac/providers/kimi_coding.py +63 -0
  32. gac/providers/lmstudio.py +59 -0
  33. gac/providers/minimax.py +38 -0
  34. gac/providers/mistral.py +38 -0
  35. gac/providers/moonshot.py +38 -0
  36. gac/providers/ollama.py +50 -0
  37. gac/providers/openai.py +38 -0
  38. gac/providers/openrouter.py +58 -0
  39. gac/providers/replicate.py +98 -0
  40. gac/providers/streamlake.py +51 -0
  41. gac/providers/synthetic.py +42 -0
  42. gac/providers/together.py +38 -0
  43. gac/providers/zai.py +59 -0
  44. gac/security.py +293 -0
  45. gac/utils.py +243 -4
  46. gac/workflow_utils.py +222 -0
  47. gac-3.6.0.dist-info/METADATA +281 -0
  48. gac-3.6.0.dist-info/RECORD +53 -0
  49. {gac-0.17.2.dist-info → gac-3.6.0.dist-info}/WHEEL +1 -1
  50. {gac-0.17.2.dist-info → gac-3.6.0.dist-info}/licenses/LICENSE +1 -1
  51. gac-0.17.2.dist-info/METADATA +0 -221
  52. gac-0.17.2.dist-info/RECORD +0 -20
  53. {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 = subprocess.check_output(["git", "rev-parse", "--show-toplevel"])
94
- return result.decode().strip()
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 = subprocess.check_output(["git", "rev-parse", "--abbrev-ref", "HEAD"])
100
- return result.decode().strip()
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 = subprocess.check_output(["git", "rev-parse", "HEAD"])
106
- return result.decode().strip()
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
- result = run_subprocess(["pre-commit", "--version"], silent=True, raise_on_error=False)
124
- if not result:
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 = subprocess.run(["pre-commit", "run"], capture_output=True, text=True, check=False)
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 set_key
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
- @click.command()
13
- def init() -> None:
14
- """Interactively set up $HOME/.gac.env for gac."""
15
- click.echo("Welcome to gac initialization!\n")
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
- providers = [
23
- ("Anthropic", "claude-3-5-haiku-latest"),
24
- ("Cerebras", "qwen-3-coder-480b"),
25
- ("Groq", "meta-llama/llama-4-maverick-17b-128e-instruct"),
26
- ("Ollama", "gemma3"),
27
- ("OpenAI", "gpt-4.1-mini"),
28
- ]
29
- provider_names = [p[0] for p in providers]
30
- provider = questionary.select("Select your provider:", choices=provider_names).ask()
31
- if not provider:
32
- click.echo("Provider selection cancelled. Exiting.")
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
- provider_key = provider.lower()
35
- model_suggestion = dict(providers)[provider]
36
- model = questionary.text(f"Enter the model (default: {model_suggestion}):", default=model_suggestion).ask()
37
- model_to_save = model.strip() if model.strip() else model_suggestion
38
- set_key(str(GAC_ENV_PATH), "GAC_MODEL", f"{provider_key}:{model_to_save}")
39
- click.echo(f"Set GAC_MODEL={provider_key}:{model_to_save}")
40
-
41
- api_key = questionary.password("Enter your API key (input hidden, can be set later):").ask()
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.")