gac 1.13.1__py3-none-any.whl → 1.15.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


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

gac/__version__.py CHANGED
@@ -1,3 +1,3 @@
1
1
  """Version information for gac package."""
2
2
 
3
- __version__ = "1.13.1"
3
+ __version__ = "1.15.0"
gac/cli.py CHANGED
@@ -13,10 +13,11 @@ import click
13
13
  from gac import __version__
14
14
  from gac.config import load_config
15
15
  from gac.config_cli import config as config_cli
16
- from gac.constants import Logging
16
+ 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.language_cli import language as language_cli
20
21
  from gac.main import main
21
22
  from gac.utils import setup_logging
22
23
 
@@ -43,6 +44,9 @@ logger = logging.getLogger(__name__)
43
44
  @click.option("--hint", "-h", default="", help="Additional context to include in the prompt")
44
45
  # Model options
45
46
  @click.option("--model", "-m", help="Override the default model (format: 'provider:model_name')")
47
+ @click.option(
48
+ "--language", "-l", help="Override the language for commit messages (e.g., 'Spanish', 'es', 'zh-CN', 'ja')"
49
+ )
46
50
  # Output options
47
51
  @click.option("--quiet", "-q", is_flag=True, help="Suppress non-error output")
48
52
  @click.option(
@@ -75,6 +79,7 @@ def cli(
75
79
  yes: bool = False,
76
80
  hint: str = "",
77
81
  model: str | None = None,
82
+ language: str | None = None,
78
83
  version: bool = False,
79
84
  dry_run: bool = False,
80
85
  verbose: bool = False,
@@ -98,6 +103,9 @@ def cli(
98
103
  # Determine if verbose mode should be enabled based on -v flag or verbose config setting
99
104
  use_verbose = bool(verbose or config.get("verbose", False))
100
105
 
106
+ # Resolve language code to full name if provided
107
+ resolved_language = Languages.resolve_code(language) if language else None
108
+
101
109
  try:
102
110
  main(
103
111
  stage_all=add_all,
@@ -113,6 +121,7 @@ def cli(
113
121
  verbose=use_verbose,
114
122
  no_verify=no_verify,
115
123
  skip_secret_scan=skip_secret_scan or bool(config.get("skip_secret_scan", False)),
124
+ language=resolved_language,
116
125
  )
117
126
  except Exception as e:
118
127
  handle_error(e, exit_program=True)
@@ -131,6 +140,7 @@ def cli(
131
140
  "yes": yes,
132
141
  "hint": hint,
133
142
  "model": model,
143
+ "language": language,
134
144
  "version": version,
135
145
  "dry_run": dry_run,
136
146
  "verbose": verbose,
@@ -141,6 +151,7 @@ def cli(
141
151
 
142
152
  cli.add_command(config_cli)
143
153
  cli.add_command(init_cli)
154
+ cli.add_command(language_cli)
144
155
  cli.add_command(diff_cli)
145
156
 
146
157
  if __name__ == "__main__":
gac/config.py CHANGED
@@ -38,6 +38,9 @@ def load_config() -> dict[str, str | int | float | bool | None]:
38
38
  "skip_secret_scan": os.getenv("GAC_SKIP_SECRET_SCAN", str(EnvDefaults.SKIP_SECRET_SCAN)).lower()
39
39
  in ("true", "1", "yes", "on"),
40
40
  "verbose": os.getenv("GAC_VERBOSE", str(EnvDefaults.VERBOSE)).lower() in ("true", "1", "yes", "on"),
41
+ "system_prompt_path": os.getenv("GAC_SYSTEM_PROMPT_PATH"),
42
+ "language": os.getenv("GAC_LANGUAGE"),
43
+ "translate_prefixes": os.getenv("GAC_TRANSLATE_PREFIXES", "false").lower() in ("true", "1", "yes", "on"),
41
44
  }
42
45
 
43
46
  return config
gac/constants.py CHANGED
@@ -150,3 +150,139 @@ class CodePatternImportance:
150
150
  r"\+\s*(test|describe|it|should)\s*\(": 1.1, # Test definitions
151
151
  r"\+\s*(assert|expect)": 1.0, # Assertions
152
152
  }
153
+
154
+
155
+ class Languages:
156
+ """Language code mappings and utilities."""
157
+
158
+ # Language code to full name mapping
159
+ # Supports ISO 639-1 codes and common variants
160
+ CODE_MAP: dict[str, str] = {
161
+ # English
162
+ "en": "English",
163
+ # Chinese
164
+ "zh": "Simplified Chinese",
165
+ "zh-cn": "Simplified Chinese",
166
+ "zh-hans": "Simplified Chinese",
167
+ "zh-tw": "Traditional Chinese",
168
+ "zh-hant": "Traditional Chinese",
169
+ # Japanese
170
+ "ja": "Japanese",
171
+ # Korean
172
+ "ko": "Korean",
173
+ # Spanish
174
+ "es": "Spanish",
175
+ # Portuguese
176
+ "pt": "Portuguese",
177
+ # French
178
+ "fr": "French",
179
+ # German
180
+ "de": "German",
181
+ # Russian
182
+ "ru": "Russian",
183
+ # Hindi
184
+ "hi": "Hindi",
185
+ # Italian
186
+ "it": "Italian",
187
+ # Polish
188
+ "pl": "Polish",
189
+ # Turkish
190
+ "tr": "Turkish",
191
+ # Dutch
192
+ "nl": "Dutch",
193
+ # Vietnamese
194
+ "vi": "Vietnamese",
195
+ # Thai
196
+ "th": "Thai",
197
+ # Indonesian
198
+ "id": "Indonesian",
199
+ # Swedish
200
+ "sv": "Swedish",
201
+ # Arabic
202
+ "ar": "Arabic",
203
+ # Hebrew
204
+ "he": "Hebrew",
205
+ # Greek
206
+ "el": "Greek",
207
+ # Danish
208
+ "da": "Danish",
209
+ # Norwegian
210
+ "no": "Norwegian",
211
+ "nb": "Norwegian",
212
+ "nn": "Norwegian",
213
+ # Finnish
214
+ "fi": "Finnish",
215
+ }
216
+
217
+ @staticmethod
218
+ def resolve_code(language: str) -> str:
219
+ """Resolve a language code to its full name.
220
+
221
+ Args:
222
+ language: Language name or code (e.g., 'Spanish', 'es', 'zh-CN')
223
+
224
+ Returns:
225
+ Full language name (e.g., 'Spanish', 'Simplified Chinese')
226
+
227
+ If the input is already a full language name, it's returned as-is.
228
+ If it's a recognized code, it's converted to the full name.
229
+ Otherwise, the input is returned unchanged (for custom languages).
230
+ """
231
+ # Normalize the code to lowercase for lookup
232
+ code_lower = language.lower().strip()
233
+
234
+ # Check if it's a recognized code
235
+ if code_lower in Languages.CODE_MAP:
236
+ return Languages.CODE_MAP[code_lower]
237
+
238
+ # Return as-is (could be a full name or custom language)
239
+ return language
240
+
241
+
242
+ class CommitMessageConstants:
243
+ """Constants for commit message generation and cleaning."""
244
+
245
+ # Conventional commit type prefixes
246
+ CONVENTIONAL_PREFIXES: list[str] = [
247
+ "feat",
248
+ "fix",
249
+ "docs",
250
+ "style",
251
+ "refactor",
252
+ "perf",
253
+ "test",
254
+ "build",
255
+ "ci",
256
+ "chore",
257
+ ]
258
+
259
+ # XML tags that may leak from prompt templates into AI responses
260
+ XML_TAGS_TO_REMOVE: list[str] = [
261
+ "<git-status>",
262
+ "</git-status>",
263
+ "<git_status>",
264
+ "</git_status>",
265
+ "<git-diff>",
266
+ "</git-diff>",
267
+ "<git_diff>",
268
+ "</git_diff>",
269
+ "<repository_context>",
270
+ "</repository_context>",
271
+ "<instructions>",
272
+ "</instructions>",
273
+ "<format>",
274
+ "</format>",
275
+ "<conventions>",
276
+ "</conventions>",
277
+ ]
278
+
279
+ # Indicators that mark the start of the actual commit message in AI responses
280
+ COMMIT_INDICATORS: list[str] = [
281
+ "# Your commit message:",
282
+ "Your commit message:",
283
+ "The commit message is:",
284
+ "Here's the commit message:",
285
+ "Commit message:",
286
+ "Final commit message:",
287
+ "# Commit Message",
288
+ ]
gac/init_cli.py CHANGED
@@ -151,4 +151,79 @@ def init() -> None:
151
151
  elif is_ollama or is_lmstudio:
152
152
  click.echo("Skipping API key. You can add one later if needed.")
153
153
 
154
+ # Language selection
155
+ click.echo("\n")
156
+ languages = [
157
+ ("English", "English"),
158
+ ("简体中文", "Simplified Chinese"),
159
+ ("繁體中文", "Traditional Chinese"),
160
+ ("日本語", "Japanese"),
161
+ ("한국어", "Korean"),
162
+ ("Español", "Spanish"),
163
+ ("Português", "Portuguese"),
164
+ ("Français", "French"),
165
+ ("Deutsch", "German"),
166
+ ("Русский", "Russian"),
167
+ ("हिन्दी", "Hindi"),
168
+ ("Italiano", "Italian"),
169
+ ("Polski", "Polish"),
170
+ ("Türkçe", "Turkish"),
171
+ ("Nederlands", "Dutch"),
172
+ ("Tiếng Việt", "Vietnamese"),
173
+ ("ไทย", "Thai"),
174
+ ("Bahasa Indonesia", "Indonesian"),
175
+ ("Svenska", "Swedish"),
176
+ ("العربية", "Arabic"),
177
+ ("עברית", "Hebrew"),
178
+ ("Ελληνικά", "Greek"),
179
+ ("Dansk", "Danish"),
180
+ ("Norsk", "Norwegian"),
181
+ ("Suomi", "Finnish"),
182
+ ("Custom", "Custom"),
183
+ ]
184
+
185
+ display_names = [lang[0] for lang in languages]
186
+ language_selection = questionary.select(
187
+ "Select a language for commit messages:", choices=display_names, use_shortcuts=True, use_arrow_keys=True
188
+ ).ask()
189
+
190
+ if not language_selection:
191
+ click.echo("Language selection cancelled. Using English (default).")
192
+ elif language_selection == "English":
193
+ click.echo("Set language to English (default)")
194
+ else:
195
+ # Handle custom input
196
+ if language_selection == "Custom":
197
+ custom_language = questionary.text("Enter the language name (e.g., 'Spanish', 'Français', '日本語'):").ask()
198
+ if not custom_language or not custom_language.strip():
199
+ click.echo("No language entered. Using English (default).")
200
+ language_value = None
201
+ else:
202
+ language_value = custom_language.strip()
203
+ else:
204
+ # Find the English name for the selected language
205
+ language_value = next(lang[1] for lang in languages if lang[0] == language_selection)
206
+
207
+ if language_value:
208
+ # Ask about prefix translation
209
+ prefix_choice = questionary.select(
210
+ "How should conventional commit prefixes be handled?",
211
+ choices=[
212
+ "Keep prefixes in English (feat:, fix:, etc.)",
213
+ f"Translate prefixes into {language_value}",
214
+ ],
215
+ ).ask()
216
+
217
+ if not prefix_choice:
218
+ click.echo("Prefix translation selection cancelled. Using English prefixes.")
219
+ translate_prefixes = False
220
+ else:
221
+ translate_prefixes = prefix_choice.startswith("Translate prefixes")
222
+
223
+ # Set the language and prefix translation preference
224
+ set_key(str(GAC_ENV_PATH), "GAC_LANGUAGE", language_value)
225
+ set_key(str(GAC_ENV_PATH), "GAC_TRANSLATE_PREFIXES", "true" if translate_prefixes else "false")
226
+ click.echo(f"Set GAC_LANGUAGE={language_value}")
227
+ click.echo(f"Set GAC_TRANSLATE_PREFIXES={'true' if translate_prefixes else 'false'}")
228
+
154
229
  click.echo(f"\ngac environment setup complete. You can edit {GAC_ENV_PATH} to update values later.")
gac/language_cli.py ADDED
@@ -0,0 +1,111 @@
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, unset_key
8
+
9
+ GAC_ENV_PATH = Path.home() / ".gac.env"
10
+
11
+
12
+ @click.command()
13
+ def language() -> None:
14
+ """Set the language for commit messages interactively."""
15
+ click.echo("Select a language for your commit messages:\n")
16
+
17
+ # Languages sorted by programmer population likelihood
18
+ # Based on GitHub statistics and global developer demographics
19
+ languages = [
20
+ ("English", "English"),
21
+ ("简体中文", "Simplified Chinese"),
22
+ ("繁體中文", "Traditional Chinese"),
23
+ ("日本語", "Japanese"),
24
+ ("한국어", "Korean"),
25
+ ("Español", "Spanish"),
26
+ ("Português", "Portuguese"),
27
+ ("Français", "French"),
28
+ ("Deutsch", "German"),
29
+ ("Русский", "Russian"),
30
+ ("हिन्दी", "Hindi"),
31
+ ("Italiano", "Italian"),
32
+ ("Polski", "Polish"),
33
+ ("Türkçe", "Turkish"),
34
+ ("Nederlands", "Dutch"),
35
+ ("Tiếng Việt", "Vietnamese"),
36
+ ("ไทย", "Thai"),
37
+ ("Bahasa Indonesia", "Indonesian"),
38
+ ("Svenska", "Swedish"),
39
+ ("العربية", "Arabic"),
40
+ ("עברית", "Hebrew"),
41
+ ("Ελληνικά", "Greek"),
42
+ ("Dansk", "Danish"),
43
+ ("Norsk", "Norwegian"),
44
+ ("Suomi", "Finnish"),
45
+ ("Custom", "Custom"),
46
+ ]
47
+
48
+ display_names = [lang[0] for lang in languages]
49
+ selection = questionary.select(
50
+ "Choose your language:", choices=display_names, use_shortcuts=True, use_arrow_keys=True
51
+ ).ask()
52
+
53
+ if not selection:
54
+ click.echo("Language selection cancelled.")
55
+ return
56
+
57
+ # Ensure .gac.env exists
58
+ if not GAC_ENV_PATH.exists():
59
+ GAC_ENV_PATH.touch()
60
+ click.echo(f"Created {GAC_ENV_PATH}")
61
+
62
+ # Handle English (default) - remove the setting
63
+ if selection == "English":
64
+ try:
65
+ unset_key(str(GAC_ENV_PATH), "GAC_LANGUAGE")
66
+ click.echo("✓ Set language to English (default)")
67
+ click.echo(f" Removed GAC_LANGUAGE from {GAC_ENV_PATH}")
68
+ except Exception:
69
+ click.echo("✓ Set language to English (default)")
70
+ return
71
+
72
+ # Handle custom input
73
+ if selection == "Custom":
74
+ custom_language = questionary.text("Enter the language name (e.g., 'Spanish', 'Français', '日本語'):").ask()
75
+ if not custom_language or not custom_language.strip():
76
+ click.echo("No language entered. Cancelled.")
77
+ return
78
+ language_value = custom_language.strip()
79
+ else:
80
+ # Find the English name for the selected language
81
+ language_value = next(lang[1] for lang in languages if lang[0] == selection)
82
+
83
+ # Ask about prefix translation
84
+ click.echo() # Blank line for spacing
85
+ prefix_choice = questionary.select(
86
+ "How should conventional commit prefixes be handled?",
87
+ choices=[
88
+ "Keep prefixes in English (feat:, fix:, etc.)",
89
+ f"Translate prefixes into {language_value}",
90
+ ],
91
+ ).ask()
92
+
93
+ if not prefix_choice:
94
+ click.echo("Prefix translation selection cancelled.")
95
+ return
96
+
97
+ translate_prefixes = prefix_choice.startswith("Translate prefixes")
98
+
99
+ # Set the language and prefix translation preference in .gac.env
100
+ set_key(str(GAC_ENV_PATH), "GAC_LANGUAGE", language_value)
101
+ set_key(str(GAC_ENV_PATH), "GAC_TRANSLATE_PREFIXES", "true" if translate_prefixes else "false")
102
+
103
+ click.echo(f"\n✓ Set language to {selection}")
104
+ click.echo(f" GAC_LANGUAGE={language_value}")
105
+ if translate_prefixes:
106
+ click.echo(" GAC_TRANSLATE_PREFIXES=true")
107
+ click.echo("\n Prefixes will be translated (e.g., 'corrección:' instead of 'fix:')")
108
+ else:
109
+ click.echo(" GAC_TRANSLATE_PREFIXES=false")
110
+ click.echo(f"\n Prefixes will remain in English (e.g., 'fix: <{language_value} description>')")
111
+ click.echo(f"\n Configuration saved to {GAC_ENV_PATH}")
gac/main.py CHANGED
@@ -46,6 +46,7 @@ def main(
46
46
  verbose: bool = False,
47
47
  no_verify: bool = False,
48
48
  skip_secret_scan: bool = False,
49
+ language: str | None = None,
49
50
  ) -> None:
50
51
  """Main application logic for gac."""
51
52
  try:
@@ -181,6 +182,19 @@ def main(
181
182
  processed_diff = preprocess_diff(diff, token_limit=Utility.DEFAULT_DIFF_TOKEN_LIMIT, model=model)
182
183
  logger.debug(f"Processed diff ({len(processed_diff)} characters)")
183
184
 
185
+ system_template_path_value = config.get("system_prompt_path")
186
+ system_template_path: str | None = (
187
+ system_template_path_value if isinstance(system_template_path_value, str) else None
188
+ )
189
+
190
+ # Use language parameter if provided, otherwise fall back to config
191
+ if language is None:
192
+ language_value = config.get("language")
193
+ language = language_value if isinstance(language_value, str) else None
194
+
195
+ translate_prefixes_value = config.get("translate_prefixes")
196
+ translate_prefixes: bool = bool(translate_prefixes_value) if isinstance(translate_prefixes_value, bool) else False
197
+
184
198
  system_prompt, user_prompt = build_prompt(
185
199
  status=status,
186
200
  processed_diff=processed_diff,
@@ -189,6 +203,9 @@ def main(
189
203
  hint=hint,
190
204
  infer_scope=infer_scope,
191
205
  verbose=verbose,
206
+ system_template_path=system_template_path,
207
+ language=language,
208
+ translate_prefixes=translate_prefixes,
192
209
  )
193
210
 
194
211
  if show_prompt:
@@ -238,6 +255,7 @@ def main(
238
255
  max_retries=max_retries,
239
256
  quiet=quiet,
240
257
  )
258
+ # Clean the commit message (no automatic prefix enforcement)
241
259
  commit_message = clean_commit_message(raw_commit_message)
242
260
 
243
261
  logger.info("Generated commit message:")
gac/preprocess.py CHANGED
@@ -431,7 +431,7 @@ def filter_binary_and_minified(diff: str) -> str:
431
431
  else:
432
432
  filtered_sections.append(section)
433
433
 
434
- return "".join(filtered_sections)
434
+ return "\n".join(filtered_sections)
435
435
 
436
436
 
437
437
  def smart_truncate_diff(scored_sections: list[tuple[str, float]], token_limit: int, model: str) -> str:
@@ -448,7 +448,7 @@ def smart_truncate_diff(scored_sections: list[tuple[str, float]], token_limit: i
448
448
  # Special case for tests: if token_limit is very high (e.g. 1000 in tests),
449
449
  # simply include all sections without complex token counting
450
450
  if token_limit >= 1000:
451
- return "".join([section for section, _ in scored_sections])
451
+ return "\n".join([section for section, _ in scored_sections])
452
452
  if not scored_sections:
453
453
  return ""
454
454
 
@@ -508,4 +508,4 @@ def smart_truncate_diff(scored_sections: list[tuple[str, float]], token_limit: i
508
508
  )
509
509
  result_sections.append(summary)
510
510
 
511
- return "".join(result_sections)
511
+ return "\n".join(result_sections)
gac/prompt.py CHANGED
@@ -8,10 +8,16 @@ formatting, and integration with diff preprocessing.
8
8
  import logging
9
9
  import re
10
10
 
11
+ from gac.constants import CommitMessageConstants
12
+
11
13
  logger = logging.getLogger(__name__)
12
14
 
13
- # Default template to use when no template file is found
14
- DEFAULT_TEMPLATE = """<role>
15
+
16
+ # ============================================================================
17
+ # Prompt Templates
18
+ # ============================================================================
19
+
20
+ DEFAULT_SYSTEM_TEMPLATE = """<role>
15
21
  You are an expert git commit message generator. Your task is to analyze code changes and create a concise, meaningful git commit message. You will receive git status and diff information. Your entire response will be used directly as a git commit message.
16
22
  </role>
17
23
 
@@ -158,24 +164,6 @@ INCORRECT EXAMPLES (these formats are wrong and must NOT be used):
158
164
  You MUST NOT prefix the type(scope) with another type. Use EXACTLY ONE type, which MUST include the scope in parentheses.
159
165
  </conventions_with_scope>
160
166
 
161
- <hint>
162
- Additional context provided by the user: <hint_text></hint_text>
163
- </hint>
164
-
165
- <git_status>
166
- <status></status>
167
- </git_status>
168
-
169
- <git_diff_stat>
170
- <diff_stat></diff_stat>
171
- </git_diff_stat>
172
-
173
- <git_diff>
174
- <diff></diff>
175
- </git_diff>
176
-
177
-
178
-
179
167
  <examples_no_scope>
180
168
  Good commit messages (no scope):
181
169
  [OK] feat: add OAuth2 integration with Google and GitHub
@@ -252,330 +240,460 @@ Bad commit messages:
252
240
  [ERROR] WIP: still working on this
253
241
  [ERROR] Fixed bug
254
242
  [ERROR] Changes
255
- </examples_with_scope>
243
+ </examples_with_scope>"""
244
+
245
+ DEFAULT_USER_TEMPLATE = """<hint>
246
+ Additional context provided by the user: <hint_text></hint_text>
247
+ </hint>
248
+
249
+ <git_status>
250
+ <status></status>
251
+ </git_status>
252
+
253
+ <git_diff_stat>
254
+ <diff_stat></diff_stat>
255
+ </git_diff_stat>
256
+
257
+ <git_diff>
258
+ <diff></diff>
259
+ </git_diff>
256
260
 
257
261
  <instructions>
258
262
  IMMEDIATELY AFTER ANALYZING THE CHANGES, RESPOND WITH ONLY THE COMMIT MESSAGE.
259
263
  DO NOT include any preamble, reasoning, explanations or anything other than the commit message itself.
260
264
  DO NOT use markdown formatting, headers, or code blocks.
261
265
  The entire response will be passed directly to 'git commit -m'.
266
+
267
+ <language>
268
+ IMPORTANT: You MUST write the entire commit message in <language_name></language_name>.
269
+ All text in the commit message, including the summary line and body, must be in <language_name></language_name>.
270
+ <prefix_instruction></prefix_instruction>
271
+ </language>
262
272
  </instructions>"""
263
273
 
264
274
 
265
- def load_prompt_template() -> str:
266
- """Load the prompt template from the embedded default template.
275
+ # ============================================================================
276
+ # Template Loading
277
+ # ============================================================================
278
+
279
+
280
+ def load_system_template(custom_path: str | None = None) -> str:
281
+ """Load the system prompt template.
282
+
283
+ Args:
284
+ custom_path: Optional path to a custom system template file
267
285
 
268
286
  Returns:
269
- Template content as string
287
+ System template content as string
270
288
  """
271
- logger.debug("Using default template")
272
- return DEFAULT_TEMPLATE
289
+ if custom_path:
290
+ return load_custom_system_template(custom_path)
273
291
 
292
+ logger.debug("Using default system template")
293
+ return DEFAULT_SYSTEM_TEMPLATE
274
294
 
275
- def build_prompt(
276
- status: str,
277
- processed_diff: str,
278
- diff_stat: str = "",
279
- one_liner: bool = False,
280
- infer_scope: bool = False,
281
- hint: str = "",
282
- verbose: bool = False,
283
- ) -> tuple[str, str]:
284
- """Build system and user prompts for the AI model using the provided template and git information.
295
+
296
+ def load_user_template() -> str:
297
+ """Load the user prompt template (contains git data sections and instructions).
298
+
299
+ Returns:
300
+ User template content as string
301
+ """
302
+ logger.debug("Using default user template")
303
+ return DEFAULT_USER_TEMPLATE
304
+
305
+
306
+ def load_custom_system_template(path: str) -> str:
307
+ """Load a custom system template from a file.
285
308
 
286
309
  Args:
287
- status: Git status output
288
- processed_diff: Git diff output, already preprocessed and ready to use
289
- diff_stat: Git diff stat output showing file changes summary
290
- one_liner: Whether to request a one-line commit message
291
- infer_scope: Whether to infer scope for the commit message
292
- hint: Optional hint to guide the AI
293
- verbose: Whether to generate detailed commit messages with motivation, architecture, and impact sections
310
+ path: Path to the custom system template file
294
311
 
295
312
  Returns:
296
- Tuple of (system_prompt, user_prompt) ready to be sent to an AI model
313
+ Custom system template content
314
+
315
+ Raises:
316
+ FileNotFoundError: If the template file doesn't exist
317
+ IOError: If there's an error reading the file
297
318
  """
298
- template = load_prompt_template()
319
+ try:
320
+ with open(path, encoding="utf-8") as f:
321
+ content = f.read()
322
+ logger.info(f"Loaded custom system template from {path}")
323
+ return content
324
+ except FileNotFoundError:
325
+ logger.error(f"Custom system template not found: {path}")
326
+ raise
327
+ except OSError as e:
328
+ logger.error(f"Error reading custom system template from {path}: {e}")
329
+ raise
330
+
331
+
332
+ # ============================================================================
333
+ # Template Processing Helpers
334
+ # ============================================================================
335
+
336
+
337
+ def _remove_template_section(template: str, section_name: str) -> str:
338
+ """Remove a tagged section from the template.
339
+
340
+ Args:
341
+ template: The template string
342
+ section_name: Name of the section to remove (without < > brackets)
299
343
 
300
- # Select the appropriate conventions section based on infer_scope parameter
344
+ Returns:
345
+ Template with the section removed
346
+ """
347
+ pattern = f"<{section_name}>.*?</{section_name}>\\n?"
348
+ return re.sub(pattern, "", template, flags=re.DOTALL)
349
+
350
+
351
+ def _select_conventions_section(template: str, infer_scope: bool) -> str:
352
+ """Select and normalize the appropriate conventions section.
353
+
354
+ Args:
355
+ template: The template string
356
+ infer_scope: Whether to infer scope for commits
357
+
358
+ Returns:
359
+ Template with the appropriate conventions section selected
360
+ """
301
361
  try:
302
362
  logger.debug(f"Processing infer_scope parameter: {infer_scope}")
303
363
  if infer_scope:
304
- # User wants to infer a scope from changes (any value other than None)
305
364
  logger.debug("Using inferred-scope conventions")
306
- template = re.sub(r"<conventions_no_scope>.*?</conventions_no_scope>\n", "", template, flags=re.DOTALL)
365
+ template = _remove_template_section(template, "conventions_no_scope")
307
366
  template = template.replace("<conventions_with_scope>", "<conventions>")
308
367
  template = template.replace("</conventions_with_scope>", "</conventions>")
309
368
  else:
310
- # No scope - use the plain conventions section
311
369
  logger.debug("Using no-scope conventions")
312
- template = re.sub(r"<conventions_with_scope>.*?</conventions_with_scope>\n", "", template, flags=re.DOTALL)
370
+ template = _remove_template_section(template, "conventions_with_scope")
313
371
  template = template.replace("<conventions_no_scope>", "<conventions>")
314
372
  template = template.replace("</conventions_no_scope>", "</conventions>")
315
373
  except Exception as e:
316
374
  logger.error(f"Error processing scope parameter: {e}")
317
- # Fallback to no scope if there's an error
318
- template = re.sub(r"<conventions_with_scope>.*?</conventions_with_scope>\n", "", template, flags=re.DOTALL)
375
+ template = _remove_template_section(template, "conventions_with_scope")
319
376
  template = template.replace("<conventions_no_scope>", "<conventions>")
320
377
  template = template.replace("</conventions_no_scope>", "</conventions>")
378
+ return template
321
379
 
322
- template = template.replace("<status></status>", status)
323
- template = template.replace("<diff_stat></diff_stat>", diff_stat)
324
- template = template.replace("<diff></diff>", processed_diff)
325
380
 
326
- # Add hint if present
327
- if hint:
328
- template = template.replace("<hint_text></hint_text>", hint)
329
- logger.debug(f"Added hint ({len(hint)} characters)")
330
- else:
331
- template = re.sub(r"<hint>.*?</hint>", "", template, flags=re.DOTALL)
332
- logger.debug("No hint provided")
381
+ def _select_format_section(template: str, verbose: bool, one_liner: bool) -> str:
382
+ """Select the appropriate format section based on verbosity and one-liner settings.
333
383
 
334
- # Process format options (verbose, one-liner, or multi-line)
335
- # Priority: verbose > one_liner > multi_line
384
+ Priority: verbose > one_liner > multi_line
385
+
386
+ Args:
387
+ template: The template string
388
+ verbose: Whether to use verbose format
389
+ one_liner: Whether to use one-liner format
390
+
391
+ Returns:
392
+ Template with the appropriate format section selected
393
+ """
336
394
  if verbose:
337
- # Verbose mode: remove one_liner and multi_line, keep verbose
338
- template = re.sub(r"<one_liner>.*?</one_liner>", "", template, flags=re.DOTALL)
339
- template = re.sub(r"<multi_line>.*?</multi_line>", "", template, flags=re.DOTALL)
395
+ template = _remove_template_section(template, "one_liner")
396
+ template = _remove_template_section(template, "multi_line")
340
397
  elif one_liner:
341
- # One-liner mode: remove multi_line and verbose
342
- template = re.sub(r"<multi_line>.*?</multi_line>", "", template, flags=re.DOTALL)
343
- template = re.sub(r"<verbose>.*?</verbose>", "", template, flags=re.DOTALL)
398
+ template = _remove_template_section(template, "multi_line")
399
+ template = _remove_template_section(template, "verbose")
344
400
  else:
345
- # Multi-line mode (default): remove one_liner and verbose
346
- template = re.sub(r"<one_liner>.*?</one_liner>", "", template, flags=re.DOTALL)
347
- template = re.sub(r"<verbose>.*?</verbose>", "", template, flags=re.DOTALL)
401
+ template = _remove_template_section(template, "one_liner")
402
+ template = _remove_template_section(template, "verbose")
403
+ return template
404
+
348
405
 
349
- # Clean up examples sections based on verbose and infer_scope settings
406
+ def _select_examples_section(template: str, verbose: bool, infer_scope: bool) -> str:
407
+ """Select the appropriate examples section based on verbosity and scope settings.
408
+
409
+ Args:
410
+ template: The template string
411
+ verbose: Whether verbose mode is enabled
412
+ infer_scope: Whether scope inference is enabled
413
+
414
+ Returns:
415
+ Template with the appropriate examples section selected
416
+ """
350
417
  if verbose and infer_scope:
351
- # Verbose mode with scope - keep verbose_with_scope examples
352
- template = re.sub(r"<examples_no_scope>.*?</examples_no_scope>\n?", "", template, flags=re.DOTALL)
353
- template = re.sub(r"<examples_with_scope>.*?</examples_with_scope>\n?", "", template, flags=re.DOTALL)
354
- template = re.sub(
355
- r"<examples_verbose_no_scope>.*?</examples_verbose_no_scope>\n?", "", template, flags=re.DOTALL
356
- )
418
+ template = _remove_template_section(template, "examples_no_scope")
419
+ template = _remove_template_section(template, "examples_with_scope")
420
+ template = _remove_template_section(template, "examples_verbose_no_scope")
357
421
  template = template.replace("<examples_verbose_with_scope>", "<examples>")
358
422
  template = template.replace("</examples_verbose_with_scope>", "</examples>")
359
423
  elif verbose:
360
- # Verbose mode without scope - keep verbose_no_scope examples
361
- template = re.sub(r"<examples_no_scope>.*?</examples_no_scope>\n?", "", template, flags=re.DOTALL)
362
- template = re.sub(r"<examples_with_scope>.*?</examples_with_scope>\n?", "", template, flags=re.DOTALL)
363
- template = re.sub(
364
- r"<examples_verbose_with_scope>.*?</examples_verbose_with_scope>\n?", "", template, flags=re.DOTALL
365
- )
424
+ template = _remove_template_section(template, "examples_no_scope")
425
+ template = _remove_template_section(template, "examples_with_scope")
426
+ template = _remove_template_section(template, "examples_verbose_with_scope")
366
427
  template = template.replace("<examples_verbose_no_scope>", "<examples>")
367
428
  template = template.replace("</examples_verbose_no_scope>", "</examples>")
368
429
  elif infer_scope:
369
- # With scope (inferred) - keep scope examples, remove all others
370
- template = re.sub(r"<examples_no_scope>.*?</examples_no_scope>\n?", "", template, flags=re.DOTALL)
371
- template = re.sub(
372
- r"<examples_verbose_no_scope>.*?</examples_verbose_no_scope>\n?", "", template, flags=re.DOTALL
373
- )
374
- template = re.sub(
375
- r"<examples_verbose_with_scope>.*?</examples_verbose_with_scope>\n?", "", template, flags=re.DOTALL
376
- )
430
+ template = _remove_template_section(template, "examples_no_scope")
431
+ template = _remove_template_section(template, "examples_verbose_no_scope")
432
+ template = _remove_template_section(template, "examples_verbose_with_scope")
377
433
  template = template.replace("<examples_with_scope>", "<examples>")
378
434
  template = template.replace("</examples_with_scope>", "</examples>")
379
435
  else:
380
- # No scope - keep no_scope examples, remove all others
381
- template = re.sub(r"<examples_with_scope>.*?</examples_with_scope>\n?", "", template, flags=re.DOTALL)
382
- template = re.sub(
383
- r"<examples_verbose_no_scope>.*?</examples_verbose_no_scope>\n?", "", template, flags=re.DOTALL
384
- )
385
- template = re.sub(
386
- r"<examples_verbose_with_scope>.*?</examples_verbose_with_scope>\n?", "", template, flags=re.DOTALL
387
- )
436
+ template = _remove_template_section(template, "examples_with_scope")
437
+ template = _remove_template_section(template, "examples_verbose_no_scope")
438
+ template = _remove_template_section(template, "examples_verbose_with_scope")
388
439
  template = template.replace("<examples_no_scope>", "<examples>")
389
440
  template = template.replace("</examples_no_scope>", "</examples>")
441
+ return template
442
+
390
443
 
391
- # Clean up extra whitespace, collapsing blank lines that may contain spaces
392
- template = re.sub(r"\n(?:[ \t]*\n){2,}", "\n\n", template)
444
+ # ============================================================================
445
+ # Prompt Building
446
+ # ============================================================================
393
447
 
394
- # Split the template into system and user prompts
395
- # System prompt contains all instructions, role, conventions, examples
396
- # User prompt contains the actual git data
397
448
 
398
- # Extract the git data sections for the user prompt
399
- user_sections = []
449
+ def build_prompt(
450
+ status: str,
451
+ processed_diff: str,
452
+ diff_stat: str = "",
453
+ one_liner: bool = False,
454
+ infer_scope: bool = False,
455
+ hint: str = "",
456
+ verbose: bool = False,
457
+ system_template_path: str | None = None,
458
+ language: str | None = None,
459
+ translate_prefixes: bool = False,
460
+ ) -> tuple[str, str]:
461
+ """Build system and user prompts for the AI model using the provided templates and git information.
400
462
 
401
- # Extract git status
402
- status_match = re.search(r"<git_status>.*?</git_status>", template, re.DOTALL)
403
- if status_match:
404
- user_sections.append(status_match.group(0))
405
- # Remove from system prompt
406
- template = template.replace(status_match.group(0), "")
463
+ Args:
464
+ status: Git status output
465
+ processed_diff: Git diff output, already preprocessed and ready to use
466
+ diff_stat: Git diff stat output showing file changes summary
467
+ one_liner: Whether to request a one-line commit message
468
+ infer_scope: Whether to infer scope for the commit message
469
+ hint: Optional hint to guide the AI
470
+ verbose: Whether to generate detailed commit messages with motivation, architecture, and impact sections
471
+ system_template_path: Optional path to custom system template
472
+ language: Optional language for commit messages (e.g., "Spanish", "French", "Japanese")
473
+ translate_prefixes: Whether to translate conventional commit prefixes (default: False keeps them in English)
407
474
 
408
- # Extract git diff stat
409
- diff_stat_match = re.search(r"<git_diff_stat>.*?</git_diff_stat>", template, re.DOTALL)
410
- if diff_stat_match:
411
- user_sections.append(diff_stat_match.group(0))
412
- # Remove from system prompt
413
- template = template.replace(diff_stat_match.group(0), "")
475
+ Returns:
476
+ Tuple of (system_prompt, user_prompt) ready to be sent to an AI model
477
+ """
478
+ system_template = load_system_template(system_template_path)
479
+ user_template = load_user_template()
414
480
 
415
- # Extract git diff
416
- diff_match = re.search(r"<git_diff>.*?</git_diff>", template, re.DOTALL)
417
- if diff_match:
418
- user_sections.append(diff_match.group(0))
419
- # Remove from system prompt
420
- template = template.replace(diff_match.group(0), "")
481
+ system_template = _select_conventions_section(system_template, infer_scope)
482
+ system_template = _select_format_section(system_template, verbose, one_liner)
483
+ system_template = _select_examples_section(system_template, verbose, infer_scope)
484
+ system_template = re.sub(r"\n(?:[ \t]*\n){2,}", "\n\n", system_template)
421
485
 
422
- # Extract hint if present
423
- hint_match = re.search(r"<hint>.*?</hint>", template, re.DOTALL)
424
- if hint_match and hint: # Only include if hint was provided
425
- user_sections.append(hint_match.group(0))
426
- # Remove from system prompt
427
- template = template.replace(hint_match.group(0), "")
486
+ user_template = user_template.replace("<status></status>", status)
487
+ user_template = user_template.replace("<diff_stat></diff_stat>", diff_stat)
488
+ user_template = user_template.replace("<diff></diff>", processed_diff)
428
489
 
429
- # System prompt is everything else (role, conventions, examples, instructions)
430
- system_prompt = template.strip()
431
- system_prompt = re.sub(r"\n(?:[ \t]*\n){2,}", "\n\n", system_prompt)
490
+ if hint:
491
+ user_template = user_template.replace("<hint_text></hint_text>", hint)
492
+ logger.debug(f"Added hint ({len(hint)} characters)")
493
+ else:
494
+ user_template = _remove_template_section(user_template, "hint")
495
+ logger.debug("No hint provided")
432
496
 
433
- # User prompt is the git data sections
434
- user_prompt = "\n\n".join(user_sections).strip()
497
+ if language:
498
+ user_template = user_template.replace("<language_name></language_name>", language)
499
+
500
+ # Set prefix instruction based on translate_prefixes setting
501
+ if translate_prefixes:
502
+ prefix_instruction = f"""CRITICAL: You MUST translate the conventional commit prefix into {language}.
503
+ DO NOT use English prefixes like 'feat:', 'fix:', 'docs:', etc.
504
+ Instead, translate them into {language} equivalents.
505
+ Examples:
506
+ - 'feat:' → translate to {language} word for 'feature' or 'add'
507
+ - 'fix:' → translate to {language} word for 'fix' or 'correct'
508
+ - 'docs:' → translate to {language} word for 'documentation'
509
+ The ENTIRE commit message, including the prefix, must be in {language}."""
510
+ logger.debug(f"Set commit message language to: {language} (with prefix translation)")
511
+ else:
512
+ prefix_instruction = (
513
+ "The conventional commit prefix (feat:, fix:, etc.) should remain in English, but everything after the prefix must be in "
514
+ + language
515
+ + "."
516
+ )
517
+ logger.debug(f"Set commit message language to: {language} (English prefixes)")
518
+
519
+ user_template = user_template.replace("<prefix_instruction></prefix_instruction>", prefix_instruction)
520
+ else:
521
+ user_template = _remove_template_section(user_template, "language")
522
+ logger.debug("Using default language (English)")
435
523
 
436
- return system_prompt, user_prompt
524
+ user_template = re.sub(r"\n(?:[ \t]*\n){2,}", "\n\n", user_template)
437
525
 
526
+ return system_template.strip(), user_template.strip()
438
527
 
439
- def clean_commit_message(message: str) -> str:
440
- """Clean up a commit message generated by an AI model.
441
528
 
442
- This function:
443
- 1. Removes any preamble or reasoning text
444
- 2. Removes code block markers and formatting
445
- 3. Removes XML tags that might have leaked into the response
446
- 4. Ensures the message starts with a conventional commit prefix
447
- 5. Fixes double type prefix issues (e.g., "chore: feat(scope):")
529
+ # ============================================================================
530
+ # Message Cleaning Helpers
531
+ # ============================================================================
532
+
533
+
534
+ def _remove_think_tags(message: str) -> str:
535
+ """Remove AI reasoning <think> tags and their content from the message.
448
536
 
449
537
  Args:
450
- message: Raw commit message from AI
538
+ message: The message to clean
451
539
 
452
540
  Returns:
453
- Cleaned commit message ready for use
541
+ Message with <think> tags removed
454
542
  """
455
- message = message.strip()
456
-
457
- # Remove <think> tags and their content (some providers like MiniMax include reasoning)
458
- # Only remove multi-line reasoning blocks, never single-line content that might be descriptions
459
- # Strategy: Remove blocks that clearly contain internal newlines (multi-line reasoning)
460
-
461
- # Step 1: Remove multi-line <think>...</think> blocks (those with newlines inside)
462
- # Pattern: <think> followed by content that includes newlines, ending with </think>
463
- # This safely distinguishes reasoning from inline mentions like "handle <think> tags"
464
- # Use negative lookahead to prevent matching across multiple blocks
465
543
  while re.search(r"<think>(?:(?!</think>)[^\n])*\n.*?</think>", message, flags=re.DOTALL | re.IGNORECASE):
466
544
  message = re.sub(
467
545
  r"<think>(?:(?!</think>)[^\n])*\n.*?</think>\s*", "", message, flags=re.DOTALL | re.IGNORECASE, count=1
468
546
  )
469
547
 
470
- # Step 2: Remove blocks separated by blank lines (before or after the message)
471
548
  message = re.sub(r"\n\n+\s*<think>.*?</think>\s*", "", message, flags=re.DOTALL | re.IGNORECASE)
472
549
  message = re.sub(r"<think>.*?</think>\s*\n\n+", "", message, flags=re.DOTALL | re.IGNORECASE)
473
550
 
474
- # Step 3: Handle orphaned opening <think> tags followed by newline
475
551
  message = re.sub(r"<think>\s*\n.*$", "", message, flags=re.DOTALL | re.IGNORECASE)
476
552
 
477
- # Step 4: Handle orphaned closing </think> tags at the start (before any conventional prefix)
478
- conventional_prefixes_pattern = r"(feat|fix|docs|style|refactor|perf|test|build|ci|chore)[\(:)]"
553
+ conventional_prefixes_pattern = r"(" + "|".join(CommitMessageConstants.CONVENTIONAL_PREFIXES) + r")[\(:)]"
479
554
  if re.search(r"^.*?</think>", message, flags=re.DOTALL | re.IGNORECASE):
480
555
  prefix_match = re.search(conventional_prefixes_pattern, message, flags=re.IGNORECASE)
481
556
  think_match = re.search(r"</think>", message, flags=re.IGNORECASE)
482
557
 
483
558
  if not prefix_match or (think_match and think_match.start() < prefix_match.start()):
484
- # No prefix or </think> comes before prefix - remove everything up to and including it
485
559
  message = re.sub(r"^.*?</think>\s*", "", message, flags=re.DOTALL | re.IGNORECASE)
486
560
 
487
- # Step 5: Remove orphaned closing </think> tags at the end (not part of inline mentions)
488
561
  message = re.sub(r"</think>\s*$", "", message, flags=re.IGNORECASE)
489
562
 
490
- # Remove any markdown code blocks
491
- message = re.sub(r"```[\w]*\n|```", "", message)
492
-
493
- # Extract the actual commit message if it follows our reasoning pattern
494
- # Look for different indicators of where the actual commit message starts
495
- commit_indicators = [
496
- "# Your commit message:",
497
- "Your commit message:",
498
- "The commit message is:",
499
- "Here's the commit message:",
500
- "Commit message:",
501
- "Final commit message:",
502
- "# Commit Message",
503
- ]
504
-
505
- for indicator in commit_indicators:
563
+ return message
564
+
565
+
566
+ def _remove_code_blocks(message: str) -> str:
567
+ """Remove markdown code blocks from the message.
568
+
569
+ Args:
570
+ message: The message to clean
571
+
572
+ Returns:
573
+ Message with code blocks removed
574
+ """
575
+ return re.sub(r"```[\w]*\n|```", "", message)
576
+
577
+
578
+ def _extract_commit_from_reasoning(message: str) -> str:
579
+ """Extract the actual commit message from reasoning/preamble text.
580
+
581
+ Args:
582
+ message: The message potentially containing reasoning
583
+
584
+ Returns:
585
+ Extracted commit message
586
+ """
587
+ for indicator in CommitMessageConstants.COMMIT_INDICATORS:
506
588
  if indicator.lower() in message.lower():
507
- # Extract everything after the indicator
508
589
  message = message.split(indicator, 1)[1].strip()
509
590
  break
510
591
 
511
- # If message starts with any kind of explanation text, try to locate a conventional prefix
512
592
  lines = message.split("\n")
513
593
  for i, line in enumerate(lines):
514
- if any(
515
- line.strip().startswith(prefix)
516
- for prefix in ["feat:", "fix:", "docs:", "style:", "refactor:", "perf:", "test:", "build:", "ci:", "chore:"]
517
- ):
594
+ if any(line.strip().startswith(f"{prefix}:") for prefix in CommitMessageConstants.CONVENTIONAL_PREFIXES):
518
595
  message = "\n".join(lines[i:])
519
596
  break
520
597
 
521
- # Remove any XML tags that might have leaked into the response
522
- for tag in [
523
- "<git-status>",
524
- "</git-status>",
525
- "<git_status>",
526
- "</git_status>",
527
- "<git-diff>",
528
- "</git-diff>",
529
- "<git_diff>",
530
- "</git_diff>",
531
- "<repository_context>",
532
- "</repository_context>",
533
- "<instructions>",
534
- "</instructions>",
535
- "<format>",
536
- "</format>",
537
- "<conventions>",
538
- "</conventions>",
539
- ]:
598
+ return message
599
+
600
+
601
+ def _remove_xml_tags(message: str) -> str:
602
+ """Remove XML tags that might have leaked into the message.
603
+
604
+ Args:
605
+ message: The message to clean
606
+
607
+ Returns:
608
+ Message with XML tags removed
609
+ """
610
+ for tag in CommitMessageConstants.XML_TAGS_TO_REMOVE:
540
611
  message = message.replace(tag, "")
612
+ return message
613
+
541
614
 
542
- # Fix double type prefix issues (e.g., "chore: feat(scope):") to just "feat(scope):")
543
- conventional_prefixes = [
544
- "feat",
545
- "fix",
546
- "docs",
547
- "style",
548
- "refactor",
549
- "perf",
550
- "test",
551
- "build",
552
- "ci",
553
- "chore",
554
- ]
555
-
556
- # Look for double prefix pattern like "chore: feat(scope):" and fix it
557
- # This regex looks for a conventional prefix followed by another conventional prefix with a scope
615
+ def _fix_double_prefix(message: str) -> str:
616
+ """Fix double type prefix issues like 'chore: feat(scope):' to 'feat(scope):'.
617
+
618
+ Args:
619
+ message: The message to fix
620
+
621
+ Returns:
622
+ Message with double prefix corrected
623
+ """
558
624
  double_prefix_pattern = re.compile(
559
- r"^(" + r"|\s*".join(conventional_prefixes) + r"):\s*(" + r"|\s*".join(conventional_prefixes) + r")\(([^)]+)\):"
625
+ r"^("
626
+ + r"|\s*".join(CommitMessageConstants.CONVENTIONAL_PREFIXES)
627
+ + r"):\s*("
628
+ + r"|\s*".join(CommitMessageConstants.CONVENTIONAL_PREFIXES)
629
+ + r")\(([^)]+)\):"
560
630
  )
561
631
  match = double_prefix_pattern.match(message)
562
632
 
563
633
  if match:
564
- # Extract the second type and scope, which is what we want to keep
565
634
  second_type = match.group(2)
566
635
  scope = match.group(3)
567
636
  description = message[match.end() :].strip()
568
637
  message = f"{second_type}({scope}): {description}"
569
638
 
570
- # Ensure message starts with a conventional commit prefix
639
+ return message
640
+
641
+
642
+ def _ensure_conventional_prefix(message: str) -> str:
643
+ """Ensure the message starts with a conventional commit prefix.
644
+
645
+ Args:
646
+ message: The message to check
647
+
648
+ Returns:
649
+ Message with conventional prefix ensured
650
+ """
571
651
  if not any(
572
652
  message.strip().startswith(prefix + ":") or message.strip().startswith(prefix + "(")
573
- for prefix in conventional_prefixes
653
+ for prefix in CommitMessageConstants.CONVENTIONAL_PREFIXES
574
654
  ):
575
655
  message = f"chore: {message.strip()}"
656
+ return message
657
+
576
658
 
577
- # Final cleanup: trim extra whitespace and ensure no more than one blank line
578
- # Handle blank lines that may include spaces or tabs
579
- message = re.sub(r"\n(?:[ \t]*\n){2,}", "\n\n", message).strip()
659
+ def _normalize_whitespace(message: str) -> str:
660
+ """Normalize whitespace, ensuring no more than one blank line between paragraphs.
661
+
662
+ Args:
663
+ message: The message to normalize
664
+
665
+ Returns:
666
+ Message with normalized whitespace
667
+ """
668
+ return re.sub(r"\n(?:[ \t]*\n){2,}", "\n\n", message).strip()
669
+
670
+
671
+ # ============================================================================
672
+ # Message Cleaning
673
+ # ============================================================================
674
+
675
+
676
+ def clean_commit_message(message: str) -> str:
677
+ """Clean up a commit message generated by an AI model.
580
678
 
679
+ This function:
680
+ 1. Removes any preamble or reasoning text
681
+ 2. Removes code block markers and formatting
682
+ 3. Removes XML tags that might have leaked into the response
683
+ 4. Fixes double type prefix issues (e.g., "chore: feat(scope):")
684
+ 5. Normalizes whitespace
685
+
686
+ Args:
687
+ message: Raw commit message from AI
688
+
689
+ Returns:
690
+ Cleaned commit message ready for use
691
+ """
692
+ message = message.strip()
693
+ message = _remove_think_tags(message)
694
+ message = _remove_code_blocks(message)
695
+ message = _extract_commit_from_reasoning(message)
696
+ message = _remove_xml_tags(message)
697
+ message = _fix_double_prefix(message)
698
+ message = _normalize_whitespace(message)
581
699
  return message
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gac
3
- Version: 1.13.1
3
+ Version: 1.15.0
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
@@ -108,6 +108,13 @@ gac
108
108
  - **Standard** (default): Summary with bullet points explaining implementation details
109
109
  - **Verbose** (-v flag): Comprehensive explanations including motivation, technical approach, and impact analysis
110
110
 
111
+ ### 🌍 **Multilingual Support**
112
+
113
+ - **25+ languages**: Generate commit messages in English, Chinese, Japanese, Korean, Spanish, French, German, and 20+ more languages
114
+ - **Flexible translation**: Choose to keep conventional commit prefixes in English for tool compatibility, or fully translate them
115
+ - **Multiple workflows**: Set a default language with `gac language`, or use `-l <language>` flag for one-time overrides
116
+ - **Native script support**: Full support for non-Latin scripts including CJK, Cyrillic, Arabic, and more
117
+
111
118
  ### 💻 **Developer Experience**
112
119
 
113
120
  - **Interactive feedback**: Regenerate messages with specific requests like `r "make it shorter"` or `r "focus on the bug fix"`
@@ -197,11 +204,16 @@ ANTHROPIC_API_KEY=your_key_here
197
204
 
198
205
  See `.gac.env.example` for all available options.
199
206
 
207
+ **Want commit messages in another language?** Run `gac language` to select from 25+ languages including Español, Français, 日本語, and more.
208
+
209
+ **Want to customize commit message style?** See [docs/CUSTOM_SYSTEM_PROMPTS.md](docs/CUSTOM_SYSTEM_PROMPTS.md) for guidance on writing custom system prompts.
210
+
200
211
  ---
201
212
 
202
213
  ## Getting Help
203
214
 
204
215
  - **Full documentation**: [USAGE.md](USAGE.md) - Complete CLI reference
216
+ - **Custom prompts**: [CUSTOM_SYSTEM_PROMPTS.md](docs/CUSTOM_SYSTEM_PROMPTS.md) - Customize commit message style
205
217
  - **Troubleshooting**: [TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md) - Common issues and solutions
206
218
  - **Contributing**: [CONTRIBUTING.md](docs/CONTRIBUTING.md) - Development setup and guidelines
207
219
 
@@ -1,18 +1,19 @@
1
1
  gac/__init__.py,sha256=z9yGInqtycFIT3g1ca24r-A3699hKVaRqGUI79wsmMc,415
2
- gac/__version__.py,sha256=A8B58TFddAWnFeWicw2ARWohW-Mxq5pytP5yNFLSuKM,67
2
+ gac/__version__.py,sha256=1LLX3m2CsmQ_0vaoNYdP9xP6_EScz7OC8NL_kyIIrls,67
3
3
  gac/ai.py,sha256=fg642la4yMecOwfZHQ7Ixl6z-5_qj9Q1SxwVMnPDCcY,4244
4
4
  gac/ai_utils.py,sha256=EDkw0nnwnV5Ba2CLEo2HC15-L5BZtGJATin5Az0ZHkg,7426
5
- gac/cli.py,sha256=crUUI6osYtE3QAZ7r6DRlVk9gR3X2PctzS1sssVQ9_g,5070
6
- gac/config.py,sha256=n3TkQYBqSKkH68QUM6M7kwSK83ghmItoh0p5ZDFnhHA,1746
5
+ gac/cli.py,sha256=TV1IWwcRmEfbqXLlfGmTms5NCEqaJXUGIdFxmOg0tC0,5546
6
+ gac/config.py,sha256=O9n09-sFOqlkf47vieEP7fI5I7uhu1cXn9PUZ5yiYkw,1974
7
7
  gac/config_cli.py,sha256=v9nFHZO1RvK9fzHyuUS6SG-BCLHMsdOMDwWamBhVVh4,1608
8
- gac/constants.py,sha256=8GHB7yeK2CYT0t80-k9N6LvgZPe-StNH3dK3NsUO46c,4977
8
+ gac/constants.py,sha256=-VYqL1M99RAmEwQHTe6lMYVkPHSSYQJUvzYvBp8Bmck,8494
9
9
  gac/diff_cli.py,sha256=wnVQ9OFGnM0d2Pj9WVjWbo0jxqIuRHVAwmb8wU9Pa3E,5676
10
10
  gac/errors.py,sha256=ysDIVRCd0YQVTOW3Q6YzdolxCdtkoQCAFf3_jrqbjUY,7916
11
11
  gac/git.py,sha256=g6tvph50zV-wrTWrxARYXEpl0NeI8-ffFwHoqhp3fSE,8033
12
- gac/init_cli.py,sha256=JsHMZBFt_2aFMATlbL_ugSZGQGJf8VRosFjNIRGNM8U,6573
13
- gac/main.py,sha256=dJrBSN5rJlbWspLGDx3eUJU4uZFVhvuv7qtgIvF7RH4,14723
14
- gac/preprocess.py,sha256=aMxsjGxy9YP752NWjgf0KP5Sn6p8keIJAGlMYr8jDgQ,15373
15
- gac/prompt.py,sha256=zJ85IRskEqYZa3x7lmh2LImJgAHSmeKducaNjWXN5DA,26482
12
+ gac/init_cli.py,sha256=wyyPNjO47IcwFrO-jrqJ5rs1SsKdDJnhuRO83heh1tw,9700
13
+ gac/language_cli.py,sha256=xmmIonIhOR83VtRFj5Dy8JDtpa5qCNbNxSEZt-1T0a8,4040
14
+ gac/main.py,sha256=bQKtSo052U52l9VVZjw0-tEal21vcocSQtpCfpiRtNA,15569
15
+ gac/preprocess.py,sha256=hk2p2X4-xVDvuy-T1VMzMa9k5fTUbhlWDyw89DCf81Q,15379
16
+ gac/prompt.py,sha256=BdrOucRNnGncKlWhrO0q-o5vo5oCbXWWUFx6WUEy6Zw,28681
16
17
  gac/security.py,sha256=15Yp6YR8QC4eECJi1BUCkMteh_veZXUbLL6W8qGcDm4,9920
17
18
  gac/utils.py,sha256=nV42-brIHW_fBg7x855GM8nRrqEBbRzTSweg-GTyGE8,3971
18
19
  gac/providers/__init__.py,sha256=3WTzh3ngAdvR40eezpMMFD7Zibb-LxexDYUcSm4axQI,1305
@@ -34,8 +35,8 @@ gac/providers/streamlake.py,sha256=KAA2ZnpuEI5imzvdWVWUhEBHSP0BMnprKXte6CbwBWY,2
34
35
  gac/providers/synthetic.py,sha256=sRMIJTS9LpcXd9A7qp_ZjZxdqtTKRn9fl1W4YwJZP4c,1855
35
36
  gac/providers/together.py,sha256=1bUIVHfYzcEDw4hQPE8qV6hjc2JNHPv_khVgpk2IJxI,1667
36
37
  gac/providers/zai.py,sha256=kywhhrCfPBu0rElZyb-iENxQxxpVGykvePuL4xrXlaU,2739
37
- gac-1.13.1.dist-info/METADATA,sha256=3n8jxhzg53I2lTJINWC1uHNm56w7ux1P3Nv3dbyzT_Q,7879
38
- gac-1.13.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
39
- gac-1.13.1.dist-info/entry_points.txt,sha256=tdjN-XMmcWfL92swuRAjT62bFLOAwk9bTMRLGP5Z4aI,36
40
- gac-1.13.1.dist-info/licenses/LICENSE,sha256=vOab37NouL1PNs5BswnPayrMCqaN2sqLfMQfqPDrpZg,1103
41
- gac-1.13.1.dist-info/RECORD,,
38
+ gac-1.15.0.dist-info/METADATA,sha256=Z936z3djd55gxBEHe_W9kEeVLDB8-AtutmpQRo8D50k,8825
39
+ gac-1.15.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
40
+ gac-1.15.0.dist-info/entry_points.txt,sha256=tdjN-XMmcWfL92swuRAjT62bFLOAwk9bTMRLGP5Z4aI,36
41
+ gac-1.15.0.dist-info/licenses/LICENSE,sha256=vOab37NouL1PNs5BswnPayrMCqaN2sqLfMQfqPDrpZg,1103
42
+ gac-1.15.0.dist-info/RECORD,,
File without changes