gac 1.13.1__py3-none-any.whl → 1.14.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.14.0"
gac/config.py CHANGED
@@ -38,6 +38,7 @@ 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"),
41
42
  }
42
43
 
43
44
  return config
gac/constants.py CHANGED
@@ -150,3 +150,52 @@ 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 CommitMessageConstants:
156
+ """Constants for commit message generation and cleaning."""
157
+
158
+ # Conventional commit type prefixes
159
+ CONVENTIONAL_PREFIXES: list[str] = [
160
+ "feat",
161
+ "fix",
162
+ "docs",
163
+ "style",
164
+ "refactor",
165
+ "perf",
166
+ "test",
167
+ "build",
168
+ "ci",
169
+ "chore",
170
+ ]
171
+
172
+ # XML tags that may leak from prompt templates into AI responses
173
+ XML_TAGS_TO_REMOVE: list[str] = [
174
+ "<git-status>",
175
+ "</git-status>",
176
+ "<git_status>",
177
+ "</git_status>",
178
+ "<git-diff>",
179
+ "</git-diff>",
180
+ "<git_diff>",
181
+ "</git_diff>",
182
+ "<repository_context>",
183
+ "</repository_context>",
184
+ "<instructions>",
185
+ "</instructions>",
186
+ "<format>",
187
+ "</format>",
188
+ "<conventions>",
189
+ "</conventions>",
190
+ ]
191
+
192
+ # Indicators that mark the start of the actual commit message in AI responses
193
+ COMMIT_INDICATORS: list[str] = [
194
+ "# Your commit message:",
195
+ "Your commit message:",
196
+ "The commit message is:",
197
+ "Here's the commit message:",
198
+ "Commit message:",
199
+ "Final commit message:",
200
+ "# Commit Message",
201
+ ]
gac/main.py CHANGED
@@ -181,6 +181,11 @@ def main(
181
181
  processed_diff = preprocess_diff(diff, token_limit=Utility.DEFAULT_DIFF_TOKEN_LIMIT, model=model)
182
182
  logger.debug(f"Processed diff ({len(processed_diff)} characters)")
183
183
 
184
+ system_template_path_value = config.get("system_prompt_path")
185
+ system_template_path: str | None = (
186
+ system_template_path_value if isinstance(system_template_path_value, str) else None
187
+ )
188
+
184
189
  system_prompt, user_prompt = build_prompt(
185
190
  status=status,
186
191
  processed_diff=processed_diff,
@@ -189,6 +194,7 @@ def main(
189
194
  hint=hint,
190
195
  infer_scope=infer_scope,
191
196
  verbose=verbose,
197
+ system_template_path=system_template_path,
192
198
  )
193
199
 
194
200
  if show_prompt:
@@ -238,7 +244,10 @@ def main(
238
244
  max_retries=max_retries,
239
245
  quiet=quiet,
240
246
  )
241
- commit_message = clean_commit_message(raw_commit_message)
247
+ # Don't enforce conventional commits when using custom system prompts
248
+ commit_message = clean_commit_message(
249
+ raw_commit_message, enforce_conventional_commits=(system_template_path is None)
250
+ )
242
251
 
243
252
  logger.info("Generated commit message:")
244
253
  logger.info(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,7 +240,23 @@ 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.
@@ -262,320 +266,401 @@ The entire response will be passed directly to 'git commit -m'.
262
266
  </instructions>"""
263
267
 
264
268
 
265
- def load_prompt_template() -> str:
266
- """Load the prompt template from the embedded default template.
269
+ # ============================================================================
270
+ # Template Loading
271
+ # ============================================================================
272
+
273
+
274
+ def load_system_template(custom_path: str | None = None) -> str:
275
+ """Load the system prompt template.
276
+
277
+ Args:
278
+ custom_path: Optional path to a custom system template file
267
279
 
268
280
  Returns:
269
- Template content as string
281
+ System template content as string
270
282
  """
271
- logger.debug("Using default template")
272
- return DEFAULT_TEMPLATE
283
+ if custom_path:
284
+ return load_custom_system_template(custom_path)
273
285
 
286
+ logger.debug("Using default system template")
287
+ return DEFAULT_SYSTEM_TEMPLATE
274
288
 
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.
289
+
290
+ def load_user_template() -> str:
291
+ """Load the user prompt template (contains git data sections and instructions).
292
+
293
+ Returns:
294
+ User template content as string
295
+ """
296
+ logger.debug("Using default user template")
297
+ return DEFAULT_USER_TEMPLATE
298
+
299
+
300
+ def load_custom_system_template(path: str) -> str:
301
+ """Load a custom system template from a file.
285
302
 
286
303
  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
304
+ path: Path to the custom system template file
294
305
 
295
306
  Returns:
296
- Tuple of (system_prompt, user_prompt) ready to be sent to an AI model
307
+ Custom system template content
308
+
309
+ Raises:
310
+ FileNotFoundError: If the template file doesn't exist
311
+ IOError: If there's an error reading the file
297
312
  """
298
- template = load_prompt_template()
313
+ try:
314
+ with open(path, encoding="utf-8") as f:
315
+ content = f.read()
316
+ logger.info(f"Loaded custom system template from {path}")
317
+ return content
318
+ except FileNotFoundError:
319
+ logger.error(f"Custom system template not found: {path}")
320
+ raise
321
+ except OSError as e:
322
+ logger.error(f"Error reading custom system template from {path}: {e}")
323
+ raise
324
+
325
+
326
+ # ============================================================================
327
+ # Template Processing Helpers
328
+ # ============================================================================
329
+
330
+
331
+ def _remove_template_section(template: str, section_name: str) -> str:
332
+ """Remove a tagged section from the template.
333
+
334
+ Args:
335
+ template: The template string
336
+ section_name: Name of the section to remove (without < > brackets)
337
+
338
+ Returns:
339
+ Template with the section removed
340
+ """
341
+ pattern = f"<{section_name}>.*?</{section_name}>\\n?"
342
+ return re.sub(pattern, "", template, flags=re.DOTALL)
343
+
344
+
345
+ def _select_conventions_section(template: str, infer_scope: bool) -> str:
346
+ """Select and normalize the appropriate conventions section.
299
347
 
300
- # Select the appropriate conventions section based on infer_scope parameter
348
+ Args:
349
+ template: The template string
350
+ infer_scope: Whether to infer scope for commits
351
+
352
+ Returns:
353
+ Template with the appropriate conventions section selected
354
+ """
301
355
  try:
302
356
  logger.debug(f"Processing infer_scope parameter: {infer_scope}")
303
357
  if infer_scope:
304
- # User wants to infer a scope from changes (any value other than None)
305
358
  logger.debug("Using inferred-scope conventions")
306
- template = re.sub(r"<conventions_no_scope>.*?</conventions_no_scope>\n", "", template, flags=re.DOTALL)
359
+ template = _remove_template_section(template, "conventions_no_scope")
307
360
  template = template.replace("<conventions_with_scope>", "<conventions>")
308
361
  template = template.replace("</conventions_with_scope>", "</conventions>")
309
362
  else:
310
- # No scope - use the plain conventions section
311
363
  logger.debug("Using no-scope conventions")
312
- template = re.sub(r"<conventions_with_scope>.*?</conventions_with_scope>\n", "", template, flags=re.DOTALL)
364
+ template = _remove_template_section(template, "conventions_with_scope")
313
365
  template = template.replace("<conventions_no_scope>", "<conventions>")
314
366
  template = template.replace("</conventions_no_scope>", "</conventions>")
315
367
  except Exception as e:
316
368
  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)
369
+ template = _remove_template_section(template, "conventions_with_scope")
319
370
  template = template.replace("<conventions_no_scope>", "<conventions>")
320
371
  template = template.replace("</conventions_no_scope>", "</conventions>")
372
+ return template
321
373
 
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
374
 
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")
375
+ def _select_format_section(template: str, verbose: bool, one_liner: bool) -> str:
376
+ """Select the appropriate format section based on verbosity and one-liner settings.
377
+
378
+ Priority: verbose > one_liner > multi_line
379
+
380
+ Args:
381
+ template: The template string
382
+ verbose: Whether to use verbose format
383
+ one_liner: Whether to use one-liner format
333
384
 
334
- # Process format options (verbose, one-liner, or multi-line)
335
- # Priority: verbose > one_liner > multi_line
385
+ Returns:
386
+ Template with the appropriate format section selected
387
+ """
336
388
  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)
389
+ template = _remove_template_section(template, "one_liner")
390
+ template = _remove_template_section(template, "multi_line")
340
391
  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)
392
+ template = _remove_template_section(template, "multi_line")
393
+ template = _remove_template_section(template, "verbose")
344
394
  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)
395
+ template = _remove_template_section(template, "one_liner")
396
+ template = _remove_template_section(template, "verbose")
397
+ return template
348
398
 
349
- # Clean up examples sections based on verbose and infer_scope settings
399
+
400
+ def _select_examples_section(template: str, verbose: bool, infer_scope: bool) -> str:
401
+ """Select the appropriate examples section based on verbosity and scope settings.
402
+
403
+ Args:
404
+ template: The template string
405
+ verbose: Whether verbose mode is enabled
406
+ infer_scope: Whether scope inference is enabled
407
+
408
+ Returns:
409
+ Template with the appropriate examples section selected
410
+ """
350
411
  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
- )
412
+ template = _remove_template_section(template, "examples_no_scope")
413
+ template = _remove_template_section(template, "examples_with_scope")
414
+ template = _remove_template_section(template, "examples_verbose_no_scope")
357
415
  template = template.replace("<examples_verbose_with_scope>", "<examples>")
358
416
  template = template.replace("</examples_verbose_with_scope>", "</examples>")
359
417
  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
- )
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_with_scope")
366
421
  template = template.replace("<examples_verbose_no_scope>", "<examples>")
367
422
  template = template.replace("</examples_verbose_no_scope>", "</examples>")
368
423
  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
- )
424
+ template = _remove_template_section(template, "examples_no_scope")
425
+ template = _remove_template_section(template, "examples_verbose_no_scope")
426
+ template = _remove_template_section(template, "examples_verbose_with_scope")
377
427
  template = template.replace("<examples_with_scope>", "<examples>")
378
428
  template = template.replace("</examples_with_scope>", "</examples>")
379
429
  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
- )
430
+ template = _remove_template_section(template, "examples_with_scope")
431
+ template = _remove_template_section(template, "examples_verbose_no_scope")
432
+ template = _remove_template_section(template, "examples_verbose_with_scope")
388
433
  template = template.replace("<examples_no_scope>", "<examples>")
389
434
  template = template.replace("</examples_no_scope>", "</examples>")
435
+ return template
390
436
 
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
437
 
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
438
+ # ============================================================================
439
+ # Prompt Building
440
+ # ============================================================================
397
441
 
398
- # Extract the git data sections for the user prompt
399
- user_sections = []
400
442
 
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), "")
443
+ def build_prompt(
444
+ status: str,
445
+ processed_diff: str,
446
+ diff_stat: str = "",
447
+ one_liner: bool = False,
448
+ infer_scope: bool = False,
449
+ hint: str = "",
450
+ verbose: bool = False,
451
+ system_template_path: str | None = None,
452
+ ) -> tuple[str, str]:
453
+ """Build system and user prompts for the AI model using the provided templates and git information.
407
454
 
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), "")
455
+ Args:
456
+ status: Git status output
457
+ processed_diff: Git diff output, already preprocessed and ready to use
458
+ diff_stat: Git diff stat output showing file changes summary
459
+ one_liner: Whether to request a one-line commit message
460
+ infer_scope: Whether to infer scope for the commit message
461
+ hint: Optional hint to guide the AI
462
+ verbose: Whether to generate detailed commit messages with motivation, architecture, and impact sections
463
+ system_template_path: Optional path to custom system template
414
464
 
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), "")
465
+ Returns:
466
+ Tuple of (system_prompt, user_prompt) ready to be sent to an AI model
467
+ """
468
+ system_template = load_system_template(system_template_path)
469
+ user_template = load_user_template()
421
470
 
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), "")
471
+ system_template = _select_conventions_section(system_template, infer_scope)
472
+ system_template = _select_format_section(system_template, verbose, one_liner)
473
+ system_template = _select_examples_section(system_template, verbose, infer_scope)
474
+ system_template = re.sub(r"\n(?:[ \t]*\n){2,}", "\n\n", system_template)
428
475
 
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)
476
+ user_template = user_template.replace("<status></status>", status)
477
+ user_template = user_template.replace("<diff_stat></diff_stat>", diff_stat)
478
+ user_template = user_template.replace("<diff></diff>", processed_diff)
432
479
 
433
- # User prompt is the git data sections
434
- user_prompt = "\n\n".join(user_sections).strip()
480
+ if hint:
481
+ user_template = user_template.replace("<hint_text></hint_text>", hint)
482
+ logger.debug(f"Added hint ({len(hint)} characters)")
483
+ else:
484
+ user_template = _remove_template_section(user_template, "hint")
485
+ logger.debug("No hint provided")
435
486
 
436
- return system_prompt, user_prompt
487
+ user_template = re.sub(r"\n(?:[ \t]*\n){2,}", "\n\n", user_template)
437
488
 
489
+ return system_template.strip(), user_template.strip()
438
490
 
439
- def clean_commit_message(message: str) -> str:
440
- """Clean up a commit message generated by an AI model.
441
491
 
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):")
492
+ # ============================================================================
493
+ # Message Cleaning Helpers
494
+ # ============================================================================
495
+
496
+
497
+ def _remove_think_tags(message: str) -> str:
498
+ """Remove AI reasoning <think> tags and their content from the message.
448
499
 
449
500
  Args:
450
- message: Raw commit message from AI
501
+ message: The message to clean
451
502
 
452
503
  Returns:
453
- Cleaned commit message ready for use
504
+ Message with <think> tags removed
454
505
  """
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
506
  while re.search(r"<think>(?:(?!</think>)[^\n])*\n.*?</think>", message, flags=re.DOTALL | re.IGNORECASE):
466
507
  message = re.sub(
467
508
  r"<think>(?:(?!</think>)[^\n])*\n.*?</think>\s*", "", message, flags=re.DOTALL | re.IGNORECASE, count=1
468
509
  )
469
510
 
470
- # Step 2: Remove blocks separated by blank lines (before or after the message)
471
511
  message = re.sub(r"\n\n+\s*<think>.*?</think>\s*", "", message, flags=re.DOTALL | re.IGNORECASE)
472
512
  message = re.sub(r"<think>.*?</think>\s*\n\n+", "", message, flags=re.DOTALL | re.IGNORECASE)
473
513
 
474
- # Step 3: Handle orphaned opening <think> tags followed by newline
475
514
  message = re.sub(r"<think>\s*\n.*$", "", message, flags=re.DOTALL | re.IGNORECASE)
476
515
 
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)[\(:)]"
516
+ conventional_prefixes_pattern = r"(" + "|".join(CommitMessageConstants.CONVENTIONAL_PREFIXES) + r")[\(:)]"
479
517
  if re.search(r"^.*?</think>", message, flags=re.DOTALL | re.IGNORECASE):
480
518
  prefix_match = re.search(conventional_prefixes_pattern, message, flags=re.IGNORECASE)
481
519
  think_match = re.search(r"</think>", message, flags=re.IGNORECASE)
482
520
 
483
521
  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
522
  message = re.sub(r"^.*?</think>\s*", "", message, flags=re.DOTALL | re.IGNORECASE)
486
523
 
487
- # Step 5: Remove orphaned closing </think> tags at the end (not part of inline mentions)
488
524
  message = re.sub(r"</think>\s*$", "", message, flags=re.IGNORECASE)
489
525
 
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:
526
+ return message
527
+
528
+
529
+ def _remove_code_blocks(message: str) -> str:
530
+ """Remove markdown code blocks from the message.
531
+
532
+ Args:
533
+ message: The message to clean
534
+
535
+ Returns:
536
+ Message with code blocks removed
537
+ """
538
+ return re.sub(r"```[\w]*\n|```", "", message)
539
+
540
+
541
+ def _extract_commit_from_reasoning(message: str) -> str:
542
+ """Extract the actual commit message from reasoning/preamble text.
543
+
544
+ Args:
545
+ message: The message potentially containing reasoning
546
+
547
+ Returns:
548
+ Extracted commit message
549
+ """
550
+ for indicator in CommitMessageConstants.COMMIT_INDICATORS:
506
551
  if indicator.lower() in message.lower():
507
- # Extract everything after the indicator
508
552
  message = message.split(indicator, 1)[1].strip()
509
553
  break
510
554
 
511
- # If message starts with any kind of explanation text, try to locate a conventional prefix
512
555
  lines = message.split("\n")
513
556
  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
- ):
557
+ if any(line.strip().startswith(f"{prefix}:") for prefix in CommitMessageConstants.CONVENTIONAL_PREFIXES):
518
558
  message = "\n".join(lines[i:])
519
559
  break
520
560
 
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
- ]:
561
+ return message
562
+
563
+
564
+ def _remove_xml_tags(message: str) -> str:
565
+ """Remove XML tags that might have leaked into the message.
566
+
567
+ Args:
568
+ message: The message to clean
569
+
570
+ Returns:
571
+ Message with XML tags removed
572
+ """
573
+ for tag in CommitMessageConstants.XML_TAGS_TO_REMOVE:
540
574
  message = message.replace(tag, "")
575
+ return message
576
+
577
+
578
+ def _fix_double_prefix(message: str) -> str:
579
+ """Fix double type prefix issues like 'chore: feat(scope):' to 'feat(scope):'.
541
580
 
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
581
+ Args:
582
+ message: The message to fix
583
+
584
+ Returns:
585
+ Message with double prefix corrected
586
+ """
558
587
  double_prefix_pattern = re.compile(
559
- r"^(" + r"|\s*".join(conventional_prefixes) + r"):\s*(" + r"|\s*".join(conventional_prefixes) + r")\(([^)]+)\):"
588
+ r"^("
589
+ + r"|\s*".join(CommitMessageConstants.CONVENTIONAL_PREFIXES)
590
+ + r"):\s*("
591
+ + r"|\s*".join(CommitMessageConstants.CONVENTIONAL_PREFIXES)
592
+ + r")\(([^)]+)\):"
560
593
  )
561
594
  match = double_prefix_pattern.match(message)
562
595
 
563
596
  if match:
564
- # Extract the second type and scope, which is what we want to keep
565
597
  second_type = match.group(2)
566
598
  scope = match.group(3)
567
599
  description = message[match.end() :].strip()
568
600
  message = f"{second_type}({scope}): {description}"
569
601
 
570
- # Ensure message starts with a conventional commit prefix
602
+ return message
603
+
604
+
605
+ def _ensure_conventional_prefix(message: str) -> str:
606
+ """Ensure the message starts with a conventional commit prefix.
607
+
608
+ Args:
609
+ message: The message to check
610
+
611
+ Returns:
612
+ Message with conventional prefix ensured
613
+ """
571
614
  if not any(
572
615
  message.strip().startswith(prefix + ":") or message.strip().startswith(prefix + "(")
573
- for prefix in conventional_prefixes
616
+ for prefix in CommitMessageConstants.CONVENTIONAL_PREFIXES
574
617
  ):
575
618
  message = f"chore: {message.strip()}"
619
+ return message
620
+
576
621
 
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()
622
+ def _normalize_whitespace(message: str) -> str:
623
+ """Normalize whitespace, ensuring no more than one blank line between paragraphs.
624
+
625
+ Args:
626
+ message: The message to normalize
627
+
628
+ Returns:
629
+ Message with normalized whitespace
630
+ """
631
+ return re.sub(r"\n(?:[ \t]*\n){2,}", "\n\n", message).strip()
580
632
 
633
+
634
+ # ============================================================================
635
+ # Message Cleaning
636
+ # ============================================================================
637
+
638
+
639
+ def clean_commit_message(message: str, enforce_conventional_commits: bool = True) -> str:
640
+ """Clean up a commit message generated by an AI model.
641
+
642
+ This function:
643
+ 1. Removes any preamble or reasoning text
644
+ 2. Removes code block markers and formatting
645
+ 3. Removes XML tags that might have leaked into the response
646
+ 4. Ensures the message starts with a conventional commit prefix (if enforce_conventional_commits is True)
647
+ 5. Fixes double type prefix issues (e.g., "chore: feat(scope):")
648
+
649
+ Args:
650
+ message: Raw commit message from AI
651
+ enforce_conventional_commits: If True, ensures message has conventional commit prefix.
652
+ Set to False when using custom system prompts.
653
+
654
+ Returns:
655
+ Cleaned commit message ready for use
656
+ """
657
+ message = message.strip()
658
+ message = _remove_think_tags(message)
659
+ message = _remove_code_blocks(message)
660
+ message = _extract_commit_from_reasoning(message)
661
+ message = _remove_xml_tags(message)
662
+ message = _fix_double_prefix(message)
663
+ if enforce_conventional_commits:
664
+ message = _ensure_conventional_prefix(message)
665
+ message = _normalize_whitespace(message)
581
666
  return message
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gac
3
- Version: 1.13.1
3
+ Version: 1.14.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
@@ -197,11 +197,14 @@ ANTHROPIC_API_KEY=your_key_here
197
197
 
198
198
  See `.gac.env.example` for all available options.
199
199
 
200
+ **Want to customize commit message style?** See [docs/CUSTOM_SYSTEM_PROMPTS.md](docs/CUSTOM_SYSTEM_PROMPTS.md) for guidance on writing custom system prompts.
201
+
200
202
  ---
201
203
 
202
204
  ## Getting Help
203
205
 
204
206
  - **Full documentation**: [USAGE.md](USAGE.md) - Complete CLI reference
207
+ - **Custom prompts**: [CUSTOM_SYSTEM_PROMPTS.md](docs/CUSTOM_SYSTEM_PROMPTS.md) - Customize commit message style
205
208
  - **Troubleshooting**: [TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md) - Common issues and solutions
206
209
  - **Contributing**: [CONTRIBUTING.md](docs/CONTRIBUTING.md) - Development setup and guidelines
207
210
 
@@ -1,18 +1,18 @@
1
1
  gac/__init__.py,sha256=z9yGInqtycFIT3g1ca24r-A3699hKVaRqGUI79wsmMc,415
2
- gac/__version__.py,sha256=A8B58TFddAWnFeWicw2ARWohW-Mxq5pytP5yNFLSuKM,67
2
+ gac/__version__.py,sha256=t4OpwkBuRKYfC3t2AQWw9JbjqP0r1BQFAjOGMvC2Swc,67
3
3
  gac/ai.py,sha256=fg642la4yMecOwfZHQ7Ixl6z-5_qj9Q1SxwVMnPDCcY,4244
4
4
  gac/ai_utils.py,sha256=EDkw0nnwnV5Ba2CLEo2HC15-L5BZtGJATin5Az0ZHkg,7426
5
5
  gac/cli.py,sha256=crUUI6osYtE3QAZ7r6DRlVk9gR3X2PctzS1sssVQ9_g,5070
6
- gac/config.py,sha256=n3TkQYBqSKkH68QUM6M7kwSK83ghmItoh0p5ZDFnhHA,1746
6
+ gac/config.py,sha256=N0Shy7O0LSEWRtVUbwFJ9HeF93K3Mvc5P6p2HnSB9A4,1813
7
7
  gac/config_cli.py,sha256=v9nFHZO1RvK9fzHyuUS6SG-BCLHMsdOMDwWamBhVVh4,1608
8
- gac/constants.py,sha256=8GHB7yeK2CYT0t80-k9N6LvgZPe-StNH3dK3NsUO46c,4977
8
+ gac/constants.py,sha256=LfaWw6vQ9JPSwZzwZ2HQ-TmTrSxTsBRjvjALRvsVcKE,6189
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
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
13
+ gac/main.py,sha256=0NTFPX8Jg2Nd7DoX8VKwEONre1iwVf1i7rMM-ilZY-Y,15153
14
+ gac/preprocess.py,sha256=hk2p2X4-xVDvuy-T1VMzMa9k5fTUbhlWDyw89DCf81Q,15379
15
+ gac/prompt.py,sha256=k3Fy6RxVze4ZCQKa6tS9aY9jUQOWDQW-UEnSUYFHYZs,27111
16
16
  gac/security.py,sha256=15Yp6YR8QC4eECJi1BUCkMteh_veZXUbLL6W8qGcDm4,9920
17
17
  gac/utils.py,sha256=nV42-brIHW_fBg7x855GM8nRrqEBbRzTSweg-GTyGE8,3971
18
18
  gac/providers/__init__.py,sha256=3WTzh3ngAdvR40eezpMMFD7Zibb-LxexDYUcSm4axQI,1305
@@ -34,8 +34,8 @@ gac/providers/streamlake.py,sha256=KAA2ZnpuEI5imzvdWVWUhEBHSP0BMnprKXte6CbwBWY,2
34
34
  gac/providers/synthetic.py,sha256=sRMIJTS9LpcXd9A7qp_ZjZxdqtTKRn9fl1W4YwJZP4c,1855
35
35
  gac/providers/together.py,sha256=1bUIVHfYzcEDw4hQPE8qV6hjc2JNHPv_khVgpk2IJxI,1667
36
36
  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,,
37
+ gac-1.14.0.dist-info/METADATA,sha256=AJVGolXlFNslvzHQO-7PoVdJeBO2VvxT0PQWfUdOR1w,8151
38
+ gac-1.14.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
39
+ gac-1.14.0.dist-info/entry_points.txt,sha256=tdjN-XMmcWfL92swuRAjT62bFLOAwk9bTMRLGP5Z4aI,36
40
+ gac-1.14.0.dist-info/licenses/LICENSE,sha256=vOab37NouL1PNs5BswnPayrMCqaN2sqLfMQfqPDrpZg,1103
41
+ gac-1.14.0.dist-info/RECORD,,
File without changes