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
+9
View File
@@ -1,6 +1,7 @@
<script lang="ts">
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 @@
<small>{isGerman ? "Commits ordnen und zusammenfassen" : "Reorder and combine commits"}</small>
</span>
</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>
{/if}
</div>
+89
View File
@@ -0,0 +1,89 @@
<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-fields">
<label><span>{de ? "Funktionierender Commit (Good)" : "Known good commit"}</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 ? "Eine ältere Revision, bei der der Fehler noch nicht auftrat." : "An older revision where the bug did not occur."}</small></label>
<label><span>{de ? "Fehlerhafter Commit (Bad)" : "Known bad commit"}</span><input bind:value={bad} placeholder="HEAD" disabled={isBusy} spellcheck="false" autocomplete="off" /><small>{de ? "Meistens HEAD, also der aktuell ausgecheckte Commit." : "Usually HEAD, the currently checked-out commit."}</small></label>
</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{width:min(650px,calc(100vw - 28px));overflow:hidden}.bisect-body{display:grid;gap:16px;padding:18px}.bisect-loading{display:flex;min-height:220px;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:13px}.bisect-intro-icon,.result-icon{display:grid;width:42px;height:42px;flex:0 0 42px;place-items:center;border:1px solid color-mix(in srgb,var(--color-primary) 30%,var(--color-border));border-radius:9px;color:var(--color-primary);background:color-mix(in srgb,var(--color-primary) 10%,var(--color-surface))}.bisect-intro h3,.bisect-current h3,.bisect-result h3{margin:0;font-size:14px}.bisect-intro p,.result-copy{margin:5px 0 0;color:var(--color-ink-muted);line-height:1.5}
.bisect-fields{display:grid;grid-template-columns:1fr 1fr;gap:12px}.bisect-fields label{display:grid;gap:6px}.bisect-fields label>span{font-size:11px;font-weight:700}.bisect-fields input{height:34px;font-family:var(--font-mono);font-size:12px}.bisect-fields small{color:var(--color-ink-faint);font-size:10px;line-height:1.4}.bisect-notice{display:flex;align-items:flex-start;gap:8px;padding:10px 11px;border:1px solid color-mix(in srgb,#e2ad4e 32%,var(--color-border));border-radius:7px;color:var(--color-ink-muted);background:color-mix(in srgb,#e2ad4e 7%,var(--color-surface))}.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}.bisect-footer .reset{margin-right:auto}
@media(max-width:620px){.bisect-fields,.verdict-grid{grid-template-columns:1fr}.bisect-body{padding:14px}.bisect-progress{align-items:flex-start;flex-direction:column;gap:4px}}
</style>
+6 -4
View File
@@ -1,12 +1,12 @@
<script lang="ts">
import { onMount } from "svelte";
import {
ArrowDownToLine, ArrowUpFromLine, Boxes, CircleHelp, FileCode, GitBranch,
ArrowDownToLine, ArrowUpFromLine, Boxes, Bug, CircleHelp, FileCode, GitBranch,
GitCompare, History, RefreshCw, Search, Settings, SlidersHorizontal, Sparkles,
} from "@lucide/svelte";
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 Props {
language: AppLanguage; hasRepository: boolean; isBusy: boolean;
@@ -17,7 +17,7 @@
onSelectCommit: (commit: GitCommit) => void | Promise<void>;
onFetch: () => void | Promise<void>; onPull: () => void | Promise<void>; onPush: () => void | Promise<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>;
onOpenSettings: () => void; onOpenAiSettings: () => void; onOpenHelp: () => void;
}
@@ -26,7 +26,7 @@
language = "en", hasRepository = false, isBusy = false, branches = [], files = [], commits = [], onClose = () => {},
onCheckoutBranch = () => {}, onOpenFile = () => {}, onSelectCommit = () => {}, onFetch = () => {}, onPull = () => {},
onPush = () => {}, onRefresh = () => {}, onOpenSearch = () => {}, onOpenCompare = () => {}, onOpenReflog = () => {},
onOpenInteractiveRebase = () => {}, onOpenWorktrees = () => {}, onOpenSyncSettings = () => {}, onOpenSettings = () => {},
onOpenInteractiveRebase = () => {}, onOpenBisect = () => {}, onOpenWorktrees = () => {}, onOpenSyncSettings = () => {}, onOpenSettings = () => {},
onOpenAiSettings = () => {}, onOpenHelp = () => {},
}: 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("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("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("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),
@@ -118,6 +119,7 @@
{:else if item.kind === "compare"}<GitCompare 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 === "bisect"}<Bug size={16} />
{:else if item.kind === "worktrees"}<Boxes size={16} />
{:else if item.kind === "sync"}<SlidersHorizontal 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 {
AiReviewResult,
AiCommitPlan,
BisectState,
CommitAiProvider,
CloneOptions,
ConflictFile,
@@ -104,6 +105,22 @@ export function getStatus(path: string): Promise<GitStatus> {
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> {
return invoke<GitLfsStatus>("git_lfs_status", { path });
}
+18
View File
@@ -236,6 +236,24 @@ export interface GitCommit {
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 {
path: string;
old_path: string | null;