feat(git): Implement manual and bulk rewrite of git commit messages via LLM
This commit is contained in:
@@ -2880,6 +2880,275 @@ fn trigger_rewrite_now(
|
|||||||
Ok(json!({ "success": true }))
|
Ok(json!({ "success": true }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn run_manual_rewrite_job(
|
||||||
|
app: AppHandle,
|
||||||
|
folder_path: String,
|
||||||
|
hashes: Vec<String>,
|
||||||
|
scope: &'static str,
|
||||||
|
) {
|
||||||
|
let state = app.state::<AppState>();
|
||||||
|
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<String, CommitIdentity> = hashes
|
||||||
|
.iter()
|
||||||
|
.filter_map(|hash| {
|
||||||
|
identities_before
|
||||||
|
.get(hash)
|
||||||
|
.cloned()
|
||||||
|
.map(|identity| (hash.clone(), identity))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let queued_identities: HashSet<CommitIdentity> = 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<String> = 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<String> = 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<String> = 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<Value> {
|
||||||
|
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<Value> {
|
||||||
|
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)]
|
#[derive(Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct TreeContextInfo {
|
struct TreeContextInfo {
|
||||||
|
|||||||
Reference in New Issue
Block a user