gac 3.10.3__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.

Potentially problematic release.


This version of gac might be problematic. Click here for more details.

Files changed (67) hide show
  1. gac/__init__.py +15 -0
  2. gac/__version__.py +3 -0
  3. gac/ai.py +109 -0
  4. gac/ai_utils.py +246 -0
  5. gac/auth_cli.py +214 -0
  6. gac/cli.py +218 -0
  7. gac/commit_executor.py +62 -0
  8. gac/config.py +125 -0
  9. gac/config_cli.py +95 -0
  10. gac/constants.py +328 -0
  11. gac/diff_cli.py +159 -0
  12. gac/errors.py +231 -0
  13. gac/git.py +372 -0
  14. gac/git_state_validator.py +184 -0
  15. gac/grouped_commit_workflow.py +423 -0
  16. gac/init_cli.py +70 -0
  17. gac/interactive_mode.py +182 -0
  18. gac/language_cli.py +377 -0
  19. gac/main.py +476 -0
  20. gac/model_cli.py +430 -0
  21. gac/oauth/__init__.py +27 -0
  22. gac/oauth/claude_code.py +464 -0
  23. gac/oauth/qwen_oauth.py +327 -0
  24. gac/oauth/token_store.py +81 -0
  25. gac/preprocess.py +511 -0
  26. gac/prompt.py +878 -0
  27. gac/prompt_builder.py +88 -0
  28. gac/providers/README.md +437 -0
  29. gac/providers/__init__.py +80 -0
  30. gac/providers/anthropic.py +17 -0
  31. gac/providers/azure_openai.py +57 -0
  32. gac/providers/base.py +329 -0
  33. gac/providers/cerebras.py +15 -0
  34. gac/providers/chutes.py +25 -0
  35. gac/providers/claude_code.py +79 -0
  36. gac/providers/custom_anthropic.py +103 -0
  37. gac/providers/custom_openai.py +44 -0
  38. gac/providers/deepseek.py +15 -0
  39. gac/providers/error_handler.py +139 -0
  40. gac/providers/fireworks.py +15 -0
  41. gac/providers/gemini.py +90 -0
  42. gac/providers/groq.py +15 -0
  43. gac/providers/kimi_coding.py +27 -0
  44. gac/providers/lmstudio.py +80 -0
  45. gac/providers/minimax.py +15 -0
  46. gac/providers/mistral.py +15 -0
  47. gac/providers/moonshot.py +15 -0
  48. gac/providers/ollama.py +73 -0
  49. gac/providers/openai.py +32 -0
  50. gac/providers/openrouter.py +21 -0
  51. gac/providers/protocol.py +71 -0
  52. gac/providers/qwen.py +64 -0
  53. gac/providers/registry.py +58 -0
  54. gac/providers/replicate.py +156 -0
  55. gac/providers/streamlake.py +31 -0
  56. gac/providers/synthetic.py +40 -0
  57. gac/providers/together.py +15 -0
  58. gac/providers/zai.py +31 -0
  59. gac/py.typed +0 -0
  60. gac/security.py +293 -0
  61. gac/utils.py +401 -0
  62. gac/workflow_utils.py +217 -0
  63. gac-3.10.3.dist-info/METADATA +283 -0
  64. gac-3.10.3.dist-info/RECORD +67 -0
  65. gac-3.10.3.dist-info/WHEEL +4 -0
  66. gac-3.10.3.dist-info/entry_points.txt +2 -0
  67. gac-3.10.3.dist-info/licenses/LICENSE +16 -0
gac/security.py ADDED
@@ -0,0 +1,293 @@
1
+ #!/usr/bin/env python3
2
+ """Security utilities for detecting secrets and API keys in git diffs.
3
+
4
+ This module provides functions to scan staged changes for potential secrets,
5
+ API keys, and other sensitive information that should not be committed.
6
+ """
7
+
8
+ import logging
9
+ import re
10
+ from dataclasses import dataclass
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ @dataclass
16
+ class DetectedSecret:
17
+ """Represents a detected secret in a file."""
18
+
19
+ file_path: str
20
+ line_number: int | None
21
+ secret_type: str
22
+ matched_text: str
23
+ context: str | None = None
24
+
25
+
26
+ class SecretPatterns:
27
+ """Regex patterns for detecting various types of secrets and API keys."""
28
+
29
+ # AWS Access Keys
30
+ AWS_ACCESS_KEY_ID = re.compile(r"(?:AWS_ACCESS_KEY_ID|aws_access_key_id)[\s:=\"']+([A-Z0-9]{20})", re.IGNORECASE)
31
+ AWS_SECRET_ACCESS_KEY = re.compile(
32
+ r"(?:AWS_SECRET_ACCESS_KEY|aws_secret_access_key)[\s:=\"']+([A-Za-z0-9/+=]{40})", re.IGNORECASE
33
+ )
34
+ AWS_SESSION_TOKEN = re.compile(r"(?:AWS_SESSION_TOKEN|aws_session_token)[\s:=\"']+([A-Za-z0-9/+=]+)", re.IGNORECASE)
35
+
36
+ # Generic API Keys
37
+ GENERIC_API_KEY = re.compile(
38
+ r"(?:api[-_]?key|api[-_]?secret|access[-_]?key|secret[-_]?key)[\s:=\"']+([A-Za-z0-9_\-]{20,})", re.IGNORECASE
39
+ )
40
+
41
+ # GitHub Tokens
42
+ GITHUB_TOKEN = re.compile(r"(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,}")
43
+
44
+ # OpenAI API Keys
45
+ OPENAI_API_KEY = re.compile(r"sk-[A-Za-z0-9]{20,}")
46
+
47
+ # Anthropic API Keys
48
+ ANTHROPIC_API_KEY = re.compile(r"sk-ant-[A-Za-z0-9\-_]{95,}")
49
+
50
+ # Stripe Keys
51
+ STRIPE_KEY = re.compile(r"(?:sk|pk|rk)_(?:live|test)_[A-Za-z0-9]{24,}")
52
+
53
+ # Private Keys (PEM format)
54
+ PRIVATE_KEY = re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----")
55
+
56
+ # Bearer Tokens (require actual token with specific characteristics)
57
+ BEARER_TOKEN = re.compile(r"Bearer\s+[A-Za-z0-9]{20,}[/=]*(?:\s|$)", re.IGNORECASE)
58
+
59
+ # JWT Tokens
60
+ JWT_TOKEN = re.compile(r"eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+")
61
+
62
+ # Database URLs with credentials
63
+ DATABASE_URL = re.compile(
64
+ r"(?:postgresql|mysql|mongodb|redis)://[A-Za-z0-9_-]+:[A-Za-z0-9_@!#$%^&*()+-=]+@[A-Za-z0-9.-]+",
65
+ re.IGNORECASE,
66
+ )
67
+
68
+ # SSH Private Keys
69
+ SSH_PRIVATE_KEY = re.compile(r"-----BEGIN (?:RSA|DSA|EC|OPENSSH) PRIVATE KEY-----")
70
+
71
+ # Slack Tokens
72
+ SLACK_TOKEN = re.compile(r"xox[baprs]-[A-Za-z0-9-]+")
73
+
74
+ # Google API Keys
75
+ GOOGLE_API_KEY = re.compile(r"AIza[0-9A-Za-z_-]{35}")
76
+
77
+ # Twilio API Keys
78
+ TWILIO_API_KEY = re.compile(r"SK[a-f0-9]{32}")
79
+
80
+ # Generic Password Fields
81
+ PASSWORD = re.compile(r"(?:password|passwd|pwd)[\s:=\"']+([^\s\"']{8,})", re.IGNORECASE)
82
+
83
+ # Excluded patterns (common false positives)
84
+ EXCLUDED_PATTERNS = [
85
+ re.compile(r"(?:example|sample|dummy|placeholder|your[-_]?api[-_]?key)", re.IGNORECASE),
86
+ re.compile(r"(?:xxx+|yyy+|zzz+)", re.IGNORECASE),
87
+ re.compile(r"\b(?:123456|password|changeme|secret|testpass|admin)\b", re.IGNORECASE), # Word boundaries
88
+ re.compile(r"ghp_[a-f0-9]{16}", re.IGNORECASE), # Short GitHub tokens (examples)
89
+ re.compile(r"sk-[a-f0-9]{16}", re.IGNORECASE), # Short OpenAI keys (examples)
90
+ re.compile(r"Bearer Token", re.IGNORECASE), # Documentation text
91
+ re.compile(r"Add Bearer Token", re.IGNORECASE), # Documentation text
92
+ re.compile(r"Test Bearer Token", re.IGNORECASE), # Documentation text
93
+ ]
94
+
95
+ @classmethod
96
+ def get_all_patterns(cls) -> dict[str, re.Pattern[str]]:
97
+ """Get all secret detection patterns.
98
+
99
+ Returns:
100
+ Dictionary mapping pattern names to compiled regex patterns
101
+ """
102
+ patterns = {}
103
+ for name, value in vars(cls).items():
104
+ if isinstance(value, re.Pattern) and not name.startswith("EXCLUDED"):
105
+ # Convert pattern name to human-readable format
106
+ readable_name = name.replace("_", " ").title()
107
+ patterns[readable_name] = value
108
+ return patterns
109
+
110
+
111
+ def is_false_positive(matched_text: str, file_path: str = "") -> bool:
112
+ """Check if a matched secret is likely a false positive.
113
+
114
+ Args:
115
+ matched_text: The text that matched a secret pattern
116
+ file_path: The file path where the match was found (for context-based filtering)
117
+
118
+ Returns:
119
+ True if the match is likely a false positive
120
+ """
121
+ # Check against excluded patterns
122
+ for pattern in SecretPatterns.EXCLUDED_PATTERNS:
123
+ if pattern.search(matched_text):
124
+ return True
125
+
126
+ # Check for all-same characters (e.g., "xxxxxxxxxxxxxxxx")
127
+ if len(set(matched_text.lower())) <= 3 and len(matched_text) > 10:
128
+ return True
129
+
130
+ # Special handling for .env.example, .env.template, .env.sample files
131
+ if any(example_file in file_path for example_file in [".env.example", ".env.template", ".env.sample"]):
132
+ return True
133
+
134
+ return False
135
+
136
+
137
+ def extract_file_path_from_diff_section(section: str) -> str | None:
138
+ """Extract the file path from a git diff section.
139
+
140
+ Args:
141
+ section: A git diff section
142
+
143
+ Returns:
144
+ The file path or None if not found
145
+ """
146
+ match = re.search(r"diff --git a/(.*?) b/", section)
147
+ if match:
148
+ return match.group(1)
149
+ return None
150
+
151
+
152
+ def extract_line_number_from_hunk(line: str, hunk_header: str | None) -> int | None:
153
+ """Extract the line number from a diff hunk.
154
+
155
+ Args:
156
+ line: The diff line containing the secret
157
+ hunk_header: The most recent hunk header (e.g., "@@ -1,4 +1,5 @@")
158
+
159
+ Returns:
160
+ The line number or None if not determinable
161
+ """
162
+ if not hunk_header:
163
+ return None
164
+
165
+ # Parse hunk header to get starting line number: @@ -old_start,old_count +new_start,new_count @@
166
+ match = re.search(r"@@ -\d+(?:,\d+)? \+(\d+)", hunk_header)
167
+ if not match:
168
+ return None
169
+
170
+ return int(match.group(1))
171
+
172
+
173
+ def scan_diff_section(section: str) -> list[DetectedSecret]:
174
+ """Scan a single git diff section for secrets.
175
+
176
+ Args:
177
+ section: A git diff section to scan
178
+
179
+ Returns:
180
+ List of detected secrets
181
+ """
182
+ secrets: list[DetectedSecret] = []
183
+ file_path = extract_file_path_from_diff_section(section)
184
+
185
+ if not file_path:
186
+ return secrets
187
+
188
+ patterns = SecretPatterns.get_all_patterns()
189
+ lines = section.split("\n")
190
+ line_counter = 0
191
+
192
+ for line in lines:
193
+ # Track hunk headers for line number extraction
194
+ if line.startswith("@@"):
195
+ # Reset line counter based on hunk header (this is the starting line number in the new file)
196
+ match = re.search(r"@@ -\d+(?:,\d+)? \+(\d+)", line)
197
+ if match:
198
+ line_counter = int(match.group(1)) - 1 # Start one line before, will increment correctly
199
+ continue
200
+
201
+ # Skip metadata lines
202
+ if line.startswith("+++") or line.startswith("---"):
203
+ continue
204
+
205
+ # Increment line counter for both added and context lines
206
+ if line.startswith("+") or line.startswith(" "):
207
+ line_counter += 1
208
+
209
+ # Only scan added lines (starting with '+')
210
+ if line.startswith("+") and not line.startswith("+++"):
211
+ # Check each pattern
212
+ content = line[1:] # Remove the '+' prefix for pattern matching
213
+ for pattern_name, pattern in patterns.items():
214
+ matches = pattern.finditer(content)
215
+ for match in matches:
216
+ matched_text = match.group(0)
217
+
218
+ # Skip false positives
219
+ if is_false_positive(matched_text, file_path):
220
+ logger.debug(f"Skipping false positive: {matched_text}")
221
+ continue
222
+
223
+ # Truncate matched text for display (avoid showing full secrets)
224
+ from gac.constants import Utility
225
+
226
+ display_text = (
227
+ matched_text[: Utility.MAX_DISPLAYED_SECRET_LENGTH] + "..."
228
+ if len(matched_text) > Utility.MAX_DISPLAYED_SECRET_LENGTH
229
+ else matched_text
230
+ )
231
+
232
+ secrets.append(
233
+ DetectedSecret(
234
+ file_path=file_path,
235
+ line_number=line_counter,
236
+ secret_type=pattern_name,
237
+ matched_text=display_text,
238
+ context=content.strip(),
239
+ )
240
+ )
241
+
242
+ return secrets
243
+
244
+
245
+ def scan_staged_diff(diff: str) -> list[DetectedSecret]:
246
+ """Scan staged git diff for secrets and API keys.
247
+
248
+ Args:
249
+ diff: The staged git diff to scan
250
+
251
+ Returns:
252
+ List of detected secrets
253
+ """
254
+ if not diff:
255
+ return []
256
+
257
+ # Split diff into sections (one per file)
258
+ sections = re.split(r"(?=^diff --git )", diff, flags=re.MULTILINE)
259
+ all_secrets = []
260
+
261
+ for section in sections:
262
+ if not section.strip():
263
+ continue
264
+
265
+ # Validate that this is a real git diff section
266
+ # Real diff sections must have diff --git header followed by --- and +++ lines
267
+ if not re.search(r"^diff --git ", section, flags=re.MULTILINE):
268
+ continue
269
+
270
+ if not re.search(r"^--- ", section, flags=re.MULTILINE):
271
+ continue
272
+
273
+ if not re.search(r"^\+\+\+ ", section, flags=re.MULTILINE):
274
+ continue
275
+
276
+ secrets = scan_diff_section(section)
277
+ all_secrets.extend(secrets)
278
+
279
+ logger.info(f"Secret scan complete: found {len(all_secrets)} potential secrets")
280
+ return all_secrets
281
+
282
+
283
+ def get_affected_files(secrets: list[DetectedSecret]) -> list[str]:
284
+ """Get unique list of files containing detected secrets.
285
+
286
+ Args:
287
+ secrets: List of detected secrets
288
+
289
+ Returns:
290
+ Sorted list of unique file paths
291
+ """
292
+ files = {secret.file_path for secret in secrets}
293
+ return sorted(files)
gac/utils.py ADDED
@@ -0,0 +1,401 @@
1
+ """Utility functions for gac."""
2
+
3
+ import locale
4
+ import logging
5
+ import os
6
+ import subprocess
7
+ import sys
8
+ from functools import lru_cache
9
+ from typing import Any
10
+
11
+ from rich.console import Console
12
+ from rich.theme import Theme
13
+
14
+ from gac.constants import EnvDefaults, Logging
15
+ from gac.errors import GacError
16
+
17
+
18
+ @lru_cache(maxsize=1)
19
+ def should_skip_ssl_verification() -> bool:
20
+ """Return True when SSL certificate verification should be skipped.
21
+
22
+ This is useful for corporate environments with proxy servers that
23
+ intercept SSL traffic and cause certificate verification failures.
24
+
25
+ Can be enabled via:
26
+ - GAC_NO_VERIFY_SSL=true environment variable
27
+ - --no-verify-ssl CLI flag (which sets the env var)
28
+
29
+ Returns:
30
+ True if SSL verification should be skipped, False otherwise.
31
+ """
32
+ value = os.getenv("GAC_NO_VERIFY_SSL", str(EnvDefaults.NO_VERIFY_SSL))
33
+ return value.lower() in ("true", "1", "yes", "on")
34
+
35
+
36
+ def get_ssl_verify() -> bool:
37
+ """Get the SSL verification setting for httpx requests.
38
+
39
+ Returns:
40
+ True to verify SSL certificates (default), False to skip verification.
41
+ """
42
+ return not should_skip_ssl_verification()
43
+
44
+
45
+ def setup_logging(
46
+ log_level: int | str = Logging.DEFAULT_LEVEL,
47
+ quiet: bool = False,
48
+ force: bool = False,
49
+ suppress_noisy: bool = False,
50
+ ) -> None:
51
+ """Configure logging for the application.
52
+
53
+ Args:
54
+ log_level: Log level to use (DEBUG, INFO, WARNING, ERROR)
55
+ quiet: If True, suppress all output except errors
56
+ force: If True, force reconfiguration of logging
57
+ suppress_noisy: If True, suppress noisy third-party loggers
58
+ """
59
+ if isinstance(log_level, str):
60
+ log_level = getattr(logging, log_level.upper(), logging.WARNING)
61
+
62
+ if quiet:
63
+ log_level = logging.ERROR
64
+
65
+ kwargs: dict[str, Any] = {"force": force} if force else {}
66
+
67
+ logging.basicConfig(
68
+ level=log_level,
69
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
70
+ datefmt="%Y-%m-%d %H:%M:%S",
71
+ **kwargs,
72
+ )
73
+
74
+ if suppress_noisy:
75
+ for noisy_logger in ["requests", "urllib3"]:
76
+ logging.getLogger(noisy_logger).setLevel(logging.WARNING)
77
+
78
+ logger.info(f"Logging initialized with level: {logging.getLevelName(log_level)}")
79
+
80
+
81
+ theme = Theme(
82
+ {
83
+ "success": "green bold",
84
+ "info": "blue",
85
+ "warning": "yellow",
86
+ "error": "red bold",
87
+ "header": "magenta",
88
+ "notification": "bright_cyan bold",
89
+ }
90
+ )
91
+ console = Console(theme=theme)
92
+ logger = logging.getLogger(__name__)
93
+
94
+
95
+ def print_message(message: str, level: str = "info") -> None:
96
+ """Print a styled message with the specified level."""
97
+ console.print(message, style=level)
98
+
99
+
100
+ def get_safe_encodings() -> list[str]:
101
+ """Get a list of safe encodings to try for subprocess calls, in order of preference.
102
+
103
+ Returns:
104
+ List of encoding strings to try, with UTF-8 first
105
+ """
106
+ encodings = ["utf-8"]
107
+
108
+ # Add locale encoding as fallback
109
+ locale_encoding = locale.getpreferredencoding(False)
110
+ if locale_encoding and locale_encoding not in encodings:
111
+ encodings.append(locale_encoding)
112
+
113
+ # Windows-specific fallbacks
114
+ if sys.platform == "win32":
115
+ windows_encodings = ["cp65001", "cp936", "cp1252"] # UTF-8, GBK, Windows-1252
116
+ for enc in windows_encodings:
117
+ if enc not in encodings:
118
+ encodings.append(enc)
119
+
120
+ # Final fallback to system default
121
+ if "utf-8" not in encodings:
122
+ encodings.append("utf-8")
123
+
124
+ return encodings
125
+
126
+
127
+ def run_subprocess_with_encoding(
128
+ command: list[str],
129
+ encoding: str,
130
+ silent: bool = False,
131
+ timeout: int = 60,
132
+ check: bool = True,
133
+ strip_output: bool = True,
134
+ raise_on_error: bool = True,
135
+ ) -> str:
136
+ """Run subprocess with a specific encoding, handling encoding errors gracefully.
137
+
138
+ Args:
139
+ command: List of command arguments
140
+ encoding: Specific encoding to use
141
+ silent: If True, suppress debug logging
142
+ timeout: Command timeout in seconds
143
+ check: Whether to check return code (for compatibility)
144
+ strip_output: Whether to strip whitespace from output
145
+ raise_on_error: Whether to raise an exception on error
146
+
147
+ Returns:
148
+ Command output as string
149
+
150
+ Raises:
151
+ GacError: If the command times out
152
+ subprocess.CalledProcessError: If the command fails and raise_on_error is True
153
+ """
154
+ if not silent:
155
+ logger.debug(f"Running command: {' '.join(command)} (encoding: {encoding})")
156
+
157
+ try:
158
+ result = subprocess.run(
159
+ command,
160
+ capture_output=True,
161
+ text=True,
162
+ check=False,
163
+ timeout=timeout,
164
+ encoding=encoding,
165
+ errors="replace", # Replace problematic characters instead of crashing
166
+ )
167
+
168
+ should_raise = result.returncode != 0 and (check or raise_on_error)
169
+
170
+ if should_raise:
171
+ if not silent:
172
+ logger.debug(f"Command stderr: {result.stderr}")
173
+ raise subprocess.CalledProcessError(result.returncode, command, result.stdout, result.stderr)
174
+
175
+ output = result.stdout
176
+ if strip_output:
177
+ output = output.strip()
178
+
179
+ return output
180
+ except subprocess.TimeoutExpired as e:
181
+ logger.error(f"Command timed out after {timeout} seconds: {' '.join(command)}")
182
+ raise GacError(f"Command timed out: {' '.join(command)}") from e
183
+ except subprocess.CalledProcessError as e:
184
+ if not silent:
185
+ logger.error(f"Command failed: {e.stderr.strip() if e.stderr else str(e)}")
186
+ if raise_on_error:
187
+ raise
188
+ return ""
189
+ except UnicodeError as e:
190
+ # This should be rare with errors="replace", but handle it just in case
191
+ if not silent:
192
+ logger.debug(f"Encoding error with {encoding}: {e}")
193
+ raise
194
+ except Exception as e:
195
+ if not silent:
196
+ logger.debug(f"Command error: {e}")
197
+ if raise_on_error:
198
+ # Convert generic exceptions to CalledProcessError for consistency
199
+ raise subprocess.CalledProcessError(1, command, "", str(e)) from e
200
+ return ""
201
+
202
+
203
+ def run_subprocess(
204
+ command: list[str],
205
+ silent: bool = False,
206
+ timeout: int = 60,
207
+ check: bool = True,
208
+ strip_output: bool = True,
209
+ raise_on_error: bool = True,
210
+ ) -> str:
211
+ """Run a subprocess command safely and return the output, trying multiple encodings.
212
+
213
+ Args:
214
+ command: List of command arguments
215
+ silent: If True, suppress debug logging
216
+ timeout: Command timeout in seconds
217
+ check: Whether to check return code (for compatibility)
218
+ strip_output: Whether to strip whitespace from output
219
+ raise_on_error: Whether to raise an exception on error
220
+
221
+ Returns:
222
+ Command output as string
223
+
224
+ Raises:
225
+ GacError: If the command times out
226
+ subprocess.CalledProcessError: If the command fails and raise_on_error is True
227
+
228
+ Note:
229
+ Tries multiple encodings in order: utf-8, locale encoding, platform-specific fallbacks
230
+ This prevents UnicodeDecodeError on systems with non-UTF-8 locales (e.g., Chinese Windows)
231
+ """
232
+ encodings = get_safe_encodings()
233
+ last_exception = None
234
+
235
+ for encoding in encodings:
236
+ try:
237
+ return run_subprocess_with_encoding(
238
+ command=command,
239
+ encoding=encoding,
240
+ silent=silent,
241
+ timeout=timeout,
242
+ check=check,
243
+ strip_output=strip_output,
244
+ raise_on_error=raise_on_error,
245
+ )
246
+ except UnicodeError as e:
247
+ last_exception = e
248
+ if not silent:
249
+ logger.debug(f"Failed to decode with {encoding}: {e}")
250
+ continue
251
+ except (subprocess.CalledProcessError, GacError, subprocess.TimeoutExpired):
252
+ # These are not encoding-related errors, so don't retry with other encodings
253
+ raise
254
+
255
+ # If we get here, all encodings failed with UnicodeError
256
+ if not silent:
257
+ logger.error(f"Failed to decode command output with any encoding: {encodings}")
258
+
259
+ # Raise the last UnicodeError we encountered
260
+ if last_exception:
261
+ raise subprocess.CalledProcessError(1, command, "", f"Encoding error: {last_exception}") from last_exception
262
+ else:
263
+ raise subprocess.CalledProcessError(1, command, "", "All encoding attempts failed")
264
+
265
+
266
+ def edit_commit_message_inplace(message: str) -> str | None:
267
+ """Edit commit message in-place using rich terminal editing.
268
+
269
+ Uses prompt_toolkit to provide a rich editing experience with:
270
+ - Multi-line editing
271
+ - Vi/Emacs key bindings
272
+ - Line editing capabilities
273
+ - Esc+Enter or Ctrl+S to submit
274
+ - Ctrl+C to cancel
275
+
276
+ Args:
277
+ message: The initial commit message
278
+
279
+ Returns:
280
+ The edited commit message, or None if editing was cancelled
281
+
282
+ Example:
283
+ >>> edited = edit_commit_message_inplace("feat: add feature")
284
+ >>> # User can edit the message using vi/emacs key bindings
285
+ >>> # Press Esc+Enter or Ctrl+S to submit
286
+ """
287
+ from prompt_toolkit import Application
288
+ from prompt_toolkit.buffer import Buffer
289
+ from prompt_toolkit.document import Document
290
+ from prompt_toolkit.enums import EditingMode
291
+ from prompt_toolkit.key_binding import KeyBindings
292
+ from prompt_toolkit.layout import HSplit, Layout, Window
293
+ from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl
294
+ from prompt_toolkit.layout.margins import ScrollbarMargin
295
+ from prompt_toolkit.styles import Style
296
+
297
+ try:
298
+ console.print("\n[info]Edit commit message:[/info]")
299
+ console.print()
300
+
301
+ # Create buffer for text editing
302
+ text_buffer = Buffer(
303
+ document=Document(text=message, cursor_position=0),
304
+ multiline=True,
305
+ enable_history_search=False,
306
+ )
307
+
308
+ # Track submission state
309
+ cancelled = {"value": False}
310
+ submitted = {"value": False}
311
+
312
+ # Create text editor window
313
+ text_window = Window(
314
+ content=BufferControl(
315
+ buffer=text_buffer,
316
+ focus_on_click=True,
317
+ ),
318
+ height=lambda: max(5, message.count("\n") + 3),
319
+ wrap_lines=True,
320
+ right_margins=[ScrollbarMargin()],
321
+ )
322
+
323
+ # Create hint window
324
+ hint_window = Window(
325
+ content=FormattedTextControl(
326
+ text=[("class:hint", " Esc+Enter or Ctrl+S to submit | Ctrl+C to cancel ")],
327
+ ),
328
+ height=1,
329
+ dont_extend_height=True,
330
+ )
331
+
332
+ # Create layout
333
+ root_container = HSplit(
334
+ [
335
+ text_window,
336
+ hint_window,
337
+ ]
338
+ )
339
+
340
+ layout = Layout(root_container, focused_element=text_window)
341
+
342
+ # Create key bindings
343
+ kb = KeyBindings()
344
+
345
+ @kb.add("c-s")
346
+ def _(event: Any) -> None:
347
+ """Submit with Ctrl+S."""
348
+ submitted["value"] = True
349
+ event.app.exit()
350
+
351
+ @kb.add("c-c")
352
+ def _(event: Any) -> None:
353
+ """Cancel editing."""
354
+ cancelled["value"] = True
355
+ event.app.exit()
356
+
357
+ @kb.add("escape", "enter")
358
+ def _(event: Any) -> None:
359
+ """Submit with Esc+Enter."""
360
+ submitted["value"] = True
361
+ event.app.exit()
362
+
363
+ # Create and run application
364
+ custom_style = Style.from_dict(
365
+ {
366
+ "hint": "#888888",
367
+ }
368
+ )
369
+
370
+ app: Application[None] = Application(
371
+ layout=layout,
372
+ key_bindings=kb,
373
+ full_screen=False,
374
+ mouse_support=False,
375
+ editing_mode=EditingMode.VI, # Enable vi key bindings
376
+ style=custom_style,
377
+ )
378
+
379
+ app.run()
380
+
381
+ # Handle result
382
+ if cancelled["value"]:
383
+ console.print("\n[yellow]Edit cancelled.[/yellow]")
384
+ return None
385
+
386
+ if submitted["value"]:
387
+ edited_message = text_buffer.text.strip()
388
+ if not edited_message:
389
+ console.print("[yellow]Commit message cannot be empty. Edit cancelled.[/yellow]")
390
+ return None
391
+ return edited_message
392
+
393
+ return None
394
+
395
+ except (EOFError, KeyboardInterrupt):
396
+ console.print("\n[yellow]Edit cancelled.[/yellow]")
397
+ return None
398
+ except Exception as e:
399
+ logger.error(f"Error during in-place editing: {e}")
400
+ console.print(f"[error]Failed to edit commit message: {e}[/error]")
401
+ return None