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..10aa554 --- /dev/null +++ b/src/lib/components/BisectDialog.svelte @@ -0,0 +1,142 @@ + + + + + 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 @@