feat(commit-ai): enhance commit message generation and caching

Improve the commit message generation process by adding a caching mechanism and refining the input handling. This change aims to enhance performance and prevent redundant computations when generating commit messages based on staged changes.

- **src-tauri/crates/commit_ai/src/cloud.rs**:
  - Introduced `looks_like_diff_echo` function to detect if the model's output is a diff instead of a commit message.
  - Updated `openai_compatible_request` and `generate_anthropic` to utilize the new function for error handling.

- **src-tauri/crates/commit_ai/src/lib.rs**:
  - Added `LocalGenerationProfile` enum for managing different generation profiles.
  - Implemented caching for generated messages to avoid redundant processing.
  - Updated `generate_commit_message` to incorporate caching logic.

- **src-tauri/src/git.rs**:
  - Added `staged_diff_local` function to handle local profile generation and exclude specific lock files from the diff.
  - Modified `commit_ai_generate` to accept and process the local generation profile.

- **src/App.svelte**:
  - Added `lastLocalAiGeneratedMessage` state to track the last generated message and prevent unnecessary updates.

- **.claude/settings.local.json**:
  - Updated settings to include additional commands for better functionality.
This commit is contained in:
Christoph Brandau
2026-07-03 07:03:35 +02:00
parent 70de45e1ba
commit 791d686c48
9 changed files with 395 additions and 64 deletions
+31 -12
View File
@@ -2,7 +2,7 @@ use std::time::Duration;
use serde::{Deserialize, Serialize};
use crate::{build_messages, sanitize_message};
use crate::{build_messages, looks_like_diff_echo, sanitize_message};
// Generous sizing so a detailed body with bullet points isn't cut off.
const DEFAULT_MAX_TOKENS: u32 = 1500;
@@ -56,8 +56,14 @@ async fn openai_compatible_request(
let body = OpenAiRequest {
model: model.to_string(),
messages: vec![
OpenAiMessage { role: "system", content: system },
OpenAiMessage { role: "user", content: user },
OpenAiMessage {
role: "system",
content: system,
},
OpenAiMessage {
role: "user",
content: user,
},
],
temperature: 0.3,
};
@@ -82,17 +88,22 @@ async fn openai_compatible_request(
return Err(format!("API error ({status}): {text}"));
}
let parsed: OpenAiResponse = serde_json::from_str(&text)
.map_err(|err| format!("Could not process response: {err}"))?;
let parsed: OpenAiResponse =
serde_json::from_str(&text).map_err(|err| format!("Could not process response: {err}"))?;
parsed
let message = parsed
.choices
.into_iter()
.next()
.and_then(|choice| choice.message.content)
.map(|content| sanitize_message(&content))
.filter(|content| !content.is_empty())
.ok_or_else(|| "The model did not return a response.".to_string())
.ok_or_else(|| "The model did not return a response.".to_string())?;
if looks_like_diff_echo(&message) {
return Err("The model returned the diff instead of a commit message.".to_string());
}
Ok(message)
}
pub async fn generate_openai(
@@ -168,7 +179,10 @@ pub async fn generate_anthropic(
model: model.to_string(),
max_tokens: DEFAULT_MAX_TOKENS,
system,
messages: vec![AnthropicMessage { role: "user", content: user }],
messages: vec![AnthropicMessage {
role: "user",
content: user,
}],
};
let client = http_client()?;
@@ -190,14 +204,19 @@ pub async fn generate_anthropic(
return Err(format!("API error ({status}): {text}"));
}
let parsed: AnthropicResponse = serde_json::from_str(&text)
.map_err(|err| format!("Could not process response: {err}"))?;
let parsed: AnthropicResponse =
serde_json::from_str(&text).map_err(|err| format!("Could not process response: {err}"))?;
parsed
let message = parsed
.content
.into_iter()
.find_map(|block| block.text)
.map(|text| sanitize_message(&text))
.filter(|text| !text.is_empty())
.ok_or_else(|| "The model did not return a response.".to_string())
.ok_or_else(|| "The model did not return a response.".to_string())?;
if looks_like_diff_echo(&message) {
return Err("The model returned the diff instead of a commit message.".to_string());
}
Ok(message)
}
+206 -27
View File
@@ -2,9 +2,13 @@ mod cloud;
pub use cloud::{generate_anthropic, generate_custom, generate_openai};
use std::sync::Arc;
use std::{
collections::hash_map::DefaultHasher,
hash::{Hash, Hasher},
sync::Arc,
};
use mistralrs::{GgufModelBuilder, Model, TextMessageRole, TextMessages};
use mistralrs::{GgufModelBuilder, Model, RequestBuilder, TextMessageRole};
use tokio::sync::RwLock;
/// One selectable local (on-device) model. Larger models produce better commit messages
@@ -19,7 +23,7 @@ pub struct LocalModelOption {
tokenizer_repo: &'static str,
}
pub const DEFAULT_LOCAL_MODEL_ID: &str = "qwen2.5-1.5b";
pub const DEFAULT_LOCAL_MODEL_ID: &str = "qwen2.5-0.5b";
pub const LOCAL_MODELS: &[LocalModelOption] = &[
LocalModelOption {
@@ -52,6 +56,59 @@ fn find_local_model(model_id: &str) -> Option<&'static LocalModelOption> {
LOCAL_MODELS.iter().find(|option| option.id == model_id)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum LocalGenerationProfile {
Fast,
Balanced,
Detailed,
}
impl Default for LocalGenerationProfile {
fn default() -> Self {
Self::Fast
}
}
impl LocalGenerationProfile {
pub fn from_id(value: Option<&str>) -> Self {
match value
.unwrap_or_default()
.trim()
.to_ascii_lowercase()
.as_str()
{
"balanced" => Self::Balanced,
"detailed" => Self::Detailed,
_ => Self::Fast,
}
}
pub fn diff_unified_context(self) -> &'static str {
match self {
Self::Fast => "--unified=1",
Self::Balanced => "--unified=2",
Self::Detailed => "--unified=3",
}
}
fn max_diff_chars(self) -> usize {
match self {
Self::Fast => 8_000,
Self::Balanced => 12_000,
Self::Detailed => 24_000,
}
}
fn max_output_tokens(self) -> usize {
match self {
Self::Fast => 160,
Self::Balanced => 360,
Self::Detailed => 750,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "lowercase")]
pub enum CommitAiPhase {
@@ -75,6 +132,20 @@ struct Inner {
model_id: Option<String>,
error: Option<String>,
model: Option<Arc<Model>>,
cache: Option<GenerationCache>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct GenerationCacheKey {
model_id: String,
profile: LocalGenerationProfile,
input_hash: u64,
}
#[derive(Debug, Clone)]
struct GenerationCache {
key: GenerationCacheKey,
message: String,
}
/// Manages the local (on-device) model only. Cloud providers are stateless HTTP calls
@@ -92,6 +163,7 @@ impl Default for CommitAiEngine {
model_id: None,
error: None,
model: None,
cache: None,
})),
}
}
@@ -129,6 +201,7 @@ impl CommitAiEngine {
guard.phase = CommitAiPhase::Error;
guard.model_id = Some(model_id.to_string());
guard.error = Some(format!("Unknown local model: {model_id}"));
guard.cache = None;
return;
};
@@ -138,6 +211,7 @@ impl CommitAiEngine {
guard.model_id = Some(model_id.to_string());
guard.error = None;
guard.model = None;
guard.cache = None;
}
let result = GgufModelBuilder::new(option.repo, vec![option.file])
@@ -157,10 +231,12 @@ impl CommitAiEngine {
guard.model = Some(Arc::new(model));
guard.phase = CommitAiPhase::Ready;
guard.error = None;
guard.cache = None;
}
Err(err) => {
guard.phase = CommitAiPhase::Error;
guard.error = Some(err.to_string());
guard.cache = None;
}
}
}
@@ -169,22 +245,36 @@ impl CommitAiEngine {
&self,
diff: &str,
notes: Option<&str>,
profile: LocalGenerationProfile,
) -> Result<String, String> {
let model = {
let (model, cache_key) = {
let guard = self.inner.read().await;
match (guard.phase, &guard.model) {
(CommitAiPhase::Ready, Some(model)) => model.clone(),
(CommitAiPhase::Ready, Some(model)) => {
let cache_key = GenerationCacheKey {
model_id: guard.model_id.clone().unwrap_or_default(),
profile,
input_hash: generation_input_hash(diff, notes),
};
if let Some(cache) = &guard.cache {
if cache.key == cache_key {
return Ok(cache.message.clone());
}
}
(model.clone(), cache_key)
}
_ => return Err("The local AI model is not ready yet.".to_string()),
}
};
let (system, user) = build_messages(diff, notes)?;
let messages = TextMessages::new()
let (system, user) = build_local_messages(diff, notes, profile)?;
let request = RequestBuilder::new()
.set_sampler_max_len(profile.max_output_tokens())
.add_message(TextMessageRole::System, system)
.add_message(TextMessageRole::User, user);
let response = model
.send_chat_request(messages)
.send_chat_request(request)
.await
.map_err(|err| err.to_string())?;
@@ -198,10 +288,29 @@ impl CommitAiEngine {
if message.is_empty() {
return Err("The model did not return a response.".to_string());
}
if looks_like_diff_echo(&message) {
return Err(
"The local model returned the diff instead of a commit message. Try a larger local model (1.5B or 3B) or a cloud provider.".to_string(),
);
}
{
let mut guard = self.inner.write().await;
guard.cache = Some(GenerationCache {
key: cache_key,
message: message.clone(),
});
}
Ok(message)
}
}
fn generation_input_hash(diff: &str, notes: Option<&str>) -> u64 {
let mut hasher = DefaultHasher::new();
diff.hash(&mut hasher);
notes.unwrap_or("").hash(&mut hasher);
hasher.finish()
}
/// Models occasionally ignore the "no code fences" instruction (small local models
/// especially) — strip a wrapping ``` fence and wrapping quotes so the result can go
/// straight into the commit-message box.
@@ -221,6 +330,74 @@ pub(crate) fn sanitize_message(raw: &str) -> String {
trimmed.to_string()
}
/// Weak models (small local ones especially) sometimes just echo the prompt's diff
/// sections back instead of writing a commit message. Catch that so the UI can show a
/// clear error instead of dumping raw diff text into the commit-message box.
pub(crate) fn looks_like_diff_echo(message: &str) -> bool {
let lower = message.to_ascii_lowercase();
lower.contains("diff --git")
|| lower.contains("staged files:")
|| lower.contains("staged changes:")
|| lower.contains("diff stat:")
|| lower.contains("detailed diff:")
|| message.lines().any(|line| line.starts_with("@@ "))
}
fn truncate_at_char_boundary(input: &str, max_chars: usize) -> String {
if input.len() <= max_chars {
return input.to_string();
}
let mut cut = max_chars;
while !input.is_char_boundary(cut) {
cut -= 1;
}
format!("{}\n\n[... diff truncated ...]", &input[..cut])
}
pub(crate) fn build_local_messages(
diff: &str,
notes: Option<&str>,
profile: LocalGenerationProfile,
) -> Result<(String, String), String> {
if diff.trim().is_empty() {
return Err("No staged changes available for a commit message.".to_string());
}
let diff = truncate_at_char_boundary(diff, profile.max_diff_chars());
// Appended to every profile below: small local models occasionally just echo the input
// (the "Staged files:" / "Diff stat:" / "Detailed diff:" sections built in git.rs's
// `staged_diff_local`) instead of writing a new commit message. Naming those exact
// section headers here makes the failure mode explicit enough for weak models to avoid.
const ANTI_ECHO: &str = " Never repeat, quote, or paraphrase the diff or its headers — \
do not include 'diff --git', '@@', 'Staged files:', 'Diff stat:', or 'Detailed diff:' \
anywhere in your answer.";
let system = match profile {
LocalGenerationProfile::Fast => {
format!(
"You generate Git commit messages. Respond only with one Conventional Commits subject line: <type>(<scope>): <subject>. Max 72 characters. No body, bullets, preamble, explanation, code fences, or quotes. Answer in English.{ANTI_ECHO}"
)
}
LocalGenerationProfile::Balanced => {
format!(
"You generate Git commit messages. Respond with a Conventional Commits subject line, then a blank line, then at most two short bullet points. Keep every line under 72 characters. No preamble, explanation, code fences, or quotes. Answer in English.{ANTI_ECHO}"
)
}
LocalGenerationProfile::Detailed => {
format!(
"You generate Git commit messages. Respond with a Conventional Commits subject line, then a blank line, then a concise body and up to four short bullet points grouped by affected area/file. Keep every line under 72 characters. No preamble, explanation, code fences, or quotes. Answer in English.{ANTI_ECHO}"
)
}
};
let mut user = String::new();
if let Some(n) = notes.filter(|n| !n.trim().is_empty()) {
user.push_str(&format!("Developer notes:\n{n}\n\n"));
}
user.push_str(&format!("Staged changes:\n{diff}"));
Ok((system, user))
}
pub(crate) fn build_messages(diff: &str, notes: Option<&str>) -> Result<(String, String), String> {
if diff.trim().is_empty() {
return Err("No staged changes available for a commit message.".to_string());
@@ -228,26 +405,28 @@ pub(crate) fn build_messages(diff: &str, notes: Option<&str>) -> Result<(String,
// Rough token estimate — small models often have an 8-32k context window.
const MAX_CHARS: usize = 24_000;
let diff = if diff.len() > MAX_CHARS {
// Pull the byte index back to a valid UTF-8 char boundary, otherwise
// slicing mid-multi-byte-character would panic.
let mut cut = MAX_CHARS;
while !diff.is_char_boundary(cut) {
cut -= 1;
}
format!("{}\n\n[... diff truncated ...]", &diff[..cut])
} else {
diff.to_string()
};
let diff = truncate_at_char_boundary(diff, MAX_CHARS);
let system = "You are a tool that generates Git commit messages. \
Respond only with the commit message in Conventional Commits format \
(<type>(<scope>): <subject>), followed by a body after a blank line. \
Subject in imperative mood, max. 72 characters. \
The body is required: summarize in a short paragraph what changed and why, \
then list the key changes as bullet points (- ...), \
grouped by affected area/file. Lines in the body max. 72 characters. \
No preamble, no explanation, no code fences, answer in English"
let system = "You are a tool that writes a Git commit message describing a staged diff. \
Output ONLY the commit message text. Never quote, restate, or paraphrase the diff itself: \
do not include lines starting with 'diff --git', '@@', '+', '-', 'index ', 'Staged files:', \
or 'Diff stat:' anywhere in your answer. \
Format: a Conventional Commits header (<type>(<scope>): <subject>) in imperative mood, \
max. 72 characters, then a blank line, then a body. \
The body is required: one short paragraph explaining what changed and why, \
then bullet points (- ...) of the key changes grouped by affected area/file. \
Lines in the body max. 72 characters. \
No preamble, no explanation, no code fences, no markdown headings, answer in English.\n\n\
Example:\n\
Diff:\n\
diff --git a/src/auth.py b/src/auth.py\n\
+def hash_password(pw):\n\
+ return bcrypt.hash(pw)\n\n\
Commit message:\n\
feat(auth): add password hashing helper\n\n\
Add a bcrypt-based helper so passwords are never stored or compared in\n\
plain text.\n\n\
- auth.py: add hash_password() using bcrypt"
.to_string();
let mut user = String::new();
+65 -19
View File
@@ -540,6 +540,50 @@ fn staged_diff(repo: &Path) -> Result<String, String> {
Ok(format!("Staged files:\n{file_list}\n\n{diff}"))
}
fn staged_diff_local(
repo: &Path,
profile: commit_ai::LocalGenerationProfile,
) -> Result<String, String> {
let name_status = run_git(repo, ["diff", "--cached", "--name-status", "-M"])?;
let file_list = String::from_utf8_lossy(&name_status).trim().to_string();
let stat = run_git(repo, ["diff", "--cached", "--stat", "--summary"])?;
let stat = String::from_utf8_lossy(&stat).trim().to_string();
let diff_args = vec![
"diff",
"--cached",
"--no-ext-diff",
"--no-textconv",
profile.diff_unified_context(),
"--",
".",
":(exclude)*package-lock.json",
":(exclude)*pnpm-lock.yaml",
":(exclude)*yarn.lock",
":(exclude)*bun.lockb",
":(exclude)*Cargo.lock",
":(exclude)*composer.lock",
":(exclude)*Gemfile.lock",
":(exclude)*poetry.lock",
":(exclude)*go.sum",
];
let diff = run_git(repo, diff_args)?;
let diff = String::from_utf8_lossy(&diff).trim().to_string();
let mut sections = Vec::new();
if !file_list.is_empty() {
sections.push(format!("Staged files:\n{file_list}"));
}
if !stat.is_empty() {
sections.push(format!("Diff stat:\n{stat}"));
}
if !diff.is_empty() {
sections.push(format!("Detailed diff:\n{diff}"));
}
Ok(sections.join("\n\n"))
}
#[tauri::command]
pub async fn commit_ai_generate(
path: String,
@@ -548,17 +592,27 @@ pub async fn commit_ai_generate(
model: Option<String>,
api_key: Option<String>,
base_url: Option<String>,
local_profile: Option<String>,
engine: tauri::State<'_, commit_ai::CommitAiEngine>,
) -> Result<String, String> {
let repo = resolve_repo(&path)?;
let diff = staged_diff(&repo)?;
let local_profile = commit_ai::LocalGenerationProfile::from_id(local_profile.as_deref());
let diff = if provider == "local" {
staged_diff_local(&repo, local_profile)?
} else {
staged_diff(&repo)?
};
let notes = notes.as_deref();
let model = model.filter(|value| !value.trim().is_empty());
let api_key = api_key.filter(|value| !value.trim().is_empty());
let base_url = base_url.filter(|value| !value.trim().is_empty());
match provider.as_str() {
"local" => engine.generate_commit_message(&diff, notes).await,
"local" => {
engine
.generate_commit_message(&diff, notes, local_profile)
.await
}
"openai" => {
let api_key = api_key.ok_or_else(|| "OpenAI API key is missing.".to_string())?;
let model = model.unwrap_or_else(|| "gpt-4o-mini".to_string());
@@ -620,9 +674,7 @@ pub fn commit(path: String, message: String) -> Result<GitStatus, String> {
let current_status = status_for_repo(&repo)?;
if has_unresolved_conflicts(&current_status) {
return Err(
"Merge conflicts must be resolved before you can commit.".to_string(),
);
return Err("Merge conflicts must be resolved before you can commit.".to_string());
}
run_git(&repo, ["commit", "-m", message.as_str()])?;
@@ -646,9 +698,7 @@ pub fn pull(
.arg(&repo)
.args(pull_args)
.output()
.map_err(|err| {
format!("Could not start Git. Is Git installed? {err}")
})?,
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?,
};
if output.status.success() {
@@ -703,8 +753,7 @@ fn cred_entry(key: &str) -> Result<keyring::Entry, String> {
if key.is_empty() {
return Err("No key provided for the credentials.".to_string());
}
keyring::Entry::new(CRED_SERVICE, key)
.map_err(|err| format!("Keychain unavailable: {err}"))
keyring::Entry::new(CRED_SERVICE, key).map_err(|err| format!("Keychain unavailable: {err}"))
}
/// Returns the remote URL used for auth key derivation (upstream remote of the
@@ -790,9 +839,8 @@ fn initial_push_remote_name(repo: &Path) -> Result<String, String> {
return Ok("origin".to_string());
}
first_remote_name(repo).ok_or_else(|| {
"This branch has no upstream and no remote is configured.".to_string()
})
first_remote_name(repo)
.ok_or_else(|| "This branch has no upstream and no remote is configured.".to_string())
}
fn push_args_for_repo(repo: &Path) -> Result<Vec<OsString>, String> {
@@ -2554,9 +2602,7 @@ fn validate_existing_local_branch_name(repo: &Path, branch: &str) -> Result<Stri
let normalized = validate_branch_ref_name(branch)?;
if !ref_exists(repo, &format!("refs/heads/{normalized}"))? {
return Err(format!(
"Local branch '{normalized}' was not found."
));
return Err(format!("Local branch '{normalized}' was not found."));
}
Ok(normalized)
@@ -2996,8 +3042,8 @@ where
thread::sleep(Duration::from_millis(60));
};
let stdout = std::fs::read(&stdout_path)
.map_err(|err| format!("Could not read Git output: {err}"))?;
let stdout =
std::fs::read(&stdout_path).map_err(|err| format!("Could not read Git output: {err}"))?;
let stderr = std::fs::read(&stderr_path)
.map_err(|err| format!("Could not read Git error output: {err}"))?;
let _ = std::fs::remove_file(&stdout_path);
@@ -4179,7 +4225,7 @@ mod tests {
);
let err = delete_branch(repo.path.to_string_lossy().to_string(), current).unwrap_err();
assert!(err.contains("aktuelle Branch"));
assert!(err.contains("current branch"));
}
#[test]