From 3c4425a4083531242460117c4d52b9b6cb7aa521 Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Sat, 4 Jul 2026 00:34:36 +0200 Subject: [PATCH] feat(git): add rebase workflow with UI state handling This introduces Git rebase commands (start, continue, abort) and exposes whether a rebase is currently in progress via the status payload. The UI now blocks committing during an active rebase and shows a dedicated rebase notice with continue/abort actions. - Add tauri commands for rebase operations and status detection - Update frontend to render rebase state and controls - Adjust conflict/resolution messaging and related UI styling --- src-tauri/src/git.rs | 88 +++++++++++++++++++++++++ src-tauri/src/main.rs | 11 ++-- src/App.svelte | 82 +++++++++++++++++++++-- src/app.css | 55 +++++++++++++++- src/lib/components/BranchPanel.svelte | 10 +++ src/lib/components/ResolveDialog.svelte | 6 +- src/lib/components/StashPanel.svelte | 26 ++++++-- src/lib/git.ts | 12 ++++ src/lib/types.ts | 1 + 9 files changed, 273 insertions(+), 18 deletions(-) diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index b04df0c..ff26b0b 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -47,6 +47,7 @@ pub struct GitStatus { pub behind: u32, pub files: Vec, pub clean: bool, + pub rebase_in_progress: bool, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] @@ -1119,6 +1120,72 @@ pub fn merge_branch(path: String, branch: String) -> Result { Err(format!("Merge failed: {details}")) } +#[tauri::command] +pub fn rebase_branch(path: String, branch: String) -> Result { + let repo = resolve_repo(&path)?; + let branch = branch.trim(); + if branch.is_empty() { + return Err("Branch name must not be empty.".to_string()); + } + + let output = git_command() + .arg("-C") + .arg(&repo) + .args(["rebase", branch]) + .output() + .map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?; + + rebase_status_or_error(&repo, output, "Rebase failed", true) +} + +#[tauri::command] +pub fn rebase_continue(path: String) -> Result { + let repo = resolve_repo(&path)?; + if !rebase_in_progress(&repo) { + return Err("No rebase is currently in progress.".to_string()); + } + + let output = git_command() + .arg("-C") + .arg(&repo) + .args(["rebase", "--continue"]) + .env("GIT_EDITOR", "true") + .output() + .map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?; + + rebase_status_or_error(&repo, output, "Rebase continue failed", false) +} + +#[tauri::command] +pub fn rebase_abort(path: String) -> Result { + let repo = resolve_repo(&path)?; + if !rebase_in_progress(&repo) { + return Err("No rebase is currently in progress.".to_string()); + } + + run_git(&repo, ["rebase", "--abort"])?; + status_for_repo(&repo) +} + +fn rebase_status_or_error( + repo: &Path, + output: Output, + context: &str, + ok_if_rebase_in_progress: bool, +) -> Result { + if output.status.success() { + return status_for_repo(repo); + } + + let status = status_for_repo(repo)?; + if has_unresolved_conflicts(&status) || (ok_if_rebase_in_progress && status.rebase_in_progress) + { + return Ok(status); + } + + Err(format!("{context}: {}", command_output_details(&output))) +} + #[tauri::command] pub fn list_commits(path: String, limit: Option) -> Result, String> { let repo = resolve_repo(&path)?; @@ -1953,9 +2020,30 @@ fn status_for_repo(repo: &Path) -> Result { behind: branch.behind, clean: files.is_empty(), files, + rebase_in_progress: rebase_in_progress(repo), }) } +fn rebase_in_progress(repo: &Path) -> bool { + git_path_exists(repo, "rebase-merge") || git_path_exists(repo, "rebase-apply") +} + +fn git_path_exists(repo: &Path, name: &str) -> bool { + let Ok(output) = run_git(repo, ["rev-parse", "--git-path", name]) else { + return false; + }; + let value = String::from_utf8_lossy(&output).trim().to_string(); + if value.is_empty() { + return false; + } + let path = PathBuf::from(value); + if path.is_absolute() { + path.exists() + } else { + repo.join(path).exists() + } +} + // `git status` only auto-detects renames between HEAD and the index (staged changes). // A file renamed on disk but not yet `git add`ed shows up as a plain delete + untracked // pair instead. We detect that case ourselves by comparing content hashes: if an unstaged diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 7a59acd..930b6e7 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -11,10 +11,10 @@ use git::{ cred_delete, cred_load, cred_save, delete_branch, diff_file_against_working_tree, fetch, get_file_patch, get_remote_url, get_status, list_branches, list_commits, list_file_history, list_repository_files, list_stashes, merge_branch, open_repo_in_explorer, open_repository, - open_repository_bundle, open_repository_file, pull, push, read_conflict, rename_branch, - resolve_conflict, resolve_conflict_side, restore_file_from_commit, restore_files, - restore_to_commit, search_code_introductions, stage_files, stash_apply, stash_drop, stash_pop, - stash_push, unstage_files, + open_repository_bundle, open_repository_file, pull, push, read_conflict, rebase_abort, + rebase_branch, rebase_continue, rename_branch, resolve_conflict, resolve_conflict_side, + restore_file_from_commit, restore_files, restore_to_commit, search_code_introductions, + stage_files, stash_apply, stash_drop, stash_pop, stash_push, unstage_files, }; fn main() { @@ -55,6 +55,9 @@ fn main() { restore_to_commit, restore_file_from_commit, merge_branch, + rebase_branch, + rebase_continue, + rebase_abort, list_repository_files, open_repository_bundle, list_file_history, diff --git a/src/App.svelte b/src/App.svelte index cec715e..6efd5f8 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -54,6 +54,9 @@ pull, push, renameBranch, + rebaseAbort, + rebaseBranch, + rebaseContinue, getRemoteUrl, credLoad, credSave, @@ -236,9 +239,12 @@ $: unstagedCount = status?.files.filter((f) => f.unstaged !== null).length ?? 0; $: conflictedFiles = changedFiles.filter((f) => f.staged === "conflicted" || f.unstaged === "conflicted"); $: hasConflicts = conflictedFiles.length > 0; - $: canCommit = hasRepository && stagedCount > 0 && commitMessage.trim().length > 0 && !hasConflicts && !isBusy; - $: commitBlockReason = hasConflicts - ? `${conflictedFiles.length} ${conflictedFiles.length === 1 ? "merge conflict must" : "merge conflicts must"} be resolved before committing.` + $: rebaseInProgress = status?.rebase_in_progress ?? false; + $: canCommit = hasRepository && stagedCount > 0 && commitMessage.trim().length > 0 && !hasConflicts && !rebaseInProgress && !isBusy; + $: commitBlockReason = rebaseInProgress + ? "A rebase is in progress. Resolve conflicts and use Rebase continue or abort the rebase." + : hasConflicts + ? `${conflictedFiles.length} ${conflictedFiles.length === 1 ? "conflict must" : "conflicts must"} be resolved before committing.` : ""; $: canCompare = hasRepository && compareFrom.length > 0 && compareTo.length > 0 && compareFrom !== compareTo && !isBusy; $: localBranches = branches.filter((b) => !b.remote); @@ -1120,6 +1126,46 @@ }); } + async function rebaseOnto(branch: GitBranchInfo) { + if (!activeRepoPath || branch.current || rebaseInProgress) return; + await runOperation(`Rebasing onto ${branch.name}`, async () => { + applyStatus(await rebaseBranch(activeRepoPath, branch.name)); + await refreshBranchList(activeRepoPath); + await refreshCommitHistory(activeRepoPath); + await refreshExplorerFiles(activeRepoPath); + await refreshFileHistory(activeRepoPath); + }); + } + + async function continueRebase() { + if (!activeRepoPath || !rebaseInProgress || hasConflicts) return; + await runOperation("Continuing rebase", async () => { + applyStatus(await rebaseContinue(activeRepoPath)); + await refreshBranchList(activeRepoPath); + await refreshCommitHistory(activeRepoPath); + await refreshExplorerFiles(activeRepoPath); + await refreshFileHistory(activeRepoPath); + }); + } + + async function abortRebase() { + if (!activeRepoPath || !rebaseInProgress) return; + const confirmed = window.confirm("Abort the current rebase and return to the previous state?"); + if (!confirmed) return; + + await runOperation("Aborting rebase", async () => { + applyStatus(await rebaseAbort(activeRepoPath)); + preparedResolutions = {}; + resolveDialogOpen = false; + conflict = null; + conflictTarget = ""; + await refreshBranchList(activeRepoPath); + await refreshCommitHistory(activeRepoPath); + await refreshExplorerFiles(activeRepoPath); + await refreshFileHistory(activeRepoPath); + }); + } + // Resolve the keychain key (host/org) for the active repo's remote. async function currentCredKey(): Promise { if (!activeRepoPath) return null; @@ -1519,7 +1565,11 @@ const message = commitMessage.trim(); if (!message || !activeRepoPath) return; if (hasConflicts) { - errorMessage = "Resolve all merge conflicts before committing."; + errorMessage = "Resolve all conflicts before committing."; + return; + } + if (rebaseInProgress) { + errorMessage = "A rebase is in progress. Use Rebase continue or abort the rebase."; return; } await runOperation("Committing", async () => { @@ -1939,14 +1989,33 @@ {/if} - {#if workspaceActive && hasConflicts} + {#if workspaceActive && hasConflicts && !rebaseInProgress} {/if} + {#if workspaceActive && rebaseInProgress} +
+
+ {/if} + {#if activeView === "management"}
@@ -2073,6 +2142,7 @@ {isBusy} onCheckout={checkout} onMerge={merge} + onRebase={rebaseOnto} onCreateBranch={createNewBranch} onRenameBranch={renameLocalBranch} onDeleteBranch={deleteLocalBranch} diff --git a/src/app.css b/src/app.css index a33b557..5bdb961 100644 --- a/src/app.css +++ b/src/app.css @@ -862,6 +862,8 @@ .notice.error { border-color: rgba(232,96,90,0.3); color: #f09090; background: rgba(232,96,90,0.08); } .notice.busy { border-color: rgba(90,140,248,0.28); color: #8ab0f8; background: rgba(90,140,248,0.07); } .notice.conflict { border-color: rgba(224,160,64,0.3); color: #e8b060; background: rgba(224,160,64,0.07); } + .notice.rebase { flex-wrap: wrap; border-color: rgba(186,130,255,0.3); color: #c9a8ff; background: rgba(186,130,255,0.075); } + .notice.rebase span { min-width: 0; flex: 1 1 auto; } .notice.conflict button { margin-left: auto; min-height: 26px; @@ -877,6 +879,20 @@ border-color: rgba(224,160,64,0.45); color: #f0c070; } + .notice.rebase button { + min-height: 26px; + padding: 0 10px; + border-color: rgba(186,130,255,0.28); + color: #d5bdff; + background: rgba(186,130,255,0.1); + font-size: 12px; + font-weight: 700; + } + .notice.rebase button:hover:not(:disabled) { + background: rgba(186,130,255,0.18); + border-color: rgba(186,130,255,0.45); + color: #eadfff; + } /* --- Update toast --- */ @@ -1110,6 +1126,10 @@ gap: 8px; } + .left-sidebar:has(.stash-panel.collapsed) { + grid-template-rows: minmax(170px, 0.85fr) auto minmax(220px, 1.15fr); + } + /* --- Main panel --- */ .main-panel { @@ -1276,6 +1296,36 @@ grid-template-rows: auto auto minmax(0, 1fr); } + .stash-panel.collapsed { + grid-template-rows: auto; + min-height: 0; + } + + .stash-head-actions { + display: inline-flex; + align-items: center; + gap: 6px; + } + + .stash-toggle { + display: inline-grid; + place-items: center; + width: 26px; + min-width: 26px; + min-height: 26px; + padding: 0; + border-color: rgba(94,110,156,0.18); + border-radius: 7px; + color: var(--color-ink-dim); + background: rgba(255,255,255,0.035); + } + + .stash-toggle:hover:not(:disabled) { + border-color: rgba(65,209,255,0.28); + color: var(--color-ink); + background: rgba(65,209,255,0.08); + } + .stash-create { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; @@ -1585,7 +1635,8 @@ .branch-info strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; color: var(--color-ink); } .branch-info span { display: block; margin-top: 2px; color: var(--color-ink-dim); font-size: 11px; } - .branch-actions { display: flex; align-items: center; justify-content: flex-end; gap: 5px; } + .branch-actions { display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: 5px; } + .branch-actions .btn-sm { min-height: 24px; padding: 0 6px; font-size: 11px; } .branch-context-menu, .explorer-context-menu { @@ -3687,6 +3738,7 @@ .history-resize-handle { display: none; } .shell-body { gap: 6px; } .left-sidebar { gap: 6px; grid-template-rows: minmax(150px, 0.7fr) minmax(145px, 0.55fr) minmax(190px, 1fr); } + .left-sidebar:has(.stash-panel.collapsed) { grid-template-rows: minmax(150px, 0.8fr) auto minmax(190px, 1.1fr); } .section-head { min-height: 40px; padding: 6px 10px; } .repo-summary { height: 40px; padding: 0 10px; } .repo-branch { max-width: 160px; } @@ -3704,6 +3756,7 @@ .workspace { grid-template-columns: 1fr; gap: 6px; } .history-aside { grid-template-columns: 1fr; grid-template-rows: minmax(200px, 1fr) minmax(150px, 0.5fr); min-height: 380px; } .left-sidebar { grid-template-rows: minmax(180px, 0.9fr) minmax(150px, 0.55fr) minmax(220px, 1fr); min-height: 560px; } + .left-sidebar:has(.stash-panel.collapsed) { grid-template-rows: minmax(180px, 1fr) auto minmax(220px, 1.1fr); } .top-section { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) 14px var(--commit-panel-height, 190px); } .repo-form { grid-template-columns: 1fr; } .repo-tabbar { grid-template-columns: minmax(0, 1fr) auto; } diff --git a/src/lib/components/BranchPanel.svelte b/src/lib/components/BranchPanel.svelte index c4663c6..6f841ea 100644 --- a/src/lib/components/BranchPanel.svelte +++ b/src/lib/components/BranchPanel.svelte @@ -49,6 +49,7 @@ isBusy: boolean; onCheckout: (branch: GitBranchInfo) => void; onMerge: (branch: GitBranchInfo) => void; + onRebase: (branch: GitBranchInfo) => void; onCreateBranch: (branchName: string) => void | Promise; onRenameBranch: (branch: GitBranchInfo) => void | Promise; onDeleteBranch: (branch: GitBranchInfo) => void | Promise; @@ -62,6 +63,7 @@ isBusy = false, onCheckout = () => {}, onMerge = () => {}, + onRebase = () => {}, onCreateBranch = () => {}, onRenameBranch = () => {}, onDeleteBranch = () => {}, @@ -370,6 +372,10 @@
{/if} @@ -446,6 +452,10 @@