feat(bisect): add guided Git bisect support

Add interactive Git bisect support to the Tauri backend and UI.
Introduce BisectState and BisectCommit types for structured state.
Expose Tauri commands to query, start, mark, and reset sessions.
Add parsers, unit tests, and a Bisect dialog with toolbar and App wiring.

- Backend: new Tauri commands, state types, and helpers to parse bisect.
- Frontend: Bisect dialog, toolbar entry, and App integration to control flow.
This commit is contained in:
2026-09-06 00:04:42 +02:00
parent fcd884ee0b
commit 7f2b04a506
8 changed files with 499 additions and 19 deletions
+260
View File
@@ -131,6 +131,26 @@ pub struct GitCommit {
pub has_note: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct BisectCommit {
pub hash: String,
pub short_hash: String,
pub summary: String,
pub author_name: String,
pub date: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct BisectState {
pub active: bool,
pub finished: bool,
pub current: Option<BisectCommit>,
pub culprit: Option<BisectCommit>,
pub remaining_revisions: Option<u32>,
pub remaining_steps: Option<u32>,
pub message: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct GitCommitFile {
pub path: String,
@@ -3377,6 +3397,181 @@ pub fn restore_reflog_entry(
status_for_repo(&repo)
}
fn bisect_in_progress(repo: &Path) -> Result<bool, String> {
Ok(git_dir_for_repo(repo)?.join("BISECT_START").is_file())
}
fn bisect_commit_for_ref(repo: &Path, revision: &str) -> Result<BisectCommit, String> {
let output = run_git(
repo,
[
"log",
"-1",
"--format=%H%x1f%h%x1f%s%x1f%an%x1f%aI",
revision,
],
)?;
let text = String::from_utf8_lossy(&output);
let fields = text.trim().splitn(5, '\x1f').collect::<Vec<_>>();
if fields.len() != 5 {
return Err("Git returned an unexpected bisect commit record.".to_string());
}
Ok(BisectCommit {
hash: fields[0].to_string(),
short_hash: fields[1].to_string(),
summary: fields[2].to_string(),
author_name: fields[3].to_string(),
date: fields[4].to_string(),
})
}
fn parse_bisect_progress(message: &str) -> (Option<u32>, Option<u32>) {
let Some(line) = message.lines().find(|line| line.contains("Bisecting:")) else {
return (None, None);
};
let revisions = line.split("Bisecting:").nth(1).and_then(|tail| {
tail.split_whitespace()
.find_map(|word| word.parse::<u32>().ok())
});
let steps = line.split("roughly").nth(1).and_then(|tail| {
tail.split_whitespace()
.find_map(|word| word.parse::<u32>().ok())
});
(revisions, steps)
}
fn parse_bisect_culprit(message: &str) -> Option<String> {
for line in message.lines() {
if line.contains(" is the first ") && line.contains("bad") && line.contains(" commit") {
return line.split_whitespace().next().map(ToString::to_string);
}
if line.contains("first") && line.contains("bad") && line.contains("commit:") {
let start = line.find('[')? + 1;
let end = line[start..].find(']')? + start;
return Some(line[start..end].to_string());
}
}
None
}
fn bisect_state_for_repo(repo: &Path, message: String) -> Result<BisectState, String> {
let active = bisect_in_progress(repo)?;
if !active {
return Ok(BisectState {
active: false,
finished: false,
current: None,
culprit: None,
remaining_revisions: None,
remaining_steps: None,
message,
});
}
let log = run_git(repo, ["bisect", "log"])
.map(|output| String::from_utf8_lossy(&output).into_owned())
.unwrap_or_default();
let culprit_hash = parse_bisect_culprit(&message).or_else(|| parse_bisect_culprit(&log));
let current = bisect_commit_for_ref(repo, "HEAD")?;
let culprit = culprit_hash
.as_deref()
.map(|hash| bisect_commit_for_ref(repo, hash))
.transpose()?;
let (remaining_revisions, remaining_steps) = parse_bisect_progress(&message);
Ok(BisectState {
active,
finished: culprit.is_some(),
current: Some(current),
culprit,
remaining_revisions,
remaining_steps,
message,
})
}
#[tauri::command]
pub async fn get_bisect_state(path: String) -> Result<BisectState, String> {
run_git_task("Could not inspect Git bisect", move || {
let repo = resolve_repo(&path)?;
bisect_state_for_repo(&repo, String::new())
})
.await
}
#[tauri::command]
pub async fn start_bisect(path: String, good: String, bad: String) -> Result<BisectState, String> {
run_git_task("Could not start Git bisect", move || {
let repo = resolve_repo(&path)?;
if bisect_in_progress(&repo)? {
return Err("A Git bisect session is already active.".to_string());
}
let status = status_for_repo(&repo)?;
if !status.clean {
return Err(
"Commit or stash working tree changes before starting Git bisect.".to_string(),
);
}
if status.rebase_in_progress || status.cherry_pick_in_progress || status.merge_in_progress {
return Err("Finish the current Git operation before starting Git bisect.".to_string());
}
let good_hash = verify_commit(&repo, &good)?;
let bad_hash = verify_commit(&repo, &bad)?;
if good_hash == bad_hash {
return Err("Good and bad must reference different commits.".to_string());
}
let ancestry = git_command()
.arg("-C")
.arg(&repo)
.args([
"merge-base",
"--is-ancestor",
good_hash.as_str(),
bad_hash.as_str(),
])
.output()
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
if !ancestry.status.success() {
return Err("The good commit must be an ancestor of the bad commit.".to_string());
}
let output = run_git(
&repo,
["bisect", "start", bad_hash.as_str(), good_hash.as_str()],
)?;
bisect_state_for_repo(&repo, String::from_utf8_lossy(&output).trim().to_string())
})
.await
}
#[tauri::command]
pub async fn mark_bisect(path: String, verdict: String) -> Result<BisectState, String> {
run_git_task("Could not continue Git bisect", move || {
let repo = resolve_repo(&path)?;
if !bisect_in_progress(&repo)? {
return Err("No Git bisect session is active.".to_string());
}
let verdict = match verdict.as_str() {
"good" => "good",
"bad" => "bad",
"skip" => "skip",
_ => return Err("Bisect verdict must be good, bad, or skip.".to_string()),
};
let output = run_git(&repo, ["bisect", verdict])?;
bisect_state_for_repo(&repo, String::from_utf8_lossy(&output).trim().to_string())
})
.await
}
#[tauri::command]
pub async fn reset_bisect(path: String) -> Result<GitStatus, String> {
run_git_task("Could not stop Git bisect", move || {
let repo = resolve_repo(&path)?;
if bisect_in_progress(&repo)? {
run_git(&repo, ["bisect", "reset"])?;
}
status_for_repo(&repo)
})
.await
}
fn interactive_rebase_commits_for_repo(
repo: &Path,
base: &str,
@@ -10117,4 +10312,69 @@ mod tests {
assert_eq!(git_output_test(&repo.path, ["ls-files"]), "keep.txt");
assert!(repo.path.join("keep.txt").is_file());
}
#[test]
fn parses_bisect_progress_and_culprit_output() {
let message = "Bisecting: 7 revisions left to test after this (roughly 3 steps)\nabc123 is the first 'bad' commit";
assert_eq!(parse_bisect_progress(message), (Some(7), Some(3)));
assert_eq!(parse_bisect_culprit(message).as_deref(), Some("abc123"));
assert_eq!(
parse_bisect_culprit("# first 'bad' commit: [def456] broken behavior").as_deref(),
Some("def456")
);
}
#[test]
fn bisect_state_tracks_a_complete_session_and_reset() {
let repo = init_temp_repo("bisect_session");
let mut hashes = Vec::new();
for index in 0..6 {
fs::write(repo.path.join("value.txt"), format!("{index}\n"))
.expect("fixture should be written");
run_git_test(&repo.path, ["add", "value.txt"]);
run_git_test(
&repo.path,
["commit", "-q", "-m", &format!("commit {index}")],
);
hashes.push(git_output_test(&repo.path, ["rev-parse", "HEAD"]));
}
let output = run_git(
&repo.path,
["bisect", "start", hashes[5].as_str(), hashes[0].as_str()],
)
.expect("bisect should start");
let mut state = bisect_state_for_repo(
&repo.path,
String::from_utf8_lossy(&output).trim().to_string(),
)
.expect("bisect state should load");
assert!(state.active);
assert!(state.current.is_some());
for _ in 0..8 {
if state.finished {
break;
}
let output =
run_git(&repo.path, ["bisect", "good"]).expect("bisect verdict should succeed");
state = bisect_state_for_repo(
&repo.path,
String::from_utf8_lossy(&output).trim().to_string(),
)
.expect("bisect state should advance");
}
assert!(state.finished);
assert_eq!(
state.culprit.as_ref().map(|commit| &commit.hash),
Some(&hashes[5])
);
run_git(&repo.path, ["bisect", "reset"]).expect("bisect should reset");
assert!(!bisect_in_progress(&repo.path).expect("bisect state should be readable"));
assert_eq!(
git_output_test(&repo.path, ["rev-parse", "HEAD"]),
hashes[5]
);
}
}
+19 -14
View File
@@ -17,20 +17,21 @@ use git::{
commit_ai_review, commit_ai_split, compare_commits, compare_file_to_head,
compare_file_to_parent, create_branch, create_tag, cred_delete, cred_load, cred_save,
delete_branch, delete_commit_note, delete_remote_branch, delete_remote_branches, delete_tag,
diff_file_against_working_tree, fetch, fetch_commit_notes, get_commit_note, get_file_blame,
get_file_patch, get_remote_url, get_status, git_lfs_install, git_lfs_prune, git_lfs_pull,
git_lfs_status, git_lfs_track, git_lfs_untrack, init_repository, last_commit_message,
list_branches, list_commits, list_file_history, list_interactive_rebase_commits, list_reflog,
list_remotes, list_repository_files, list_stashes, list_tags, list_worktrees, lock_worktree,
merge_abort, merge_branch, merge_continue, move_worktree, open_repo_in_explorer,
open_repository, open_repository_bundle, open_repository_file, prune_worktrees, pull, push,
push_commit_notes, push_tag, read_conflict, rebase_abort, rebase_branch, rebase_continue,
remove_remote, remove_worktree, rename_branch, rename_remote_branch, repair_worktree,
resolve_conflict, resolve_conflict_side, restore_file_from_commit, restore_files,
restore_reflog_entry, restore_to_commit, revert_commit, run_sequence_editor_if_requested,
search_code_introductions, set_branch_upstream, set_commit_note, stage_files,
start_interactive_rebase, stash_apply, stash_drop, stash_pop, stash_push, undo_last_commit,
unlock_worktree, unstage_files, untrack_paths, update_remote,
diff_file_against_working_tree, fetch, fetch_commit_notes, get_bisect_state, get_commit_note,
get_file_blame, get_file_patch, get_remote_url, get_status, git_lfs_install, git_lfs_prune,
git_lfs_pull, git_lfs_status, git_lfs_track, git_lfs_untrack, init_repository,
last_commit_message, list_branches, list_commits, list_file_history,
list_interactive_rebase_commits, list_reflog, list_remotes, list_repository_files,
list_stashes, list_tags, list_worktrees, lock_worktree, mark_bisect, merge_abort, merge_branch,
merge_continue, move_worktree, open_repo_in_explorer, open_repository, open_repository_bundle,
open_repository_file, prune_worktrees, pull, push, push_commit_notes, push_tag, read_conflict,
rebase_abort, rebase_branch, rebase_continue, remove_remote, remove_worktree, rename_branch,
rename_remote_branch, repair_worktree, reset_bisect, resolve_conflict, resolve_conflict_side,
restore_file_from_commit, restore_files, restore_reflog_entry, restore_to_commit,
revert_commit, run_sequence_editor_if_requested, search_code_introductions,
set_branch_upstream, set_commit_note, stage_files, start_bisect, start_interactive_rebase,
stash_apply, stash_drop, stash_pop, stash_push, undo_last_commit, unlock_worktree,
unstage_files, untrack_paths, update_remote,
};
use integrations::list_integration_repositories;
use std::path::{Path, PathBuf};
@@ -411,6 +412,10 @@ async fn main() {
start_interactive_rebase,
list_reflog,
restore_reflog_entry,
get_bisect_state,
start_bisect,
mark_bisect,
reset_bisect,
list_repository_files,
open_repository_bundle,
list_file_history,