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 +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 +332 -214
- {gac-1.13.0.dist-info → gac-1.14.0.dist-info}/METADATA +5 -2
- {gac-1.13.0.dist-info → gac-1.14.0.dist-info}/RECORD +11 -11
- {gac-1.13.0.dist-info → gac-1.14.0.dist-info}/WHEEL +0 -0
- {gac-1.13.0.dist-info → gac-1.14.0.dist-info}/entry_points.txt +0 -0
- {gac-1.13.0.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,287 +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
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
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
|
-
|
|
335
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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
|
-
|
|
399
|
-
|
|
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
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
443
|
-
|
|
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:
|
|
501
|
+
message: The message to clean
|
|
451
502
|
|
|
452
503
|
Returns:
|
|
453
|
-
|
|
504
|
+
Message with <think> tags removed
|
|
454
505
|
"""
|
|
455
|
-
message =
|
|
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
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
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
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
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"^("
|
|
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
|
-
|
|
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
|
|
616
|
+
for prefix in CommitMessageConstants.CONVENTIONAL_PREFIXES
|
|
541
617
|
):
|
|
542
618
|
message = f"chore: {message.strip()}"
|
|
619
|
+
return message
|
|
620
|
+
|
|
543
621
|
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
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.
|
|
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
|
|
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=
|
|
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
|