gac 0.17.2__py3-none-any.whl → 3.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. gac/__version__.py +1 -1
  2. gac/ai.py +69 -123
  3. gac/ai_utils.py +227 -0
  4. gac/auth_cli.py +69 -0
  5. gac/cli.py +87 -19
  6. gac/config.py +13 -7
  7. gac/config_cli.py +26 -5
  8. gac/constants.py +176 -5
  9. gac/errors.py +14 -0
  10. gac/git.py +207 -11
  11. gac/init_cli.py +52 -29
  12. gac/language_cli.py +378 -0
  13. gac/main.py +922 -189
  14. gac/model_cli.py +374 -0
  15. gac/oauth/__init__.py +1 -0
  16. gac/oauth/claude_code.py +397 -0
  17. gac/preprocess.py +5 -5
  18. gac/prompt.py +656 -219
  19. gac/providers/__init__.py +88 -0
  20. gac/providers/anthropic.py +51 -0
  21. gac/providers/azure_openai.py +97 -0
  22. gac/providers/cerebras.py +38 -0
  23. gac/providers/chutes.py +71 -0
  24. gac/providers/claude_code.py +102 -0
  25. gac/providers/custom_anthropic.py +133 -0
  26. gac/providers/custom_openai.py +98 -0
  27. gac/providers/deepseek.py +38 -0
  28. gac/providers/fireworks.py +38 -0
  29. gac/providers/gemini.py +87 -0
  30. gac/providers/groq.py +63 -0
  31. gac/providers/kimi_coding.py +63 -0
  32. gac/providers/lmstudio.py +59 -0
  33. gac/providers/minimax.py +38 -0
  34. gac/providers/mistral.py +38 -0
  35. gac/providers/moonshot.py +38 -0
  36. gac/providers/ollama.py +50 -0
  37. gac/providers/openai.py +38 -0
  38. gac/providers/openrouter.py +58 -0
  39. gac/providers/replicate.py +98 -0
  40. gac/providers/streamlake.py +51 -0
  41. gac/providers/synthetic.py +42 -0
  42. gac/providers/together.py +38 -0
  43. gac/providers/zai.py +59 -0
  44. gac/security.py +293 -0
  45. gac/utils.py +243 -4
  46. gac/workflow_utils.py +222 -0
  47. gac-3.6.0.dist-info/METADATA +281 -0
  48. gac-3.6.0.dist-info/RECORD +53 -0
  49. {gac-0.17.2.dist-info → gac-3.6.0.dist-info}/WHEEL +1 -1
  50. {gac-0.17.2.dist-info → gac-3.6.0.dist-info}/licenses/LICENSE +1 -1
  51. gac-0.17.2.dist-info/METADATA +0 -221
  52. gac-0.17.2.dist-info/RECORD +0 -20
  53. {gac-0.17.2.dist-info → gac-3.6.0.dist-info}/entry_points.txt +0 -0
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
 
@@ -33,17 +39,53 @@ When changes span multiple areas:
33
39
 
34
40
  <format>
35
41
  <one_liner>
36
- Create a single-line commit message (50-72 characters if possible).
42
+ Create a single-line commit message.
37
43
  Your message should be clear, concise, and descriptive of the core change.
38
44
  Use present tense ("Add feature" not "Added feature").
39
45
  </one_liner><multi_line>
40
46
  Create a commit message with:
41
- - First line: A concise summary (50-72 characters) that could stand alone
47
+ - First line: A concise summary that could stand alone
42
48
  - Blank line after the summary
43
49
  - Detailed body with multiple bullet points explaining the key changes
44
50
  - Focus on WHY changes were made, not just WHAT was changed
45
51
  - Order points from most important to least important
46
- </multi_line>
52
+ </multi_line><verbose>
53
+ IMPORTANT: You MUST create a MULTI-PARAGRAPH commit message with detailed sections.
54
+ DO NOT create a single-line commit message.
55
+
56
+ Your commit message MUST follow this structure:
57
+
58
+ Line 1: A concise summary (that could stand alone) with conventional commit prefix
59
+ Line 2: BLANK LINE (required)
60
+ Lines 3+: Detailed multi-paragraph body with the following sections:
61
+
62
+ ## Motivation
63
+ Explain why this commit exists in 2-3 sentences. What problem does it solve? What need does it address?
64
+
65
+ ## Architecture / Approach
66
+ Describe how it was implemented in 2-4 sentences. Include key design decisions and any rejected alternatives.
67
+ Reference specific modules, functions, or classes when relevant.
68
+
69
+ ## Affected Components
70
+ List the main modules, subsystems, or directories impacted by this change.
71
+
72
+ OPTIONAL sections (include only if relevant):
73
+
74
+ ## Performance / Security Impact
75
+ Describe any performance improvements, trade-offs, or security considerations.
76
+ Include concrete data such as benchmark results if available.
77
+
78
+ ## Compatibility / Testing
79
+ Mention any compatibility considerations, known limitations, testing performed, or next steps for validation.
80
+
81
+ REQUIREMENTS:
82
+ - Your response MUST be at least 10 lines long with multiple paragraphs
83
+ - Use active voice and present tense ("Implements", "Adds", "Refactors")
84
+ - Provide concrete, specific information rather than vague descriptions
85
+ - Keep the tone professional and technical
86
+ - Focus on intent and reasoning, not just code changes
87
+ - Use markdown headers (##) for section organization
88
+ </verbose>
47
89
  </format>
48
90
 
49
91
  <conventions_no_scope>
@@ -73,43 +115,7 @@ If you cannot confidently determine a type, use 'chore'.
73
115
  Do NOT include a scope in your commit prefix.
74
116
  </conventions_no_scope>
75
117
 
76
- <conventions_scope_provided>
77
- You MUST write a conventional commit message with EXACTLY ONE type and the REQUIRED scope '{scope}'.
78
-
79
- FORMAT: type({scope}): description
80
-
81
- IMPORTANT: Check file types FIRST when determining the commit type:
82
- - If changes are ONLY to documentation files (*.md, *.rst, *.txt in docs/, README*, CHANGELOG*, etc.), ALWAYS use 'docs'
83
- - If changes include both documentation and code, use the prefix for the code changes, unless it is a documentation-only change
84
-
85
- Select ONE type from this list that best matches the primary purpose of the changes:
86
- - feat: A new feature or functionality addition
87
- - fix: A bug fix or error correction
88
- - docs: Documentation changes only (INCLUDING README and CHANGELOG updates, regardless of how significant)
89
- - style: Changes to code style/formatting without logic changes
90
- - refactor: Code restructuring without behavior changes
91
- - perf: Performance improvements
92
- - test: Adding/modifying tests
93
- - build: Changes to build system/dependencies
94
- - ci: Changes to CI configuration
95
- - chore: Miscellaneous changes not affecting src/test files
96
-
97
- CORRECT EXAMPLES (these formats are correct):
98
- ✅ feat({scope}): add new feature
99
- ✅ fix({scope}): resolve bug
100
- ✅ refactor({scope}): improve code structure
101
- ✅ chore({scope}): update dependencies
102
-
103
- INCORRECT EXAMPLES (these formats are wrong and must NOT be used):
104
- ❌ chore: feat({scope}): description
105
- ❌ fix: refactor({scope}): description
106
- ❌ feat: feat({scope}): description
107
- ❌ chore: chore({scope}): description
108
-
109
- You MUST NOT prefix the type({scope}) with another type. Use EXACTLY ONE type, which MUST include the scope in parentheses.
110
- </conventions_scope_provided>
111
-
112
- <conventions_scope_inferred>
118
+ <conventions_with_scope>
113
119
  You MUST write a conventional commit message with EXACTLY ONE type and an inferred scope.
114
120
 
115
121
  FORMAT: type(scope): description
@@ -156,25 +162,7 @@ INCORRECT EXAMPLES (these formats are wrong and must NOT be used):
156
162
  ❌ chore: chore(component): description
157
163
 
158
164
  You MUST NOT prefix the type(scope) with another type. Use EXACTLY ONE type, which MUST include the scope in parentheses.
159
- </conventions_scope_inferred>
160
-
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
-
165
+ </conventions_with_scope>
178
166
 
179
167
  <examples_no_scope>
180
168
  Good commit messages (no scope):
@@ -194,8 +182,50 @@ Bad commit messages:
194
182
  [ERROR] Changes
195
183
  </examples_no_scope>
196
184
 
185
+ <examples_verbose_no_scope>
186
+ Example of a good VERBOSE commit message (without scope):
187
+
188
+ feat: add verbose mode for detailed commit message generation
189
+
190
+ ## Motivation
191
+ Users need the ability to generate comprehensive commit messages that follow best practices for code review and documentation. The existing one-liner and multi-line modes don't provide sufficient structure for complex changes that require detailed explanations of motivation, architecture decisions, and impact.
192
+
193
+ ## Architecture / Approach
194
+ Adds a new --verbose/-v flag to the CLI that modifies the prompt generation in build_prompt(). When enabled, the prompt instructs the AI to generate commit messages with structured sections including Motivation, Architecture/Approach, Affected Components, and optional Performance/Testing sections. The implementation uses the existing format selection logic with verbose taking priority over one_liner and multi_line modes.
195
+
196
+ ## Affected Components
197
+ - src/gac/cli.py: Added --verbose flag and parameter passing
198
+ - src/gac/main.py: Extended main() to accept and pass verbose parameter
199
+ - src/gac/prompt.py: Added <verbose> template section with detailed instructions
200
+ - tests/test_prompt.py: Added test coverage for verbose mode
201
+
202
+ ## Compatibility / Testing
203
+ Added new test test_build_prompt_verbose_mode to verify the verbose template generation. All existing tests pass. The verbose mode is opt-in via the -v flag, maintaining backward compatibility.
204
+ </examples_verbose_no_scope>
205
+
206
+ <examples_verbose_with_scope>
207
+ Example of a good VERBOSE commit message (with scope):
208
+
209
+ feat(cli): add verbose mode for detailed commit message generation
210
+
211
+ ## Motivation
212
+ Users need the ability to generate comprehensive commit messages that follow best practices for code review and documentation. The existing one-liner and multi-line modes don't provide sufficient structure for complex changes that require detailed explanations of motivation, architecture decisions, and impact.
213
+
214
+ ## Architecture / Approach
215
+ Adds a new --verbose/-v flag to the CLI that modifies the prompt generation in build_prompt(). When enabled, the prompt instructs the AI to generate commit messages with structured sections including Motivation, Architecture/Approach, Affected Components, and optional Performance/Testing sections. The implementation uses the existing format selection logic with verbose taking priority over one_liner and multi_line modes.
216
+
217
+ ## Affected Components
218
+ - src/gac/cli.py: Added --verbose flag and parameter passing
219
+ - src/gac/main.py: Extended main() to accept and pass verbose parameter
220
+ - src/gac/prompt.py: Added <verbose> template section with detailed instructions
221
+ - tests/test_prompt.py: Added test coverage for verbose mode
222
+
223
+ ## Compatibility / Testing
224
+ Added new test test_build_prompt_verbose_mode to verify the verbose template generation. All existing tests pass. The verbose mode is opt-in via the -v flag, maintaining backward compatibility.
225
+ </examples_verbose_with_scope>
226
+
197
227
  <examples_with_scope>
198
- Good commit messages (with scope):
228
+ Good commit message top lines (with scope):
199
229
  [OK] feat(auth): add OAuth2 integration with Google and GitHub
200
230
  [OK] fix(api): resolve race condition in user session management
201
231
  [OK] docs(readme): add troubleshooting section for common installation issues
@@ -210,24 +240,259 @@ Bad commit messages:
210
240
  [ERROR] WIP: still working on this
211
241
  [ERROR] Fixed bug
212
242
  [ERROR] Changes
213
- </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_diff>
250
+ <diff></diff>
251
+ </git_diff>
252
+
253
+ <git_diff_stat>
254
+ <diff_stat></diff_stat>
255
+ </git_diff_stat>
214
256
 
215
- <instructions>
257
+ <git_status>
258
+ <status></status>
259
+ </git_status>
260
+
261
+ <language_instructions>
262
+ IMPORTANT: You MUST write the entire commit message in <language_name></language_name>.
263
+ All text in the commit message, including the summary line and body, must be in <language_name></language_name>.
264
+ <prefix_instruction></prefix_instruction>
265
+ </language_instructions>
266
+
267
+ <format_instructions>
216
268
  IMMEDIATELY AFTER ANALYZING THE CHANGES, RESPOND WITH ONLY THE COMMIT MESSAGE.
217
269
  DO NOT include any preamble, reasoning, explanations or anything other than the commit message itself.
218
270
  DO NOT use markdown formatting, headers, or code blocks.
219
271
  The entire response will be passed directly to 'git commit -m'.
220
- </instructions>"""
272
+ </format_instructions>"""
273
+
274
+ QUESTION_GENERATION_TEMPLATE = """<role>
275
+ You are an expert code reviewer specializing in identifying missing context and intent in code changes. Your task is to analyze git diffs and generate focused questions that clarify the "why" behind the changes.
276
+ </role>
277
+
278
+ <focus>
279
+ Analyze the git diff and identify missing "why" context. Generate 3-7 focused questions to clarify intent, motivation, and impact. Your questions should help the developer provide the essential context needed for a meaningful commit message.
280
+ </focus>
281
+
282
+ <guidelines>
283
+ - Focus on WHY the changes were made, not just WHAT was changed
284
+ - Ask about the intent, motivation, or business purpose behind the changes
285
+ - Consider what future developers need to understand about this change
286
+ - Ask about the broader impact or consequences of the changes
287
+ - Target areas where technical implementation doesn't reveal the underlying purpose
288
+ - Prioritize questions that would most help generate an informative commit message
289
+ - Keep questions concise and specific
290
+ - Format as a clean list for easy parsing
291
+ </guidelines>
292
+
293
+ <rules>
294
+ NEVER write or rewrite the commit message; only ask questions.
295
+ DO NOT suggest specific commit message formats or wording.
296
+ DO NOT ask about implementation details that are already clear from the diff.
297
+ DO NOT include any explanations or preamble with your response.
298
+ </rules>
299
+
300
+ <output_format>
301
+ Respond with ONLY a numbered list of questions, one per line:
302
+ 1. First focused question?
303
+ 2. Second focused question?
304
+ 3. Third focused question?
305
+ 4. [etc...]
306
+ </output_format>
307
+
308
+ <examples>
309
+ Good example questions:
310
+ 1. What problem or user need does this change address?
311
+ 2. Why was this particular approach chosen over alternatives?
312
+ 3. What impact will this have on existing functionality?
313
+ 4. What motivated the addition of these new error cases?
314
+ 5. Why are these validation rules being added now?
315
+
316
+ Bad examples (violates rules):
317
+ ❌ feat: add user authentication - This is a commit message, not a question
318
+ ❌ Should I use "feat" or "fix" for this change? - This asks about formatting, not context
319
+ ❌ Why did you rename the variable from x to y? - Too implementation-specific
320
+ ❌ You should reformat this as "fix: resolve authentication issue" - This rewrites the message
321
+ </examples>"""
322
+
323
+
324
+ # ============================================================================
325
+ # Template Loading
326
+ # ============================================================================
327
+
328
+
329
+ def load_system_template(custom_path: str | None = None) -> str:
330
+ """Load the system prompt template.
331
+
332
+ Args:
333
+ custom_path: Optional path to a custom system template file
334
+
335
+ Returns:
336
+ System template content as string
337
+ """
338
+ if custom_path:
339
+ return load_custom_system_template(custom_path)
340
+
341
+ logger.debug("Using default system template")
342
+ return DEFAULT_SYSTEM_TEMPLATE
343
+
344
+
345
+ def load_user_template() -> str:
346
+ """Load the user prompt template (contains git data sections and instructions).
347
+
348
+ Returns:
349
+ User template content as string
350
+ """
351
+ logger.debug("Using default user template")
352
+ return DEFAULT_USER_TEMPLATE
353
+
354
+
355
+ def load_custom_system_template(path: str) -> str:
356
+ """Load a custom system template from a file.
357
+
358
+ Args:
359
+ path: Path to the custom system template file
360
+
361
+ Returns:
362
+ Custom system template content
363
+
364
+ Raises:
365
+ FileNotFoundError: If the template file doesn't exist
366
+ IOError: If there's an error reading the file
367
+ """
368
+ try:
369
+ with open(path, encoding="utf-8") as f:
370
+ content = f.read()
371
+ logger.info(f"Loaded custom system template from {path}")
372
+ return content
373
+ except FileNotFoundError:
374
+ logger.error(f"Custom system template not found: {path}")
375
+ raise
376
+ except OSError as e:
377
+ logger.error(f"Error reading custom system template from {path}: {e}")
378
+ raise
379
+
380
+
381
+ # ============================================================================
382
+ # Template Processing Helpers
383
+ # ============================================================================
221
384
 
222
385
 
223
- def load_prompt_template() -> str:
224
- """Load the prompt template from the embedded default template.
386
+ def _remove_template_section(template: str, section_name: str) -> str:
387
+ """Remove a tagged section from the template.
388
+
389
+ Args:
390
+ template: The template string
391
+ section_name: Name of the section to remove (without < > brackets)
392
+
393
+ Returns:
394
+ Template with the section removed
395
+ """
396
+ pattern = f"<{section_name}>.*?</{section_name}>\\n?"
397
+ return re.sub(pattern, "", template, flags=re.DOTALL)
398
+
399
+
400
+ def _select_conventions_section(template: str, infer_scope: bool) -> str:
401
+ """Select and normalize the appropriate conventions section.
402
+
403
+ Args:
404
+ template: The template string
405
+ infer_scope: Whether to infer scope for commits
406
+
407
+ Returns:
408
+ Template with the appropriate conventions section selected
409
+ """
410
+ try:
411
+ logger.debug(f"Processing infer_scope parameter: {infer_scope}")
412
+ if infer_scope:
413
+ logger.debug("Using inferred-scope conventions")
414
+ template = _remove_template_section(template, "conventions_no_scope")
415
+ template = template.replace("<conventions_with_scope>", "<conventions>")
416
+ template = template.replace("</conventions_with_scope>", "</conventions>")
417
+ else:
418
+ logger.debug("Using no-scope conventions")
419
+ template = _remove_template_section(template, "conventions_with_scope")
420
+ template = template.replace("<conventions_no_scope>", "<conventions>")
421
+ template = template.replace("</conventions_no_scope>", "</conventions>")
422
+ except Exception as e:
423
+ logger.error(f"Error processing scope parameter: {e}")
424
+ template = _remove_template_section(template, "conventions_with_scope")
425
+ template = template.replace("<conventions_no_scope>", "<conventions>")
426
+ template = template.replace("</conventions_no_scope>", "</conventions>")
427
+ return template
428
+
429
+
430
+ def _select_format_section(template: str, verbose: bool, one_liner: bool) -> str:
431
+ """Select the appropriate format section based on verbosity and one-liner settings.
432
+
433
+ Priority: verbose > one_liner > multi_line
434
+
435
+ Args:
436
+ template: The template string
437
+ verbose: Whether to use verbose format
438
+ one_liner: Whether to use one-liner format
225
439
 
226
440
  Returns:
227
- Template content as string
441
+ Template with the appropriate format section selected
228
442
  """
229
- logger.debug("Using default template")
230
- return DEFAULT_TEMPLATE
443
+ if verbose:
444
+ template = _remove_template_section(template, "one_liner")
445
+ template = _remove_template_section(template, "multi_line")
446
+ elif one_liner:
447
+ template = _remove_template_section(template, "multi_line")
448
+ template = _remove_template_section(template, "verbose")
449
+ else:
450
+ template = _remove_template_section(template, "one_liner")
451
+ template = _remove_template_section(template, "verbose")
452
+ return template
453
+
454
+
455
+ def _select_examples_section(template: str, verbose: bool, infer_scope: bool) -> str:
456
+ """Select the appropriate examples section based on verbosity and scope settings.
457
+
458
+ Args:
459
+ template: The template string
460
+ verbose: Whether verbose mode is enabled
461
+ infer_scope: Whether scope inference is enabled
462
+
463
+ Returns:
464
+ Template with the appropriate examples section selected
465
+ """
466
+ if verbose and infer_scope:
467
+ template = _remove_template_section(template, "examples_no_scope")
468
+ template = _remove_template_section(template, "examples_with_scope")
469
+ template = _remove_template_section(template, "examples_verbose_no_scope")
470
+ template = template.replace("<examples_verbose_with_scope>", "<examples>")
471
+ template = template.replace("</examples_verbose_with_scope>", "</examples>")
472
+ elif verbose:
473
+ template = _remove_template_section(template, "examples_no_scope")
474
+ template = _remove_template_section(template, "examples_with_scope")
475
+ template = _remove_template_section(template, "examples_verbose_with_scope")
476
+ template = template.replace("<examples_verbose_no_scope>", "<examples>")
477
+ template = template.replace("</examples_verbose_no_scope>", "</examples>")
478
+ elif infer_scope:
479
+ template = _remove_template_section(template, "examples_no_scope")
480
+ template = _remove_template_section(template, "examples_verbose_no_scope")
481
+ template = _remove_template_section(template, "examples_verbose_with_scope")
482
+ template = template.replace("<examples_with_scope>", "<examples>")
483
+ template = template.replace("</examples_with_scope>", "</examples>")
484
+ else:
485
+ template = _remove_template_section(template, "examples_with_scope")
486
+ template = _remove_template_section(template, "examples_verbose_no_scope")
487
+ template = _remove_template_section(template, "examples_verbose_with_scope")
488
+ template = template.replace("<examples_no_scope>", "<examples>")
489
+ template = template.replace("</examples_no_scope>", "</examples>")
490
+ return template
491
+
492
+
493
+ # ============================================================================
494
+ # Prompt Building
495
+ # ============================================================================
231
496
 
232
497
 
233
498
  def build_prompt(
@@ -235,212 +500,384 @@ def build_prompt(
235
500
  processed_diff: str,
236
501
  diff_stat: str = "",
237
502
  one_liner: bool = False,
503
+ infer_scope: bool = False,
238
504
  hint: str = "",
239
- scope: str | None = None,
240
- ) -> str:
241
- """Build a prompt for the AI model using the provided template and git information.
505
+ verbose: bool = False,
506
+ system_template_path: str | None = None,
507
+ language: str | None = None,
508
+ translate_prefixes: bool = False,
509
+ ) -> tuple[str, str]:
510
+ """Build system and user prompts for the AI model using the provided templates and git information.
242
511
 
243
512
  Args:
244
513
  status: Git status output
245
514
  processed_diff: Git diff output, already preprocessed and ready to use
246
515
  diff_stat: Git diff stat output showing file changes summary
247
516
  one_liner: Whether to request a one-line commit message
517
+ infer_scope: Whether to infer scope for the commit message
248
518
  hint: Optional hint to guide the AI
249
- scope: Optional scope parameter. None = no scope, "infer" = infer scope, any other string = use as scope
519
+ verbose: Whether to generate detailed commit messages with motivation, architecture, and impact sections
520
+ system_template_path: Optional path to custom system template
521
+ language: Optional language for commit messages (e.g., "Spanish", "French", "Japanese")
522
+ translate_prefixes: Whether to translate conventional commit prefixes (default: False keeps them in English)
250
523
 
251
524
  Returns:
252
- Formatted prompt string ready to be sent to an AI model
525
+ Tuple of (system_prompt, user_prompt) ready to be sent to an AI model
253
526
  """
254
- template = load_prompt_template()
527
+ system_template = load_system_template(system_template_path)
528
+ user_template = load_user_template()
255
529
 
256
- # Select the appropriate conventions section based on scope parameter
257
- try:
258
- logger.debug(f"Processing scope parameter: {scope}")
259
- if scope is None:
260
- # No scope - use the plain conventions section
261
- logger.debug("Using no-scope conventions")
262
- template = re.sub(
263
- r"<conventions_scope_provided>.*?</conventions_scope_provided>\n", "", template, flags=re.DOTALL
264
- )
265
- template = re.sub(
266
- r"<conventions_scope_inferred>.*?</conventions_scope_inferred>\n", "", template, flags=re.DOTALL
267
- )
268
- template = template.replace("<conventions_no_scope>", "<conventions>")
269
- template = template.replace("</conventions_no_scope>", "</conventions>")
270
- elif scope == "infer" or scope == "":
271
- # User wants to infer a scope from changes (either with "infer" or empty string)
272
- logger.debug(f"Using inferred-scope conventions (scope={scope})")
273
- template = re.sub(
274
- r"<conventions_scope_provided>.*?</conventions_scope_provided>\n", "", template, flags=re.DOTALL
275
- )
276
- template = re.sub(r"<conventions_no_scope>.*?</conventions_no_scope>\n", "", template, flags=re.DOTALL)
277
- template = template.replace("<conventions_scope_inferred>", "<conventions>")
278
- template = template.replace("</conventions_scope_inferred>", "</conventions>")
279
- else:
280
- # User provided a specific scope
281
- logger.debug(f"Using provided-scope conventions with scope '{scope}'")
282
- template = re.sub(
283
- r"<conventions_scope_inferred>.*?</conventions_scope_inferred>\n", "", template, flags=re.DOTALL
284
- )
285
- template = re.sub(r"<conventions_no_scope>.*?</conventions_no_scope>\n", "", template, flags=re.DOTALL)
286
- template = template.replace("<conventions_scope_provided>", "<conventions>")
287
- template = template.replace("</conventions_scope_provided>", "</conventions>")
288
- template = template.replace("{scope}", scope)
289
- except Exception as e:
290
- logger.error(f"Error processing scope parameter: {e}")
291
- # Fallback to no scope if there's an error
292
- template = re.sub(
293
- r"<conventions_scope_provided>.*?</conventions_scope_provided>\n", "", template, flags=re.DOTALL
294
- )
295
- template = re.sub(
296
- r"<conventions_scope_inferred>.*?</conventions_scope_inferred>\n", "", template, flags=re.DOTALL
297
- )
298
- template = template.replace("<conventions_no_scope>", "<conventions>")
299
- template = template.replace("</conventions_no_scope>", "</conventions>")
530
+ system_template = _select_conventions_section(system_template, infer_scope)
531
+ system_template = _select_format_section(system_template, verbose, one_liner)
532
+ system_template = _select_examples_section(system_template, verbose, infer_scope)
533
+ system_template = re.sub(r"\n(?:[ \t]*\n){2,}", "\n\n", system_template)
300
534
 
301
- template = template.replace("<status></status>", status)
302
- template = template.replace("<diff_stat></diff_stat>", diff_stat)
303
- template = template.replace("<diff></diff>", processed_diff)
535
+ user_template = user_template.replace("<status></status>", status)
536
+ user_template = user_template.replace("<diff_stat></diff_stat>", diff_stat)
537
+ user_template = user_template.replace("<diff></diff>", processed_diff)
304
538
 
305
- # Add hint if present
306
539
  if hint:
307
- template = template.replace("<hint_text></hint_text>", hint)
540
+ user_template = user_template.replace("<hint_text></hint_text>", hint)
308
541
  logger.debug(f"Added hint ({len(hint)} characters)")
309
542
  else:
310
- template = re.sub(r"<hint>.*?</hint>", "", template, flags=re.DOTALL)
543
+ user_template = _remove_template_section(user_template, "hint")
311
544
  logger.debug("No hint provided")
312
545
 
313
- # Process format options (one-liner vs multi-line)
314
- if one_liner:
315
- template = re.sub(r"<multi_line>.*?</multi_line>", "", template, flags=re.DOTALL)
316
- else:
317
- template = re.sub(r"<one_liner>.*?</one_liner>", "", template, flags=re.DOTALL)
546
+ if language:
547
+ user_template = user_template.replace("<language_name></language_name>", language)
548
+
549
+ # Set prefix instruction based on translate_prefixes setting
550
+ if translate_prefixes:
551
+ prefix_instruction = f"""CRITICAL: You MUST translate the conventional commit prefix into {language}.
552
+ DO NOT use English prefixes like 'feat:', 'fix:', 'docs:', etc.
553
+ Instead, translate them into {language} equivalents.
554
+ Examples:
555
+ - 'feat:' → translate to {language} word for 'feature' or 'add'
556
+ - 'fix:' → translate to {language} word for 'fix' or 'correct'
557
+ - 'docs:' → translate to {language} word for 'documentation'
558
+ The ENTIRE commit message, including the prefix, must be in {language}."""
559
+ logger.debug(f"Set commit message language to: {language} (with prefix translation)")
560
+ else:
561
+ prefix_instruction = (
562
+ "The conventional commit prefix (feat:, fix:, etc.) should remain in English, but everything after the prefix must be in "
563
+ + language
564
+ + "."
565
+ )
566
+ logger.debug(f"Set commit message language to: {language} (English prefixes)")
318
567
 
319
- # Clean up examples sections based on scope settings
320
- if scope is None:
321
- # No scope - keep no_scope examples, remove scope examples
322
- template = re.sub(r"<examples_with_scope>.*?</examples_with_scope>\n?", "", template, flags=re.DOTALL)
323
- template = template.replace("<examples_no_scope>", "<examples>")
324
- template = template.replace("</examples_no_scope>", "</examples>")
568
+ user_template = user_template.replace("<prefix_instruction></prefix_instruction>", prefix_instruction)
325
569
  else:
326
- # With scope (either provided or inferred) - keep scope examples, remove no_scope examples
327
- template = re.sub(r"<examples_no_scope>.*?</examples_no_scope>\n?", "", template, flags=re.DOTALL)
328
- template = template.replace("<examples_with_scope>", "<examples>")
329
- template = template.replace("</examples_with_scope>", "</examples>")
570
+ user_template = _remove_template_section(user_template, "language_instructions")
571
+ logger.debug("Using default language (English)")
330
572
 
331
- # Clean up extra whitespace, collapsing blank lines that may contain spaces
332
- template = re.sub(r"\n(?:[ \t]*\n){2,}", "\n\n", template)
573
+ user_template = re.sub(r"\n(?:[ \t]*\n){2,}", "\n\n", user_template)
333
574
 
334
- return template.strip()
575
+ return system_template.strip(), user_template.strip()
335
576
 
336
577
 
337
- def clean_commit_message(message: str) -> str:
338
- """Clean up a commit message generated by an AI model.
578
+ def build_group_prompt(
579
+ status: str,
580
+ processed_diff: str,
581
+ diff_stat: str,
582
+ one_liner: bool,
583
+ hint: str,
584
+ infer_scope: bool,
585
+ verbose: bool,
586
+ system_template_path: str | None,
587
+ language: str | None,
588
+ translate_prefixes: bool,
589
+ ) -> tuple[str, str]:
590
+ """Build prompt for grouped commit generation (JSON output with multiple commits)."""
591
+ system_prompt, user_prompt = build_prompt(
592
+ status=status,
593
+ processed_diff=processed_diff,
594
+ diff_stat=diff_stat,
595
+ one_liner=one_liner,
596
+ hint=hint,
597
+ infer_scope=infer_scope,
598
+ verbose=verbose,
599
+ system_template_path=system_template_path,
600
+ language=language,
601
+ translate_prefixes=translate_prefixes,
602
+ )
339
603
 
340
- This function:
341
- 1. Removes any preamble or reasoning text
342
- 2. Removes code block markers and formatting
343
- 3. Removes XML tags that might have leaked into the response
344
- 4. Ensures the message starts with a conventional commit prefix
345
- 5. Fixes double type prefix issues (e.g., "chore: feat(scope):")
604
+ user_prompt = _remove_template_section(user_prompt, "format_instructions")
605
+
606
+ grouping_instructions = """
607
+ <format_instructions>
608
+ Your task is to split the changed files into separate, logical commits. Think of this like sorting files into different folders where each file belongs in exactly one folder.
609
+
610
+ CRITICAL REQUIREMENT - Every File Used Exactly Once:
611
+ You must assign EVERY file from the diff to exactly ONE commit.
612
+ - NO file should be left out
613
+ - NO file should appear in multiple commits
614
+ - EVERY file must be used once and ONLY once
615
+
616
+ Think of it like dealing cards: Once you've dealt a card to a player, that card cannot be dealt to another player.
617
+
618
+ HOW TO SPLIT THE FILES:
619
+ 1. Review all changed files in the diff
620
+ 2. Group files by logical relationship (e.g., related features, bug fixes, documentation)
621
+ 3. Assign each file to exactly one commit based on what makes the most sense
622
+ 4. If a file could fit in multiple commits, pick the best fit and move on - do NOT duplicate it
623
+ 5. Continue until every single file has been assigned to a commit
624
+
625
+ ORDERING:
626
+ Order the commits in a logical sequence considering dependencies, natural progression, and overall workflow.
627
+
628
+ YOUR RESPONSE FORMAT:
629
+ Respond with valid JSON following this structure:
630
+ ```json
631
+ {
632
+ "commits": [
633
+ {
634
+ "files": ["src/auth/login.ts", "src/auth/logout.ts"],
635
+ "message": "<commit_message_conforming_to_prescribed_structure_and_format>"
636
+ },
637
+ {
638
+ "files": ["src/db/schema.sql", "src/db/migrations/001.sql"],
639
+ "message": "<commit_message_conforming_to_prescribed_structure_and_format>"
640
+ },
641
+ {
642
+ "files": ["tests/auth.test.ts", "tests/db.test.ts", "README.md"],
643
+ "message": "<commit_message_conforming_to_prescribed_structure_and_format>"
644
+ }
645
+ ]
646
+ }
647
+ ```
648
+
649
+ ☝️ Notice how EVERY file path in the example above appears exactly ONCE across all commits. "src/auth/login.ts" appears once. "tests/auth.test.ts" appears once. No file is repeated.
650
+
651
+ VALIDATION CHECKLIST - Before responding, verify:
652
+ □ Total files across all commits = Total files in the diff
653
+ □ Each file appears in exactly 1 commit (no duplicates, no omissions)
654
+ □ Every commit has at least one file
655
+ □ If you list all files from all commits and count them, you get the same count as unique files in the diff
656
+ </format_instructions>
657
+ """
658
+
659
+ user_prompt = user_prompt + grouping_instructions
660
+
661
+ return system_prompt, user_prompt
662
+
663
+
664
+ def build_question_generation_prompt(
665
+ status: str,
666
+ processed_diff: str,
667
+ diff_stat: str = "",
668
+ hint: str = "",
669
+ ) -> tuple[str, str]:
670
+ """Build system and user prompts for question generation about staged changes.
346
671
 
347
672
  Args:
348
- message: Raw commit message from AI
673
+ status: Git status output
674
+ processed_diff: Git diff output, already preprocessed and ready to use
675
+ diff_stat: Git diff stat output showing file changes summary
676
+ hint: Optional hint to guide the question generation
349
677
 
350
678
  Returns:
351
- Cleaned commit message ready for use
679
+ Tuple of (system_prompt, user_prompt) ready to be sent to an AI model
352
680
  """
353
- message = message.strip()
681
+ system_prompt = QUESTION_GENERATION_TEMPLATE
682
+
683
+ # Build user prompt with git context
684
+ user_prompt = f"""<git_diff>
685
+ {processed_diff}
686
+ </git_diff>
687
+
688
+ <git_diff_stat>
689
+ {diff_stat}
690
+ </git_diff_stat>
691
+
692
+ <git_status>
693
+ {status}
694
+ </git_status>"""
695
+
696
+ if hint:
697
+ user_prompt = f"""<hint>
698
+ Additional context provided by the user: {hint}
699
+ </hint>
700
+
701
+ {user_prompt}"""
702
+
703
+ # Add instruction to ask questions in the appropriate language if specified
704
+ user_prompt += """
354
705
 
355
- # Remove any markdown code blocks
356
- message = re.sub(r"```[\w]*\n|```", "", message)
357
-
358
- # Extract the actual commit message if it follows our reasoning pattern
359
- # Look for different indicators of where the actual commit message starts
360
- commit_indicators = [
361
- "# Your commit message:",
362
- "Your commit message:",
363
- "The commit message is:",
364
- "Here's the commit message:",
365
- "Commit message:",
366
- "Final commit message:",
367
- "# Commit Message",
368
- ]
369
-
370
- for indicator in commit_indicators:
706
+ <format_instructions>
707
+ Analyze the changes above and generate 3-7 focused questions that clarify the intent, motivation, and impact of these changes. Respond with ONLY a numbered list of questions as specified in the system prompt.
708
+ </format_instructions>"""
709
+
710
+ return system_prompt.strip(), user_prompt.strip()
711
+
712
+
713
+ # ============================================================================
714
+ # Message Cleaning Helpers
715
+ # ============================================================================
716
+
717
+
718
+ def _remove_think_tags(message: str) -> str:
719
+ """Remove AI reasoning <think> tags and their content from the message.
720
+
721
+ Args:
722
+ message: The message to clean
723
+
724
+ Returns:
725
+ Message with <think> tags removed
726
+ """
727
+ while re.search(r"<think>(?:(?!</think>)[^\n])*\n.*?</think>", message, flags=re.DOTALL | re.IGNORECASE):
728
+ message = re.sub(
729
+ r"<think>(?:(?!</think>)[^\n])*\n.*?</think>\s*", "", message, flags=re.DOTALL | re.IGNORECASE, count=1
730
+ )
731
+
732
+ message = re.sub(r"\n\n+\s*<think>.*?</think>\s*", "", message, flags=re.DOTALL | re.IGNORECASE)
733
+ message = re.sub(r"<think>.*?</think>\s*\n\n+", "", message, flags=re.DOTALL | re.IGNORECASE)
734
+
735
+ message = re.sub(r"<think>\s*\n.*$", "", message, flags=re.DOTALL | re.IGNORECASE)
736
+
737
+ conventional_prefixes_pattern = r"(" + "|".join(CommitMessageConstants.CONVENTIONAL_PREFIXES) + r")[\(:)]"
738
+ if re.search(r"^.*?</think>", message, flags=re.DOTALL | re.IGNORECASE):
739
+ prefix_match = re.search(conventional_prefixes_pattern, message, flags=re.IGNORECASE)
740
+ think_match = re.search(r"</think>", message, flags=re.IGNORECASE)
741
+
742
+ if not prefix_match or (think_match and think_match.start() < prefix_match.start()):
743
+ message = re.sub(r"^.*?</think>\s*", "", message, flags=re.DOTALL | re.IGNORECASE)
744
+
745
+ message = re.sub(r"</think>\s*$", "", message, flags=re.IGNORECASE)
746
+
747
+ return message
748
+
749
+
750
+ def _remove_code_blocks(message: str) -> str:
751
+ """Remove markdown code blocks from the message.
752
+
753
+ Args:
754
+ message: The message to clean
755
+
756
+ Returns:
757
+ Message with code blocks removed
758
+ """
759
+ return re.sub(r"```[\w]*\n|```", "", message)
760
+
761
+
762
+ def _extract_commit_from_reasoning(message: str) -> str:
763
+ """Extract the actual commit message from reasoning/preamble text.
764
+
765
+ Args:
766
+ message: The message potentially containing reasoning
767
+
768
+ Returns:
769
+ Extracted commit message
770
+ """
771
+ for indicator in CommitMessageConstants.COMMIT_INDICATORS:
371
772
  if indicator.lower() in message.lower():
372
- # Extract everything after the indicator
373
773
  message = message.split(indicator, 1)[1].strip()
374
774
  break
375
775
 
376
- # If message starts with any kind of explanation text, try to locate a conventional prefix
377
776
  lines = message.split("\n")
378
777
  for i, line in enumerate(lines):
379
- if any(
380
- line.strip().startswith(prefix)
381
- for prefix in ["feat:", "fix:", "docs:", "style:", "refactor:", "perf:", "test:", "build:", "ci:", "chore:"]
382
- ):
778
+ if any(line.strip().startswith(f"{prefix}:") for prefix in CommitMessageConstants.CONVENTIONAL_PREFIXES):
383
779
  message = "\n".join(lines[i:])
384
780
  break
385
781
 
386
- # Remove any XML tags that might have leaked into the response
387
- for tag in [
388
- "<git-status>",
389
- "</git-status>",
390
- "<git_status>",
391
- "</git_status>",
392
- "<git-diff>",
393
- "</git-diff>",
394
- "<git_diff>",
395
- "</git_diff>",
396
- "<repository_context>",
397
- "</repository_context>",
398
- "<instructions>",
399
- "</instructions>",
400
- "<format>",
401
- "</format>",
402
- "<conventions>",
403
- "</conventions>",
404
- ]:
782
+ return message
783
+
784
+
785
+ def _remove_xml_tags(message: str) -> str:
786
+ """Remove XML tags that might have leaked into the message.
787
+
788
+ Args:
789
+ message: The message to clean
790
+
791
+ Returns:
792
+ Message with XML tags removed
793
+ """
794
+ for tag in CommitMessageConstants.XML_TAGS_TO_REMOVE:
405
795
  message = message.replace(tag, "")
796
+ return message
797
+
798
+
799
+ def _fix_double_prefix(message: str) -> str:
800
+ """Fix double type prefix issues like 'chore: feat(scope):' to 'feat(scope):'.
801
+
802
+ Args:
803
+ message: The message to fix
406
804
 
407
- # Fix double type prefix issues (e.g., "chore: feat(scope):") to just "feat(scope):")
408
- conventional_prefixes = [
409
- "feat",
410
- "fix",
411
- "docs",
412
- "style",
413
- "refactor",
414
- "perf",
415
- "test",
416
- "build",
417
- "ci",
418
- "chore",
419
- ]
420
-
421
- # Look for double prefix pattern like "chore: feat(scope):" and fix it
422
- # This regex looks for a conventional prefix followed by another conventional prefix with a scope
805
+ Returns:
806
+ Message with double prefix corrected
807
+ """
423
808
  double_prefix_pattern = re.compile(
424
- r"^(" + r"|\s*".join(conventional_prefixes) + r"):\s*(" + r"|\s*".join(conventional_prefixes) + r")\(([^)]+)\):"
809
+ r"^("
810
+ + r"|\s*".join(CommitMessageConstants.CONVENTIONAL_PREFIXES)
811
+ + r"):\s*("
812
+ + r"|\s*".join(CommitMessageConstants.CONVENTIONAL_PREFIXES)
813
+ + r")\(([^)]+)\):"
425
814
  )
426
815
  match = double_prefix_pattern.match(message)
427
816
 
428
817
  if match:
429
- # Extract the second type and scope, which is what we want to keep
430
818
  second_type = match.group(2)
431
819
  scope = match.group(3)
432
820
  description = message[match.end() :].strip()
433
821
  message = f"{second_type}({scope}): {description}"
434
822
 
435
- # Ensure message starts with a conventional commit prefix
823
+ return message
824
+
825
+
826
+ def _ensure_conventional_prefix(message: str) -> str:
827
+ """Ensure the message starts with a conventional commit prefix.
828
+
829
+ Args:
830
+ message: The message to check
831
+
832
+ Returns:
833
+ Message with conventional prefix ensured
834
+ """
436
835
  if not any(
437
836
  message.strip().startswith(prefix + ":") or message.strip().startswith(prefix + "(")
438
- for prefix in conventional_prefixes
837
+ for prefix in CommitMessageConstants.CONVENTIONAL_PREFIXES
439
838
  ):
440
839
  message = f"chore: {message.strip()}"
840
+ return message
841
+
842
+
843
+ def _normalize_whitespace(message: str) -> str:
844
+ """Normalize whitespace, ensuring no more than one blank line between paragraphs.
845
+
846
+ Args:
847
+ message: The message to normalize
848
+
849
+ Returns:
850
+ Message with normalized whitespace
851
+ """
852
+ return re.sub(r"\n(?:[ \t]*\n){2,}", "\n\n", message).strip()
441
853
 
442
- # Final cleanup: trim extra whitespace and ensure no more than one blank line
443
- # Handle blank lines that may include spaces or tabs
444
- message = re.sub(r"\n(?:[ \t]*\n){2,}", "\n\n", message).strip()
445
854
 
855
+ # ============================================================================
856
+ # Message Cleaning
857
+ # ============================================================================
858
+
859
+
860
+ def clean_commit_message(message: str) -> str:
861
+ """Clean up a commit message generated by an AI model.
862
+
863
+ This function:
864
+ 1. Removes any preamble or reasoning text
865
+ 2. Removes code block markers and formatting
866
+ 3. Removes XML tags that might have leaked into the response
867
+ 4. Fixes double type prefix issues (e.g., "chore: feat(scope):")
868
+ 5. Normalizes whitespace
869
+
870
+ Args:
871
+ message: Raw commit message from AI
872
+
873
+ Returns:
874
+ Cleaned commit message ready for use
875
+ """
876
+ message = message.strip()
877
+ message = _remove_think_tags(message)
878
+ message = _remove_code_blocks(message)
879
+ message = _extract_commit_from_reasoning(message)
880
+ message = _remove_xml_tags(message)
881
+ message = _fix_double_prefix(message)
882
+ message = _normalize_whitespace(message)
446
883
  return message