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.
- gac/__init__.py +15 -0
- gac/__version__.py +3 -0
- gac/ai.py +109 -0
- gac/ai_utils.py +246 -0
- gac/auth_cli.py +214 -0
- gac/cli.py +218 -0
- gac/commit_executor.py +62 -0
- gac/config.py +125 -0
- gac/config_cli.py +95 -0
- gac/constants.py +328 -0
- gac/diff_cli.py +159 -0
- gac/errors.py +231 -0
- gac/git.py +372 -0
- gac/git_state_validator.py +184 -0
- gac/grouped_commit_workflow.py +423 -0
- gac/init_cli.py +70 -0
- gac/interactive_mode.py +182 -0
- gac/language_cli.py +377 -0
- gac/main.py +476 -0
- gac/model_cli.py +430 -0
- gac/oauth/__init__.py +27 -0
- gac/oauth/claude_code.py +464 -0
- gac/oauth/qwen_oauth.py +327 -0
- gac/oauth/token_store.py +81 -0
- gac/preprocess.py +511 -0
- gac/prompt.py +878 -0
- gac/prompt_builder.py +88 -0
- gac/providers/README.md +437 -0
- gac/providers/__init__.py +80 -0
- gac/providers/anthropic.py +17 -0
- gac/providers/azure_openai.py +57 -0
- gac/providers/base.py +329 -0
- gac/providers/cerebras.py +15 -0
- gac/providers/chutes.py +25 -0
- gac/providers/claude_code.py +79 -0
- gac/providers/custom_anthropic.py +103 -0
- gac/providers/custom_openai.py +44 -0
- gac/providers/deepseek.py +15 -0
- gac/providers/error_handler.py +139 -0
- gac/providers/fireworks.py +15 -0
- gac/providers/gemini.py +90 -0
- gac/providers/groq.py +15 -0
- gac/providers/kimi_coding.py +27 -0
- gac/providers/lmstudio.py +80 -0
- gac/providers/minimax.py +15 -0
- gac/providers/mistral.py +15 -0
- gac/providers/moonshot.py +15 -0
- gac/providers/ollama.py +73 -0
- gac/providers/openai.py +32 -0
- gac/providers/openrouter.py +21 -0
- gac/providers/protocol.py +71 -0
- gac/providers/qwen.py +64 -0
- gac/providers/registry.py +58 -0
- gac/providers/replicate.py +156 -0
- gac/providers/streamlake.py +31 -0
- gac/providers/synthetic.py +40 -0
- gac/providers/together.py +15 -0
- gac/providers/zai.py +31 -0
- gac/py.typed +0 -0
- gac/security.py +293 -0
- gac/utils.py +401 -0
- gac/workflow_utils.py +217 -0
- gac-3.10.3.dist-info/METADATA +283 -0
- gac-3.10.3.dist-info/RECORD +67 -0
- gac-3.10.3.dist-info/WHEEL +4 -0
- gac-3.10.3.dist-info/entry_points.txt +2 -0
- 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
|