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