gac 1.13.0__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.0"
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,287 +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
288
+
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
274
298
 
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.
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
312
+ """
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
297
340
  """
298
- template = load_prompt_template()
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.
347
+
348
+ Args:
349
+ template: The template string
350
+ infer_scope: Whether to infer scope for commits
299
351
 
300
- # Select the appropriate conventions section based on infer_scope parameter
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
333
379
 
334
- # Process format options (verbose, one-liner, or multi-line)
335
- # Priority: verbose > one_liner > multi_line
380
+ Args:
381
+ template: The template string
382
+ verbose: Whether to use verbose format
383
+ one_liner: Whether to use one-liner format
384
+
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
398
+
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.
348
402
 
349
- # Clean up examples sections based on verbose and infer_scope settings
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
436
+
437
+
438
+ # ============================================================================
439
+ # Prompt Building
440
+ # ============================================================================
390
441
 
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
442
 
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
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.
397
454
 
398
- # Extract the git data sections for the user prompt
399
- user_sections = []
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
400
464
 
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), "")
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()
407
470
 
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), "")
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)
414
475
 
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), "")
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)
421
479
 
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), "")
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")
428
486
 
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)
487
+ user_template = re.sub(r"\n(?:[ \t]*\n){2,}", "\n\n", user_template)
432
488
 
433
- # User prompt is the git data sections
434
- user_prompt = "\n\n".join(user_sections).strip()
489
+ return system_template.strip(), user_template.strip()
435
490
 
436
- return system_prompt, user_prompt
437
491
 
492
+ # ============================================================================
493
+ # Message Cleaning Helpers
494
+ # ============================================================================
438
495
 
439
- def clean_commit_message(message: str) -> str:
440
- """Clean up a commit message generated by an AI model.
441
496
 
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):")
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()
506
+ while re.search(r"<think>(?:(?!</think>)[^\n])*\n.*?</think>", message, flags=re.DOTALL | re.IGNORECASE):
507
+ message = re.sub(
508
+ r"<think>(?:(?!</think>)[^\n])*\n.*?</think>\s*", "", message, flags=re.DOTALL | re.IGNORECASE, count=1
509
+ )
510
+
511
+ message = re.sub(r"\n\n+\s*<think>.*?</think>\s*", "", message, flags=re.DOTALL | re.IGNORECASE)
512
+ message = re.sub(r"<think>.*?</think>\s*\n\n+", "", message, flags=re.DOTALL | re.IGNORECASE)
513
+
514
+ message = re.sub(r"<think>\s*\n.*$", "", message, flags=re.DOTALL | re.IGNORECASE)
515
+
516
+ conventional_prefixes_pattern = r"(" + "|".join(CommitMessageConstants.CONVENTIONAL_PREFIXES) + r")[\(:)]"
517
+ if re.search(r"^.*?</think>", message, flags=re.DOTALL | re.IGNORECASE):
518
+ prefix_match = re.search(conventional_prefixes_pattern, message, flags=re.IGNORECASE)
519
+ think_match = re.search(r"</think>", message, flags=re.IGNORECASE)
520
+
521
+ if not prefix_match or (think_match and think_match.start() < prefix_match.start()):
522
+ message = re.sub(r"^.*?</think>\s*", "", message, flags=re.DOTALL | re.IGNORECASE)
523
+
524
+ message = re.sub(r"</think>\s*$", "", message, flags=re.IGNORECASE)
525
+
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
456
546
 
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:
547
+ Returns:
548
+ Extracted commit message
549
+ """
550
+ for indicator in CommitMessageConstants.COMMIT_INDICATORS:
473
551
  if indicator.lower() in message.lower():
474
- # Extract everything after the indicator
475
552
  message = message.split(indicator, 1)[1].strip()
476
553
  break
477
554
 
478
- # If message starts with any kind of explanation text, try to locate a conventional prefix
479
555
  lines = message.split("\n")
480
556
  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
- ):
557
+ if any(line.strip().startswith(f"{prefix}:") for prefix in CommitMessageConstants.CONVENTIONAL_PREFIXES):
485
558
  message = "\n".join(lines[i:])
486
559
  break
487
560
 
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
- ]:
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:
507
574
  message = message.replace(tag, "")
575
+ return message
576
+
508
577
 
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
578
+ def _fix_double_prefix(message: str) -> str:
579
+ """Fix double type prefix issues like 'chore: feat(scope):' to 'feat(scope):'.
580
+
581
+ Args:
582
+ message: The message to fix
583
+
584
+ Returns:
585
+ Message with double prefix corrected
586
+ """
525
587
  double_prefix_pattern = re.compile(
526
- 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")\(([^)]+)\):"
527
593
  )
528
594
  match = double_prefix_pattern.match(message)
529
595
 
530
596
  if match:
531
- # Extract the second type and scope, which is what we want to keep
532
597
  second_type = match.group(2)
533
598
  scope = match.group(3)
534
599
  description = message[match.end() :].strip()
535
600
  message = f"{second_type}({scope}): {description}"
536
601
 
537
- # 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
+ """
538
614
  if not any(
539
615
  message.strip().startswith(prefix + ":") or message.strip().startswith(prefix + "(")
540
- for prefix in conventional_prefixes
616
+ for prefix in CommitMessageConstants.CONVENTIONAL_PREFIXES
541
617
  ):
542
618
  message = f"chore: {message.strip()}"
619
+ return message
620
+
543
621
 
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()
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()
547
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)
548
666
  return message
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gac
3
- Version: 1.13.0
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
@@ -94,7 +94,7 @@ gac
94
94
  - **Anthropic** • **Cerebras** • **Chutes.ai** • **DeepSeek** • **Fireworks**
95
95
  - **Gemini** • **Groq** • **LM Studio** • **MiniMax** • **Ollama** • **OpenAI**
96
96
  - **OpenRouter** • **Streamlake** • **Synthetic.new** • **Together AI**
97
- - **Z.AI** • **Z.AI Coding** • **Custom Endpoint (Anthropic/OpenAI)**
97
+ - **Z.AI** • **Z.AI Coding** • **Custom Endpoints (Anthropic/OpenAI)**
98
98
 
99
99
  ### 🧠 **Smart LLM Analysis**
100
100
 
@@ -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=oFvFxf8Y4B3yJ6IccvXPcWK4lxUIK8Dr3Vc_FOg4a8E,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=d_kBXmhf3bDVLyDj8J7AS7GBAxF2jlc8lXoHX3Dzi5k,24255
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.0.dist-info/METADATA,sha256=pu638LGgkEXsDWzoHnchIkOOne5MjzQ25ILkDtXVOfs,7878
38
- gac-1.13.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
39
- gac-1.13.0.dist-info/entry_points.txt,sha256=tdjN-XMmcWfL92swuRAjT62bFLOAwk9bTMRLGP5Z4aI,36
40
- gac-1.13.0.dist-info/licenses/LICENSE,sha256=vOab37NouL1PNs5BswnPayrMCqaN2sqLfMQfqPDrpZg,1103
41
- gac-1.13.0.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