gac 2.3.0__tar.gz → 2.4.1__tar.gz

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 (45) hide show
  1. {gac-2.3.0 → gac-2.4.1}/PKG-INFO +32 -9
  2. {gac-2.3.0 → gac-2.4.1}/README.md +31 -8
  3. {gac-2.3.0 → gac-2.4.1}/src/gac/__version__.py +1 -1
  4. {gac-2.3.0 → gac-2.4.1}/src/gac/cli.py +3 -1
  5. {gac-2.3.0 → gac-2.4.1}/src/gac/config.py +1 -0
  6. {gac-2.3.0 → gac-2.4.1}/src/gac/git.py +65 -8
  7. {gac-2.3.0 → gac-2.4.1}/src/gac/init_cli.py +138 -19
  8. gac-2.4.1/src/gac/language_cli.py +250 -0
  9. {gac-2.3.0 → gac-2.4.1}/src/gac/utils.py +104 -3
  10. gac-2.3.0/src/gac/language_cli.py +0 -82
  11. {gac-2.3.0 → gac-2.4.1}/.gitignore +0 -0
  12. {gac-2.3.0 → gac-2.4.1}/LICENSE +0 -0
  13. {gac-2.3.0 → gac-2.4.1}/pyproject.toml +0 -0
  14. {gac-2.3.0 → gac-2.4.1}/src/gac/__init__.py +0 -0
  15. {gac-2.3.0 → gac-2.4.1}/src/gac/ai.py +0 -0
  16. {gac-2.3.0 → gac-2.4.1}/src/gac/ai_utils.py +0 -0
  17. {gac-2.3.0 → gac-2.4.1}/src/gac/config_cli.py +0 -0
  18. {gac-2.3.0 → gac-2.4.1}/src/gac/constants.py +0 -0
  19. {gac-2.3.0 → gac-2.4.1}/src/gac/diff_cli.py +0 -0
  20. {gac-2.3.0 → gac-2.4.1}/src/gac/errors.py +0 -0
  21. {gac-2.3.0 → gac-2.4.1}/src/gac/main.py +0 -0
  22. {gac-2.3.0 → gac-2.4.1}/src/gac/preprocess.py +0 -0
  23. {gac-2.3.0 → gac-2.4.1}/src/gac/prompt.py +0 -0
  24. {gac-2.3.0 → gac-2.4.1}/src/gac/providers/__init__.py +0 -0
  25. {gac-2.3.0 → gac-2.4.1}/src/gac/providers/anthropic.py +0 -0
  26. {gac-2.3.0 → gac-2.4.1}/src/gac/providers/cerebras.py +0 -0
  27. {gac-2.3.0 → gac-2.4.1}/src/gac/providers/chutes.py +0 -0
  28. {gac-2.3.0 → gac-2.4.1}/src/gac/providers/custom_anthropic.py +0 -0
  29. {gac-2.3.0 → gac-2.4.1}/src/gac/providers/custom_openai.py +0 -0
  30. {gac-2.3.0 → gac-2.4.1}/src/gac/providers/deepseek.py +0 -0
  31. {gac-2.3.0 → gac-2.4.1}/src/gac/providers/fireworks.py +0 -0
  32. {gac-2.3.0 → gac-2.4.1}/src/gac/providers/gemini.py +0 -0
  33. {gac-2.3.0 → gac-2.4.1}/src/gac/providers/groq.py +0 -0
  34. {gac-2.3.0 → gac-2.4.1}/src/gac/providers/lmstudio.py +0 -0
  35. {gac-2.3.0 → gac-2.4.1}/src/gac/providers/minimax.py +0 -0
  36. {gac-2.3.0 → gac-2.4.1}/src/gac/providers/mistral.py +0 -0
  37. {gac-2.3.0 → gac-2.4.1}/src/gac/providers/ollama.py +0 -0
  38. {gac-2.3.0 → gac-2.4.1}/src/gac/providers/openai.py +0 -0
  39. {gac-2.3.0 → gac-2.4.1}/src/gac/providers/openrouter.py +0 -0
  40. {gac-2.3.0 → gac-2.4.1}/src/gac/providers/streamlake.py +0 -0
  41. {gac-2.3.0 → gac-2.4.1}/src/gac/providers/synthetic.py +0 -0
  42. {gac-2.3.0 → gac-2.4.1}/src/gac/providers/together.py +0 -0
  43. {gac-2.3.0 → gac-2.4.1}/src/gac/providers/zai.py +0 -0
  44. {gac-2.3.0 → gac-2.4.1}/src/gac/security.py +0 -0
  45. {gac-2.3.0 → gac-2.4.1}/src/gac/workflow_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gac
3
- Version: 2.3.0
3
+ Version: 2.4.1
4
4
  Summary: LLM-powered Git commit message generator with multi-provider support
5
5
  Project-URL: Homepage, https://github.com/cellwebb/gac
6
6
  Project-URL: Documentation, https://github.com/cellwebb/gac#readme
@@ -41,6 +41,9 @@ Requires-Dist: twine; extra == 'dev'
41
41
  Description-Content-Type: text/markdown
42
42
 
43
43
  <!-- markdownlint-disable MD013 -->
44
+ <!-- markdownlint-disable MD033 MD036 -->
45
+
46
+ <div align="center">
44
47
 
45
48
  # 🚀 Git Auto Commit (gac)
46
49
 
@@ -50,9 +53,11 @@ Description-Content-Type: text/markdown
50
53
  [![codecov](https://codecov.io/gh/cellwebb/gac/branch/main/graph/badge.svg)](https://app.codecov.io/gh/cellwebb/gac)
51
54
  [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
52
55
  [![mypy](https://img.shields.io/badge/mypy-checked-blue.svg)](https://mypy-lang.org/)
53
- [![Contributions welcome](https://img.shields.io/badge/contributions-welcome-brightgreen.svg)](docs/CONTRIBUTING.md)
56
+ [![Contributions welcome](https://img.shields.io/badge/contributions-welcome-brightgreen.svg)](docs/en/CONTRIBUTING.md)
54
57
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
55
58
 
59
+ **English** | [简体中文](README.zh-CN.md) | [繁體中文](README.zh-TW.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [हिन्दी](README.hi.md) | [Français](README.fr.md) | [Русский](README.ru.md) | [Español](README.es.md) | [Português](README.pt.md) | [Deutsch](README.de.md) | [Nederlands](README.nl.md)
60
+
56
61
  **LLM-powered commit messages that understand your code!**
57
62
 
58
63
  **Automate your commits!** Replace `git commit -m "..."` with `gac` for contextual, well-formatted commit messages generated by large language models!
@@ -67,12 +72,19 @@ Intelligent, contextual messages that explain the **why** behind your changes:
67
72
 
68
73
  ---
69
74
 
75
+ </div>
76
+
77
+ <!-- markdownlint-enable MD033 MD036 -->
78
+
79
+ <!-- markdownlint-enable MD033 MD036 -->
80
+
70
81
  ## Quick Start
71
82
 
72
83
  ### Use gac without installing
73
84
 
74
85
  ```bash
75
- uvx gac init # Configure your LLM provider
86
+ uvx gac init # Configure your provider, model, and language
87
+ uvx gac model # Re-run provider/model setup without language prompts
76
88
  uvx gac # Generate and commit with LLM
77
89
  ```
78
90
 
@@ -83,6 +95,7 @@ That's it! Review the generated message and confirm with `y`.
83
95
  ```bash
84
96
  uv tool install gac
85
97
  gac init
98
+ gac model
86
99
  gac
87
100
  ```
88
101
 
@@ -220,6 +233,8 @@ The edit feature (`e`) provides rich in-place terminal editing, allowing you to:
220
233
 
221
234
  Run `gac init` to configure your provider interactively, or set environment variables:
222
235
 
236
+ Need to change providers or models later without touching language settings? Use `gac model` for a streamlined flow that skips the language prompts.
237
+
223
238
  ```bash
224
239
  # Example configuration
225
240
  GAC_MODEL=anthropic:your-model-name
@@ -231,16 +246,24 @@ See `.gac.env.example` for all available options.
231
246
 
232
247
  **Want commit messages in another language?** Run `gac language` to select from 25+ languages including Español, Français, 日本語, and more.
233
248
 
234
- **Want to customize commit message style?** See [docs/CUSTOM_SYSTEM_PROMPTS.md](docs/CUSTOM_SYSTEM_PROMPTS.md) for guidance on writing custom system prompts.
249
+ **Want to customize commit message style?** See [docs/CUSTOM_SYSTEM_PROMPTS.md](docs/en/CUSTOM_SYSTEM_PROMPTS.md) for guidance on writing custom system prompts.
250
+
251
+ ---
252
+
253
+ ## Project Analytics
254
+
255
+ 📊 **[View live usage analytics and statistics →](https://clickpy.clickhouse.com/dashboard/gac)**
256
+
257
+ Track real-time installation metrics and package download statistics.
235
258
 
236
259
  ---
237
260
 
238
261
  ## Getting Help
239
262
 
240
- - **Full documentation**: [USAGE.md](USAGE.md) - Complete CLI reference
241
- - **Custom prompts**: [CUSTOM_SYSTEM_PROMPTS.md](docs/CUSTOM_SYSTEM_PROMPTS.md) - Customize commit message style
242
- - **Troubleshooting**: [TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md) - Common issues and solutions
243
- - **Contributing**: [CONTRIBUTING.md](docs/CONTRIBUTING.md) - Development setup and guidelines
263
+ - **Full documentation**: [docs/USAGE.md](docs/en/USAGE.md) - Complete CLI reference
264
+ - **Custom prompts**: [docs/CUSTOM_SYSTEM_PROMPTS.md](docs/en/CUSTOM_SYSTEM_PROMPTS.md) - Customize commit message style
265
+ - **Troubleshooting**: [docs/TROUBLESHOOTING.md](docs/en/TROUBLESHOOTING.md) - Common issues and solutions
266
+ - **Contributing**: [docs/CONTRIBUTING.md](docs/en/CONTRIBUTING.md) - Development setup and guidelines
244
267
 
245
268
  ---
246
269
 
@@ -250,7 +273,7 @@ See `.gac.env.example` for all available options.
250
273
 
251
274
  Made with ❤️ for developers who want better commit messages
252
275
 
253
- [⭐ Star us on GitHub](https://github.com/cellwebb/gac) • [🐛 Report issues](https://github.com/cellwebb/gac/issues) • [📖 Full docs](USAGE.md)
276
+ [⭐ Star us on GitHub](https://github.com/cellwebb/gac) • [🐛 Report issues](https://github.com/cellwebb/gac/issues) • [📖 Full docs](docs/en/USAGE.md)
254
277
 
255
278
  </div>
256
279
 
@@ -1,4 +1,7 @@
1
1
  <!-- markdownlint-disable MD013 -->
2
+ <!-- markdownlint-disable MD033 MD036 -->
3
+
4
+ <div align="center">
2
5
 
3
6
  # 🚀 Git Auto Commit (gac)
4
7
 
@@ -8,9 +11,11 @@
8
11
  [![codecov](https://codecov.io/gh/cellwebb/gac/branch/main/graph/badge.svg)](https://app.codecov.io/gh/cellwebb/gac)
9
12
  [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
10
13
  [![mypy](https://img.shields.io/badge/mypy-checked-blue.svg)](https://mypy-lang.org/)
11
- [![Contributions welcome](https://img.shields.io/badge/contributions-welcome-brightgreen.svg)](docs/CONTRIBUTING.md)
14
+ [![Contributions welcome](https://img.shields.io/badge/contributions-welcome-brightgreen.svg)](docs/en/CONTRIBUTING.md)
12
15
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
13
16
 
17
+ **English** | [简体中文](README.zh-CN.md) | [繁體中文](README.zh-TW.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [हिन्दी](README.hi.md) | [Français](README.fr.md) | [Русский](README.ru.md) | [Español](README.es.md) | [Português](README.pt.md) | [Deutsch](README.de.md) | [Nederlands](README.nl.md)
18
+
14
19
  **LLM-powered commit messages that understand your code!**
15
20
 
16
21
  **Automate your commits!** Replace `git commit -m "..."` with `gac` for contextual, well-formatted commit messages generated by large language models!
@@ -25,12 +30,19 @@ Intelligent, contextual messages that explain the **why** behind your changes:
25
30
 
26
31
  ---
27
32
 
33
+ </div>
34
+
35
+ <!-- markdownlint-enable MD033 MD036 -->
36
+
37
+ <!-- markdownlint-enable MD033 MD036 -->
38
+
28
39
  ## Quick Start
29
40
 
30
41
  ### Use gac without installing
31
42
 
32
43
  ```bash
33
- uvx gac init # Configure your LLM provider
44
+ uvx gac init # Configure your provider, model, and language
45
+ uvx gac model # Re-run provider/model setup without language prompts
34
46
  uvx gac # Generate and commit with LLM
35
47
  ```
36
48
 
@@ -41,6 +53,7 @@ That's it! Review the generated message and confirm with `y`.
41
53
  ```bash
42
54
  uv tool install gac
43
55
  gac init
56
+ gac model
44
57
  gac
45
58
  ```
46
59
 
@@ -178,6 +191,8 @@ The edit feature (`e`) provides rich in-place terminal editing, allowing you to:
178
191
 
179
192
  Run `gac init` to configure your provider interactively, or set environment variables:
180
193
 
194
+ Need to change providers or models later without touching language settings? Use `gac model` for a streamlined flow that skips the language prompts.
195
+
181
196
  ```bash
182
197
  # Example configuration
183
198
  GAC_MODEL=anthropic:your-model-name
@@ -189,16 +204,24 @@ See `.gac.env.example` for all available options.
189
204
 
190
205
  **Want commit messages in another language?** Run `gac language` to select from 25+ languages including Español, Français, 日本語, and more.
191
206
 
192
- **Want to customize commit message style?** See [docs/CUSTOM_SYSTEM_PROMPTS.md](docs/CUSTOM_SYSTEM_PROMPTS.md) for guidance on writing custom system prompts.
207
+ **Want to customize commit message style?** See [docs/CUSTOM_SYSTEM_PROMPTS.md](docs/en/CUSTOM_SYSTEM_PROMPTS.md) for guidance on writing custom system prompts.
208
+
209
+ ---
210
+
211
+ ## Project Analytics
212
+
213
+ 📊 **[View live usage analytics and statistics →](https://clickpy.clickhouse.com/dashboard/gac)**
214
+
215
+ Track real-time installation metrics and package download statistics.
193
216
 
194
217
  ---
195
218
 
196
219
  ## Getting Help
197
220
 
198
- - **Full documentation**: [USAGE.md](USAGE.md) - Complete CLI reference
199
- - **Custom prompts**: [CUSTOM_SYSTEM_PROMPTS.md](docs/CUSTOM_SYSTEM_PROMPTS.md) - Customize commit message style
200
- - **Troubleshooting**: [TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md) - Common issues and solutions
201
- - **Contributing**: [CONTRIBUTING.md](docs/CONTRIBUTING.md) - Development setup and guidelines
221
+ - **Full documentation**: [docs/USAGE.md](docs/en/USAGE.md) - Complete CLI reference
222
+ - **Custom prompts**: [docs/CUSTOM_SYSTEM_PROMPTS.md](docs/en/CUSTOM_SYSTEM_PROMPTS.md) - Customize commit message style
223
+ - **Troubleshooting**: [docs/TROUBLESHOOTING.md](docs/en/TROUBLESHOOTING.md) - Common issues and solutions
224
+ - **Contributing**: [docs/CONTRIBUTING.md](docs/en/CONTRIBUTING.md) - Development setup and guidelines
202
225
 
203
226
  ---
204
227
 
@@ -208,7 +231,7 @@ See `.gac.env.example` for all available options.
208
231
 
209
232
  Made with ❤️ for developers who want better commit messages
210
233
 
211
- [⭐ Star us on GitHub](https://github.com/cellwebb/gac) • [🐛 Report issues](https://github.com/cellwebb/gac/issues) • [📖 Full docs](USAGE.md)
234
+ [⭐ Star us on GitHub](https://github.com/cellwebb/gac) • [🐛 Report issues](https://github.com/cellwebb/gac/issues) • [📖 Full docs](docs/en/USAGE.md)
212
235
 
213
236
  </div>
214
237
 
@@ -1,3 +1,3 @@
1
1
  """Version information for gac package."""
2
2
 
3
- __version__ = "2.3.0"
3
+ __version__ = "2.4.1"
@@ -17,6 +17,7 @@ from gac.constants import Languages, Logging
17
17
  from gac.diff_cli import diff as diff_cli
18
18
  from gac.errors import handle_error
19
19
  from gac.init_cli import init as init_cli
20
+ from gac.init_cli import model as model_cli
20
21
  from gac.language_cli import language as language_cli
21
22
  from gac.main import main
22
23
  from gac.utils import setup_logging
@@ -154,9 +155,10 @@ def cli(
154
155
 
155
156
 
156
157
  cli.add_command(config_cli)
158
+ cli.add_command(diff_cli)
157
159
  cli.add_command(init_cli)
158
160
  cli.add_command(language_cli)
159
- cli.add_command(diff_cli)
161
+ cli.add_command(model_cli)
160
162
 
161
163
 
162
164
  @click.command(context_settings=language_cli.context_settings)
@@ -41,6 +41,7 @@ def load_config() -> dict[str, str | int | float | bool | None]:
41
41
  "system_prompt_path": os.getenv("GAC_SYSTEM_PROMPT_PATH"),
42
42
  "language": os.getenv("GAC_LANGUAGE"),
43
43
  "translate_prefixes": os.getenv("GAC_TRANSLATE_PREFIXES", "false").lower() in ("true", "1", "yes", "on"),
44
+ "rtl_confirmed": os.getenv("GAC_RTL_CONFIRMED", "false").lower() in ("true", "1", "yes", "on"),
44
45
  }
45
46
 
46
47
  return config
@@ -14,6 +14,63 @@ from gac.utils import run_subprocess
14
14
  logger = logging.getLogger(__name__)
15
15
 
16
16
 
17
+ def run_subprocess_with_encoding_fallback(
18
+ command: list[str], silent: bool = False, timeout: int = 60
19
+ ) -> subprocess.CompletedProcess:
20
+ """Run subprocess with encoding fallback, returning full CompletedProcess object.
21
+
22
+ This is used for cases where we need both stdout and stderr separately,
23
+ like pre-commit and lefthook hook execution.
24
+
25
+ Args:
26
+ command: List of command arguments
27
+ silent: If True, suppress debug logging
28
+ timeout: Command timeout in seconds
29
+
30
+ Returns:
31
+ CompletedProcess object with stdout, stderr, and returncode
32
+ """
33
+ from gac.utils import get_safe_encodings
34
+
35
+ encodings = get_safe_encodings()
36
+ last_exception: Exception | None = None
37
+
38
+ for encoding in encodings:
39
+ try:
40
+ if not silent:
41
+ logger.debug(f"Running command: {' '.join(command)} (encoding: {encoding})")
42
+
43
+ result = subprocess.run(
44
+ command,
45
+ capture_output=True,
46
+ text=True,
47
+ check=False,
48
+ timeout=timeout,
49
+ encoding=encoding,
50
+ errors="replace",
51
+ )
52
+ return result
53
+ except UnicodeError as e:
54
+ last_exception = e
55
+ if not silent:
56
+ logger.debug(f"Failed to decode with {encoding}: {e}")
57
+ continue
58
+ except subprocess.TimeoutExpired:
59
+ raise
60
+ except Exception as e:
61
+ if not silent:
62
+ logger.debug(f"Command error: {e}")
63
+ # Try next encoding for non-timeout errors
64
+ last_exception = e
65
+ continue
66
+
67
+ # If we get here, all encodings failed
68
+ if last_exception:
69
+ raise subprocess.CalledProcessError(1, command, "", f"Encoding error: {last_exception}") from last_exception
70
+ else:
71
+ raise subprocess.CalledProcessError(1, command, "", "All encoding attempts failed")
72
+
73
+
17
74
  def run_git_command(args: list[str], silent: bool = False, timeout: int = 30) -> str:
18
75
  """Run a git command and return the output."""
19
76
  command = ["git"] + args
@@ -132,20 +189,20 @@ def get_diff(staged: bool = True, color: bool = True, commit1: str | None = None
132
189
 
133
190
  def get_repo_root() -> str:
134
191
  """Get absolute path of repository root."""
135
- result = subprocess.check_output(["git", "rev-parse", "--show-toplevel"])
136
- return result.decode().strip()
192
+ result = run_git_command(["rev-parse", "--show-toplevel"])
193
+ return result
137
194
 
138
195
 
139
196
  def get_current_branch() -> str:
140
197
  """Get name of current git branch."""
141
- result = subprocess.check_output(["git", "rev-parse", "--abbrev-ref", "HEAD"])
142
- return result.decode().strip()
198
+ result = run_git_command(["rev-parse", "--abbrev-ref", "HEAD"])
199
+ return result
143
200
 
144
201
 
145
202
  def get_commit_hash() -> str:
146
203
  """Get SHA-1 hash of current commit."""
147
- result = subprocess.check_output(["git", "rev-parse", "HEAD"])
148
- return result.decode().strip()
204
+ result = run_git_command(["rev-parse", "HEAD"])
205
+ return result
149
206
 
150
207
 
151
208
  def run_pre_commit_hooks() -> bool:
@@ -170,7 +227,7 @@ def run_pre_commit_hooks() -> bool:
170
227
  # Run pre-commit hooks on staged files
171
228
  logger.info("Running pre-commit hooks...")
172
229
  # Run pre-commit and capture both stdout and stderr
173
- result = subprocess.run(["pre-commit", "run"], capture_output=True, text=True, check=False)
230
+ result = run_subprocess_with_encoding_fallback(["pre-commit", "run"])
174
231
 
175
232
  if result.returncode == 0:
176
233
  # All hooks passed
@@ -220,7 +277,7 @@ def run_lefthook_hooks() -> bool:
220
277
  # Run lefthook hooks on staged files
221
278
  logger.info("Running Lefthook hooks...")
222
279
  # Run lefthook and capture both stdout and stderr
223
- result = subprocess.run(["lefthook", "run", "pre-commit"], capture_output=True, text=True, check=False)
280
+ result = run_subprocess_with_encoding_fallback(["lefthook", "run", "pre-commit"])
224
281
 
225
282
  if result.returncode == 0:
226
283
  # All hooks passed
@@ -1,16 +1,58 @@
1
1
  """CLI for initializing gac configuration interactively."""
2
2
 
3
+ import os
3
4
  from pathlib import Path
4
5
 
5
6
  import click
6
7
  import questionary
7
- from dotenv import dotenv_values, set_key
8
+ from dotenv import dotenv_values, load_dotenv, set_key
8
9
 
9
10
  from gac.constants import Languages
10
11
 
11
12
  GAC_ENV_PATH = Path.home() / ".gac.env"
12
13
 
13
14
 
15
+ def _should_show_rtl_warning_for_init() -> bool:
16
+ """Check if RTL warning should be shown based on init's GAC_ENV_PATH.
17
+
18
+ Returns:
19
+ True if warning should be shown, False if user previously confirmed
20
+ """
21
+ if GAC_ENV_PATH.exists():
22
+ load_dotenv(GAC_ENV_PATH)
23
+ rtl_confirmed = os.getenv("GAC_RTL_CONFIRMED", "false").lower() in ("true", "1", "yes", "on")
24
+ return not rtl_confirmed
25
+ return True # Show warning if no config exists
26
+
27
+
28
+ def _show_rtl_warning_for_init(language_name: str) -> bool:
29
+ """Show RTL language warning for init command and save preference to GAC_ENV_PATH.
30
+
31
+ Args:
32
+ language_name: Name of the RTL language
33
+
34
+ Returns:
35
+ True if user wants to proceed, False if they cancel
36
+ """
37
+
38
+ terminal_width = 80 # Use default width
39
+ title = "⚠️ RTL Language Detected".center(terminal_width)
40
+
41
+ click.echo()
42
+ click.echo(click.style(title, fg="yellow", bold=True))
43
+ click.echo()
44
+ click.echo("Right-to-left (RTL) languages may not display correctly in gac due to terminal limitations.")
45
+ click.echo("However, the commit messages will work fine and should be readable in Git clients")
46
+ click.echo("that properly support RTL text (like most web interfaces and modern tools).\n")
47
+
48
+ proceed = questionary.confirm("Do you want to proceed anyway?").ask()
49
+ if proceed:
50
+ # Remember that user has confirmed RTL acceptance
51
+ set_key(str(GAC_ENV_PATH), "GAC_RTL_CONFIRMED", "true")
52
+ click.echo("✓ RTL preference saved - you won't see this warning again")
53
+ return proceed if proceed is not None else False
54
+
55
+
14
56
  def _prompt_required_text(prompt: str) -> str | None:
15
57
  """Prompt until a non-empty string is provided or the user cancels."""
16
58
  while True:
@@ -23,20 +65,20 @@ def _prompt_required_text(prompt: str) -> str | None:
23
65
  click.echo("A value is required. Please try again.")
24
66
 
25
67
 
26
- @click.command()
27
- def init() -> None:
28
- """Interactively set up $HOME/.gac.env for gac."""
29
- click.echo("Welcome to gac initialization!\n")
30
-
31
- # Load existing environment values
32
- existing_env = {}
68
+ def _load_existing_env() -> dict[str, str]:
69
+ """Ensure the env file exists and return its current values."""
70
+ existing_env: dict[str, str] = {}
33
71
  if GAC_ENV_PATH.exists():
34
72
  click.echo(f"$HOME/.gac.env already exists at {GAC_ENV_PATH}. Values will be updated.")
35
- existing_env = dict(dotenv_values(str(GAC_ENV_PATH)))
73
+ existing_env = {k: v for k, v in dotenv_values(str(GAC_ENV_PATH)).items() if v is not None}
36
74
  else:
37
75
  GAC_ENV_PATH.touch()
38
76
  click.echo(f"Created $HOME/.gac.env at {GAC_ENV_PATH}.")
77
+ return existing_env
39
78
 
79
+
80
+ def _configure_model(existing_env: dict[str, str]) -> bool:
81
+ """Run the provider/model/API key configuration flow."""
40
82
  providers = [
41
83
  ("Anthropic", "claude-haiku-4-5"),
42
84
  ("Cerebras", "zai-glm-4.6"),
@@ -63,7 +105,7 @@ def init() -> None:
63
105
  provider = questionary.select("Select your provider:", choices=provider_names).ask()
64
106
  if not provider:
65
107
  click.echo("Provider selection cancelled. Exiting.")
66
- return
108
+ return False
67
109
  provider_key = provider.lower().replace(".", "").replace(" ", "-").replace("(", "").replace(")", "")
68
110
 
69
111
  is_ollama = provider_key == "ollama"
@@ -77,7 +119,7 @@ def init() -> None:
77
119
  endpoint_id = _prompt_required_text("Enter the Streamlake inference endpoint ID (required):")
78
120
  if endpoint_id is None:
79
121
  click.echo("Streamlake configuration cancelled. Exiting.")
80
- return
122
+ return False
81
123
  model_to_save = endpoint_id
82
124
  else:
83
125
  model_suggestion = dict(providers)[provider]
@@ -88,7 +130,7 @@ def init() -> None:
88
130
  model = questionary.text(model_prompt, default=model_suggestion).ask()
89
131
  if model is None:
90
132
  click.echo("Model entry cancelled. Exiting.")
91
- return
133
+ return False
92
134
  model_to_save = model.strip() if model.strip() else model_suggestion
93
135
 
94
136
  set_key(str(GAC_ENV_PATH), "GAC_MODEL", f"{provider_key}:{model_to_save}")
@@ -98,7 +140,7 @@ def init() -> None:
98
140
  base_url = _prompt_required_text("Enter the custom Anthropic-compatible base URL (required):")
99
141
  if base_url is None:
100
142
  click.echo("Custom Anthropic base URL entry cancelled. Exiting.")
101
- return
143
+ return False
102
144
  set_key(str(GAC_ENV_PATH), "CUSTOM_ANTHROPIC_BASE_URL", base_url)
103
145
  click.echo(f"Set CUSTOM_ANTHROPIC_BASE_URL={base_url}")
104
146
 
@@ -112,7 +154,7 @@ def init() -> None:
112
154
  base_url = _prompt_required_text("Enter the custom OpenAI-compatible base URL (required):")
113
155
  if base_url is None:
114
156
  click.echo("Custom OpenAI base URL entry cancelled. Exiting.")
115
- return
157
+ return False
116
158
  set_key(str(GAC_ENV_PATH), "CUSTOM_OPENAI_BASE_URL", base_url)
117
159
  click.echo(f"Set CUSTOM_OPENAI_BASE_URL={base_url}")
118
160
  elif is_ollama:
@@ -120,7 +162,7 @@ def init() -> None:
120
162
  url = questionary.text(f"Enter the Ollama API URL (default: {url_default}):", default=url_default).ask()
121
163
  if url is None:
122
164
  click.echo("Ollama URL entry cancelled. Exiting.")
123
- return
165
+ return False
124
166
  url_to_save = url.strip() if url.strip() else url_default
125
167
  set_key(str(GAC_ENV_PATH), "OLLAMA_API_URL", url_to_save)
126
168
  click.echo(f"Set OLLAMA_API_URL={url_to_save}")
@@ -129,7 +171,7 @@ def init() -> None:
129
171
  url = questionary.text(f"Enter the LM Studio API URL (default: {url_default}):", default=url_default).ask()
130
172
  if url is None:
131
173
  click.echo("LM Studio URL entry cancelled. Exiting.")
132
- return
174
+ return False
133
175
  url_to_save = url.strip() if url.strip() else url_default
134
176
  set_key(str(GAC_ENV_PATH), "LMSTUDIO_API_URL", url_to_save)
135
177
  click.echo(f"Set LMSTUDIO_API_URL={url_to_save}")
@@ -189,7 +231,13 @@ def init() -> None:
189
231
  else:
190
232
  click.echo("No API key entered. You can add one later by editing ~/.gac.env")
191
233
 
192
- # Language selection
234
+ return True
235
+
236
+
237
+ def _configure_language(existing_env: dict[str, str]) -> None:
238
+ """Run the language configuration flow."""
239
+ from gac.language_cli import is_rtl_text
240
+
193
241
  click.echo("\n")
194
242
  existing_language = existing_env.get("GAC_LANGUAGE")
195
243
 
@@ -216,7 +264,11 @@ def init() -> None:
216
264
  # Proceed with language selection
217
265
  display_names = [lang[0] for lang in Languages.LANGUAGES]
218
266
  language_selection = questionary.select(
219
- "Select a language for commit messages:", choices=display_names, use_shortcuts=True, use_arrow_keys=True
267
+ "Select a language for commit messages:",
268
+ choices=display_names,
269
+ use_shortcuts=True,
270
+ use_arrow_keys=True,
271
+ use_jk_keys=False,
220
272
  ).ask()
221
273
 
222
274
  if not language_selection:
@@ -237,10 +289,30 @@ def init() -> None:
237
289
  language_value = None
238
290
  else:
239
291
  language_value = custom_language.strip()
292
+
293
+ # Check if the custom language appears to be RTL
294
+ if is_rtl_text(language_value):
295
+ if not _should_show_rtl_warning_for_init():
296
+ click.echo(
297
+ f"\nℹ️ Using RTL language {language_value} (RTL warning previously confirmed)"
298
+ )
299
+ else:
300
+ if not _show_rtl_warning_for_init(language_value):
301
+ click.echo("Language selection cancelled. Keeping existing language.")
302
+ language_value = None
240
303
  else:
241
304
  # Find the English name for the selected language
242
305
  language_value = next(lang[1] for lang in Languages.LANGUAGES if lang[0] == language_selection)
243
306
 
307
+ # Check if predefined language is RTL
308
+ if is_rtl_text(language_value):
309
+ if not _should_show_rtl_warning_for_init():
310
+ click.echo(f"\nℹ️ Using RTL language {language_value} (RTL warning previously confirmed)")
311
+ else:
312
+ if not _show_rtl_warning_for_init(language_value):
313
+ click.echo("Language selection cancelled. Keeping existing language.")
314
+ language_value = None
315
+
244
316
  if language_value:
245
317
  # Ask about prefix translation
246
318
  prefix_choice = questionary.select(
@@ -266,7 +338,11 @@ def init() -> None:
266
338
  # No existing language - proceed with normal flow
267
339
  display_names = [lang[0] for lang in Languages.LANGUAGES]
268
340
  language_selection = questionary.select(
269
- "Select a language for commit messages:", choices=display_names, use_shortcuts=True, use_arrow_keys=True
341
+ "Select a language for commit messages:",
342
+ choices=display_names,
343
+ use_shortcuts=True,
344
+ use_arrow_keys=True,
345
+ use_jk_keys=False,
270
346
  ).ask()
271
347
 
272
348
  if not language_selection:
@@ -287,10 +363,28 @@ def init() -> None:
287
363
  language_value = None
288
364
  else:
289
365
  language_value = custom_language.strip()
366
+
367
+ # Check if the custom language appears to be RTL
368
+ if is_rtl_text(language_value):
369
+ if not _should_show_rtl_warning_for_init():
370
+ click.echo(f"\nℹ️ Using RTL language {language_value} (RTL warning previously confirmed)")
371
+ else:
372
+ if not _show_rtl_warning_for_init(language_value):
373
+ click.echo("Language selection cancelled. Using English (default).")
374
+ language_value = None
290
375
  else:
291
376
  # Find the English name for the selected language
292
377
  language_value = next(lang[1] for lang in Languages.LANGUAGES if lang[0] == language_selection)
293
378
 
379
+ # Check if predefined language is RTL
380
+ if is_rtl_text(language_value):
381
+ if not _should_show_rtl_warning_for_init():
382
+ click.echo(f"\nℹ️ Using RTL language {language_value} (RTL warning previously confirmed)")
383
+ else:
384
+ if not _show_rtl_warning_for_init(language_value):
385
+ click.echo("Language selection cancelled. Using English (default).")
386
+ language_value = None
387
+
294
388
  if language_value:
295
389
  # Ask about prefix translation
296
390
  prefix_choice = questionary.select(
@@ -313,4 +407,29 @@ def init() -> None:
313
407
  click.echo(f"Set GAC_LANGUAGE={language_value}")
314
408
  click.echo(f"Set GAC_TRANSLATE_PREFIXES={'true' if translate_prefixes else 'false'}")
315
409
 
410
+ return
411
+
412
+
413
+ @click.command()
414
+ def init() -> None:
415
+ """Interactively set up $HOME/.gac.env for gac."""
416
+ click.echo("Welcome to gac initialization!\n")
417
+
418
+ existing_env = _load_existing_env()
419
+ if not _configure_model(existing_env):
420
+ return
421
+ _configure_language(existing_env)
422
+
316
423
  click.echo(f"\ngac environment setup complete. You can edit {GAC_ENV_PATH} to update values later.")
424
+
425
+
426
+ @click.command()
427
+ def model() -> None:
428
+ """Interactively update provider/model/API key without language prompts."""
429
+ click.echo("Welcome to gac model configuration!\n")
430
+
431
+ existing_env = _load_existing_env()
432
+ if not _configure_model(existing_env):
433
+ return
434
+
435
+ click.echo(f"\nModel configuration complete. You can edit {GAC_ENV_PATH} to update values later.")
@@ -0,0 +1,250 @@
1
+ """CLI for selecting commit message language interactively."""
2
+
3
+ import os
4
+ import unicodedata
5
+ from pathlib import Path
6
+
7
+ import click
8
+ import questionary
9
+ from dotenv import load_dotenv, set_key
10
+
11
+ from gac.constants import Languages
12
+
13
+ GAC_ENV_PATH = Path.home() / ".gac.env"
14
+
15
+
16
+ def should_show_rtl_warning() -> bool:
17
+ """Check if RTL warning should be shown based on saved preference.
18
+
19
+ Returns:
20
+ True if warning should be shown, False if user previously confirmed
21
+ """
22
+ # Load the current config to check RTL confirmation
23
+ if GAC_ENV_PATH.exists():
24
+ load_dotenv(GAC_ENV_PATH)
25
+ rtl_confirmed = os.getenv("GAC_RTL_CONFIRMED", "false").lower() in ("true", "1", "yes", "on")
26
+ return not rtl_confirmed
27
+ return True # Show warning if no config exists
28
+
29
+
30
+ def is_rtl_text(text: str) -> bool:
31
+ """Detect if text contains RTL characters or is a known RTL language.
32
+
33
+ Args:
34
+ text: Text to analyze
35
+
36
+ Returns:
37
+ True if text contains RTL script characters or is RTL language
38
+ """
39
+ # Known RTL language names (case insensitive)
40
+ rtl_languages = {
41
+ "arabic",
42
+ "ar",
43
+ "العربية",
44
+ "hebrew",
45
+ "he",
46
+ "עברית",
47
+ "persian",
48
+ "farsi",
49
+ "fa",
50
+ "urdu",
51
+ "ur",
52
+ "اردو",
53
+ "pashto",
54
+ "ps",
55
+ "kurdish",
56
+ "ku",
57
+ "کوردی",
58
+ "yiddish",
59
+ "yi",
60
+ "ייִדיש",
61
+ }
62
+
63
+ # Check if it's a known RTL language name or code (case insensitive)
64
+ if text.lower().strip() in rtl_languages:
65
+ return True
66
+
67
+ rtl_scripts = {"Arabic", "Hebrew", "Thaana", "Nko", "Syriac", "Mandeic", "Samaritan", "Mongolian", "Phags-Pa"}
68
+
69
+ for char in text:
70
+ if unicodedata.name(char, "").startswith(("ARABIC", "HEBREW")):
71
+ return True
72
+ script = unicodedata.name(char, "").split()[0] if unicodedata.name(char, "") else ""
73
+ if script in rtl_scripts:
74
+ return True
75
+ return False
76
+
77
+
78
+ def center_text(text: str, width: int = 80) -> str:
79
+ """Center text within specified width, handling display width properly.
80
+
81
+ Args:
82
+ text: Text to center
83
+ width: Terminal width to center within (default 80)
84
+
85
+ Returns:
86
+ Centered text with proper padding
87
+ """
88
+ import unicodedata
89
+
90
+ def get_display_width(s: str) -> int:
91
+ """Get the display width of a string, accounting for wide characters."""
92
+ width = 0
93
+ for char in s:
94
+ # East Asian characters are typically 2 columns wide
95
+ if unicodedata.east_asian_width(char) in ("W", "F"):
96
+ width += 2
97
+ else:
98
+ width += 1
99
+ return width
100
+
101
+ # Handle multi-line text
102
+ lines = text.split("\n")
103
+ centered_lines = []
104
+
105
+ for line in lines:
106
+ # Strip existing whitespace to avoid double padding
107
+ stripped_line = line.strip()
108
+ if stripped_line:
109
+ # Calculate padding using display width for accurate centering
110
+ display_width = get_display_width(stripped_line)
111
+ padding = max(0, (width - display_width) // 2)
112
+ centered_line = " " * padding + stripped_line
113
+ centered_lines.append(centered_line)
114
+ else:
115
+ centered_lines.append("")
116
+
117
+ return "\n".join(centered_lines)
118
+
119
+
120
+ def get_terminal_width() -> int:
121
+ """Get the current terminal width.
122
+
123
+ Returns:
124
+ Terminal width in characters, or default if can't be determined
125
+ """
126
+ try:
127
+ import shutil
128
+
129
+ return shutil.get_terminal_size().columns
130
+ except (OSError, AttributeError):
131
+ return 80 # Fallback to 80 columns
132
+
133
+
134
+ def show_rtl_warning(language_name: str) -> bool:
135
+ """Show RTL language warning and ask for confirmation.
136
+
137
+ Args:
138
+ language_name: Name of the RTL language
139
+
140
+ Returns:
141
+ True if user wants to proceed, False if they cancel
142
+ """
143
+ terminal_width = get_terminal_width()
144
+
145
+ # Center just the title
146
+ title = center_text("⚠️ RTL Language Detected", terminal_width)
147
+
148
+ click.echo()
149
+ click.echo(click.style(title, fg="yellow", bold=True))
150
+ click.echo()
151
+ click.echo("Right-to-left (RTL) languages may not display correctly in gac due to terminal limitations.")
152
+ click.echo("However, the commit messages will work fine and should be readable in Git clients")
153
+ click.echo("that properly support RTL text (like most web interfaces and modern tools).\n")
154
+
155
+ proceed = questionary.confirm("Do you want to proceed anyway?").ask()
156
+ if proceed:
157
+ # Remember that user has confirmed RTL acceptance
158
+ set_key(str(GAC_ENV_PATH), "GAC_RTL_CONFIRMED", "true")
159
+ click.echo("✓ RTL preference saved - you won't see this warning again")
160
+ return proceed if proceed is not None else False
161
+
162
+
163
+ @click.command()
164
+ def language() -> None:
165
+ """Set the language for commit messages interactively."""
166
+ click.echo("Select a language for your commit messages:\n")
167
+
168
+ display_names = [lang[0] for lang in Languages.LANGUAGES]
169
+ selection = questionary.select(
170
+ "Choose your language:", choices=display_names, use_shortcuts=True, use_arrow_keys=True, use_jk_keys=False
171
+ ).ask()
172
+
173
+ if not selection:
174
+ click.echo("Language selection cancelled.")
175
+ return
176
+
177
+ # Ensure .gac.env exists
178
+ if not GAC_ENV_PATH.exists():
179
+ GAC_ENV_PATH.touch()
180
+ click.echo(f"Created {GAC_ENV_PATH}")
181
+
182
+ # Handle English - set explicitly
183
+ if selection == "English":
184
+ set_key(str(GAC_ENV_PATH), "GAC_LANGUAGE", "English")
185
+ set_key(str(GAC_ENV_PATH), "GAC_TRANSLATE_PREFIXES", "false")
186
+ click.echo("✓ Set language to English")
187
+ click.echo(" GAC_LANGUAGE=English")
188
+ click.echo(" GAC_TRANSLATE_PREFIXES=false")
189
+ click.echo(f"\n Configuration saved to {GAC_ENV_PATH}")
190
+ return
191
+
192
+ # Handle custom input
193
+ if selection == "Custom":
194
+ custom_language = questionary.text("Enter the language name (e.g., 'Spanish', 'Français', '日本語'):").ask()
195
+ if not custom_language or not custom_language.strip():
196
+ click.echo("No language entered. Cancelled.")
197
+ return
198
+ language_value = custom_language.strip()
199
+
200
+ # Check if the custom language appears to be RTL
201
+ if is_rtl_text(language_value):
202
+ if not should_show_rtl_warning():
203
+ click.echo(f"\nℹ️ Using RTL language {language_value} (RTL warning previously confirmed)")
204
+ else:
205
+ if not show_rtl_warning(language_value):
206
+ click.echo("Language selection cancelled.")
207
+ return
208
+
209
+ else:
210
+ # Find the English name for the selected language
211
+ language_value = next(lang[1] for lang in Languages.LANGUAGES if lang[0] == selection)
212
+
213
+ # Check if predefined language is RTL
214
+ if is_rtl_text(language_value):
215
+ if not should_show_rtl_warning():
216
+ click.echo(f"\nℹ️ Using RTL language {language_value} (RTL warning previously confirmed)")
217
+ else:
218
+ if not show_rtl_warning(language_value):
219
+ click.echo("Language selection cancelled.")
220
+ return
221
+
222
+ # Ask about prefix translation
223
+ click.echo() # Blank line for spacing
224
+ prefix_choice = questionary.select(
225
+ "How should conventional commit prefixes be handled?",
226
+ choices=[
227
+ "Keep prefixes in English (feat:, fix:, etc.)",
228
+ f"Translate prefixes into {language_value}",
229
+ ],
230
+ ).ask()
231
+
232
+ if not prefix_choice:
233
+ click.echo("Prefix translation selection cancelled.")
234
+ return
235
+
236
+ translate_prefixes = prefix_choice.startswith("Translate prefixes")
237
+
238
+ # Set the language and prefix translation preference in .gac.env
239
+ set_key(str(GAC_ENV_PATH), "GAC_LANGUAGE", language_value)
240
+ set_key(str(GAC_ENV_PATH), "GAC_TRANSLATE_PREFIXES", "true" if translate_prefixes else "false")
241
+
242
+ click.echo(f"\n✓ Set language to {selection}")
243
+ click.echo(f" GAC_LANGUAGE={language_value}")
244
+ if translate_prefixes:
245
+ click.echo(" GAC_TRANSLATE_PREFIXES=true")
246
+ click.echo("\n Prefixes will be translated (e.g., 'corrección:' instead of 'fix:')")
247
+ else:
248
+ click.echo(" GAC_TRANSLATE_PREFIXES=false")
249
+ click.echo(f"\n Prefixes will remain in English (e.g., 'fix: <{language_value} description>')")
250
+ click.echo(f"\n Configuration saved to {GAC_ENV_PATH}")
@@ -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}")
@@ -132,6 +170,69 @@ def run_subprocess(
132
170
  return ""
133
171
 
134
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
+
135
236
  def edit_commit_message_inplace(message: str) -> str | None:
136
237
  """Edit commit message in-place using rich terminal editing.
137
238
 
@@ -1,82 +0,0 @@
1
- """CLI for selecting commit message language interactively."""
2
-
3
- from pathlib import Path
4
-
5
- import click
6
- import questionary
7
- from dotenv import set_key
8
-
9
- from gac.constants import Languages
10
-
11
- GAC_ENV_PATH = Path.home() / ".gac.env"
12
-
13
-
14
- @click.command()
15
- def language() -> None:
16
- """Set the language for commit messages interactively."""
17
- click.echo("Select a language for your commit messages:\n")
18
-
19
- display_names = [lang[0] for lang in Languages.LANGUAGES]
20
- selection = questionary.select(
21
- "Choose your language:", choices=display_names, use_shortcuts=True, use_arrow_keys=True
22
- ).ask()
23
-
24
- if not selection:
25
- click.echo("Language selection cancelled.")
26
- return
27
-
28
- # Ensure .gac.env exists
29
- if not GAC_ENV_PATH.exists():
30
- GAC_ENV_PATH.touch()
31
- click.echo(f"Created {GAC_ENV_PATH}")
32
-
33
- # Handle English - set explicitly
34
- if selection == "English":
35
- set_key(str(GAC_ENV_PATH), "GAC_LANGUAGE", "English")
36
- set_key(str(GAC_ENV_PATH), "GAC_TRANSLATE_PREFIXES", "false")
37
- click.echo("✓ Set language to English")
38
- click.echo(" GAC_LANGUAGE=English")
39
- click.echo(" GAC_TRANSLATE_PREFIXES=false")
40
- click.echo(f"\n Configuration saved to {GAC_ENV_PATH}")
41
- return
42
-
43
- # Handle custom input
44
- if selection == "Custom":
45
- custom_language = questionary.text("Enter the language name (e.g., 'Spanish', 'Français', '日本語'):").ask()
46
- if not custom_language or not custom_language.strip():
47
- click.echo("No language entered. Cancelled.")
48
- return
49
- language_value = custom_language.strip()
50
- else:
51
- # Find the English name for the selected language
52
- language_value = next(lang[1] for lang in Languages.LANGUAGES if lang[0] == selection)
53
-
54
- # Ask about prefix translation
55
- click.echo() # Blank line for spacing
56
- prefix_choice = questionary.select(
57
- "How should conventional commit prefixes be handled?",
58
- choices=[
59
- "Keep prefixes in English (feat:, fix:, etc.)",
60
- f"Translate prefixes into {language_value}",
61
- ],
62
- ).ask()
63
-
64
- if not prefix_choice:
65
- click.echo("Prefix translation selection cancelled.")
66
- return
67
-
68
- translate_prefixes = prefix_choice.startswith("Translate prefixes")
69
-
70
- # Set the language and prefix translation preference in .gac.env
71
- set_key(str(GAC_ENV_PATH), "GAC_LANGUAGE", language_value)
72
- set_key(str(GAC_ENV_PATH), "GAC_TRANSLATE_PREFIXES", "true" if translate_prefixes else "false")
73
-
74
- click.echo(f"\n✓ Set language to {selection}")
75
- click.echo(f" GAC_LANGUAGE={language_value}")
76
- if translate_prefixes:
77
- click.echo(" GAC_TRANSLATE_PREFIXES=true")
78
- click.echo("\n Prefixes will be translated (e.g., 'corrección:' instead of 'fix:')")
79
- else:
80
- click.echo(" GAC_TRANSLATE_PREFIXES=false")
81
- click.echo(f"\n Prefixes will remain in English (e.g., 'fix: <{language_value} description>')")
82
- click.echo(f"\n Configuration saved to {GAC_ENV_PATH}")
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes