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.
- gac/__version__.py +1 -1
- gac/ai.py +33 -47
- gac/ai_utils.py +113 -41
- gac/auth_cli.py +214 -0
- gac/cli.py +72 -2
- gac/config.py +63 -6
- gac/config_cli.py +26 -5
- gac/constants.py +178 -2
- gac/git.py +158 -12
- gac/init_cli.py +40 -125
- gac/language_cli.py +378 -0
- gac/main.py +868 -158
- gac/model_cli.py +429 -0
- gac/oauth/__init__.py +27 -0
- gac/oauth/claude_code.py +464 -0
- gac/oauth/qwen_oauth.py +323 -0
- gac/oauth/token_store.py +81 -0
- gac/preprocess.py +3 -3
- gac/prompt.py +573 -226
- gac/providers/__init__.py +49 -0
- gac/providers/anthropic.py +11 -1
- gac/providers/azure_openai.py +101 -0
- gac/providers/cerebras.py +11 -1
- gac/providers/chutes.py +11 -1
- gac/providers/claude_code.py +112 -0
- gac/providers/custom_anthropic.py +6 -2
- gac/providers/custom_openai.py +6 -3
- gac/providers/deepseek.py +11 -1
- gac/providers/fireworks.py +11 -1
- gac/providers/gemini.py +11 -1
- gac/providers/groq.py +5 -1
- gac/providers/kimi_coding.py +67 -0
- gac/providers/lmstudio.py +12 -1
- gac/providers/minimax.py +11 -1
- gac/providers/mistral.py +48 -0
- gac/providers/moonshot.py +48 -0
- gac/providers/ollama.py +11 -1
- gac/providers/openai.py +11 -1
- gac/providers/openrouter.py +11 -1
- gac/providers/qwen.py +76 -0
- gac/providers/replicate.py +110 -0
- gac/providers/streamlake.py +11 -1
- gac/providers/synthetic.py +11 -1
- gac/providers/together.py +11 -1
- gac/providers/zai.py +11 -1
- gac/security.py +1 -1
- gac/utils.py +272 -4
- gac/workflow_utils.py +217 -0
- {gac-1.13.0.dist-info → gac-3.8.1.dist-info}/METADATA +90 -27
- gac-3.8.1.dist-info/RECORD +56 -0
- {gac-1.13.0.dist-info → gac-3.8.1.dist-info}/WHEEL +1 -1
- gac-1.13.0.dist-info/RECORD +0 -41
- {gac-1.13.0.dist-info → gac-3.8.1.dist-info}/entry_points.txt +0 -0
- {gac-1.13.0.dist-info → gac-3.8.1.dist-info}/licenses/LICENSE +0 -0
gac/providers/zai.py
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
"""Z.AI API provider for gac."""
|
|
2
2
|
|
|
3
|
+
import logging
|
|
3
4
|
import os
|
|
4
5
|
|
|
5
6
|
import httpx
|
|
6
7
|
|
|
8
|
+
from gac.constants import ProviderDefaults
|
|
7
9
|
from gac.errors import AIError
|
|
10
|
+
from gac.utils import get_ssl_verify
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
8
13
|
|
|
9
14
|
|
|
10
15
|
def _call_zai_api_impl(
|
|
@@ -18,8 +23,12 @@ def _call_zai_api_impl(
|
|
|
18
23
|
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
|
19
24
|
data = {"model": model, "messages": messages, "temperature": temperature, "max_tokens": max_tokens}
|
|
20
25
|
|
|
26
|
+
logger.debug(f"Calling {api_name} API with model={model}")
|
|
27
|
+
|
|
21
28
|
try:
|
|
22
|
-
response = httpx.post(
|
|
29
|
+
response = httpx.post(
|
|
30
|
+
url, headers=headers, json=data, timeout=ProviderDefaults.HTTP_TIMEOUT, verify=get_ssl_verify()
|
|
31
|
+
)
|
|
23
32
|
response.raise_for_status()
|
|
24
33
|
response_data = response.json()
|
|
25
34
|
|
|
@@ -32,6 +41,7 @@ def _call_zai_api_impl(
|
|
|
32
41
|
raise AIError.model_error(f"{api_name} API returned null content")
|
|
33
42
|
if content == "":
|
|
34
43
|
raise AIError.model_error(f"{api_name} API returned empty content")
|
|
44
|
+
logger.debug(f"{api_name} API response received successfully")
|
|
35
45
|
return content
|
|
36
46
|
else:
|
|
37
47
|
raise AIError.model_error(f"{api_name} API response missing content: {response_data}")
|
gac/security.py
CHANGED
|
@@ -54,7 +54,7 @@ class SecretPatterns:
|
|
|
54
54
|
PRIVATE_KEY = re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----")
|
|
55
55
|
|
|
56
56
|
# Bearer Tokens (require actual token with specific characteristics)
|
|
57
|
-
BEARER_TOKEN = re.compile(r"Bearer\s+[A-Za-z0-9]{20,}[/=]
|
|
57
|
+
BEARER_TOKEN = re.compile(r"Bearer\s+[A-Za-z0-9]{20,}[/=]*(?:\s|$)", re.IGNORECASE)
|
|
58
58
|
|
|
59
59
|
# JWT Tokens
|
|
60
60
|
JWT_TOKEN = re.compile(r"eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+")
|
gac/utils.py
CHANGED
|
@@ -1,15 +1,46 @@
|
|
|
1
1
|
"""Utility functions for gac."""
|
|
2
2
|
|
|
3
|
+
import locale
|
|
3
4
|
import logging
|
|
5
|
+
import os
|
|
4
6
|
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from functools import lru_cache
|
|
5
9
|
|
|
6
10
|
from rich.console import Console
|
|
7
11
|
from rich.theme import Theme
|
|
8
12
|
|
|
9
|
-
from gac.constants import Logging
|
|
13
|
+
from gac.constants import EnvDefaults, Logging
|
|
10
14
|
from gac.errors import GacError
|
|
11
15
|
|
|
12
16
|
|
|
17
|
+
@lru_cache(maxsize=1)
|
|
18
|
+
def should_skip_ssl_verification() -> bool:
|
|
19
|
+
"""Return True when SSL certificate verification should be skipped.
|
|
20
|
+
|
|
21
|
+
This is useful for corporate environments with proxy servers that
|
|
22
|
+
intercept SSL traffic and cause certificate verification failures.
|
|
23
|
+
|
|
24
|
+
Can be enabled via:
|
|
25
|
+
- GAC_NO_VERIFY_SSL=true environment variable
|
|
26
|
+
- --no-verify-ssl CLI flag (which sets the env var)
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
True if SSL verification should be skipped, False otherwise.
|
|
30
|
+
"""
|
|
31
|
+
value = os.getenv("GAC_NO_VERIFY_SSL", str(EnvDefaults.NO_VERIFY_SSL))
|
|
32
|
+
return value.lower() in ("true", "1", "yes", "on")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_ssl_verify() -> bool:
|
|
36
|
+
"""Get the SSL verification setting for httpx requests.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
True to verify SSL certificates (default), False to skip verification.
|
|
40
|
+
"""
|
|
41
|
+
return not should_skip_ssl_verification()
|
|
42
|
+
|
|
43
|
+
|
|
13
44
|
def setup_logging(
|
|
14
45
|
log_level: int | str = Logging.DEFAULT_LEVEL,
|
|
15
46
|
quiet: bool = False,
|
|
@@ -65,18 +96,47 @@ def print_message(message: str, level: str = "info") -> None:
|
|
|
65
96
|
console.print(message, style=level)
|
|
66
97
|
|
|
67
98
|
|
|
68
|
-
def
|
|
99
|
+
def get_safe_encodings() -> list[str]:
|
|
100
|
+
"""Get a list of safe encodings to try for subprocess calls, in order of preference.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
List of encoding strings to try, with UTF-8 first
|
|
104
|
+
"""
|
|
105
|
+
encodings = ["utf-8"]
|
|
106
|
+
|
|
107
|
+
# Add locale encoding as fallback
|
|
108
|
+
locale_encoding = locale.getpreferredencoding(False)
|
|
109
|
+
if locale_encoding and locale_encoding not in encodings:
|
|
110
|
+
encodings.append(locale_encoding)
|
|
111
|
+
|
|
112
|
+
# Windows-specific fallbacks
|
|
113
|
+
if sys.platform == "win32":
|
|
114
|
+
windows_encodings = ["cp65001", "cp936", "cp1252"] # UTF-8, GBK, Windows-1252
|
|
115
|
+
for enc in windows_encodings:
|
|
116
|
+
if enc not in encodings:
|
|
117
|
+
encodings.append(enc)
|
|
118
|
+
|
|
119
|
+
# Final fallback to system default
|
|
120
|
+
if "utf-8" not in encodings:
|
|
121
|
+
encodings.append("utf-8")
|
|
122
|
+
|
|
123
|
+
return encodings
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def run_subprocess_with_encoding(
|
|
69
127
|
command: list[str],
|
|
128
|
+
encoding: str,
|
|
70
129
|
silent: bool = False,
|
|
71
130
|
timeout: int = 60,
|
|
72
131
|
check: bool = True,
|
|
73
132
|
strip_output: bool = True,
|
|
74
133
|
raise_on_error: bool = True,
|
|
75
134
|
) -> str:
|
|
76
|
-
"""Run a
|
|
135
|
+
"""Run subprocess with a specific encoding, handling encoding errors gracefully.
|
|
77
136
|
|
|
78
137
|
Args:
|
|
79
138
|
command: List of command arguments
|
|
139
|
+
encoding: Specific encoding to use
|
|
80
140
|
silent: If True, suppress debug logging
|
|
81
141
|
timeout: Command timeout in seconds
|
|
82
142
|
check: Whether to check return code (for compatibility)
|
|
@@ -91,7 +151,7 @@ def run_subprocess(
|
|
|
91
151
|
subprocess.CalledProcessError: If the command fails and raise_on_error is True
|
|
92
152
|
"""
|
|
93
153
|
if not silent:
|
|
94
|
-
logger.debug(f"Running command: {' '.join(command)}")
|
|
154
|
+
logger.debug(f"Running command: {' '.join(command)} (encoding: {encoding})")
|
|
95
155
|
|
|
96
156
|
try:
|
|
97
157
|
result = subprocess.run(
|
|
@@ -100,6 +160,8 @@ def run_subprocess(
|
|
|
100
160
|
text=True,
|
|
101
161
|
check=False,
|
|
102
162
|
timeout=timeout,
|
|
163
|
+
encoding=encoding,
|
|
164
|
+
errors="replace", # Replace problematic characters instead of crashing
|
|
103
165
|
)
|
|
104
166
|
|
|
105
167
|
should_raise = result.returncode != 0 and (check or raise_on_error)
|
|
@@ -123,6 +185,11 @@ def run_subprocess(
|
|
|
123
185
|
if raise_on_error:
|
|
124
186
|
raise
|
|
125
187
|
return ""
|
|
188
|
+
except UnicodeError as e:
|
|
189
|
+
# This should be rare with errors="replace", but handle it just in case
|
|
190
|
+
if not silent:
|
|
191
|
+
logger.debug(f"Encoding error with {encoding}: {e}")
|
|
192
|
+
raise
|
|
126
193
|
except Exception as e:
|
|
127
194
|
if not silent:
|
|
128
195
|
logger.debug(f"Command error: {e}")
|
|
@@ -130,3 +197,204 @@ def run_subprocess(
|
|
|
130
197
|
# Convert generic exceptions to CalledProcessError for consistency
|
|
131
198
|
raise subprocess.CalledProcessError(1, command, "", str(e)) from e
|
|
132
199
|
return ""
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def run_subprocess(
|
|
203
|
+
command: list[str],
|
|
204
|
+
silent: bool = False,
|
|
205
|
+
timeout: int = 60,
|
|
206
|
+
check: bool = True,
|
|
207
|
+
strip_output: bool = True,
|
|
208
|
+
raise_on_error: bool = True,
|
|
209
|
+
) -> str:
|
|
210
|
+
"""Run a subprocess command safely and return the output, trying multiple encodings.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
command: List of command arguments
|
|
214
|
+
silent: If True, suppress debug logging
|
|
215
|
+
timeout: Command timeout in seconds
|
|
216
|
+
check: Whether to check return code (for compatibility)
|
|
217
|
+
strip_output: Whether to strip whitespace from output
|
|
218
|
+
raise_on_error: Whether to raise an exception on error
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
Command output as string
|
|
222
|
+
|
|
223
|
+
Raises:
|
|
224
|
+
GacError: If the command times out
|
|
225
|
+
subprocess.CalledProcessError: If the command fails and raise_on_error is True
|
|
226
|
+
|
|
227
|
+
Note:
|
|
228
|
+
Tries multiple encodings in order: utf-8, locale encoding, platform-specific fallbacks
|
|
229
|
+
This prevents UnicodeDecodeError on systems with non-UTF-8 locales (e.g., Chinese Windows)
|
|
230
|
+
"""
|
|
231
|
+
encodings = get_safe_encodings()
|
|
232
|
+
last_exception = None
|
|
233
|
+
|
|
234
|
+
for encoding in encodings:
|
|
235
|
+
try:
|
|
236
|
+
return run_subprocess_with_encoding(
|
|
237
|
+
command=command,
|
|
238
|
+
encoding=encoding,
|
|
239
|
+
silent=silent,
|
|
240
|
+
timeout=timeout,
|
|
241
|
+
check=check,
|
|
242
|
+
strip_output=strip_output,
|
|
243
|
+
raise_on_error=raise_on_error,
|
|
244
|
+
)
|
|
245
|
+
except UnicodeError as e:
|
|
246
|
+
last_exception = e
|
|
247
|
+
if not silent:
|
|
248
|
+
logger.debug(f"Failed to decode with {encoding}: {e}")
|
|
249
|
+
continue
|
|
250
|
+
except (subprocess.CalledProcessError, GacError, subprocess.TimeoutExpired):
|
|
251
|
+
# These are not encoding-related errors, so don't retry with other encodings
|
|
252
|
+
raise
|
|
253
|
+
|
|
254
|
+
# If we get here, all encodings failed with UnicodeError
|
|
255
|
+
if not silent:
|
|
256
|
+
logger.error(f"Failed to decode command output with any encoding: {encodings}")
|
|
257
|
+
|
|
258
|
+
# Raise the last UnicodeError we encountered
|
|
259
|
+
if last_exception:
|
|
260
|
+
raise subprocess.CalledProcessError(1, command, "", f"Encoding error: {last_exception}") from last_exception
|
|
261
|
+
else:
|
|
262
|
+
raise subprocess.CalledProcessError(1, command, "", "All encoding attempts failed")
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def edit_commit_message_inplace(message: str) -> str | None:
|
|
266
|
+
"""Edit commit message in-place using rich terminal editing.
|
|
267
|
+
|
|
268
|
+
Uses prompt_toolkit to provide a rich editing experience with:
|
|
269
|
+
- Multi-line editing
|
|
270
|
+
- Vi/Emacs key bindings
|
|
271
|
+
- Line editing capabilities
|
|
272
|
+
- Esc+Enter or Ctrl+S to submit
|
|
273
|
+
- Ctrl+C to cancel
|
|
274
|
+
|
|
275
|
+
Args:
|
|
276
|
+
message: The initial commit message
|
|
277
|
+
|
|
278
|
+
Returns:
|
|
279
|
+
The edited commit message, or None if editing was cancelled
|
|
280
|
+
|
|
281
|
+
Example:
|
|
282
|
+
>>> edited = edit_commit_message_inplace("feat: add feature")
|
|
283
|
+
>>> # User can edit the message using vi/emacs key bindings
|
|
284
|
+
>>> # Press Esc+Enter or Ctrl+S to submit
|
|
285
|
+
"""
|
|
286
|
+
from prompt_toolkit import Application
|
|
287
|
+
from prompt_toolkit.buffer import Buffer
|
|
288
|
+
from prompt_toolkit.document import Document
|
|
289
|
+
from prompt_toolkit.enums import EditingMode
|
|
290
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
291
|
+
from prompt_toolkit.layout import HSplit, Layout, Window
|
|
292
|
+
from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl
|
|
293
|
+
from prompt_toolkit.layout.margins import ScrollbarMargin
|
|
294
|
+
from prompt_toolkit.styles import Style
|
|
295
|
+
|
|
296
|
+
try:
|
|
297
|
+
console.print("\n[info]Edit commit message:[/info]")
|
|
298
|
+
console.print()
|
|
299
|
+
|
|
300
|
+
# Create buffer for text editing
|
|
301
|
+
text_buffer = Buffer(
|
|
302
|
+
document=Document(text=message, cursor_position=0),
|
|
303
|
+
multiline=True,
|
|
304
|
+
enable_history_search=False,
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
# Track submission state
|
|
308
|
+
cancelled = {"value": False}
|
|
309
|
+
submitted = {"value": False}
|
|
310
|
+
|
|
311
|
+
# Create text editor window
|
|
312
|
+
text_window = Window(
|
|
313
|
+
content=BufferControl(
|
|
314
|
+
buffer=text_buffer,
|
|
315
|
+
focus_on_click=True,
|
|
316
|
+
),
|
|
317
|
+
height=lambda: max(5, message.count("\n") + 3),
|
|
318
|
+
wrap_lines=True,
|
|
319
|
+
right_margins=[ScrollbarMargin()],
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
# Create hint window
|
|
323
|
+
hint_window = Window(
|
|
324
|
+
content=FormattedTextControl(
|
|
325
|
+
text=[("class:hint", " Esc+Enter or Ctrl+S to submit | Ctrl+C to cancel ")],
|
|
326
|
+
),
|
|
327
|
+
height=1,
|
|
328
|
+
dont_extend_height=True,
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
# Create layout
|
|
332
|
+
root_container = HSplit(
|
|
333
|
+
[
|
|
334
|
+
text_window,
|
|
335
|
+
hint_window,
|
|
336
|
+
]
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
layout = Layout(root_container, focused_element=text_window)
|
|
340
|
+
|
|
341
|
+
# Create key bindings
|
|
342
|
+
kb = KeyBindings()
|
|
343
|
+
|
|
344
|
+
@kb.add("c-s")
|
|
345
|
+
def _(event):
|
|
346
|
+
"""Submit with Ctrl+S."""
|
|
347
|
+
submitted["value"] = True
|
|
348
|
+
event.app.exit()
|
|
349
|
+
|
|
350
|
+
@kb.add("c-c")
|
|
351
|
+
def _(event):
|
|
352
|
+
"""Cancel editing."""
|
|
353
|
+
cancelled["value"] = True
|
|
354
|
+
event.app.exit()
|
|
355
|
+
|
|
356
|
+
@kb.add("escape", "enter")
|
|
357
|
+
def _(event):
|
|
358
|
+
"""Submit with Esc+Enter."""
|
|
359
|
+
submitted["value"] = True
|
|
360
|
+
event.app.exit()
|
|
361
|
+
|
|
362
|
+
# Create and run application
|
|
363
|
+
custom_style = Style.from_dict(
|
|
364
|
+
{
|
|
365
|
+
"hint": "#888888",
|
|
366
|
+
}
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
app: Application[None] = Application(
|
|
370
|
+
layout=layout,
|
|
371
|
+
key_bindings=kb,
|
|
372
|
+
full_screen=False,
|
|
373
|
+
mouse_support=False,
|
|
374
|
+
editing_mode=EditingMode.VI, # Enable vi key bindings
|
|
375
|
+
style=custom_style,
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
app.run()
|
|
379
|
+
|
|
380
|
+
# Handle result
|
|
381
|
+
if cancelled["value"]:
|
|
382
|
+
console.print("\n[yellow]Edit cancelled.[/yellow]")
|
|
383
|
+
return None
|
|
384
|
+
|
|
385
|
+
if submitted["value"]:
|
|
386
|
+
edited_message = text_buffer.text.strip()
|
|
387
|
+
if not edited_message:
|
|
388
|
+
console.print("[yellow]Commit message cannot be empty. Edit cancelled.[/yellow]")
|
|
389
|
+
return None
|
|
390
|
+
return edited_message
|
|
391
|
+
|
|
392
|
+
return None
|
|
393
|
+
|
|
394
|
+
except (EOFError, KeyboardInterrupt):
|
|
395
|
+
console.print("\n[yellow]Edit cancelled.[/yellow]")
|
|
396
|
+
return None
|
|
397
|
+
except Exception as e:
|
|
398
|
+
logger.error(f"Error during in-place editing: {e}")
|
|
399
|
+
console.print(f"[error]Failed to edit commit message: {e}[/error]")
|
|
400
|
+
return None
|
gac/workflow_utils.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import tempfile
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
from prompt_toolkit import prompt
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.panel import Panel
|
|
9
|
+
|
|
10
|
+
from gac.constants import EnvDefaults
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
console = Console()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def handle_confirmation_loop(
|
|
17
|
+
commit_message: str,
|
|
18
|
+
conversation_messages: list[dict[str, str]],
|
|
19
|
+
quiet: bool,
|
|
20
|
+
model: str,
|
|
21
|
+
) -> tuple[str, str, list[dict[str, str]]]:
|
|
22
|
+
from gac.utils import edit_commit_message_inplace
|
|
23
|
+
|
|
24
|
+
while True:
|
|
25
|
+
response = click.prompt(
|
|
26
|
+
"Proceed with commit above? [y/n/r/e/<feedback>]",
|
|
27
|
+
type=str,
|
|
28
|
+
show_default=False,
|
|
29
|
+
).strip()
|
|
30
|
+
response_lower = response.lower()
|
|
31
|
+
|
|
32
|
+
if response_lower in ["y", "yes"]:
|
|
33
|
+
return ("yes", commit_message, conversation_messages)
|
|
34
|
+
if response_lower in ["n", "no"]:
|
|
35
|
+
return ("no", commit_message, conversation_messages)
|
|
36
|
+
if response == "":
|
|
37
|
+
continue
|
|
38
|
+
if response_lower in ["e", "edit"]:
|
|
39
|
+
edited_message = edit_commit_message_inplace(commit_message)
|
|
40
|
+
if edited_message:
|
|
41
|
+
commit_message = edited_message
|
|
42
|
+
conversation_messages[-1] = {"role": "assistant", "content": commit_message}
|
|
43
|
+
logger.info("Commit message edited by user")
|
|
44
|
+
console.print("\n[bold green]Edited commit message:[/bold green]")
|
|
45
|
+
console.print(Panel(commit_message, title="Commit Message", border_style="cyan"))
|
|
46
|
+
else:
|
|
47
|
+
console.print("[yellow]Using previous message.[/yellow]")
|
|
48
|
+
console.print(Panel(commit_message, title="Commit Message", border_style="cyan"))
|
|
49
|
+
continue
|
|
50
|
+
if response_lower in ["r", "reroll"]:
|
|
51
|
+
msg = "Please provide an alternative commit message using the same repository context."
|
|
52
|
+
conversation_messages.append({"role": "user", "content": msg})
|
|
53
|
+
console.print("[cyan]Regenerating commit message...[/cyan]")
|
|
54
|
+
return ("regenerate", commit_message, conversation_messages)
|
|
55
|
+
|
|
56
|
+
msg = f"Please revise the commit message based on this feedback: {response}"
|
|
57
|
+
conversation_messages.append({"role": "user", "content": msg})
|
|
58
|
+
console.print(f"[cyan]Regenerating commit message with feedback: {response}[/cyan]")
|
|
59
|
+
return ("regenerate", commit_message, conversation_messages)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def execute_commit(commit_message: str, no_verify: bool, hook_timeout: int | None = None) -> None:
|
|
63
|
+
from gac.git import run_git_command
|
|
64
|
+
|
|
65
|
+
commit_args = ["commit", "-m", commit_message]
|
|
66
|
+
if no_verify:
|
|
67
|
+
commit_args.append("--no-verify")
|
|
68
|
+
effective_timeout = hook_timeout if hook_timeout and hook_timeout > 0 else EnvDefaults.HOOK_TIMEOUT
|
|
69
|
+
run_git_command(commit_args, timeout=effective_timeout)
|
|
70
|
+
logger.info("Commit created successfully")
|
|
71
|
+
console.print("[green]Commit created successfully[/green]")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def check_token_warning(
|
|
75
|
+
prompt_tokens: int,
|
|
76
|
+
warning_limit: int,
|
|
77
|
+
require_confirmation: bool,
|
|
78
|
+
) -> bool:
|
|
79
|
+
if warning_limit and prompt_tokens > warning_limit:
|
|
80
|
+
console.print(
|
|
81
|
+
f"[yellow]⚠️ WARNING: Prompt has {prompt_tokens} tokens (warning threshold: {warning_limit})[/yellow]"
|
|
82
|
+
)
|
|
83
|
+
if require_confirmation:
|
|
84
|
+
proceed = click.confirm("Do you want to continue anyway?", default=True)
|
|
85
|
+
if not proceed:
|
|
86
|
+
console.print("[yellow]Aborted due to large token count.[/yellow]")
|
|
87
|
+
return False
|
|
88
|
+
return True
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def display_commit_message(commit_message: str, prompt_tokens: int, model: str, quiet: bool) -> None:
|
|
92
|
+
from gac.ai_utils import count_tokens
|
|
93
|
+
|
|
94
|
+
console.print("[bold green]Generated commit message:[/bold green]")
|
|
95
|
+
console.print(Panel(commit_message, title="Commit Message", border_style="cyan"))
|
|
96
|
+
|
|
97
|
+
if not quiet:
|
|
98
|
+
completion_tokens = count_tokens(commit_message, model)
|
|
99
|
+
total_tokens = prompt_tokens + completion_tokens
|
|
100
|
+
console.print(
|
|
101
|
+
f"[dim]Token usage: {prompt_tokens} prompt + {completion_tokens} completion = {total_tokens} total[/dim]"
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def restore_staging(staged_files: list[str], staged_diff: str | None = None) -> None:
|
|
106
|
+
"""Restore the git staging area to a previous state.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
staged_files: List of file paths that should be staged
|
|
110
|
+
staged_diff: Optional staged diff to reapply for partial staging
|
|
111
|
+
"""
|
|
112
|
+
from gac.git import run_git_command
|
|
113
|
+
|
|
114
|
+
run_git_command(["reset", "HEAD"])
|
|
115
|
+
|
|
116
|
+
if staged_diff:
|
|
117
|
+
temp_path: Path | None = None
|
|
118
|
+
try:
|
|
119
|
+
with tempfile.NamedTemporaryFile("w", delete=False) as tmp:
|
|
120
|
+
tmp.write(staged_diff)
|
|
121
|
+
temp_path = Path(tmp.name)
|
|
122
|
+
run_git_command(["apply", "--cached", str(temp_path)])
|
|
123
|
+
return
|
|
124
|
+
except Exception as e:
|
|
125
|
+
logger.warning(f"Failed to reapply staged diff, falling back to file list: {e}")
|
|
126
|
+
finally:
|
|
127
|
+
if temp_path:
|
|
128
|
+
temp_path.unlink(missing_ok=True)
|
|
129
|
+
|
|
130
|
+
for file_path in staged_files:
|
|
131
|
+
try:
|
|
132
|
+
run_git_command(["add", file_path])
|
|
133
|
+
except Exception as e:
|
|
134
|
+
logger.warning(f"Failed to restore staging for {file_path}: {e}")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def collect_interactive_answers(questions: list[str]) -> dict[str, str] | None:
|
|
138
|
+
"""Collect user answers to generated questions interactively.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
questions: List of generated questions
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
Dictionary mapping questions to answers, or None if user aborted
|
|
145
|
+
"""
|
|
146
|
+
if not questions:
|
|
147
|
+
return {}
|
|
148
|
+
|
|
149
|
+
console.print("\n[bold cyan]🤝 Let's clarify your commit intent:[/bold cyan]")
|
|
150
|
+
console.print("[dim]Answer each question, press Enter to skip, or type:[/dim]")
|
|
151
|
+
console.print("[dim] • 'skip' - skip remaining questions[/dim]")
|
|
152
|
+
console.print("[dim] • 'quit' - abort interactive mode[/dim]\n")
|
|
153
|
+
|
|
154
|
+
answers = {}
|
|
155
|
+
|
|
156
|
+
for i, question in enumerate(questions, 1):
|
|
157
|
+
# Display the question with nice formatting
|
|
158
|
+
console.print(f"[bold blue]Question {i}:[/bold blue] {question}")
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
answer = prompt("Your answer: ").strip()
|
|
162
|
+
|
|
163
|
+
# Handle special commands
|
|
164
|
+
answer_lower = answer.lower()
|
|
165
|
+
|
|
166
|
+
if answer_lower == "quit":
|
|
167
|
+
console.print("\n[yellow]⚠️ Interactive mode aborted by user[/yellow]")
|
|
168
|
+
return None
|
|
169
|
+
elif answer_lower == "skip":
|
|
170
|
+
console.print("[dim]Skipping remaining questions...[/dim]")
|
|
171
|
+
break
|
|
172
|
+
elif answer_lower == "" or answer_lower == "none":
|
|
173
|
+
# User explicitly skipped this question
|
|
174
|
+
console.print("[dim]↳ Skipped[/dim]")
|
|
175
|
+
continue
|
|
176
|
+
else:
|
|
177
|
+
# Valid answer provided
|
|
178
|
+
answers[question] = answer
|
|
179
|
+
console.print("[dim]↳ Got it![/dim]")
|
|
180
|
+
|
|
181
|
+
except KeyboardInterrupt:
|
|
182
|
+
# User pressed Ctrl+C
|
|
183
|
+
console.print("\n[yellow]⚠️ Interactive mode aborted by user[/yellow]")
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
console.print() # Add spacing between questions
|
|
187
|
+
|
|
188
|
+
return answers
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def format_answers_for_prompt(answers: dict[str, str]) -> str:
|
|
192
|
+
"""Format collected answers for inclusion in the commit message prompt.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
answers: Dictionary mapping questions to answers
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
Formatted string for inclusion in the prompt
|
|
199
|
+
"""
|
|
200
|
+
if not answers:
|
|
201
|
+
return ""
|
|
202
|
+
|
|
203
|
+
formatted_lines = []
|
|
204
|
+
for question, answer in answers.items():
|
|
205
|
+
formatted_lines.append(f"Q: {question}")
|
|
206
|
+
formatted_lines.append(f"A: {answer}")
|
|
207
|
+
formatted_lines.append("")
|
|
208
|
+
|
|
209
|
+
answers_text = "\n".join(formatted_lines).rstrip()
|
|
210
|
+
|
|
211
|
+
return (
|
|
212
|
+
f"\n\n<user_answers>\n"
|
|
213
|
+
f"The user provided the following clarifying information:\n\n"
|
|
214
|
+
f"{answers_text}\n\n"
|
|
215
|
+
f"</user_answers>\n\n"
|
|
216
|
+
f"<context_request>Use the user's answers above to craft a more accurate and informative commit message that captures their specific intent and context.</context_request>"
|
|
217
|
+
)
|