From 7f2b04a5060137595e0e43e8d7963dae29bd5cee Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Sun, 6 Sep 2026 00:04:42 +0200 Subject: [PATCH 1/2] 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. --- src-tauri/src/git.rs | 260 +++++++++++++++++++++++ src-tauri/src/main.rs | 33 +-- src/App.svelte | 82 ++++++- src/lib/RepoToolbar.svelte | 9 + src/lib/components/BisectDialog.svelte | 89 ++++++++ src/lib/components/CommandPalette.svelte | 10 +- src/lib/git.ts | 17 ++ src/lib/types.ts | 18 ++ 8 files changed, 499 insertions(+), 19 deletions(-) create mode 100644 src/lib/components/BisectDialog.svelte diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index d90a967..3ac8c24 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -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, + pub culprit: Option, + pub remaining_revisions: Option, + pub remaining_steps: Option, + 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 { + Ok(git_dir_for_repo(repo)?.join("BISECT_START").is_file()) +} + +fn bisect_commit_for_ref(repo: &Path, revision: &str) -> Result { + 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::>(); + 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, Option) { + 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::().ok()) + }); + let steps = line.split("roughly").nth(1).and_then(|tail| { + tail.split_whitespace() + .find_map(|word| word.parse::().ok()) + }); + (revisions, steps) +} + +fn parse_bisect_culprit(message: &str) -> Option { + 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 { + 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 { + 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 { + 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 { + 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 { + 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] + ); + } } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index b8a3d55..ca96627 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -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, diff --git a/src/App.svelte b/src/App.svelte index 34ca14b..77815a5 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -68,6 +68,7 @@ compareFileToParent, fetchCommitNotes, fetchRemote, + getBisectState, getCommitNote, getFileBlame, getGitLfsStatus, @@ -85,6 +86,7 @@ listReflog, listRepositoryFiles, mergeBranch, + markBisect, mergeAbort, mergeContinue, openRepoInExplorer, @@ -102,6 +104,7 @@ removeRemote, removeWorktree, repairWorktree, + resetBisect, revertCommit, setBranchUpstream, setCommitNote, @@ -128,6 +131,7 @@ restoreFiles, restoreToCommit, searchCodeIntroductions, + startBisect, startInteractiveRebase, setSyncBadge, stageFiles, @@ -151,6 +155,7 @@ AppLanguage, AppTheme, AnalyticsSettings, + BisectState, CloneOptions, CustomThemeColors, ConflictFile, @@ -423,6 +428,10 @@ let reflogEntries: ReflogEntry[] = []; let reflogLoading = false; let reflogError = ""; + let bisectOpen = false; + let bisectState: BisectState | null = null; + let bisectLoading = false; + let bisectError = ""; let selectedDiffPath = ""; let diffHighlightQuery = ""; let pendingRestoreFile: { commit: GitCommit; file: GitCommitFile } | null = null; @@ -999,7 +1008,7 @@ } async function autoRefreshTick() { - if (appShuttingDown || !autoRefreshEnabled || activeView !== "repository" || !activeRepoPath || isBusy || autoRefreshInFlight || resolveDialogOpen || compareDialogOpen || compareSelectOpen || interactiveRebaseOpen || reflogOpen || worktreeDialogOpen || gitLfsDialogOpen || newBranchCommit || commitNoteTarget || globalSearchOpen || helpOpen) return; + if (appShuttingDown || !autoRefreshEnabled || activeView !== "repository" || !activeRepoPath || isBusy || autoRefreshInFlight || resolveDialogOpen || compareDialogOpen || compareSelectOpen || interactiveRebaseOpen || reflogOpen || bisectOpen || worktreeDialogOpen || gitLfsDialogOpen || newBranchCommit || commitNoteTarget || globalSearchOpen || helpOpen) return; const path = activeRepoPath; autoRefreshInFlight = true; try { @@ -3325,6 +3334,57 @@ } } + async function openBisect() { + if (!hasRepository || isBusy) return; + bisectOpen = true; + bisectState = null; + bisectError = ""; + bisectLoading = true; + try { + bisectState = await getBisectState(activeRepoPath); + trackEvent("bisect_opened", { active: bisectState.active ? 1 : 0 }); + } catch (error) { + bisectError = errorToMessage(error); + } finally { + bisectLoading = false; + } + } + + async function beginBisect(good: string, bad: string) { + if (!activeRepoPath || isBusy) return; + bisectError = ""; + await runOperation("Starting Git bisect", async () => { + bisectState = await startBisect(activeRepoPath, good, bad); + await refreshRepositoryViews(activeRepoPath); + trackEvent("bisect_started"); + }); + if (bisectOpen && errorMessage) bisectError = errorMessage; + } + + async function continueBisect(verdict: "good" | "bad" | "skip") { + if (!activeRepoPath || isBusy) return; + bisectError = ""; + await runOperation("Continuing Git bisect", async () => { + bisectState = await markBisect(activeRepoPath, verdict); + await refreshRepositoryViews(activeRepoPath); + trackEvent("bisect_commit_marked", { verdict, finished: bisectState.finished ? 1 : 0 }); + }); + if (bisectOpen && errorMessage) bisectError = errorMessage; + } + + async function stopBisect() { + if (!activeRepoPath || isBusy) return; + bisectError = ""; + await runOperation("Stopping Git bisect", async () => { + applyStatus(await resetBisect(activeRepoPath)); + bisectOpen = false; + bisectState = null; + await refreshRepositoryViews(activeRepoPath); + trackEvent("bisect_stopped"); + }); + if (bisectOpen && errorMessage) bisectError = errorMessage; + } + async function previewReflogEntry(entry: ReflogEntry) { if (!activeRepoPath || isBusy) return; await runOperation("Previewing reflog entry", async () => { @@ -5041,6 +5101,7 @@ else if (event.key === "Escape" && gitLfsDialogOpen) closeGitLfsDialog(); else if (event.key === "Escape" && interactiveRebaseOpen && !isBusy) interactiveRebaseOpen = false; else if (event.key === "Escape" && reflogOpen && !isBusy) reflogOpen = false; + else if (event.key === "Escape" && bisectOpen && !isBusy) bisectOpen = false; else if (event.key === "Escape" && compareSelectOpen) compareSelectOpen = false; else if (event.key === "Escape" && globalSearchOpen) closeGlobalSearchDialog(); } @@ -5106,6 +5167,7 @@ onCompare={openCompareSelect} onInteractiveRebase={openInteractiveRebase} onReflog={openReflog} + onBisect={openBisect} onOpenInEditor={openActiveRepoInEditor} onOpenTerminal={openActiveRepoTerminal} onOpenInExplorer={openActiveRepoFileManager} @@ -5579,6 +5641,7 @@ onOpenSearch={openGlobalSearchDialog} onOpenCompare={openCompareSelect} onOpenReflog={openReflog} + onOpenBisect={openBisect} onOpenInteractiveRebase={openInteractiveRebase} onOpenWorktrees={openWorktreeDialog} onOpenSyncSettings={openSyncOptions} @@ -5879,6 +5942,23 @@ /> {/if} +{#if bisectOpen} + {#await import("./lib/components/BisectDialog.svelte") then module} + { if (!isBusy) bisectOpen = false; }} + /> + {/await} +{/if} + {#if compareSelectOpen} import { Box, + Bug, ChevronDown, Code2, CloudDownload, @@ -36,6 +37,7 @@ export let onCompare: () => void = () => {}; export let onInteractiveRebase: () => void = () => {}; export let onReflog: () => void = () => {}; + export let onBisect: () => void = () => {}; export let onOpenInExplorer: () => void = () => {}; export let onOpenInEditor: () => void = () => {}; export let onOpenTerminal: () => void = () => {}; @@ -208,6 +210,13 @@ {isGerman ? "Commits ordnen und zusammenfassen" : "Reorder and combine commits"} + {/if} diff --git a/src/lib/components/BisectDialog.svelte b/src/lib/components/BisectDialog.svelte new file mode 100644 index 0000000..a70848f --- /dev/null +++ b/src/lib/components/BisectDialog.svelte @@ -0,0 +1,89 @@ + + + + + diff --git a/src/lib/components/CommandPalette.svelte b/src/lib/components/CommandPalette.svelte index f18e6d4..5629ed1 100644 --- a/src/lib/components/CommandPalette.svelte +++ b/src/lib/components/CommandPalette.svelte @@ -1,12 +1,12 @@