Skip to content

Commit 0040136

Browse files
committed
feat: improve commit message generation and cleaning
1 parent 0ba5131 commit 0040136

6 files changed

Lines changed: 128 additions & 8 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,4 @@ target
3232
.mcp.json
3333
.python-version
3434
lcov.info
35+
.env

src/config.rs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,7 @@ const DEFAULT_SYSTEM_PROMPT: &str = "You are to act as an author of a commit mes
1818
Your mission is to create clean and comprehensive commit messages as per
1919
the Conventional Commit specification and explain WHAT were the changes and mainly WHY the changes were done.
2020
I'll send you an output of 'git diff --staged' command, and you are to convert
21-
it into a commit message. Use the present tense.
22-
Lines must not be longer than 80 characters. Use english for the commit message.";
21+
it into a commit message. Use the present tense. Use english for the commit message.";
2322

2423
#[derive(Debug, Clone, Serialize, Deserialize)]
2524
pub struct AppConfig {

src/main.rs

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -257,9 +257,10 @@ fn generate_final_message(
257257
println!("\n{}", "LLM system prompt:".cyan().bold());
258258
println!("{system_prompt}\n");
259259
}
260-
let (mut message, fallback_name) =
260+
let (raw_message, fallback_name) =
261261
provider::call_llm_with_fallback(cfg, &system_prompt, diff)
262262
.context("LLM API call failed")?;
263+
let mut message = prompt::clean_commit_message(&raw_message);
263264

264265
if let Some(ref name) = fallback_name {
265266
println!(
@@ -273,7 +274,11 @@ fn generate_final_message(
273274

274275
let final_msg = if cfg.review_commit {
275276
loop {
276-
let candidate = cfg.commit_template.replace("$msg", message.trim());
277+
let candidate = cfg
278+
.commit_template
279+
.replace("$msg", message.trim())
280+
.trim()
281+
.to_string();
277282

278283
if time_to_ready.is_none() {
279284
time_to_ready = Some(gen_start.elapsed());
@@ -284,10 +289,10 @@ fn generate_final_message(
284289
match review_message()? {
285290
ReviewAction::Accept => break candidate,
286291
ReviewAction::Regenerate => {
287-
let (new_msg, fb) =
292+
let (new_raw, fb) =
288293
provider::call_llm_with_fallback(cfg, &system_prompt, diff)
289294
.context("LLM API call failed")?;
290-
message = new_msg;
295+
message = prompt::clean_commit_message(&new_raw);
291296
if let Some(ref name) = fb {
292297
println!(
293298
" {} Used fallback preset: {}",
@@ -307,7 +312,11 @@ fn generate_final_message(
307312
}
308313
}
309314
} else {
310-
let final_msg = cfg.commit_template.replace("$msg", message.trim());
315+
let final_msg = cfg
316+
.commit_template
317+
.replace("$msg", message.trim())
318+
.trim()
319+
.to_string();
311320
time_to_ready = Some(gen_start.elapsed());
312321
println!("\n{} {}", "Commit message:".green().bold(), final_msg);
313322
final_msg

src/prompt.rs

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,3 +70,73 @@ pub fn build_system_prompt(cfg: &AppConfig) -> String {
7070

7171
parts.join("\n\n")
7272
}
73+
74+
/// Strip common LLM artifacts from the raw response so only the commit message remains.
75+
///
76+
/// Handles:
77+
/// - Markdown code fences (``` or ```commit / ```text / etc.)
78+
/// - Leading label lines ("Here is your commit message:", "Commit message:", etc.)
79+
/// - Surrounding quotation marks
80+
pub fn clean_commit_message(raw: &str) -> String {
81+
let s = raw.trim();
82+
83+
// Strip markdown code fences
84+
let s = strip_code_fence(s);
85+
86+
// Strip a leading label line (everything before the first blank line or
87+
// the first line that looks like a conventional commit / gitmoji prefix).
88+
let s = strip_label_prefix(s);
89+
90+
// Strip surrounding straight or curly quotes
91+
let s = strip_surrounding_quotes(s);
92+
93+
s.trim().to_string()
94+
}
95+
96+
fn strip_code_fence(s: &str) -> &str {
97+
// Match opening fence with optional language tag (e.g., ```commit, ```text)
98+
if let Some(inner) = s.strip_prefix("```") {
99+
// Skip the language tag on the first line
100+
let after_tag = inner.trim_start_matches(|c: char| c.is_alphanumeric() || c == '-');
101+
// Must start with a newline after the tag
102+
if let Some(body) = after_tag.strip_prefix('\n') {
103+
if let Some(end) = body.rfind("```") {
104+
return body[..end].trim();
105+
}
106+
}
107+
}
108+
s
109+
}
110+
111+
fn strip_label_prefix(s: &str) -> &str {
112+
// Common prefixes LLMs put before the actual message
113+
let label_patterns: &[&str] = &[
114+
"commit message:",
115+
"here is the commit message:",
116+
"here's the commit message:",
117+
"here is your commit message:",
118+
"here's your commit message:",
119+
"generated commit message:",
120+
"suggested commit message:",
121+
"the commit message:",
122+
];
123+
124+
let lower = s.to_lowercase();
125+
for pat in label_patterns {
126+
if let Some(rest) = lower.strip_prefix(pat) {
127+
// Trim blank lines / whitespace after the label
128+
return s[pat.len()..][rest.len() - rest.trim_start().len()..].trim_start();
129+
}
130+
}
131+
s
132+
}
133+
134+
fn strip_surrounding_quotes(s: &str) -> &str {
135+
let quote_pairs: &[(char, char)] = &[('"', '"'), ('\'', '\''), ('\u{201c}', '\u{201d}')];
136+
for &(open, close) in quote_pairs {
137+
if s.starts_with(open) && s.ends_with(close) && s.len() > 1 {
138+
return &s[open.len_utf8()..s.len() - close.len_utf8()];
139+
}
140+
}
141+
s
142+
}

tests/config_tests.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ fn load_uses_defaults_when_no_layers_exist() {
4747
let _acr = EnvGuard::clear(&acr_env_keys());
4848

4949
let _force = EnvGuard::set(&[
50+
// Re-set after _acr cleared it so global_config_path() stays isolated
51+
("ACR_CONFIG_HOME", cfg_dir.path().to_string_lossy().as_ref()),
5052
("ACR_PROVIDER", "groq"),
5153
("ACR_MODEL", "llama-3.3-70b-versatile"),
5254
("ACR_LOCALE", "en"),

tests/prompt_tests.rs

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use auto_commit_rs::config::AppConfig;
2-
use auto_commit_rs::prompt::build_system_prompt;
2+
use auto_commit_rs::prompt::{build_system_prompt, clean_commit_message};
33

44
#[test]
55
fn prompt_includes_core_sections_by_default() {
@@ -60,3 +60,42 @@ fn prompt_uses_custom_base_prompt() {
6060
let prompt = build_system_prompt(&cfg);
6161
assert!(prompt.starts_with("custom base prompt"));
6262
}
63+
64+
#[test]
65+
fn clean_message_strips_markdown_code_fence() {
66+
let raw = "```\nfeat: add login\n```";
67+
assert_eq!(clean_commit_message(raw), "feat: add login");
68+
}
69+
70+
#[test]
71+
fn clean_message_strips_code_fence_with_language_tag() {
72+
let raw = "```commit\nfix(auth): correct redirect\n```";
73+
assert_eq!(clean_commit_message(raw), "fix(auth): correct redirect");
74+
}
75+
76+
#[test]
77+
fn clean_message_strips_label_prefix() {
78+
let raw = "Here's your commit message:\nfeat: implement dark mode";
79+
assert_eq!(clean_commit_message(raw), "feat: implement dark mode");
80+
}
81+
82+
#[test]
83+
fn clean_message_strips_surrounding_quotes() {
84+
let raw = "\"feat: add user authentication\"";
85+
assert_eq!(clean_commit_message(raw), "feat: add user authentication");
86+
}
87+
88+
#[test]
89+
fn clean_message_passes_through_clean_input() {
90+
let raw = "feat(api): improve response time";
91+
assert_eq!(clean_commit_message(raw), raw);
92+
}
93+
94+
#[test]
95+
fn clean_message_handles_multiline_with_fence() {
96+
let raw = "```\nfeat: add search\n\nAdds full-text search support.\n```";
97+
assert_eq!(
98+
clean_commit_message(raw),
99+
"feat: add search\n\nAdds full-text search support."
100+
);
101+
}

0 commit comments

Comments
 (0)