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
+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]