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:
@@ -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<BisectCommit>,
|
||||
pub culprit: Option<BisectCommit>,
|
||||
pub remaining_revisions: Option<u32>,
|
||||
pub remaining_steps: Option<u32>,
|
||||
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<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(
|
||||
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]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+19
-14
@@ -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,
|
||||
|
||||
+81
-1
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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} />
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user