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 +1 -1
- gac/config.py +1 -0
- gac/constants.py +49 -0
- gac/main.py +10 -1
- gac/preprocess.py +3 -3
- gac/prompt.py +314 -229
- {gac-1.13.1.dist-info → gac-1.14.0.dist-info}/METADATA +4 -1
- {gac-1.13.1.dist-info → gac-1.14.0.dist-info}/RECORD +11 -11
- {gac-1.13.1.dist-info → gac-1.14.0.dist-info}/WHEEL +0 -0
- {gac-1.13.1.dist-info → gac-1.14.0.dist-info}/entry_points.txt +0 -0
- {gac-1.13.1.dist-info → gac-1.14.0.dist-info}/licenses/LICENSE +0 -0
gac/__version__.py
CHANGED
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
|
-
|
|
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
|
-
|
|
14
|
-
|
|
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
|
-
|
|
266
|
-
|
|
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
|
-
|
|
281
|
+
System template content as string
|
|
270
282
|
"""
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
|
|
335
|
-
|
|
385
|
+
Returns:
|
|
386
|
+
Template with the appropriate format section selected
|
|
387
|
+
"""
|
|
336
388
|
if verbose:
|
|
337
|
-
|
|
338
|
-
template =
|
|
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
|
-
|
|
342
|
-
template =
|
|
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
|
-
|
|
346
|
-
template =
|
|
347
|
-
|
|
395
|
+
template = _remove_template_section(template, "one_liner")
|
|
396
|
+
template = _remove_template_section(template, "verbose")
|
|
397
|
+
return template
|
|
348
398
|
|
|
349
|
-
|
|
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
|
-
|
|
352
|
-
template =
|
|
353
|
-
template =
|
|
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
|
-
|
|
361
|
-
template =
|
|
362
|
-
template =
|
|
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
|
-
|
|
370
|
-
template =
|
|
371
|
-
template =
|
|
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
|
-
|
|
381
|
-
template =
|
|
382
|
-
template =
|
|
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
|
-
|
|
395
|
-
|
|
396
|
-
|
|
438
|
+
# ============================================================================
|
|
439
|
+
# Prompt Building
|
|
440
|
+
# ============================================================================
|
|
397
441
|
|
|
398
|
-
# Extract the git data sections for the user prompt
|
|
399
|
-
user_sections = []
|
|
400
442
|
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
-
|
|
434
|
-
|
|
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
|
-
|
|
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
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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:
|
|
501
|
+
message: The message to clean
|
|
451
502
|
|
|
452
503
|
Returns:
|
|
453
|
-
|
|
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
|
-
|
|
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
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
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
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
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
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
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"^("
|
|
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
|
-
|
|
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
|
|
616
|
+
for prefix in CommitMessageConstants.CONVENTIONAL_PREFIXES
|
|
574
617
|
):
|
|
575
618
|
message = f"chore: {message.strip()}"
|
|
619
|
+
return message
|
|
620
|
+
|
|
576
621
|
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
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.
|
|
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=
|
|
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=
|
|
6
|
+
gac/config.py,sha256=N0Shy7O0LSEWRtVUbwFJ9HeF93K3Mvc5P6p2HnSB9A4,1813
|
|
7
7
|
gac/config_cli.py,sha256=v9nFHZO1RvK9fzHyuUS6SG-BCLHMsdOMDwWamBhVVh4,1608
|
|
8
|
-
gac/constants.py,sha256=
|
|
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=
|
|
14
|
-
gac/preprocess.py,sha256=
|
|
15
|
-
gac/prompt.py,sha256=
|
|
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.
|
|
38
|
-
gac-1.
|
|
39
|
-
gac-1.
|
|
40
|
-
gac-1.
|
|
41
|
-
gac-1.
|
|
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
|
|
File without changes
|
|
File without changes
|