From b73af714a5c5a7083fcb02a1ff57f2e42d0d91e2 Mon Sep 17 00:00:00 2001 From: Victor Giers Date: Sun, 12 Jul 2026 11:23:21 +0200 Subject: [PATCH] feat(git): Implement manual and bulk rewrite of git commit messages via LLM --- src-tauri/src/main.rs | 269 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 269 insertions(+) diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 6777626..a73e217 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -2880,6 +2880,275 @@ fn trigger_rewrite_now( Ok(json!({ "success": true })) } +fn run_manual_rewrite_job( + app: AppHandle, + folder_path: String, + hashes: Vec, + scope: &'static str, +) { + let state = app.state::(); + let total = hashes.len(); + let (model, queued_before, original_birthday) = match state.store.lock() { + Ok(store) => { + let Some(folder) = store.folders.iter().find(|folder| folder.path == folder_path) else { + return; + }; + ( + store + .commit_model + .clone() + .unwrap_or_else(|| "qwen2.5-coder:7b".to_string()), + folder.llm_candidates.clone(), + folder.first_candidate_birthday, + ) + } + Err(err) => { + eprintln!("[manualRewrite] Could not lock store: {err}"); + return; + } + }; + + let mut identity_hashes = queued_before.clone(); + identity_hashes.extend(hashes.clone()); + let identities_before = identities_for_hashes(&folder_path, &identity_hashes); + let attempted_identities: HashMap = hashes + .iter() + .filter_map(|hash| { + identities_before + .get(hash) + .cloned() + .map(|identity| (hash.clone(), identity)) + }) + .collect(); + let queued_identities: HashSet = queued_before + .iter() + .filter_map(|hash| resolve_commit_hash(&folder_path, hash)) + .filter_map(|hash| identities_before.get(&hash).cloned()) + .collect(); + + let mut successful_messages = HashMap::new(); + let mut llm_failures: Vec<(String, String)> = Vec::new(); + for (index, hash) in hashes.iter().enumerate() { + emit_rewrite_progress( + &app, + &folder_path, + scope, + "running", + index, + total, + Some(hash), + None, + ); + match generate_llm_message_for_commit(&app, &folder_path, hash, &model) { + Ok(message) => { + successful_messages.insert(hash.clone(), message); + } + Err(err) => { + eprintln!("[manualRewrite] {} failed: {err}", short_hash(hash)); + llm_failures.push((hash.clone(), err)); + } + } + emit_rewrite_progress( + &app, + &folder_path, + scope, + "running", + index + 1, + total, + Some(hash), + None, + ); + } + + let successful_hashes: Vec = hashes + .iter() + .filter(|hash| successful_messages.contains_key(*hash)) + .cloned() + .collect(); + let history_error = if successful_hashes.is_empty() { + None + } else { + match reword_commits_sequentially(&folder_path, &successful_messages, &successful_hashes) { + Ok(()) => None, + Err(err) => { + eprintln!("[manualRewrite] Git rewrite failed: {err}"); + let _ = run_git(&folder_path, &["rebase", "--abort"]); + Some(err) + } + } + }; + + let mut desired_identities = queued_identities; + if history_error.is_none() { + for hash in &successful_hashes { + if let Some(identity) = attempted_identities.get(hash) { + desired_identities.remove(identity); + } + } + for (hash, _) in &llm_failures { + if let Some(identity) = attempted_identities.get(hash) { + desired_identities.insert(identity.clone()); + } + } + } else { + desired_identities.extend(attempted_identities.values().cloned()); + } + + let identities_after = current_commit_identities(&folder_path); + let mut candidates: Vec = desired_identities + .iter() + .filter_map(|identity| identities_after.get(identity).cloned()) + .collect(); + for queued in &queued_before { + if resolve_commit_hash(&folder_path, queued).is_none() { + candidates.push(queued.clone()); + } + } + if scope == "pending" || history_error.is_some() { + candidates = pending_rewrite_hashes(&folder_path, &candidates); + } + candidates.sort(); + candidates.dedup(); + + if let Ok(mut store) = state.store.lock() { + if let Some(folder) = store + .folders + .iter_mut() + .find(|folder| folder.path == folder_path) + { + candidates.extend(folder.llm_buffer.clone()); + candidates.sort(); + candidates.dedup(); + folder.llm_candidates = candidates; + folder.llm_buffer.clear(); + folder.rewrite_in_progress = false; + folder.rewrite_started_at = None; + folder.first_candidate_birthday = if folder.llm_candidates.is_empty() { + None + } else { + original_birthday.or_else(|| Some(now_ms())) + }; + } + } + if let Err(err) = save_store(&state) { + eprintln!("[manualRewrite] Could not save rewrite state: {err}"); + } + emit(&app, "repo-updated", folder_path.clone()); + + let errors: Vec = llm_failures + .iter() + .map(|(hash, err)| format!("{}: {err}", short_hash(hash))) + .chain(history_error.iter().map(|err| format!("Git: {err}"))) + .collect(); + let status = if history_error.is_some() || successful_hashes.is_empty() { + "failed" + } else if llm_failures.is_empty() { + "succeeded" + } else { + "partial" + }; + let error_text = if errors.is_empty() { + None + } else { + Some(errors.join("\n")) + }; + emit_rewrite_progress( + &app, + &folder_path, + scope, + status, + total, + total, + None, + error_text.as_deref(), + ); +} + +#[tauri::command] +fn rewrite_commit( + app: AppHandle, + state: tauri::State<'_, AppState>, + folder_path: String, + hash: String, +) -> CommandResult { + let full_hash = resolve_commit_hash(&folder_path, &hash) + .ok_or_else(|| format!("Commit {hash} was not found."))?; + if !is_commit_on_current_history(&folder_path, &full_hash) { + return Ok(json!({ + "success": false, + "error": "This commit is not in the current HEAD history. Jump to its branch/history first." + })); + } + if is_rebase_in_progress(&folder_path) { + return Ok(json!({ "success": false, "error": "A Git rebase is already in progress." })); + } + { + let mut store = state.store.lock().map_err(|e| e.to_string())?; + let Some(folder) = store + .folders + .iter_mut() + .find(|folder| folder.path == folder_path) + else { + return Ok(json!({ "success": false, "error": "folder not found" })); + }; + if folder.needs_relocation { + return Ok(json!({ "success": false, "error": "needs relocation" })); + } + if folder.rewrite_in_progress { + return Ok(json!({ + "success": false, + "error": "Another rewrite is already running for this repository." + })); + } + folder.rewrite_in_progress = true; + folder.rewrite_started_at = Some(now_ms()); + } + save_store(&state)?; + thread::spawn(move || run_manual_rewrite_job(app, folder_path, vec![full_hash], "single")); + Ok(json!({ "success": true, "count": 1 })) +} + +#[tauri::command] +fn rewrite_pending_commits( + app: AppHandle, + state: tauri::State<'_, AppState>, + folder_path: String, +) -> CommandResult { + if is_rebase_in_progress(&folder_path) { + return Ok(json!({ "success": false, "error": "A Git rebase is already in progress." })); + } + let hashes = { + let mut store = state.store.lock().map_err(|e| e.to_string())?; + let Some(folder) = store + .folders + .iter_mut() + .find(|folder| folder.path == folder_path) + else { + return Ok(json!({ "success": false, "error": "folder not found" })); + }; + if folder.needs_relocation { + return Ok(json!({ "success": false, "error": "needs relocation" })); + } + if folder.rewrite_in_progress { + return Ok(json!({ + "success": false, + "error": "Another rewrite is already running for this repository." + })); + } + let hashes = pending_rewrite_hashes(&folder_path, &folder.llm_candidates); + if hashes.is_empty() { + return Ok(json!({ "success": false, "error": "There are no pending commits to rewrite." })); + } + folder.rewrite_in_progress = true; + folder.rewrite_started_at = Some(now_ms()); + hashes + }; + let count = hashes.len(); + save_store(&state)?; + thread::spawn(move || run_manual_rewrite_job(app, folder_path, hashes, "pending")); + Ok(json!({ "success": true, "count": count })) +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct TreeContextInfo {