gac 1.13.0__py3-none-any.whl → 3.8.1__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 (54) hide show
  1. gac/__version__.py +1 -1
  2. gac/ai.py +33 -47
  3. gac/ai_utils.py +113 -41
  4. gac/auth_cli.py +214 -0
  5. gac/cli.py +72 -2
  6. gac/config.py +63 -6
  7. gac/config_cli.py +26 -5
  8. gac/constants.py +178 -2
  9. gac/git.py +158 -12
  10. gac/init_cli.py +40 -125
  11. gac/language_cli.py +378 -0
  12. gac/main.py +868 -158
  13. gac/model_cli.py +429 -0
  14. gac/oauth/__init__.py +27 -0
  15. gac/oauth/claude_code.py +464 -0
  16. gac/oauth/qwen_oauth.py +323 -0
  17. gac/oauth/token_store.py +81 -0
  18. gac/preprocess.py +3 -3
  19. gac/prompt.py +573 -226
  20. gac/providers/__init__.py +49 -0
  21. gac/providers/anthropic.py +11 -1
  22. gac/providers/azure_openai.py +101 -0
  23. gac/providers/cerebras.py +11 -1
  24. gac/providers/chutes.py +11 -1
  25. gac/providers/claude_code.py +112 -0
  26. gac/providers/custom_anthropic.py +6 -2
  27. gac/providers/custom_openai.py +6 -3
  28. gac/providers/deepseek.py +11 -1
  29. gac/providers/fireworks.py +11 -1
  30. gac/providers/gemini.py +11 -1
  31. gac/providers/groq.py +5 -1
  32. gac/providers/kimi_coding.py +67 -0
  33. gac/providers/lmstudio.py +12 -1
  34. gac/providers/minimax.py +11 -1
  35. gac/providers/mistral.py +48 -0
  36. gac/providers/moonshot.py +48 -0
  37. gac/providers/ollama.py +11 -1
  38. gac/providers/openai.py +11 -1
  39. gac/providers/openrouter.py +11 -1
  40. gac/providers/qwen.py +76 -0
  41. gac/providers/replicate.py +110 -0
  42. gac/providers/streamlake.py +11 -1
  43. gac/providers/synthetic.py +11 -1
  44. gac/providers/together.py +11 -1
  45. gac/providers/zai.py +11 -1
  46. gac/security.py +1 -1
  47. gac/utils.py +272 -4
  48. gac/workflow_utils.py +217 -0
  49. {gac-1.13.0.dist-info → gac-3.8.1.dist-info}/METADATA +90 -27
  50. gac-3.8.1.dist-info/RECORD +56 -0
  51. {gac-1.13.0.dist-info → gac-3.8.1.dist-info}/WHEEL +1 -1
  52. gac-1.13.0.dist-info/RECORD +0 -41
  53. {gac-1.13.0.dist-info → gac-3.8.1.dist-info}/entry_points.txt +0 -0
  54. {gac-1.13.0.dist-info → gac-3.8.1.dist-info}/licenses/LICENSE +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:
@@ -126,9 +225,9 @@ def run_pre_commit_hooks() -> bool:
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,7 +252,7 @@ def run_pre_commit_hooks() -> bool:
153
252
  return True
154
253
 
155
254
 
156
- def run_lefthook_hooks() -> bool:
255
+ def run_lefthook_hooks(hook_timeout: int = 120) -> bool:
157
256
  """Run Lefthook hooks if they exist.
158
257
 
159
258
  Returns:
@@ -176,9 +275,9 @@ def run_lefthook_hooks() -> bool:
176
275
  return True
177
276
 
178
277
  # Run lefthook hooks on staged files
179
- logger.info("Running Lefthook hooks...")
278
+ logger.info(f"Running Lefthook hooks with {hook_timeout}s timeout...")
180
279
  # Run lefthook and capture both stdout and stderr
181
- result = subprocess.run(["lefthook", "run", "pre-commit"], capture_output=True, text=True, check=False)
280
+ result = run_subprocess_with_encoding_fallback(["lefthook", "run", "pre-commit"], timeout=hook_timeout)
182
281
 
183
282
  if result.returncode == 0:
184
283
  # All hooks passed
@@ -224,3 +323,50 @@ def push_changes() -> bool:
224
323
  except Exception as e:
225
324
  logger.error(f"Failed to push changes: {e}")
226
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,7 +4,10 @@ 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
 
@@ -21,134 +24,46 @@ def _prompt_required_text(prompt: str) -> str | None:
21
24
  click.echo("A value is required. Please try again.")
22
25
 
23
26
 
24
- @click.command()
25
- def init() -> None:
26
- """Interactively set up $HOME/.gac.env for gac."""
27
- click.echo("Welcome to gac initialization!\n")
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] = {}
28
30
  if GAC_ENV_PATH.exists():
29
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}
30
33
  else:
31
34
  GAC_ENV_PATH.touch()
32
35
  click.echo(f"Created $HOME/.gac.env at {GAC_ENV_PATH}.")
36
+ return existing_env
33
37
 
34
- providers = [
35
- ("Anthropic", "claude-haiku-4-5"),
36
- ("Cerebras", "qwen-3-coder-480b"),
37
- ("Chutes", "zai-org/GLM-4.6-FP8"),
38
- ("Custom (Anthropic)", ""),
39
- ("Custom (OpenAI)", ""),
40
- ("DeepSeek", "deepseek-chat"),
41
- ("Fireworks", "accounts/fireworks/models/gpt-oss-20b"),
42
- ("Gemini", "gemini-2.5-flash"),
43
- ("Groq", "meta-llama/llama-4-maverick-17b-128e-instruct"),
44
- ("LM Studio", "gemma3"),
45
- ("MiniMax", "MiniMax-M2"),
46
- ("Ollama", "gemma3"),
47
- ("OpenAI", "gpt-4.1-mini"),
48
- ("OpenRouter", "openrouter/auto"),
49
- ("Streamlake", ""),
50
- ("Synthetic", "hf:zai-org/GLM-4.6"),
51
- ("Together AI", "openai/gpt-oss-20B"),
52
- ("Z.AI", "glm-4.5-air"),
53
- ("Z.AI Coding", "glm-4.6"),
54
- ]
55
- provider_names = [p[0] for p in providers]
56
- provider = questionary.select("Select your provider:", choices=provider_names).ask()
57
- if not provider:
58
- click.echo("Provider selection cancelled. Exiting.")
59
- return
60
- provider_key = provider.lower().replace(".", "").replace(" ", "-").replace("(", "").replace(")", "")
61
-
62
- is_ollama = provider_key == "ollama"
63
- is_lmstudio = provider_key == "lm-studio"
64
- is_streamlake = provider_key == "streamlake"
65
- is_zai = provider_key in ("zai", "zai-coding")
66
- is_custom_anthropic = provider_key == "custom-anthropic"
67
- is_custom_openai = provider_key == "custom-openai"
68
-
69
- if is_streamlake:
70
- endpoint_id = _prompt_required_text("Enter the Streamlake inference endpoint ID (required):")
71
- if endpoint_id is None:
72
- click.echo("Streamlake configuration cancelled. Exiting.")
73
- return
74
- model_to_save = endpoint_id
38
+
39
+ def _configure_language(existing_env: dict[str, str]) -> None:
40
+ """Run the language configuration flow using consolidated logic."""
41
+ click.echo("\n")
42
+
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.")
75
48
  else:
76
- model_suggestion = dict(providers)[provider]
77
- if model_suggestion == "":
78
- model_prompt = "Enter the model (required):"
79
- else:
80
- model_prompt = f"Enter the model (default: {model_suggestion}):"
81
- model = questionary.text(model_prompt, default=model_suggestion).ask()
82
- if model is None:
83
- click.echo("Model entry cancelled. Exiting.")
84
- return
85
- model_to_save = model.strip() if model.strip() else model_suggestion
86
-
87
- set_key(str(GAC_ENV_PATH), "GAC_MODEL", f"{provider_key}:{model_to_save}")
88
- click.echo(f"Set GAC_MODEL={provider_key}:{model_to_save}")
89
-
90
- if is_custom_anthropic:
91
- base_url = _prompt_required_text("Enter the custom Anthropic-compatible base URL (required):")
92
- if base_url is None:
93
- click.echo("Custom Anthropic base URL entry cancelled. Exiting.")
94
- return
95
- set_key(str(GAC_ENV_PATH), "CUSTOM_ANTHROPIC_BASE_URL", base_url)
96
- click.echo(f"Set CUSTOM_ANTHROPIC_BASE_URL={base_url}")
97
-
98
- api_version = questionary.text(
99
- "Enter the API version (optional, press Enter for default: 2023-06-01):", default="2023-06-01"
100
- ).ask()
101
- if api_version and api_version != "2023-06-01":
102
- set_key(str(GAC_ENV_PATH), "CUSTOM_ANTHROPIC_VERSION", api_version)
103
- click.echo(f"Set CUSTOM_ANTHROPIC_VERSION={api_version}")
104
- elif is_custom_openai:
105
- base_url = _prompt_required_text("Enter the custom OpenAI-compatible base URL (required):")
106
- if base_url is None:
107
- click.echo("Custom OpenAI base URL entry cancelled. Exiting.")
108
- return
109
- set_key(str(GAC_ENV_PATH), "CUSTOM_OPENAI_BASE_URL", base_url)
110
- click.echo(f"Set CUSTOM_OPENAI_BASE_URL={base_url}")
111
- elif is_ollama:
112
- url_default = "http://localhost:11434"
113
- url = questionary.text(f"Enter the Ollama API URL (default: {url_default}):", default=url_default).ask()
114
- if url is None:
115
- click.echo("Ollama URL entry cancelled. Exiting.")
116
- return
117
- url_to_save = url.strip() if url.strip() else url_default
118
- set_key(str(GAC_ENV_PATH), "OLLAMA_API_URL", url_to_save)
119
- click.echo(f"Set OLLAMA_API_URL={url_to_save}")
120
- elif is_lmstudio:
121
- url_default = "http://localhost:1234"
122
- url = questionary.text(f"Enter the LM Studio API URL (default: {url_default}):", default=url_default).ask()
123
- if url is None:
124
- click.echo("LM Studio URL entry cancelled. Exiting.")
125
- return
126
- url_to_save = url.strip() if url.strip() else url_default
127
- set_key(str(GAC_ENV_PATH), "LMSTUDIO_API_URL", url_to_save)
128
- click.echo(f"Set LMSTUDIO_API_URL={url_to_save}")
129
-
130
- api_key_prompt = "Enter your API key (input hidden, can be set later):"
131
- if is_ollama or is_lmstudio:
132
- click.echo(
133
- "This provider typically runs locally. API keys are optional unless your instance requires authentication."
134
- )
135
- api_key_prompt = "Enter your API key (optional, press Enter to skip):"
136
-
137
- api_key = questionary.password(api_key_prompt).ask()
138
- if api_key:
139
- if is_lmstudio:
140
- api_key_name = "LMSTUDIO_API_KEY"
141
- elif is_zai:
142
- api_key_name = "ZAI_API_KEY"
143
- elif is_custom_anthropic:
144
- api_key_name = "CUSTOM_ANTHROPIC_API_KEY"
145
- elif is_custom_openai:
146
- api_key_name = "CUSTOM_OPENAI_API_KEY"
147
- else:
148
- api_key_name = f"{provider_key.upper()}_API_KEY"
149
- set_key(str(GAC_ENV_PATH), api_key_name, api_key)
150
- click.echo(f"Set {api_key_name} (hidden)")
151
- elif is_ollama or is_lmstudio:
152
- click.echo("Skipping API key. You can add one later if needed.")
153
-
154
- click.echo(f"\ngac environment setup complete. You can edit {GAC_ENV_PATH} to update values later.")
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.")
61
+ return
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.")