Merge pull request 'Bisect' (#31) from bisect into main

Reviewed-on: #31
This commit was merged in pull request #31.
This commit is contained in:
2026-09-06 18:19:09 +00:00
8 changed files with 552 additions and 19 deletions
+260
View File
@@ -131,6 +131,26 @@ pub struct GitCommit {
pub has_note: bool, 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)] #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct GitCommitFile { pub struct GitCommitFile {
pub path: String, pub path: String,
@@ -3377,6 +3397,181 @@ pub fn restore_reflog_entry(
status_for_repo(&repo) 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( fn interactive_rebase_commits_for_repo(
repo: &Path, repo: &Path,
base: &str, base: &str,
@@ -10117,4 +10312,69 @@ mod tests {
assert_eq!(git_output_test(&repo.path, ["ls-files"]), "keep.txt"); assert_eq!(git_output_test(&repo.path, ["ls-files"]), "keep.txt");
assert!(repo.path.join("keep.txt").is_file()); 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, 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, 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, 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, diff_file_against_working_tree, fetch, fetch_commit_notes, get_bisect_state, get_commit_note,
get_file_patch, get_remote_url, get_status, git_lfs_install, git_lfs_prune, git_lfs_pull, get_file_blame, get_file_patch, get_remote_url, get_status, git_lfs_install, git_lfs_prune,
git_lfs_status, git_lfs_track, git_lfs_untrack, init_repository, last_commit_message, git_lfs_pull, git_lfs_status, git_lfs_track, git_lfs_untrack, init_repository,
list_branches, list_commits, list_file_history, list_interactive_rebase_commits, list_reflog, last_commit_message, list_branches, list_commits, list_file_history,
list_remotes, list_repository_files, list_stashes, list_tags, list_worktrees, lock_worktree, list_interactive_rebase_commits, list_reflog, list_remotes, list_repository_files,
merge_abort, merge_branch, merge_continue, move_worktree, open_repo_in_explorer, list_stashes, list_tags, list_worktrees, lock_worktree, mark_bisect, merge_abort, merge_branch,
open_repository, open_repository_bundle, open_repository_file, prune_worktrees, pull, push, merge_continue, move_worktree, open_repo_in_explorer, open_repository, open_repository_bundle,
push_commit_notes, push_tag, read_conflict, rebase_abort, rebase_branch, rebase_continue, open_repository_file, prune_worktrees, pull, push, push_commit_notes, push_tag, read_conflict,
remove_remote, remove_worktree, rename_branch, rename_remote_branch, repair_worktree, rebase_abort, rebase_branch, rebase_continue, remove_remote, remove_worktree, rename_branch,
resolve_conflict, resolve_conflict_side, restore_file_from_commit, restore_files, rename_remote_branch, repair_worktree, reset_bisect, resolve_conflict, resolve_conflict_side,
restore_reflog_entry, restore_to_commit, revert_commit, run_sequence_editor_if_requested, restore_file_from_commit, restore_files, restore_reflog_entry, restore_to_commit,
search_code_introductions, set_branch_upstream, set_commit_note, stage_files, revert_commit, run_sequence_editor_if_requested, search_code_introductions,
start_interactive_rebase, stash_apply, stash_drop, stash_pop, stash_push, undo_last_commit, set_branch_upstream, set_commit_note, stage_files, start_bisect, start_interactive_rebase,
unlock_worktree, unstage_files, untrack_paths, update_remote, 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 integrations::list_integration_repositories;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -411,6 +412,10 @@ async fn main() {
start_interactive_rebase, start_interactive_rebase,
list_reflog, list_reflog,
restore_reflog_entry, restore_reflog_entry,
get_bisect_state,
start_bisect,
mark_bisect,
reset_bisect,
list_repository_files, list_repository_files,
open_repository_bundle, open_repository_bundle,
list_file_history, list_file_history,
+81 -1
View File
@@ -68,6 +68,7 @@
compareFileToParent, compareFileToParent,
fetchCommitNotes, fetchCommitNotes,
fetchRemote, fetchRemote,
getBisectState,
getCommitNote, getCommitNote,
getFileBlame, getFileBlame,
getGitLfsStatus, getGitLfsStatus,
@@ -85,6 +86,7 @@
listReflog, listReflog,
listRepositoryFiles, listRepositoryFiles,
mergeBranch, mergeBranch,
markBisect,
mergeAbort, mergeAbort,
mergeContinue, mergeContinue,
openRepoInExplorer, openRepoInExplorer,
@@ -102,6 +104,7 @@
removeRemote, removeRemote,
removeWorktree, removeWorktree,
repairWorktree, repairWorktree,
resetBisect,
revertCommit, revertCommit,
setBranchUpstream, setBranchUpstream,
setCommitNote, setCommitNote,
@@ -128,6 +131,7 @@
restoreFiles, restoreFiles,
restoreToCommit, restoreToCommit,
searchCodeIntroductions, searchCodeIntroductions,
startBisect,
startInteractiveRebase, startInteractiveRebase,
setSyncBadge, setSyncBadge,
stageFiles, stageFiles,
@@ -151,6 +155,7 @@
AppLanguage, AppLanguage,
AppTheme, AppTheme,
AnalyticsSettings, AnalyticsSettings,
BisectState,
CloneOptions, CloneOptions,
CustomThemeColors, CustomThemeColors,
ConflictFile, ConflictFile,
@@ -423,6 +428,10 @@
let reflogEntries: ReflogEntry[] = []; let reflogEntries: ReflogEntry[] = [];
let reflogLoading = false; let reflogLoading = false;
let reflogError = ""; let reflogError = "";
let bisectOpen = false;
let bisectState: BisectState | null = null;
let bisectLoading = false;
let bisectError = "";
let selectedDiffPath = ""; let selectedDiffPath = "";
let diffHighlightQuery = ""; let diffHighlightQuery = "";
let pendingRestoreFile: { commit: GitCommit; file: GitCommitFile } | null = null; let pendingRestoreFile: { commit: GitCommit; file: GitCommitFile } | null = null;
@@ -999,7 +1008,7 @@
} }
async function autoRefreshTick() { 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; const path = activeRepoPath;
autoRefreshInFlight = true; autoRefreshInFlight = true;
try { 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) { async function previewReflogEntry(entry: ReflogEntry) {
if (!activeRepoPath || isBusy) return; if (!activeRepoPath || isBusy) return;
await runOperation("Previewing reflog entry", async () => { await runOperation("Previewing reflog entry", async () => {
@@ -5041,6 +5101,7 @@
else if (event.key === "Escape" && gitLfsDialogOpen) closeGitLfsDialog(); else if (event.key === "Escape" && gitLfsDialogOpen) closeGitLfsDialog();
else if (event.key === "Escape" && interactiveRebaseOpen && !isBusy) interactiveRebaseOpen = false; else if (event.key === "Escape" && interactiveRebaseOpen && !isBusy) interactiveRebaseOpen = false;
else if (event.key === "Escape" && reflogOpen && !isBusy) reflogOpen = 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" && compareSelectOpen) compareSelectOpen = false;
else if (event.key === "Escape" && globalSearchOpen) closeGlobalSearchDialog(); else if (event.key === "Escape" && globalSearchOpen) closeGlobalSearchDialog();
} }
@@ -5106,6 +5167,7 @@
onCompare={openCompareSelect} onCompare={openCompareSelect}
onInteractiveRebase={openInteractiveRebase} onInteractiveRebase={openInteractiveRebase}
onReflog={openReflog} onReflog={openReflog}
onBisect={openBisect}
onOpenInEditor={openActiveRepoInEditor} onOpenInEditor={openActiveRepoInEditor}
onOpenTerminal={openActiveRepoTerminal} onOpenTerminal={openActiveRepoTerminal}
onOpenInExplorer={openActiveRepoFileManager} onOpenInExplorer={openActiveRepoFileManager}
@@ -5579,6 +5641,7 @@
onOpenSearch={openGlobalSearchDialog} onOpenSearch={openGlobalSearchDialog}
onOpenCompare={openCompareSelect} onOpenCompare={openCompareSelect}
onOpenReflog={openReflog} onOpenReflog={openReflog}
onOpenBisect={openBisect}
onOpenInteractiveRebase={openInteractiveRebase} onOpenInteractiveRebase={openInteractiveRebase}
onOpenWorktrees={openWorktreeDialog} onOpenWorktrees={openWorktreeDialog}
onOpenSyncSettings={openSyncOptions} onOpenSyncSettings={openSyncOptions}
@@ -5879,6 +5942,23 @@
/> />
{/if} {/if}
{#if bisectOpen}
{#await import("./lib/components/BisectDialog.svelte") then module}
<module.default
language={appLanguage}
{bisectState}
isLoading={bisectLoading}
{isBusy}
{operation}
error={bisectError}
onStart={beginBisect}
onMark={continueBisect}
onReset={stopBisect}
onClose={() => { if (!isBusy) bisectOpen = false; }}
/>
{/await}
{/if}
<!-- Compare: pick two branches or commits to diff --> <!-- Compare: pick two branches or commits to diff -->
{#if compareSelectOpen} {#if compareSelectOpen}
<CompareSelectDialog <CompareSelectDialog
+9
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { import {
Box, Box,
Bug,
ChevronDown, ChevronDown,
Code2, Code2,
CloudDownload, CloudDownload,
@@ -36,6 +37,7 @@
export let onCompare: () => void = () => {}; export let onCompare: () => void = () => {};
export let onInteractiveRebase: () => void = () => {}; export let onInteractiveRebase: () => void = () => {};
export let onReflog: () => void = () => {}; export let onReflog: () => void = () => {};
export let onBisect: () => void = () => {};
export let onOpenInExplorer: () => void = () => {}; export let onOpenInExplorer: () => void = () => {};
export let onOpenInEditor: () => void = () => {}; export let onOpenInEditor: () => void = () => {};
export let onOpenTerminal: () => void = () => {}; export let onOpenTerminal: () => void = () => {};
@@ -208,6 +210,13 @@
<small>{isGerman ? "Commits ordnen und zusammenfassen" : "Reorder and combine commits"}</small> <small>{isGerman ? "Commits ordnen und zusammenfassen" : "Reorder and combine commits"}</small>
</span> </span>
</button> </button>
<button type="button" role="menuitem" onclick={() => runHistoryAction(onBisect)}>
<Bug size={15} aria-hidden="true" />
<span>
<strong>Git Bisect</strong>
<small>{isGerman ? "Fehlerhaften Commit schrittweise finden" : "Find a bad commit step by step"}</small>
</span>
</button>
</div> </div>
{/if} {/if}
</div> </div>
+142
View File
@@ -0,0 +1,142 @@
<script lang="ts">
import { AlertTriangle, Bug, Check, GitCommitHorizontal, LoaderCircle, Play, RotateCcw, SkipForward, X } from "@lucide/svelte";
import type { BisectState } from "../types";
interface Props {
language: "en" | "de";
bisectState: BisectState | null;
isLoading: boolean;
isBusy: boolean;
operation: string;
error: string;
onStart: (good: string, bad: string) => void;
onMark: (verdict: "good" | "bad" | "skip") => void;
onReset: () => void;
onClose: () => void;
}
let { language = "en", bisectState = null, isLoading = false, isBusy = false, operation = "", error = "", onStart = () => {}, onMark = () => {}, onReset = () => {}, onClose = () => {} }: Props = $props();
let good = $state("");
let bad = $state("HEAD");
const de = $derived(language === "de");
const canStart = $derived(Boolean(good.trim() && bad.trim()) && !isLoading && !isBusy);
</script>
<div class="dialog-backdrop app-chrome-backdrop" role="presentation">
<div class="dialog bisect-dialog" role="dialog" aria-modal="true" aria-label={de ? "Geführtes Git Bisect" : "Guided Git bisect"} tabindex="-1">
<header class="dialog-header">
<div><span class="eyebrow">{de ? "Fehlerursache eingrenzen" : "Find the regression"}</span><h2 class="dialog-title">Git Bisect</h2></div>
<button class="dialog-close" type="button" onclick={onClose} disabled={isBusy} title={de ? "Schließen" : "Close"}><X size={18} aria-hidden="true" /></button>
</header>
<div class="bisect-body">
{#if error}<div class="bisect-notice error"><AlertTriangle size={16} /><span>{error}</span></div>{/if}
{#if isLoading}
<div class="bisect-loading"><LoaderCircle class="spin" size={18} />{de ? "Bisect-Status wird geladen …" : "Loading bisect status …"}</div>
{:else if !bisectState?.active}
<section class="bisect-intro">
<div class="bisect-intro-icon"><Bug size={24} /></div>
<div><h3>{de ? "Welcher Commit hat den Fehler eingeführt?" : "Which commit introduced the bug?"}</h3><p>{de ? "Gitty checkt schrittweise frühere Commits aus. Markiere jede getestete Version als Good, Bad oder Skip." : "Gitty checks out earlier commits step by step. Mark each tested version as Good, Bad, or Skip."}</p></div>
</section>
<div class="bisect-range">
<div class="range-heading">
<span>{de ? "Suchbereich" : "Search range"}</span>
<small>{de ? "Von funktionierend bis fehlerhaft" : "From working to broken"}</small>
</div>
<div class="bisect-fields">
<label class="commit-field good-field">
<span class="field-title"><Check size={14} />{de ? "Funktionierender Commit" : "Known good commit"}<em>Good</em></span>
<input bind:value={good} placeholder={de ? "z. B. v1.4.0 oder Commit-Hash" : "e.g. v1.4.0 or commit hash"} disabled={isBusy} spellcheck="false" autocomplete="off" />
<small>{de ? "Hier trat der Fehler noch nicht auf." : "The bug did not occur here."}</small>
</label>
<label class="commit-field bad-field">
<span class="field-title"><Bug size={14} />{de ? "Fehlerhafter Commit" : "Known bad commit"}<em>Bad</em></span>
<input bind:value={bad} placeholder="HEAD" disabled={isBusy} spellcheck="false" autocomplete="off" />
<small>{de ? "Meistens HEAD, der aktuelle Commit." : "Usually HEAD, the current commit."}</small>
</label>
</div>
</div>
<div class="bisect-notice"><AlertTriangle size={15} /><span>{de ? "Der Arbeitsbaum muss sauber sein. Während des Bisects wechselt Gitty vorübergehend zwischen Commits." : "The working tree must be clean. Gitty temporarily switches between commits during the bisect."}</span></div>
{:else if bisectState.finished && bisectState.culprit}
<section class="bisect-result">
<div class="result-icon"><Bug size={22} /></div>
<div><span>{de ? "Erster fehlerhafter Commit gefunden" : "First bad commit found"}</span><h3>{bisectState.culprit.summary}</h3><div class="commit-meta"><code>{bisectState.culprit.short_hash}</code><span>{bisectState.culprit.author_name}</span><span>{new Date(bisectState.culprit.date).toLocaleString()}</span></div></div>
</section>
<p class="result-copy">{de ? "Beende den Bisect, um zum ursprünglichen Branch und Arbeitsstand zurückzukehren." : "Finish the bisect to return to the original branch and working state."}</p>
{:else if bisectState.current}
<div class="bisect-progress">
<span>{de ? "Aktueller Test-Commit" : "Current test commit"}</span>
{#if bisectState.remaining_steps !== null}<strong>{de ? `Noch ungefähr ${bisectState.remaining_steps} Schritte` : `About ${bisectState.remaining_steps} steps remaining`}</strong>{/if}
</div>
<section class="bisect-current">
<GitCommitHorizontal size={22} />
<div><h3>{bisectState.current.summary}</h3><div class="commit-meta"><code>{bisectState.current.short_hash}</code><span>{bisectState.current.author_name}</span><span>{new Date(bisectState.current.date).toLocaleString()}</span></div></div>
</section>
<div class="bisect-question"><strong>{de ? "Tritt der Fehler bei diesem Commit auf?" : "Does the bug occur at this commit?"}</strong><span>{de ? "Teste die Anwendung oder führe deine Prüfschritte aus, bevor du entscheidest." : "Test the application or run your checks before choosing a verdict."}</span></div>
<div class="verdict-grid">
<button class="verdict good" type="button" onclick={() => onMark("good")} disabled={isBusy}><Check size={17} /><span><strong>Good</strong><small>{de ? "Fehler tritt nicht auf" : "Bug is absent"}</small></span></button>
<button class="verdict bad" type="button" onclick={() => onMark("bad")} disabled={isBusy}><Bug size={17} /><span><strong>Bad</strong><small>{de ? "Fehler tritt auf" : "Bug occurs"}</small></span></button>
<button class="verdict skip" type="button" onclick={() => onMark("skip")} disabled={isBusy}><SkipForward size={17} /><span><strong>Skip</strong><small>{de ? "Nicht testbar" : "Cannot test"}</small></span></button>
</div>
{/if}
</div>
<footer class="dialog-footer bisect-footer">
{#if bisectState?.active}
<button class="btn-secondary reset" type="button" onclick={onReset} disabled={isBusy}><RotateCcw size={15} />{bisectState.finished ? (de ? "Bisect beenden" : "Finish bisect") : (de ? "Bisect abbrechen" : "Abort bisect")}</button>
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>{de ? "Später fortsetzen" : "Continue later"}</button>
{:else}
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>{de ? "Abbrechen" : "Cancel"}</button>
<button class="btn-primary" type="button" onclick={() => onStart(good.trim(), bad.trim())} disabled={!canStart}>{#if operation === "Starting Git bisect"}<LoaderCircle class="spin" size={16} />{:else}<Play size={16} />{/if}{de ? "Bisect starten" : "Start bisect"}</button>
{/if}
</footer>
</div>
</div>
<style>
.bisect-dialog{grid-template-rows:auto minmax(0,auto) auto;width:min(640px,calc(100vw - 28px));height:auto;max-height:calc(100vh - 40px);overflow:hidden}
.bisect-body{display:grid;align-content:start;gap:14px;padding:16px 18px;overflow:auto}
.bisect-loading{display:flex;min-height:150px;align-items:center;justify-content:center;gap:8px;color:var(--color-ink-muted)}
.bisect-intro,.bisect-current,.bisect-result{display:flex;align-items:flex-start;gap:12px}
.bisect-intro-icon,.result-icon{display:grid;width:38px;height:38px;flex:0 0 38px;place-items:center;border:1px solid color-mix(in srgb,var(--color-primary) 32%,var(--color-border));border-radius:8px;color:var(--color-primary);background:color-mix(in srgb,var(--color-primary) 9%,var(--color-surface))}
.bisect-intro h3,.bisect-current h3,.bisect-result h3{margin:0;font-size:13px}
.bisect-intro p,.result-copy{max-width:520px;margin:4px 0 0;color:var(--color-ink-muted);font-size:12px;line-height:1.45}
.bisect-range{display:grid;gap:9px;padding:12px;border:1px solid var(--color-border-subtle);border-radius:9px;background:color-mix(in srgb,var(--color-surface) 72%,transparent)}
.range-heading{display:flex;align-items:baseline;justify-content:space-between;gap:12px}
.range-heading>span{color:var(--color-ink);font-size:10px;font-weight:800;letter-spacing:.07em;text-transform:uppercase}
.range-heading>small{color:var(--color-ink-faint);font-size:10px}
.bisect-fields{display:grid;grid-template-columns:1fr 1fr;gap:10px}
.commit-field{display:grid;gap:6px;padding:10px;border:1px solid var(--color-border-subtle);border-radius:7px;background:var(--color-surface-raised)}
.field-title{display:flex;align-items:center;gap:6px;color:var(--color-ink);font-size:11px;font-weight:700}
.field-title em{margin-left:auto;padding:1px 5px;border-radius:4px;font-size:9px;font-style:normal;font-weight:800;text-transform:uppercase}
.good-field .field-title :global(svg),.good-field .field-title em{color:#69c986}
.good-field .field-title em{background:color-mix(in srgb,#69c986 10%,transparent)}
.bad-field .field-title :global(svg),.bad-field .field-title em{color:#ef737b}
.bad-field .field-title em{background:color-mix(in srgb,#ef737b 10%,transparent)}
.bisect-fields input{height:32px;font-family:var(--font-mono);font-size:11.5px}
.bisect-fields small{color:var(--color-ink-faint);font-size:9.5px;line-height:1.35}
.bisect-notice{display:flex;align-items:flex-start;gap:8px;padding:9px 11px;border:1px solid color-mix(in srgb,#e2ad4e 28%,var(--color-border));border-radius:7px;color:var(--color-ink-muted);background:color-mix(in srgb,#e2ad4e 6%,var(--color-surface));font-size:11.5px;line-height:1.4}
.bisect-notice :global(svg){flex:0 0 auto;color:#e2ad4e}
.bisect-notice.error{border-color:color-mix(in srgb,#e45c65 40%,var(--color-border));color:#ef979d;background:color-mix(in srgb,#c92f3a 8%,var(--color-surface))}
.bisect-notice.error :global(svg){color:#ef6972}
.bisect-progress{display:flex;align-items:center;justify-content:space-between;color:var(--color-ink-muted);font-size:11px}
.bisect-progress strong{color:var(--color-primary)}
.bisect-current{padding:14px;border:1px solid var(--color-border);border-radius:8px;background:var(--color-surface)}
.bisect-current>:global(svg){flex:0 0 auto;color:var(--color-primary)}
.commit-meta{display:flex;flex-wrap:wrap;align-items:center;gap:7px 12px;margin-top:7px;color:var(--color-ink-faint);font-size:10px}
.commit-meta code{padding:2px 5px;border-radius:4px;color:var(--color-primary);background:color-mix(in srgb,var(--color-primary) 10%,transparent)}
.bisect-question{display:grid;gap:4px}
.bisect-question span{color:var(--color-ink-muted);font-size:11px}
.verdict-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:9px}
.verdict{min-height:58px;justify-content:flex-start;padding:8px 11px;text-align:left}
.verdict>span{display:grid;gap:2px}
.verdict small{color:var(--color-ink-faint);font-size:9.5px}
.verdict.good{color:#69c986}.verdict.bad{color:#ef737b}.verdict.skip{color:#ddb45c}
.bisect-result{padding:16px;border:1px solid color-mix(in srgb,#ef737b 36%,var(--color-border));border-radius:8px;background:color-mix(in srgb,#c92f3a 7%,var(--color-surface))}
.bisect-result>div:last-child>span{color:#ef838b;font-size:10px;font-weight:800;text-transform:uppercase}
.result-copy{margin:0}
.bisect-footer{justify-content:flex-end;padding-block:9px}
.bisect-footer .reset{margin-right:auto}
@media(max-width:620px){.bisect-fields,.verdict-grid{grid-template-columns:1fr}.bisect-body{padding:14px}.range-heading{align-items:flex-start;flex-direction:column;gap:2px}.bisect-progress{align-items:flex-start;flex-direction:column;gap:4px}}
</style>
+6 -4
View File
@@ -1,12 +1,12 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import { import {
ArrowDownToLine, ArrowUpFromLine, Boxes, CircleHelp, FileCode, GitBranch, ArrowDownToLine, ArrowUpFromLine, Boxes, Bug, CircleHelp, FileCode, GitBranch,
GitCompare, History, RefreshCw, Search, Settings, SlidersHorizontal, Sparkles, GitCompare, History, RefreshCw, Search, Settings, SlidersHorizontal, Sparkles,
} from "@lucide/svelte"; } from "@lucide/svelte";
import type { AppLanguage, GitBranch as GitBranchInfo, GitCommit, GitRepositoryFile } from "../types"; import type { AppLanguage, GitBranch as GitBranchInfo, GitCommit, GitRepositoryFile } from "../types";
type ItemKind = "fetch" | "pull" | "push" | "refresh" | "search" | "compare" | "reflog" | "rebase" | "worktrees" | "sync" | "settings" | "ai-settings" | "help" | "branch" | "file" | "commit"; type ItemKind = "fetch" | "pull" | "push" | "refresh" | "search" | "compare" | "reflog" | "rebase" | "bisect" | "worktrees" | "sync" | "settings" | "ai-settings" | "help" | "branch" | "file" | "commit";
interface Item { id: string; group: string; kind: ItemKind; title: string; subtitle: string; search: string; disabled?: boolean; run: () => void | Promise<void>; } interface Item { id: string; group: string; kind: ItemKind; title: string; subtitle: string; search: string; disabled?: boolean; run: () => void | Promise<void>; }
interface Props { interface Props {
language: AppLanguage; hasRepository: boolean; isBusy: boolean; language: AppLanguage; hasRepository: boolean; isBusy: boolean;
@@ -17,7 +17,7 @@
onSelectCommit: (commit: GitCommit) => void | Promise<void>; onSelectCommit: (commit: GitCommit) => void | Promise<void>;
onFetch: () => void | Promise<void>; onPull: () => void | Promise<void>; onPush: () => void | Promise<void>; onFetch: () => void | Promise<void>; onPull: () => void | Promise<void>; onPush: () => void | Promise<void>;
onRefresh: () => void | Promise<void>; onOpenSearch: () => void; onOpenCompare: () => void; onRefresh: () => void | Promise<void>; onOpenSearch: () => void; onOpenCompare: () => void;
onOpenReflog: () => void | Promise<void>; onOpenInteractiveRebase: () => void; onOpenReflog: () => void | Promise<void>; onOpenInteractiveRebase: () => void; onOpenBisect: () => void | Promise<void>;
onOpenWorktrees: () => void | Promise<void>; onOpenSyncSettings: () => void | Promise<void>; onOpenWorktrees: () => void | Promise<void>; onOpenSyncSettings: () => void | Promise<void>;
onOpenSettings: () => void; onOpenAiSettings: () => void; onOpenHelp: () => void; onOpenSettings: () => void; onOpenAiSettings: () => void; onOpenHelp: () => void;
} }
@@ -26,7 +26,7 @@
language = "en", hasRepository = false, isBusy = false, branches = [], files = [], commits = [], onClose = () => {}, language = "en", hasRepository = false, isBusy = false, branches = [], files = [], commits = [], onClose = () => {},
onCheckoutBranch = () => {}, onOpenFile = () => {}, onSelectCommit = () => {}, onFetch = () => {}, onPull = () => {}, onCheckoutBranch = () => {}, onOpenFile = () => {}, onSelectCommit = () => {}, onFetch = () => {}, onPull = () => {},
onPush = () => {}, onRefresh = () => {}, onOpenSearch = () => {}, onOpenCompare = () => {}, onOpenReflog = () => {}, onPush = () => {}, onRefresh = () => {}, onOpenSearch = () => {}, onOpenCompare = () => {}, onOpenReflog = () => {},
onOpenInteractiveRebase = () => {}, onOpenWorktrees = () => {}, onOpenSyncSettings = () => {}, onOpenSettings = () => {}, onOpenInteractiveRebase = () => {}, onOpenBisect = () => {}, onOpenWorktrees = () => {}, onOpenSyncSettings = () => {}, onOpenSettings = () => {},
onOpenAiSettings = () => {}, onOpenHelp = () => {}, onOpenAiSettings = () => {}, onOpenHelp = () => {},
}: Props = $props(); }: Props = $props();
@@ -50,6 +50,7 @@
action("compare", "compare", isGerman ? "Branches oder Commits vergleichen" : "Compare branches or commits", isGerman ? "Zwei vollständige Revisionen vergleichen" : "Diff two complete revisions", onOpenCompare), action("compare", "compare", isGerman ? "Branches oder Commits vergleichen" : "Compare branches or commits", isGerman ? "Zwei vollständige Revisionen vergleichen" : "Diff two complete revisions", onOpenCompare),
action("reflog", "reflog", "Reflog", isGerman ? "Verlorene Commits finden und wiederherstellen" : "Find and recover lost commits", onOpenReflog), action("reflog", "reflog", "Reflog", isGerman ? "Verlorene Commits finden und wiederherstellen" : "Find and recover lost commits", onOpenReflog),
action("rebase", "rebase", "Interactive Rebase", isGerman ? "Commit-Historie bearbeiten" : "Edit commit history", onOpenInteractiveRebase), action("rebase", "rebase", "Interactive Rebase", isGerman ? "Commit-Historie bearbeiten" : "Edit commit history", onOpenInteractiveRebase),
action("bisect", "bisect", "Git Bisect", isGerman ? "Fehlerhaften Commit schrittweise finden" : "Find a bad commit step by step", onOpenBisect),
action("worktrees", "worktrees", "Worktrees", isGerman ? "Arbeitsverzeichnisse verwalten" : "Manage linked working trees", onOpenWorktrees), action("worktrees", "worktrees", "Worktrees", isGerman ? "Arbeitsverzeichnisse verwalten" : "Manage linked working trees", onOpenWorktrees),
action("sync", "sync", isGerman ? "Synchronisierung konfigurieren" : "Configure synchronization", isGerman ? "Remote, Upstream und Pull-Strategie" : "Remote, upstream and pull strategy", onOpenSyncSettings), action("sync", "sync", isGerman ? "Synchronisierung konfigurieren" : "Configure synchronization", isGerman ? "Remote, Upstream und Pull-Strategie" : "Remote, upstream and pull strategy", onOpenSyncSettings),
action("settings", "settings", isGerman ? "Einstellungen" : "Settings", isGerman ? "Darstellung, Sprache und Verhalten" : "Appearance, language and behavior", onOpenSettings, false), action("settings", "settings", isGerman ? "Einstellungen" : "Settings", isGerman ? "Darstellung, Sprache und Verhalten" : "Appearance, language and behavior", onOpenSettings, false),
@@ -118,6 +119,7 @@
{:else if item.kind === "compare"}<GitCompare size={16} /> {:else if item.kind === "compare"}<GitCompare size={16} />
{:else if item.kind === "reflog" || item.kind === "commit"}<History size={16} /> {:else if item.kind === "reflog" || item.kind === "commit"}<History size={16} />
{:else if item.kind === "rebase" || item.kind === "branch"}<GitBranch size={16} /> {:else if item.kind === "rebase" || item.kind === "branch"}<GitBranch size={16} />
{:else if item.kind === "bisect"}<Bug size={16} />
{:else if item.kind === "worktrees"}<Boxes size={16} /> {:else if item.kind === "worktrees"}<Boxes size={16} />
{:else if item.kind === "sync"}<SlidersHorizontal size={16} /> {:else if item.kind === "sync"}<SlidersHorizontal size={16} />
{:else if item.kind === "settings"}<Settings size={16} /> {:else if item.kind === "settings"}<Settings size={16} />
+17
View File
@@ -3,6 +3,7 @@ import { tracedInvoke as invoke } from "./telemetry";
import type { import type {
AiReviewResult, AiReviewResult,
AiCommitPlan, AiCommitPlan,
BisectState,
CommitAiProvider, CommitAiProvider,
CloneOptions, CloneOptions,
ConflictFile, ConflictFile,
@@ -104,6 +105,22 @@ export function getStatus(path: string): Promise<GitStatus> {
return invoke<GitStatus>("get_status", { path }); return invoke<GitStatus>("get_status", { path });
} }
export function getBisectState(path: string): Promise<BisectState> {
return invoke<BisectState>("get_bisect_state", { path });
}
export function startBisect(path: string, good: string, bad: string): Promise<BisectState> {
return invoke<BisectState>("start_bisect", { path, good, bad });
}
export function markBisect(path: string, verdict: "good" | "bad" | "skip"): Promise<BisectState> {
return invoke<BisectState>("mark_bisect", { path, verdict });
}
export function resetBisect(path: string): Promise<GitStatus> {
return invoke<GitStatus>("reset_bisect", { path });
}
export function getGitLfsStatus(path: string): Promise<GitLfsStatus> { export function getGitLfsStatus(path: string): Promise<GitLfsStatus> {
return invoke<GitLfsStatus>("git_lfs_status", { path }); return invoke<GitLfsStatus>("git_lfs_status", { path });
} }
+18
View File
@@ -236,6 +236,24 @@ export interface GitCommit {
has_note: boolean; has_note: boolean;
} }
export interface BisectCommit {
hash: string;
short_hash: string;
summary: string;
author_name: string;
date: string;
}
export interface BisectState {
active: boolean;
finished: boolean;
current: BisectCommit | null;
culprit: BisectCommit | null;
remaining_revisions: number | null;
remaining_steps: number | null;
message: string;
}
export interface GitCommitFile { export interface GitCommitFile {
path: string; path: string;
old_path: string | null; old_path: string | null;