gac 1.13.0__py3-none-any.whl → 3.8.1__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 (54) hide show
  1. gac/__version__.py +1 -1
  2. gac/ai.py +33 -47
  3. gac/ai_utils.py +113 -41
  4. gac/auth_cli.py +214 -0
  5. gac/cli.py +72 -2
  6. gac/config.py +63 -6
  7. gac/config_cli.py +26 -5
  8. gac/constants.py +178 -2
  9. gac/git.py +158 -12
  10. gac/init_cli.py +40 -125
  11. gac/language_cli.py +378 -0
  12. gac/main.py +868 -158
  13. gac/model_cli.py +429 -0
  14. gac/oauth/__init__.py +27 -0
  15. gac/oauth/claude_code.py +464 -0
  16. gac/oauth/qwen_oauth.py +323 -0
  17. gac/oauth/token_store.py +81 -0
  18. gac/preprocess.py +3 -3
  19. gac/prompt.py +573 -226
  20. gac/providers/__init__.py +49 -0
  21. gac/providers/anthropic.py +11 -1
  22. gac/providers/azure_openai.py +101 -0
  23. gac/providers/cerebras.py +11 -1
  24. gac/providers/chutes.py +11 -1
  25. gac/providers/claude_code.py +112 -0
  26. gac/providers/custom_anthropic.py +6 -2
  27. gac/providers/custom_openai.py +6 -3
  28. gac/providers/deepseek.py +11 -1
  29. gac/providers/fireworks.py +11 -1
  30. gac/providers/gemini.py +11 -1
  31. gac/providers/groq.py +5 -1
  32. gac/providers/kimi_coding.py +67 -0
  33. gac/providers/lmstudio.py +12 -1
  34. gac/providers/minimax.py +11 -1
  35. gac/providers/mistral.py +48 -0
  36. gac/providers/moonshot.py +48 -0
  37. gac/providers/ollama.py +11 -1
  38. gac/providers/openai.py +11 -1
  39. gac/providers/openrouter.py +11 -1
  40. gac/providers/qwen.py +76 -0
  41. gac/providers/replicate.py +110 -0
  42. gac/providers/streamlake.py +11 -1
  43. gac/providers/synthetic.py +11 -1
  44. gac/providers/together.py +11 -1
  45. gac/providers/zai.py +11 -1
  46. gac/security.py +1 -1
  47. gac/utils.py +272 -4
  48. gac/workflow_utils.py +217 -0
  49. {gac-1.13.0.dist-info → gac-3.8.1.dist-info}/METADATA +90 -27
  50. gac-3.8.1.dist-info/RECORD +56 -0
  51. {gac-1.13.0.dist-info → gac-3.8.1.dist-info}/WHEEL +1 -1
  52. gac-1.13.0.dist-info/RECORD +0 -41
  53. {gac-1.13.0.dist-info → gac-3.8.1.dist-info}/entry_points.txt +0 -0
  54. {gac-1.13.0.dist-info → gac-3.8.1.dist-info}/licenses/LICENSE +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,12 +39,12 @@ 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
@@ -49,7 +55,7 @@ When changes span multiple areas:
49
55
 
50
56
  Your commit message MUST follow this structure:
51
57
 
52
- Line 1: A concise summary (up to ~72 characters) with conventional commit prefix
58
+ Line 1: A concise summary (that could stand alone) with conventional commit prefix
53
59
  Line 2: BLANK LINE (required)
54
60
  Lines 3+: Detailed multi-paragraph body with the following sections:
55
61
 
@@ -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,297 +240,656 @@ 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_diff>
250
+ <diff></diff>
251
+ </git_diff>
252
+
253
+ <git_diff_stat>
254
+ <diff_stat></diff_stat>
255
+ </git_diff_stat>
256
256
 
257
- <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>
258
268
  IMMEDIATELY AFTER ANALYZING THE CHANGES, RESPOND WITH ONLY THE COMMIT MESSAGE.
259
269
  DO NOT include any preamble, reasoning, explanations or anything other than the commit message itself.
260
270
  DO NOT use markdown formatting, headers, or code blocks.
261
271
  The entire response will be passed directly to 'git commit -m'.
262
- </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 determine the appropriate number of questions based on change complexity. Generate 1-5 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>
263
281
 
282
+ <adaptive_guidelines>
283
+ - For very small changes (single file, <10 lines): Ask 1-2 essential questions about core purpose
284
+ - For small changes (few files, <50 lines): Ask 1-3 questions covering intent and impact
285
+ - For medium changes (multiple files, <200 lines): Ask 2-4 questions covering scope, intent, and impact
286
+ - For large changes (many files or substantial modifications): Ask 3-5 questions covering all aspects
287
+ - Always prioritize questions that would most help generate an informative commit message
288
+ - Lean toward fewer questions for straightforward changes
289
+ </adaptive_guidelines>
290
+
291
+ <guidelines>
292
+ - Focus on WHY the changes were made, not just WHAT was changed
293
+ - Ask about the intent, motivation, or business purpose behind the changes
294
+ - Consider what future developers need to understand about this change
295
+ - Ask about the broader impact or consequences of the changes
296
+ - Target areas where technical implementation doesn't reveal the underlying purpose
297
+ - Keep questions concise and specific
298
+ - Format as a clean list for easy parsing
299
+ </guidelines>
300
+
301
+ <rules>
302
+ NEVER write or rewrite the commit message; only ask questions.
303
+ DO NOT suggest specific commit message formats or wording.
304
+ DO NOT ask about implementation details that are already clear from the diff.
305
+ DO NOT include any explanations or preamble with your response.
306
+ </rules>
307
+
308
+ <output_format>
309
+ Respond with ONLY a numbered list of questions, one per line:
310
+ 1. First focused question?
311
+ 2. Second focused question?
312
+ 3. Third focused question?
313
+ 4. [etc...]
314
+ </output_format>
315
+
316
+ <examples>
317
+ Good example questions for small changes:
318
+ 1. What problem does this fix?
319
+ 2. Why was this approach chosen?
320
+
321
+ Good example questions for larger changes:
322
+ 1. What problem or user need does this change address?
323
+ 2. Why was this particular approach chosen over alternatives?
324
+ 3. What impact will this have on existing functionality?
325
+ 4. What motivated the addition of these new error cases?
326
+ 5. Why are these validation rules being added now?
327
+
328
+ Bad examples (violates rules):
329
+ ❌ feat: add user authentication - This is a commit message, not a question
330
+ ❌ Should I use "feat" or "fix" for this change? - This asks about formatting, not context
331
+ ❌ Why did you rename the variable from x to y? - Too implementation-specific
332
+ ❌ You should reformat this as "fix: resolve authentication issue" - This rewrites the message
333
+ </examples>"""
334
+
335
+
336
+ # ============================================================================
337
+ # Template Loading
338
+ # ============================================================================
339
+
340
+
341
+ def load_system_template(custom_path: str | None = None) -> str:
342
+ """Load the system prompt template.
264
343
 
265
- def load_prompt_template() -> str:
266
- """Load the prompt template from the embedded default template.
344
+ Args:
345
+ custom_path: Optional path to a custom system template file
267
346
 
268
347
  Returns:
269
- Template content as string
348
+ System template content as string
270
349
  """
271
- logger.debug("Using default template")
272
- return DEFAULT_TEMPLATE
350
+ if custom_path:
351
+ return load_custom_system_template(custom_path)
273
352
 
353
+ logger.debug("Using default system template")
354
+ return DEFAULT_SYSTEM_TEMPLATE
274
355
 
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.
356
+
357
+ def load_user_template() -> str:
358
+ """Load the user prompt template (contains git data sections and instructions).
359
+
360
+ Returns:
361
+ User template content as string
362
+ """
363
+ logger.debug("Using default user template")
364
+ return DEFAULT_USER_TEMPLATE
365
+
366
+
367
+ def load_custom_system_template(path: str) -> str:
368
+ """Load a custom system template from a file.
285
369
 
286
370
  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
371
+ path: Path to the custom system template file
294
372
 
295
373
  Returns:
296
- Tuple of (system_prompt, user_prompt) ready to be sent to an AI model
374
+ Custom system template content
375
+
376
+ Raises:
377
+ FileNotFoundError: If the template file doesn't exist
378
+ IOError: If there's an error reading the file
379
+ """
380
+ try:
381
+ with open(path, encoding="utf-8") as f:
382
+ content = f.read()
383
+ logger.info(f"Loaded custom system template from {path}")
384
+ return content
385
+ except FileNotFoundError:
386
+ logger.error(f"Custom system template not found: {path}")
387
+ raise
388
+ except OSError as e:
389
+ logger.error(f"Error reading custom system template from {path}: {e}")
390
+ raise
391
+
392
+
393
+ # ============================================================================
394
+ # Template Processing Helpers
395
+ # ============================================================================
396
+
397
+
398
+ def _remove_template_section(template: str, section_name: str) -> str:
399
+ """Remove a tagged section from the template.
400
+
401
+ Args:
402
+ template: The template string
403
+ section_name: Name of the section to remove (without < > brackets)
404
+
405
+ Returns:
406
+ Template with the section removed
297
407
  """
298
- template = load_prompt_template()
408
+ pattern = f"<{section_name}>.*?</{section_name}>\\n?"
409
+ return re.sub(pattern, "", template, flags=re.DOTALL)
410
+
299
411
 
300
- # Select the appropriate conventions section based on infer_scope parameter
412
+ def _select_conventions_section(template: str, infer_scope: bool) -> str:
413
+ """Select and normalize the appropriate conventions section.
414
+
415
+ Args:
416
+ template: The template string
417
+ infer_scope: Whether to infer scope for commits
418
+
419
+ Returns:
420
+ Template with the appropriate conventions section selected
421
+ """
301
422
  try:
302
423
  logger.debug(f"Processing infer_scope parameter: {infer_scope}")
303
424
  if infer_scope:
304
- # User wants to infer a scope from changes (any value other than None)
305
425
  logger.debug("Using inferred-scope conventions")
306
- template = re.sub(r"<conventions_no_scope>.*?</conventions_no_scope>\n", "", template, flags=re.DOTALL)
426
+ template = _remove_template_section(template, "conventions_no_scope")
307
427
  template = template.replace("<conventions_with_scope>", "<conventions>")
308
428
  template = template.replace("</conventions_with_scope>", "</conventions>")
309
429
  else:
310
- # No scope - use the plain conventions section
311
430
  logger.debug("Using no-scope conventions")
312
- template = re.sub(r"<conventions_with_scope>.*?</conventions_with_scope>\n", "", template, flags=re.DOTALL)
431
+ template = _remove_template_section(template, "conventions_with_scope")
313
432
  template = template.replace("<conventions_no_scope>", "<conventions>")
314
433
  template = template.replace("</conventions_no_scope>", "</conventions>")
315
434
  except Exception as e:
316
435
  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)
436
+ template = _remove_template_section(template, "conventions_with_scope")
319
437
  template = template.replace("<conventions_no_scope>", "<conventions>")
320
438
  template = template.replace("</conventions_no_scope>", "</conventions>")
439
+ return template
321
440
 
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
441
 
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")
442
+ def _select_format_section(template: str, verbose: bool, one_liner: bool) -> str:
443
+ """Select the appropriate format section based on verbosity and one-liner settings.
444
+
445
+ Priority: verbose > one_liner > multi_line
446
+
447
+ Args:
448
+ template: The template string
449
+ verbose: Whether to use verbose format
450
+ one_liner: Whether to use one-liner format
333
451
 
334
- # Process format options (verbose, one-liner, or multi-line)
335
- # Priority: verbose > one_liner > multi_line
452
+ Returns:
453
+ Template with the appropriate format section selected
454
+ """
336
455
  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)
456
+ template = _remove_template_section(template, "one_liner")
457
+ template = _remove_template_section(template, "multi_line")
340
458
  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)
459
+ template = _remove_template_section(template, "multi_line")
460
+ template = _remove_template_section(template, "verbose")
344
461
  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)
462
+ template = _remove_template_section(template, "one_liner")
463
+ template = _remove_template_section(template, "verbose")
464
+ return template
465
+
466
+
467
+ def _select_examples_section(template: str, verbose: bool, infer_scope: bool) -> str:
468
+ """Select the appropriate examples section based on verbosity and scope settings.
348
469
 
349
- # Clean up examples sections based on verbose and infer_scope settings
470
+ Args:
471
+ template: The template string
472
+ verbose: Whether verbose mode is enabled
473
+ infer_scope: Whether scope inference is enabled
474
+
475
+ Returns:
476
+ Template with the appropriate examples section selected
477
+ """
350
478
  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
- )
479
+ template = _remove_template_section(template, "examples_no_scope")
480
+ template = _remove_template_section(template, "examples_with_scope")
481
+ template = _remove_template_section(template, "examples_verbose_no_scope")
357
482
  template = template.replace("<examples_verbose_with_scope>", "<examples>")
358
483
  template = template.replace("</examples_verbose_with_scope>", "</examples>")
359
484
  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
- )
485
+ template = _remove_template_section(template, "examples_no_scope")
486
+ template = _remove_template_section(template, "examples_with_scope")
487
+ template = _remove_template_section(template, "examples_verbose_with_scope")
366
488
  template = template.replace("<examples_verbose_no_scope>", "<examples>")
367
489
  template = template.replace("</examples_verbose_no_scope>", "</examples>")
368
490
  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
- )
491
+ template = _remove_template_section(template, "examples_no_scope")
492
+ template = _remove_template_section(template, "examples_verbose_no_scope")
493
+ template = _remove_template_section(template, "examples_verbose_with_scope")
377
494
  template = template.replace("<examples_with_scope>", "<examples>")
378
495
  template = template.replace("</examples_with_scope>", "</examples>")
379
496
  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
- )
497
+ template = _remove_template_section(template, "examples_with_scope")
498
+ template = _remove_template_section(template, "examples_verbose_no_scope")
499
+ template = _remove_template_section(template, "examples_verbose_with_scope")
388
500
  template = template.replace("<examples_no_scope>", "<examples>")
389
501
  template = template.replace("</examples_no_scope>", "</examples>")
502
+ return template
503
+
504
+
505
+ # ============================================================================
506
+ # Prompt Building
507
+ # ============================================================================
508
+
509
+
510
+ def build_prompt(
511
+ status: str,
512
+ processed_diff: str,
513
+ diff_stat: str = "",
514
+ one_liner: bool = False,
515
+ infer_scope: bool = False,
516
+ hint: str = "",
517
+ verbose: bool = False,
518
+ system_template_path: str | None = None,
519
+ language: str | None = None,
520
+ translate_prefixes: bool = False,
521
+ ) -> tuple[str, str]:
522
+ """Build system and user prompts for the AI model using the provided templates and git information.
523
+
524
+ Args:
525
+ status: Git status output
526
+ processed_diff: Git diff output, already preprocessed and ready to use
527
+ diff_stat: Git diff stat output showing file changes summary
528
+ one_liner: Whether to request a one-line commit message
529
+ infer_scope: Whether to infer scope for the commit message
530
+ hint: Optional hint to guide the AI
531
+ verbose: Whether to generate detailed commit messages with motivation, architecture, and impact sections
532
+ system_template_path: Optional path to custom system template
533
+ language: Optional language for commit messages (e.g., "Spanish", "French", "Japanese")
534
+ translate_prefixes: Whether to translate conventional commit prefixes (default: False keeps them in English)
535
+
536
+ Returns:
537
+ Tuple of (system_prompt, user_prompt) ready to be sent to an AI model
538
+ """
539
+ system_template = load_system_template(system_template_path)
540
+ user_template = load_user_template()
390
541
 
391
- # Clean up extra whitespace, collapsing blank lines that may contain spaces
392
- template = re.sub(r"\n(?:[ \t]*\n){2,}", "\n\n", template)
393
-
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
-
398
- # Extract the git data sections for the user prompt
399
- user_sections = []
400
-
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), "")
407
-
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), "")
414
-
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), "")
421
-
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), "")
428
-
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)
432
-
433
- # User prompt is the git data sections
434
- user_prompt = "\n\n".join(user_sections).strip()
542
+ system_template = _select_conventions_section(system_template, infer_scope)
543
+ system_template = _select_format_section(system_template, verbose, one_liner)
544
+ system_template = _select_examples_section(system_template, verbose, infer_scope)
545
+ system_template = re.sub(r"\n(?:[ \t]*\n){2,}", "\n\n", system_template)
546
+
547
+ user_template = user_template.replace("<status></status>", status)
548
+ user_template = user_template.replace("<diff_stat></diff_stat>", diff_stat)
549
+ user_template = user_template.replace("<diff></diff>", processed_diff)
550
+
551
+ if hint:
552
+ user_template = user_template.replace("<hint_text></hint_text>", hint)
553
+ logger.debug(f"Added hint ({len(hint)} characters)")
554
+ else:
555
+ user_template = _remove_template_section(user_template, "hint")
556
+ logger.debug("No hint provided")
557
+
558
+ if language:
559
+ user_template = user_template.replace("<language_name></language_name>", language)
560
+
561
+ # Set prefix instruction based on translate_prefixes setting
562
+ if translate_prefixes:
563
+ prefix_instruction = f"""CRITICAL: You MUST translate the conventional commit prefix into {language}.
564
+ DO NOT use English prefixes like 'feat:', 'fix:', 'docs:', etc.
565
+ Instead, translate them into {language} equivalents.
566
+ Examples:
567
+ - 'feat:' translate to {language} word for 'feature' or 'add'
568
+ - 'fix:' → translate to {language} word for 'fix' or 'correct'
569
+ - 'docs:' → translate to {language} word for 'documentation'
570
+ The ENTIRE commit message, including the prefix, must be in {language}."""
571
+ logger.debug(f"Set commit message language to: {language} (with prefix translation)")
572
+ else:
573
+ prefix_instruction = (
574
+ "The conventional commit prefix (feat:, fix:, etc.) should remain in English, but everything after the prefix must be in "
575
+ + language
576
+ + "."
577
+ )
578
+ logger.debug(f"Set commit message language to: {language} (English prefixes)")
579
+
580
+ user_template = user_template.replace("<prefix_instruction></prefix_instruction>", prefix_instruction)
581
+ else:
582
+ user_template = _remove_template_section(user_template, "language_instructions")
583
+ logger.debug("Using default language (English)")
584
+
585
+ user_template = re.sub(r"\n(?:[ \t]*\n){2,}", "\n\n", user_template)
586
+
587
+ return system_template.strip(), user_template.strip()
588
+
589
+
590
+ def build_group_prompt(
591
+ status: str,
592
+ processed_diff: str,
593
+ diff_stat: str,
594
+ one_liner: bool,
595
+ hint: str,
596
+ infer_scope: bool,
597
+ verbose: bool,
598
+ system_template_path: str | None,
599
+ language: str | None,
600
+ translate_prefixes: bool,
601
+ ) -> tuple[str, str]:
602
+ """Build prompt for grouped commit generation (JSON output with multiple commits)."""
603
+ system_prompt, user_prompt = build_prompt(
604
+ status=status,
605
+ processed_diff=processed_diff,
606
+ diff_stat=diff_stat,
607
+ one_liner=one_liner,
608
+ hint=hint,
609
+ infer_scope=infer_scope,
610
+ verbose=verbose,
611
+ system_template_path=system_template_path,
612
+ language=language,
613
+ translate_prefixes=translate_prefixes,
614
+ )
615
+
616
+ user_prompt = _remove_template_section(user_prompt, "format_instructions")
617
+
618
+ grouping_instructions = """
619
+ <format_instructions>
620
+ 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.
621
+
622
+ CRITICAL REQUIREMENT - Every File Used Exactly Once:
623
+ You must assign EVERY file from the diff to exactly ONE commit.
624
+ - NO file should be left out
625
+ - NO file should appear in multiple commits
626
+ - EVERY file must be used once and ONLY once
627
+
628
+ Think of it like dealing cards: Once you've dealt a card to a player, that card cannot be dealt to another player.
629
+
630
+ HOW TO SPLIT THE FILES:
631
+ 1. Review all changed files in the diff
632
+ 2. Group files by logical relationship (e.g., related features, bug fixes, documentation)
633
+ 3. Assign each file to exactly one commit based on what makes the most sense
634
+ 4. If a file could fit in multiple commits, pick the best fit and move on - do NOT duplicate it
635
+ 5. Continue until every single file has been assigned to a commit
636
+
637
+ ORDERING:
638
+ Order the commits in a logical sequence considering dependencies, natural progression, and overall workflow.
639
+
640
+ YOUR RESPONSE FORMAT:
641
+ Respond with valid JSON following this structure:
642
+ ```json
643
+ {
644
+ "commits": [
645
+ {
646
+ "files": ["src/auth/login.ts", "src/auth/logout.ts"],
647
+ "message": "<commit_message_conforming_to_prescribed_structure_and_format>"
648
+ },
649
+ {
650
+ "files": ["src/db/schema.sql", "src/db/migrations/001.sql"],
651
+ "message": "<commit_message_conforming_to_prescribed_structure_and_format>"
652
+ },
653
+ {
654
+ "files": ["tests/auth.test.ts", "tests/db.test.ts", "README.md"],
655
+ "message": "<commit_message_conforming_to_prescribed_structure_and_format>"
656
+ }
657
+ ]
658
+ }
659
+ ```
660
+
661
+ ☝️ 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.
662
+
663
+ VALIDATION CHECKLIST - Before responding, verify:
664
+ □ Total files across all commits = Total files in the diff
665
+ □ Each file appears in exactly 1 commit (no duplicates, no omissions)
666
+ □ Every commit has at least one file
667
+ □ If you list all files from all commits and count them, you get the same count as unique files in the diff
668
+ </format_instructions>
669
+ """
670
+
671
+ user_prompt = user_prompt + grouping_instructions
435
672
 
436
673
  return system_prompt, user_prompt
437
674
 
438
675
 
439
- def clean_commit_message(message: str) -> str:
440
- """Clean up a commit message generated by an AI model.
676
+ def build_question_generation_prompt(
677
+ status: str,
678
+ processed_diff: str,
679
+ diff_stat: str = "",
680
+ hint: str = "",
681
+ ) -> tuple[str, str]:
682
+ """Build system and user prompts for question generation about staged changes.
441
683
 
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):")
684
+ Args:
685
+ status: Git status output
686
+ processed_diff: Git diff output, already preprocessed and ready to use
687
+ diff_stat: Git diff stat output showing file changes summary
688
+ hint: Optional hint to guide the question generation
689
+
690
+ Returns:
691
+ Tuple of (system_prompt, user_prompt) ready to be sent to an AI model
692
+ """
693
+ system_prompt = QUESTION_GENERATION_TEMPLATE
694
+
695
+ # Build user prompt with git context
696
+ user_prompt = f"""<git_diff>
697
+ {processed_diff}
698
+ </git_diff>
699
+
700
+ <git_diff_stat>
701
+ {diff_stat}
702
+ </git_diff_stat>
703
+
704
+ <git_status>
705
+ {status}
706
+ </git_status>"""
707
+
708
+ if hint:
709
+ user_prompt = f"""<hint>
710
+ Additional context provided by the user: {hint}
711
+ </hint>
712
+
713
+ {user_prompt}"""
714
+
715
+ # Add instruction to ask questions in the appropriate language if specified
716
+ user_prompt += """
717
+
718
+ <format_instructions>
719
+ Analyze the changes above and determine the appropriate number of questions based on the change complexity. Generate 1-5 focused questions that clarify the intent, motivation, and impact of these changes. For very small changes, ask only 1-2 essential questions. Respond with ONLY a numbered list of questions as specified in the system prompt.
720
+ </format_instructions>"""
721
+
722
+ return system_prompt.strip(), user_prompt.strip()
723
+
724
+
725
+ # ============================================================================
726
+ # Message Cleaning Helpers
727
+ # ============================================================================
728
+
729
+
730
+ def _remove_think_tags(message: str) -> str:
731
+ """Remove AI reasoning <think> tags and their content from the message.
448
732
 
449
733
  Args:
450
- message: Raw commit message from AI
734
+ message: The message to clean
451
735
 
452
736
  Returns:
453
- Cleaned commit message ready for use
737
+ Message with <think> tags removed
454
738
  """
455
- message = message.strip()
739
+ while re.search(r"<think>(?:(?!</think>)[^\n])*\n.*?</think>", message, flags=re.DOTALL | re.IGNORECASE):
740
+ message = re.sub(
741
+ r"<think>(?:(?!</think>)[^\n])*\n.*?</think>\s*", "", message, flags=re.DOTALL | re.IGNORECASE, count=1
742
+ )
743
+
744
+ message = re.sub(r"\n\n+\s*<think>.*?</think>\s*", "", message, flags=re.DOTALL | re.IGNORECASE)
745
+ message = re.sub(r"<think>.*?</think>\s*\n\n+", "", message, flags=re.DOTALL | re.IGNORECASE)
746
+
747
+ message = re.sub(r"<think>\s*\n.*$", "", message, flags=re.DOTALL | re.IGNORECASE)
748
+
749
+ conventional_prefixes_pattern = r"(" + "|".join(CommitMessageConstants.CONVENTIONAL_PREFIXES) + r")[\(:)]"
750
+ if re.search(r"^.*?</think>", message, flags=re.DOTALL | re.IGNORECASE):
751
+ prefix_match = re.search(conventional_prefixes_pattern, message, flags=re.IGNORECASE)
752
+ think_match = re.search(r"</think>", message, flags=re.IGNORECASE)
753
+
754
+ if not prefix_match or (think_match and think_match.start() < prefix_match.start()):
755
+ message = re.sub(r"^.*?</think>\s*", "", message, flags=re.DOTALL | re.IGNORECASE)
456
756
 
457
- # Remove any markdown code blocks
458
- message = re.sub(r"```[\w]*\n|```", "", message)
459
-
460
- # Extract the actual commit message if it follows our reasoning pattern
461
- # Look for different indicators of where the actual commit message starts
462
- commit_indicators = [
463
- "# Your commit message:",
464
- "Your commit message:",
465
- "The commit message is:",
466
- "Here's the commit message:",
467
- "Commit message:",
468
- "Final commit message:",
469
- "# Commit Message",
470
- ]
471
-
472
- for indicator in commit_indicators:
757
+ message = re.sub(r"</think>\s*$", "", message, flags=re.IGNORECASE)
758
+
759
+ return message
760
+
761
+
762
+ def _remove_code_blocks(message: str) -> str:
763
+ """Remove markdown code blocks from the message.
764
+
765
+ Args:
766
+ message: The message to clean
767
+
768
+ Returns:
769
+ Message with code blocks removed
770
+ """
771
+ return re.sub(r"```[\w]*\n|```", "", message)
772
+
773
+
774
+ def _extract_commit_from_reasoning(message: str) -> str:
775
+ """Extract the actual commit message from reasoning/preamble text.
776
+
777
+ Args:
778
+ message: The message potentially containing reasoning
779
+
780
+ Returns:
781
+ Extracted commit message
782
+ """
783
+ for indicator in CommitMessageConstants.COMMIT_INDICATORS:
473
784
  if indicator.lower() in message.lower():
474
- # Extract everything after the indicator
475
785
  message = message.split(indicator, 1)[1].strip()
476
786
  break
477
787
 
478
- # If message starts with any kind of explanation text, try to locate a conventional prefix
479
788
  lines = message.split("\n")
480
789
  for i, line in enumerate(lines):
481
- if any(
482
- line.strip().startswith(prefix)
483
- for prefix in ["feat:", "fix:", "docs:", "style:", "refactor:", "perf:", "test:", "build:", "ci:", "chore:"]
484
- ):
790
+ if any(line.strip().startswith(f"{prefix}:") for prefix in CommitMessageConstants.CONVENTIONAL_PREFIXES):
485
791
  message = "\n".join(lines[i:])
486
792
  break
487
793
 
488
- # Remove any XML tags that might have leaked into the response
489
- for tag in [
490
- "<git-status>",
491
- "</git-status>",
492
- "<git_status>",
493
- "</git_status>",
494
- "<git-diff>",
495
- "</git-diff>",
496
- "<git_diff>",
497
- "</git_diff>",
498
- "<repository_context>",
499
- "</repository_context>",
500
- "<instructions>",
501
- "</instructions>",
502
- "<format>",
503
- "</format>",
504
- "<conventions>",
505
- "</conventions>",
506
- ]:
794
+ return message
795
+
796
+
797
+ def _remove_xml_tags(message: str) -> str:
798
+ """Remove XML tags that might have leaked into the message.
799
+
800
+ Args:
801
+ message: The message to clean
802
+
803
+ Returns:
804
+ Message with XML tags removed
805
+ """
806
+ for tag in CommitMessageConstants.XML_TAGS_TO_REMOVE:
507
807
  message = message.replace(tag, "")
808
+ return message
809
+
810
+
811
+ def _fix_double_prefix(message: str) -> str:
812
+ """Fix double type prefix issues like 'chore: feat(scope):' to 'feat(scope):'.
813
+
814
+ Args:
815
+ message: The message to fix
508
816
 
509
- # Fix double type prefix issues (e.g., "chore: feat(scope):") to just "feat(scope):")
510
- conventional_prefixes = [
511
- "feat",
512
- "fix",
513
- "docs",
514
- "style",
515
- "refactor",
516
- "perf",
517
- "test",
518
- "build",
519
- "ci",
520
- "chore",
521
- ]
522
-
523
- # Look for double prefix pattern like "chore: feat(scope):" and fix it
524
- # This regex looks for a conventional prefix followed by another conventional prefix with a scope
817
+ Returns:
818
+ Message with double prefix corrected
819
+ """
525
820
  double_prefix_pattern = re.compile(
526
- r"^(" + r"|\s*".join(conventional_prefixes) + r"):\s*(" + r"|\s*".join(conventional_prefixes) + r")\(([^)]+)\):"
821
+ r"^("
822
+ + r"|\s*".join(CommitMessageConstants.CONVENTIONAL_PREFIXES)
823
+ + r"):\s*("
824
+ + r"|\s*".join(CommitMessageConstants.CONVENTIONAL_PREFIXES)
825
+ + r")\(([^)]+)\):"
527
826
  )
528
827
  match = double_prefix_pattern.match(message)
529
828
 
530
829
  if match:
531
- # Extract the second type and scope, which is what we want to keep
532
830
  second_type = match.group(2)
533
831
  scope = match.group(3)
534
832
  description = message[match.end() :].strip()
535
833
  message = f"{second_type}({scope}): {description}"
536
834
 
537
- # Ensure message starts with a conventional commit prefix
835
+ return message
836
+
837
+
838
+ def _ensure_conventional_prefix(message: str) -> str:
839
+ """Ensure the message starts with a conventional commit prefix.
840
+
841
+ Args:
842
+ message: The message to check
843
+
844
+ Returns:
845
+ Message with conventional prefix ensured
846
+ """
538
847
  if not any(
539
848
  message.strip().startswith(prefix + ":") or message.strip().startswith(prefix + "(")
540
- for prefix in conventional_prefixes
849
+ for prefix in CommitMessageConstants.CONVENTIONAL_PREFIXES
541
850
  ):
542
851
  message = f"chore: {message.strip()}"
852
+ return message
853
+
854
+
855
+ def _normalize_whitespace(message: str) -> str:
856
+ """Normalize whitespace, ensuring no more than one blank line between paragraphs.
857
+
858
+ Args:
859
+ message: The message to normalize
860
+
861
+ Returns:
862
+ Message with normalized whitespace
863
+ """
864
+ return re.sub(r"\n(?:[ \t]*\n){2,}", "\n\n", message).strip()
865
+
543
866
 
544
- # Final cleanup: trim extra whitespace and ensure no more than one blank line
545
- # Handle blank lines that may include spaces or tabs
546
- message = re.sub(r"\n(?:[ \t]*\n){2,}", "\n\n", message).strip()
867
+ # ============================================================================
868
+ # Message Cleaning
869
+ # ============================================================================
547
870
 
871
+
872
+ def clean_commit_message(message: str) -> str:
873
+ """Clean up a commit message generated by an AI model.
874
+
875
+ This function:
876
+ 1. Removes any preamble or reasoning text
877
+ 2. Removes code block markers and formatting
878
+ 3. Removes XML tags that might have leaked into the response
879
+ 4. Fixes double type prefix issues (e.g., "chore: feat(scope):")
880
+ 5. Normalizes whitespace
881
+
882
+ Args:
883
+ message: Raw commit message from AI
884
+
885
+ Returns:
886
+ Cleaned commit message ready for use
887
+ """
888
+ message = message.strip()
889
+ message = _remove_think_tags(message)
890
+ message = _remove_code_blocks(message)
891
+ message = _extract_commit_from_reasoning(message)
892
+ message = _remove_xml_tags(message)
893
+ message = _fix_double_prefix(message)
894
+ message = _normalize_whitespace(message)
548
895
  return message