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.
Files changed (79) hide show
  1. gac/__init__.py +4 -6
  2. gac/__version__.py +1 -1
  3. gac/ai_utils.py +59 -43
  4. gac/auth_cli.py +181 -36
  5. gac/cli.py +26 -9
  6. gac/commit_executor.py +59 -0
  7. gac/config.py +81 -2
  8. gac/config_cli.py +19 -7
  9. gac/constants/__init__.py +34 -0
  10. gac/constants/commit.py +63 -0
  11. gac/constants/defaults.py +40 -0
  12. gac/constants/file_patterns.py +110 -0
  13. gac/constants/languages.py +119 -0
  14. gac/diff_cli.py +0 -22
  15. gac/errors.py +8 -2
  16. gac/git.py +6 -6
  17. gac/git_state_validator.py +193 -0
  18. gac/grouped_commit_workflow.py +458 -0
  19. gac/init_cli.py +2 -1
  20. gac/interactive_mode.py +179 -0
  21. gac/language_cli.py +0 -1
  22. gac/main.py +231 -926
  23. gac/model_cli.py +67 -11
  24. gac/model_identifier.py +70 -0
  25. gac/oauth/__init__.py +26 -0
  26. gac/oauth/claude_code.py +89 -22
  27. gac/oauth/qwen_oauth.py +327 -0
  28. gac/oauth/token_store.py +81 -0
  29. gac/oauth_retry.py +161 -0
  30. gac/postprocess.py +155 -0
  31. gac/prompt.py +21 -479
  32. gac/prompt_builder.py +88 -0
  33. gac/providers/README.md +437 -0
  34. gac/providers/__init__.py +70 -78
  35. gac/providers/anthropic.py +12 -46
  36. gac/providers/azure_openai.py +48 -88
  37. gac/providers/base.py +329 -0
  38. gac/providers/cerebras.py +10 -33
  39. gac/providers/chutes.py +16 -62
  40. gac/providers/claude_code.py +64 -87
  41. gac/providers/custom_anthropic.py +51 -81
  42. gac/providers/custom_openai.py +29 -83
  43. gac/providers/deepseek.py +10 -33
  44. gac/providers/error_handler.py +139 -0
  45. gac/providers/fireworks.py +10 -33
  46. gac/providers/gemini.py +66 -63
  47. gac/providers/groq.py +10 -58
  48. gac/providers/kimi_coding.py +19 -55
  49. gac/providers/lmstudio.py +64 -43
  50. gac/providers/minimax.py +10 -33
  51. gac/providers/mistral.py +10 -33
  52. gac/providers/moonshot.py +10 -33
  53. gac/providers/ollama.py +56 -33
  54. gac/providers/openai.py +30 -36
  55. gac/providers/openrouter.py +15 -52
  56. gac/providers/protocol.py +71 -0
  57. gac/providers/qwen.py +64 -0
  58. gac/providers/registry.py +58 -0
  59. gac/providers/replicate.py +140 -82
  60. gac/providers/streamlake.py +26 -46
  61. gac/providers/synthetic.py +35 -37
  62. gac/providers/together.py +10 -33
  63. gac/providers/zai.py +29 -57
  64. gac/py.typed +0 -0
  65. gac/security.py +1 -1
  66. gac/templates/__init__.py +1 -0
  67. gac/templates/question_generation.txt +60 -0
  68. gac/templates/system_prompt.txt +224 -0
  69. gac/templates/user_prompt.txt +28 -0
  70. gac/utils.py +36 -6
  71. gac/workflow_context.py +162 -0
  72. gac/workflow_utils.py +3 -8
  73. {gac-3.6.0.dist-info → gac-3.10.10.dist-info}/METADATA +6 -4
  74. gac-3.10.10.dist-info/RECORD +79 -0
  75. gac/constants.py +0 -321
  76. gac-3.6.0.dist-info/RECORD +0 -53
  77. {gac-3.6.0.dist-info → gac-3.10.10.dist-info}/WHEEL +0 -0
  78. {gac-3.6.0.dist-info → gac-3.10.10.dist-info}/entry_points.txt +0 -0
  79. {gac-3.6.0.dist-info → gac-3.10.10.dist-info}/licenses/LICENSE +0 -0
gac/__init__.py CHANGED
@@ -1,15 +1,13 @@
1
1
  """Git Auto Commit (gac) - Generate commit messages using AI."""
2
2
 
3
+ from gac import init_cli
3
4
  from gac.__version__ import __version__
4
5
  from gac.ai import generate_commit_message
5
- from gac.git import get_staged_files, push_changes
6
- from gac.prompt import build_prompt, clean_commit_message
6
+ from gac.prompt import build_prompt
7
7
 
8
8
  __all__ = [
9
9
  "__version__",
10
- "generate_commit_message",
11
10
  "build_prompt",
12
- "clean_commit_message",
13
- "get_staged_files",
14
- "push_changes",
11
+ "generate_commit_message",
12
+ "init_cli",
15
13
  ]
gac/__version__.py CHANGED
@@ -1,3 +1,3 @@
1
1
  """Version information for gac package."""
2
2
 
3
- __version__ = "3.6.0"
3
+ __version__ = "3.10.10"
gac/ai_utils.py CHANGED
@@ -3,11 +3,13 @@
3
3
  This module provides utility functions that support the AI provider implementations.
4
4
  """
5
5
 
6
+ import json
6
7
  import logging
7
8
  import os
8
9
  import time
10
+ from collections.abc import Callable
9
11
  from functools import lru_cache
10
- from typing import Any
12
+ from typing import Any, cast
11
13
 
12
14
  import tiktoken
13
15
  from rich.console import Console
@@ -15,6 +17,8 @@ from rich.status import Status
15
17
 
16
18
  from gac.constants import EnvDefaults, Utility
17
19
  from gac.errors import AIError
20
+ from gac.oauth import QwenOAuthProvider, refresh_token_if_expired
21
+ from gac.oauth.token_store import TokenStore
18
22
  from gac.providers import SUPPORTED_PROVIDERS
19
23
 
20
24
  logger = logging.getLogger(__name__)
@@ -40,7 +44,7 @@ def count_tokens(content: str | list[dict[str, str]] | dict[str, Any], model: st
40
44
  try:
41
45
  encoding = get_encoding(model)
42
46
  return len(encoding.encode(text))
43
- except Exception as e:
47
+ except (KeyError, UnicodeError, ValueError) as e:
44
48
  logger.error(f"Error counting tokens: {e}")
45
49
  # Fallback to rough estimation (4 chars per token on average)
46
50
  return len(text) // 4
@@ -53,7 +57,7 @@ def extract_text_content(content: str | list[dict[str, str]] | dict[str, Any]) -
53
57
  elif isinstance(content, list):
54
58
  return "\n".join(msg["content"] for msg in content if isinstance(msg, dict) and "content" in msg)
55
59
  elif isinstance(content, dict) and "content" in content:
56
- return content["content"] # type: ignore[no-any-return]
60
+ return cast(str, content["content"])
57
61
  return ""
58
62
 
59
63
 
@@ -70,36 +74,13 @@ def get_encoding(model: str) -> tiktoken.Encoding:
70
74
  except KeyError:
71
75
  # Fall back to default encoding if model not found
72
76
  return tiktoken.get_encoding(Utility.DEFAULT_ENCODING)
73
- except Exception:
77
+ except (OSError, ConnectionError):
74
78
  # If there are any network/SSL issues, fall back to default encoding
75
79
  return tiktoken.get_encoding(Utility.DEFAULT_ENCODING)
76
80
 
77
81
 
78
- def _classify_error(error_str: str) -> str:
79
- """Classify error types based on error message content."""
80
- error_str = error_str.lower()
81
-
82
- if (
83
- "api key" in error_str
84
- or "unauthorized" in error_str
85
- or "authentication" in error_str
86
- or "invalid api key" in error_str
87
- ):
88
- return "authentication"
89
- elif "timeout" in error_str or "timed out" in error_str or "request timeout" in error_str:
90
- return "timeout"
91
- elif "rate limit" in error_str or "too many requests" in error_str or "rate limit exceeded" in error_str:
92
- return "rate_limit"
93
- elif "connect" in error_str or "network" in error_str or "network connection failed" in error_str:
94
- return "connection"
95
- elif "model" in error_str or "not found" in error_str or "model not found" in error_str:
96
- return "model"
97
- else:
98
- return "unknown"
99
-
100
-
101
82
  def generate_with_retries(
102
- provider_funcs: dict,
83
+ provider_funcs: dict[str, Callable[..., str]],
103
84
  model: str,
104
85
  messages: list[dict[str, str]],
105
86
  temperature: float,
@@ -124,6 +105,48 @@ def generate_with_retries(
124
105
  if not messages:
125
106
  raise AIError.model_error("No messages provided for AI generation")
126
107
 
108
+ # Load Claude Code token from TokenStore if needed
109
+ if provider == "claude-code":
110
+ # Check token expiry and refresh if needed
111
+ if not refresh_token_if_expired(quiet=True):
112
+ raise AIError.authentication_error(
113
+ "Claude Code token not found or expired. Please authenticate with 'gac auth claude-code login'."
114
+ )
115
+
116
+ # Load the (possibly refreshed) token
117
+ token_store = TokenStore()
118
+ token_data = token_store.get_token("claude-code")
119
+ if token_data and "access_token" in token_data:
120
+ os.environ["CLAUDE_CODE_ACCESS_TOKEN"] = token_data["access_token"]
121
+ else:
122
+ raise AIError.authentication_error(
123
+ "Claude Code token not found. Please authenticate with 'gac auth claude-code login'."
124
+ )
125
+
126
+ # Check Qwen OAuth token expiry and refresh if needed
127
+ if provider == "qwen":
128
+ oauth_provider = QwenOAuthProvider(TokenStore())
129
+ token = oauth_provider.get_token()
130
+ if not token:
131
+ if not quiet:
132
+ console.print("[yellow]⚠ Qwen authentication not found or expired[/yellow]")
133
+ console.print("[cyan]🔐 Starting automatic authentication...[/cyan]")
134
+ try:
135
+ oauth_provider.initiate_auth(open_browser=True)
136
+ token = oauth_provider.get_token()
137
+ if not token:
138
+ raise AIError.authentication_error(
139
+ "Qwen authentication failed. Run 'gac auth qwen login' to authenticate manually."
140
+ )
141
+ if not quiet:
142
+ console.print("[green]✓ Authentication successful![/green]\n")
143
+ except AIError:
144
+ raise
145
+ except (ValueError, KeyError, json.JSONDecodeError, ConnectionError, OSError) as e:
146
+ raise AIError.authentication_error(
147
+ f"Qwen authentication failed: {e}. Run 'gac auth qwen login' to authenticate manually."
148
+ ) from e
149
+
127
150
  # Set up spinner
128
151
  if is_group:
129
152
  message_type = f"grouped {task_description}s"
@@ -136,7 +159,7 @@ def generate_with_retries(
136
159
  spinner = Status(f"Generating {message_type} with {provider} {model_name}...")
137
160
  spinner.start()
138
161
 
139
- last_exception = None
162
+ last_exception: Exception | None = None
140
163
  last_error_type = "unknown"
141
164
 
142
165
  for attempt in range(max_retries):
@@ -161,14 +184,14 @@ def generate_with_retries(
161
184
  console.print(f"✓ Generated {message_type} with {provider} {model_name}")
162
185
 
163
186
  if content is not None and content.strip():
164
- return content.strip() # type: ignore[no-any-return]
187
+ return content.strip()
165
188
  else:
166
189
  logger.warning(f"Empty or None content received from {provider} {model_name}: {repr(content)}")
167
190
  raise AIError.model_error("Empty response from AI model")
168
191
 
169
- except Exception as e:
192
+ except AIError as e:
170
193
  last_exception = e
171
- error_type = _classify_error(str(e))
194
+ error_type = e.error_type
172
195
  last_error_type = error_type
173
196
 
174
197
  # For authentication and model errors, don't retry
@@ -176,23 +199,16 @@ def generate_with_retries(
176
199
  if spinner and not skip_success_message:
177
200
  spinner.stop()
178
201
  console.print(f"✗ Failed to generate {message_type} with {provider} {model_name}")
179
-
180
- # Create the appropriate error type based on classification
181
- if error_type == "authentication":
182
- raise AIError.authentication_error(f"AI generation failed: {str(e)}") from e
183
- elif error_type == "model":
184
- raise AIError.model_error(f"AI generation failed: {str(e)}") from e
202
+ raise
185
203
 
186
204
  if attempt < max_retries - 1:
187
205
  # Exponential backoff
188
206
  wait_time = 2**attempt
189
207
  if not quiet and not skip_success_message:
190
208
  if attempt == 0:
191
- logger.warning(f"AI generation failed, retrying in {wait_time}s: {str(e)}")
209
+ logger.warning(f"AI generation failed, retrying in {wait_time}s: {e}")
192
210
  else:
193
- logger.warning(
194
- f"AI generation failed (attempt {attempt + 1}), retrying in {wait_time}s: {str(e)}"
195
- )
211
+ logger.warning(f"AI generation failed (attempt {attempt + 1}), retrying in {wait_time}s: {e}")
196
212
 
197
213
  if spinner and not skip_success_message:
198
214
  for i in range(wait_time, 0, -1):
@@ -203,7 +219,7 @@ def generate_with_retries(
203
219
  else:
204
220
  num_retries = max_retries
205
221
  retry_word = "retry" if num_retries == 1 else "retries"
206
- logger.error(f"AI generation failed after {num_retries} {retry_word}: {str(e)}")
222
+ logger.error(f"AI generation failed after {num_retries} {retry_word}: {e}")
207
223
 
208
224
  if spinner and not skip_success_message:
209
225
  spinner.stop()
gac/auth_cli.py CHANGED
@@ -1,54 +1,95 @@
1
- """CLI for authenticating Claude Code OAuth tokens.
1
+ """CLI for OAuth authentication with various providers.
2
2
 
3
- Provides a command to authenticate and re-authenticate Claude Code subscriptions.
3
+ Provides commands to authenticate and manage OAuth tokens for supported providers.
4
4
  """
5
5
 
6
6
  import logging
7
7
 
8
8
  import click
9
9
 
10
- from gac.oauth.claude_code import authenticate_and_save, load_stored_token
10
+ from gac.oauth import (
11
+ QwenOAuthProvider,
12
+ TokenStore,
13
+ authenticate_and_save,
14
+ remove_token,
15
+ )
11
16
  from gac.utils import setup_logging
12
17
 
13
18
  logger = logging.getLogger(__name__)
14
19
 
15
20
 
16
- @click.command()
17
- @click.option(
18
- "--quiet",
19
- "-q",
20
- is_flag=True,
21
- help="Suppress non-error output",
22
- )
23
- @click.option(
24
- "--log-level",
25
- default="INFO",
26
- type=click.Choice(["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"], case_sensitive=False),
27
- help="Set log level (default: INFO)",
28
- )
29
- def auth(quiet: bool = False, log_level: str = "INFO") -> None:
30
- """Authenticate Claude Code OAuth token.
21
+ @click.group(invoke_without_command=True)
22
+ @click.pass_context
23
+ def auth(ctx: click.Context) -> None:
24
+ """Manage OAuth authentication for AI providers.
31
25
 
32
- This command allows you to authenticate or re-authenticate your
33
- Claude Code OAuth token when it expires or you want to refresh it.
34
- It opens a browser window for the OAuth flow and saves the token
35
- to ~/.gac.env.
26
+ Supports authentication for:
27
+ - claude-code: Claude Code subscription OAuth
28
+ - qwen: Qwen AI OAuth (device flow)
36
29
 
37
- The token is used by the Claude Code provider to access your
38
- Claude Code subscription instead of requiring an Anthropic API key.
30
+ Examples:
31
+ gac auth # Show authentication status
32
+ gac auth claude-code login # Login to Claude Code
33
+ gac auth claude-code logout # Logout from Claude Code
34
+ gac auth claude-code status # Check Claude Code auth status
35
+ gac auth qwen login # Login to Qwen
36
+ gac auth qwen logout # Logout from Qwen
37
+ gac auth qwen status # Check Qwen auth status
39
38
  """
40
- # Setup logging
41
- if quiet:
42
- effective_log_level = "ERROR"
39
+ if ctx.invoked_subcommand is None:
40
+ _show_auth_status()
41
+
42
+
43
+ def _show_auth_status() -> None:
44
+ """Show authentication status for all providers."""
45
+ click.echo("OAuth Authentication Status")
46
+ click.echo("-" * 40)
47
+
48
+ token_store = TokenStore()
49
+
50
+ claude_token = token_store.get_token("claude-code")
51
+ if claude_token:
52
+ click.echo("Claude Code: ✓ Authenticated")
43
53
  else:
44
- effective_log_level = log_level
45
- setup_logging(effective_log_level)
54
+ click.echo("Claude Code: ✗ Not authenticated")
55
+ click.echo(" Run 'gac auth claude-code login' to login")
56
+
57
+ qwen_token = token_store.get_token("qwen")
58
+ if qwen_token:
59
+ click.echo("Qwen: ✓ Authenticated")
60
+ else:
61
+ click.echo("Qwen: ✗ Not authenticated")
62
+ click.echo(" Run 'gac auth qwen login' to login")
63
+
64
+
65
+ # Claude Code commands
66
+ @auth.group("claude-code")
67
+ def claude_code() -> None:
68
+ """Manage Claude Code OAuth authentication.
69
+
70
+ Use browser-based authentication to log in to Claude Code.
71
+ """
72
+ pass
46
73
 
47
- # Check if there's an existing token
48
- existing_token = load_stored_token()
49
- if existing_token and not quiet:
50
- click.echo("✓ Found existing Claude Code access token.")
51
- click.echo()
74
+
75
+ @claude_code.command("login")
76
+ @click.option("--quiet", "-q", is_flag=True, help="Suppress non-error output")
77
+ def claude_code_login(quiet: bool = False) -> None:
78
+ """Login to Claude Code using OAuth.
79
+
80
+ Opens a browser to authenticate with Claude Code. The token is stored
81
+ securely in ~/.gac/oauth/claude-code.json.
82
+ """
83
+ if not quiet:
84
+ setup_logging("INFO")
85
+
86
+ token_store = TokenStore()
87
+ existing_token = token_store.get_token("claude-code")
88
+ if existing_token:
89
+ if not quiet:
90
+ click.echo("✓ Already authenticated with Claude Code.")
91
+ if not click.confirm("Re-authenticate?"):
92
+ return
52
93
 
53
94
  if not quiet:
54
95
  click.echo("🔐 Starting Claude Code OAuth authentication...")
@@ -56,14 +97,118 @@ def auth(quiet: bool = False, log_level: str = "INFO") -> None:
56
97
  click.echo(" (Waiting up to 3 minutes for callback)")
57
98
  click.echo()
58
99
 
59
- # Perform OAuth authentication
60
100
  success = authenticate_and_save(quiet=quiet)
61
101
 
62
102
  if success:
63
103
  if not quiet:
104
+ click.echo()
64
105
  click.echo("✅ Claude Code authentication completed successfully!")
65
- click.echo(" Your new token has been saved and is ready to use.")
66
106
  else:
67
107
  click.echo("❌ Claude Code authentication failed.")
68
108
  click.echo(" Please try again or check your network connection.")
69
109
  raise click.ClickException("Claude Code authentication failed")
110
+
111
+
112
+ @claude_code.command("logout")
113
+ @click.option("--quiet", "-q", is_flag=True, help="Suppress non-error output")
114
+ def claude_code_logout(quiet: bool = False) -> None:
115
+ """Logout from Claude Code and remove stored tokens."""
116
+ token_store = TokenStore()
117
+ existing_token = token_store.get_token("claude-code")
118
+
119
+ if not existing_token:
120
+ if not quiet:
121
+ click.echo("Not currently authenticated with Claude Code.")
122
+ return
123
+
124
+ try:
125
+ remove_token()
126
+ if not quiet:
127
+ click.echo("✅ Successfully logged out from Claude Code.")
128
+ except Exception as e:
129
+ click.echo("❌ Failed to remove Claude Code token.")
130
+ raise click.ClickException("Claude Code logout failed") from e
131
+
132
+
133
+ @claude_code.command("status")
134
+ def claude_code_status() -> None:
135
+ """Check Claude Code authentication status."""
136
+ token_store = TokenStore()
137
+ token = token_store.get_token("claude-code")
138
+
139
+ if token:
140
+ click.echo("Claude Code Authentication Status: ✓ Authenticated")
141
+ else:
142
+ click.echo("Claude Code Authentication Status: ✗ Not authenticated")
143
+ click.echo("Run 'gac auth claude-code login' to authenticate.")
144
+
145
+
146
+ # Qwen commands
147
+ @auth.group()
148
+ def qwen() -> None:
149
+ """Manage Qwen OAuth authentication.
150
+
151
+ Use device flow authentication to log in to Qwen AI.
152
+ """
153
+ pass
154
+
155
+
156
+ @qwen.command("login")
157
+ @click.option("--no-browser", is_flag=True, help="Don't automatically open browser")
158
+ @click.option("--quiet", "-q", is_flag=True, help="Suppress non-error output")
159
+ def qwen_login(no_browser: bool = False, quiet: bool = False) -> None:
160
+ """Login to Qwen using OAuth device flow.
161
+
162
+ Opens a browser to authenticate with Qwen. The token is stored
163
+ securely in ~/.gac/oauth/qwen.json.
164
+ """
165
+ if not quiet:
166
+ setup_logging("INFO")
167
+
168
+ provider = QwenOAuthProvider()
169
+
170
+ if provider.is_authenticated():
171
+ if not quiet:
172
+ click.echo("✓ Already authenticated with Qwen.")
173
+ if not click.confirm("Re-authenticate?"):
174
+ return
175
+
176
+ try:
177
+ provider.initiate_auth(open_browser=not no_browser)
178
+ if not quiet:
179
+ click.echo()
180
+ click.echo("✅ Qwen authentication completed successfully!")
181
+ except Exception as e:
182
+ click.echo(f"❌ Qwen authentication failed: {e}")
183
+ raise click.ClickException("Qwen authentication failed") from None
184
+
185
+
186
+ @qwen.command("logout")
187
+ @click.option("--quiet", "-q", is_flag=True, help="Suppress non-error output")
188
+ def qwen_logout(quiet: bool = False) -> None:
189
+ """Logout from Qwen and remove stored tokens."""
190
+ provider = QwenOAuthProvider()
191
+
192
+ if not provider.is_authenticated():
193
+ if not quiet:
194
+ click.echo("Not currently authenticated with Qwen.")
195
+ return
196
+
197
+ provider.logout()
198
+ if not quiet:
199
+ click.echo("✅ Successfully logged out from Qwen.")
200
+
201
+
202
+ @qwen.command("status")
203
+ def qwen_status() -> None:
204
+ """Check Qwen authentication status."""
205
+ provider = QwenOAuthProvider()
206
+ token = provider.get_token()
207
+
208
+ if token:
209
+ click.echo("Qwen Authentication Status: ✓ Authenticated")
210
+ if token.get("resource_url"):
211
+ click.echo(f"API Endpoint: {token['resource_url']}")
212
+ else:
213
+ click.echo("Qwen Authentication Status: ✗ Not authenticated")
214
+ click.echo("Run 'gac auth qwen login' to authenticate.")
gac/cli.py CHANGED
@@ -6,14 +6,16 @@ Defines the Click-based command-line interface and delegates execution to the ma
6
6
  """
7
7
 
8
8
  import logging
9
+ import os
9
10
  import sys
11
+ from typing import Any
10
12
 
11
13
  import click
12
14
  from rich.console import Console
13
15
 
14
16
  from gac import __version__
15
17
  from gac.auth_cli import auth as auth_cli
16
- from gac.config import load_config
18
+ from gac.config import GACConfig, load_config
17
19
  from gac.config_cli import config as config_cli
18
20
  from gac.constants import Languages, Logging
19
21
  from gac.diff_cli import diff as diff_cli
@@ -23,8 +25,9 @@ from gac.language_cli import language as language_cli
23
25
  from gac.main import main
24
26
  from gac.model_cli import model as model_cli
25
27
  from gac.utils import setup_logging
28
+ from gac.workflow_context import CLIOptions
26
29
 
27
- config = load_config()
30
+ config: GACConfig = load_config()
28
31
  logger = logging.getLogger(__name__)
29
32
  console = Console()
30
33
 
@@ -73,6 +76,11 @@ console = Console()
73
76
  # Advanced options
74
77
  @click.option("--no-verify", is_flag=True, help="Skip pre-commit and lefthook hooks when committing")
75
78
  @click.option("--skip-secret-scan", is_flag=True, help="Skip security scan for secrets in staged changes")
79
+ @click.option(
80
+ "--no-verify-ssl",
81
+ is_flag=True,
82
+ help="Skip SSL certificate verification (useful for corporate proxies)",
83
+ )
76
84
  @click.option(
77
85
  "--hook-timeout",
78
86
  type=int,
@@ -103,6 +111,7 @@ def cli(
103
111
  verbose: bool = False,
104
112
  no_verify: bool = False,
105
113
  skip_secret_scan: bool = False,
114
+ no_verify_ssl: bool = False,
106
115
  hook_timeout: int = 0,
107
116
  ) -> None:
108
117
  """Git Auto Commit - Generate commit messages with AI."""
@@ -116,6 +125,11 @@ def cli(
116
125
  setup_logging(effective_log_level)
117
126
  logger.info("Starting gac")
118
127
 
128
+ # Set SSL verification environment variable if flag is used or config is set
129
+ if no_verify_ssl or config["no_verify_ssl"]:
130
+ os.environ["GAC_NO_VERIFY_SSL"] = "true"
131
+ logger.info("SSL certificate verification disabled")
132
+
119
133
  # Validate incompatible flag combinations
120
134
  if message_only and group:
121
135
  console.print("[red]Error: --message-only and --group options are mutually exclusive[/red]")
@@ -124,16 +138,16 @@ def cli(
124
138
  sys.exit(1)
125
139
 
126
140
  # Determine if we should infer scope based on -s flag or always_include_scope setting
127
- infer_scope = bool(scope or config.get("always_include_scope", False))
141
+ infer_scope = bool(scope or config["always_include_scope"])
128
142
 
129
143
  # Determine if verbose mode should be enabled based on -v flag or verbose config setting
130
- use_verbose = bool(verbose or config.get("verbose", False))
144
+ use_verbose = bool(verbose or config["verbose"])
131
145
 
132
146
  # Resolve language code to full name if provided
133
147
  resolved_language = Languages.resolve_code(language) if language else None
134
148
 
135
149
  try:
136
- main(
150
+ opts = CLIOptions(
137
151
  stage_all=add_all,
138
152
  group=group,
139
153
  interactive=interactive,
@@ -149,15 +163,17 @@ def cli(
149
163
  message_only=message_only,
150
164
  verbose=use_verbose,
151
165
  no_verify=no_verify,
152
- skip_secret_scan=skip_secret_scan or bool(config.get("skip_secret_scan", False)),
166
+ skip_secret_scan=skip_secret_scan or config["skip_secret_scan"],
153
167
  language=resolved_language,
154
- hook_timeout=hook_timeout if hook_timeout > 0 else int(config.get("hook_timeout", 120) or 120),
168
+ hook_timeout=hook_timeout if hook_timeout > 0 else config["hook_timeout"],
155
169
  )
170
+ exit_code = main(opts)
171
+ sys.exit(exit_code)
156
172
  except Exception as e:
157
173
  handle_error(e, exit_program=True)
158
174
  else:
159
175
  # Determine if we should infer scope based on -s flag or always_include_scope setting
160
- infer_scope = bool(scope or config.get("always_include_scope", False))
176
+ infer_scope = bool(scope or config["always_include_scope"])
161
177
 
162
178
  ctx.obj = {
163
179
  "add_all": add_all,
@@ -179,6 +195,7 @@ def cli(
179
195
  "verbose": verbose,
180
196
  "no_verify": no_verify,
181
197
  "skip_secret_scan": skip_secret_scan,
198
+ "no_verify_ssl": no_verify_ssl,
182
199
  "hook_timeout": hook_timeout,
183
200
  }
184
201
 
@@ -193,7 +210,7 @@ cli.add_command(model_cli)
193
210
 
194
211
  @click.command(context_settings=language_cli.context_settings)
195
212
  @click.pass_context
196
- def lang(ctx):
213
+ def lang(ctx: Any) -> None:
197
214
  """Set the language for commit messages interactively. (Alias for 'language')"""
198
215
  ctx.forward(language_cli)
199
216
 
gac/commit_executor.py ADDED
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env python3
2
+ """Commit execution logic for gac."""
3
+
4
+ import logging
5
+
6
+ from rich.console import Console
7
+
8
+ from gac.errors import GitError
9
+ from gac.git import get_staged_files, push_changes
10
+ from gac.workflow_utils import execute_commit
11
+
12
+ logger = logging.getLogger(__name__)
13
+ console = Console()
14
+
15
+
16
+ class CommitExecutor:
17
+ """Handles commit creation and related operations."""
18
+
19
+ def __init__(self, dry_run: bool = False, quiet: bool = False, no_verify: bool = False, hook_timeout: int = 120):
20
+ self.dry_run = dry_run
21
+ self.quiet = quiet
22
+ self.no_verify = no_verify
23
+ self.hook_timeout = hook_timeout
24
+
25
+ def create_commit(self, commit_message: str) -> None:
26
+ """Create a single commit with the given message."""
27
+ if self.dry_run:
28
+ console.print("[yellow]Dry run: Commit message generated but not applied[/yellow]")
29
+ console.print("Would commit with message:")
30
+ from rich.panel import Panel
31
+
32
+ console.print(Panel(commit_message, title="Commit Message", border_style="cyan"))
33
+ staged_files = get_staged_files(existing_only=False)
34
+ console.print(f"Would commit {len(staged_files)} files")
35
+ logger.info(f"Would commit {len(staged_files)} files")
36
+ else:
37
+ execute_commit(commit_message, self.no_verify, self.hook_timeout)
38
+
39
+ def push_to_remote(self) -> None:
40
+ """Push changes to remote repository.
41
+
42
+ Raises:
43
+ GitError: If push fails or remote is not configured.
44
+ """
45
+ if self.dry_run:
46
+ staged_files = get_staged_files(existing_only=False)
47
+ logger.info("Dry run: Would push changes")
48
+ logger.info(f"Would push {len(staged_files)} files")
49
+ console.print("[yellow]Dry run: Would push changes[/yellow]")
50
+ console.print(f"Would push {len(staged_files)} files")
51
+ return
52
+
53
+ if push_changes():
54
+ logger.info("Changes pushed successfully")
55
+ if not self.quiet:
56
+ console.print("[green]Changes pushed successfully[/green]")
57
+ else:
58
+ console.print("[red]Failed to push changes. Check your remote configuration and network connection.[/red]")
59
+ raise GitError("Failed to push changes")