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
+81 -1
View File
@@ -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}
<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 -->
{#if compareSelectOpen}
<CompareSelectDialog