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
This commit is contained in:
Christoph Brandau
2026-07-04 00:34:36 +02:00
parent 1d67312ee4
commit 3c4425a408
9 changed files with 273 additions and 18 deletions
+88
View File
@@ -47,6 +47,7 @@ pub struct GitStatus {
pub behind: u32,
pub files: Vec<GitFileStatus>,
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<GitStatus, String> {
Err(format!("Merge failed: {details}"))
}
#[tauri::command]
pub fn rebase_branch(path: String, branch: String) -> Result<GitStatus, String> {
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<GitStatus, String> {
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<GitStatus, String> {
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<GitStatus, String> {
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<u32>) -> Result<Vec<GitCommit>, String> {
let repo = resolve_repo(&path)?;
@@ -1953,9 +2020,30 @@ fn status_for_repo(repo: &Path) -> Result<GitStatus, String> {
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
+7 -4
View File
@@ -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,
+76 -6
View File
@@ -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<string | null> {
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 @@
</section>
{/if}
{#if workspaceActive && hasConflicts}
{#if workspaceActive && hasConflicts && !rebaseInProgress}
<section class="notice conflict" role="alert">
<GitMerge size={17} aria-hidden="true" />
<span>{conflictedFiles.length} {conflictedFiles.length === 1 ? "file has" : "files have"} merge conflicts.</span>
<span>{conflictedFiles.length} {conflictedFiles.length === 1 ? "file has" : "files have"} conflicts.</span>
<button type="button" onclick={openResolveDialog} disabled={isBusy}>Resolve conflicts</button>
</section>
{/if}
{#if workspaceActive && rebaseInProgress}
<section class="notice rebase" role="status">
<GitBranch size={17} aria-hidden="true" />
<span>
Rebase in progress.
{#if hasConflicts}
Resolve conflicts, then continue.
{:else}
Continue when the index is ready, or abort to return to the previous state.
{/if}
</span>
{#if hasConflicts}
<button type="button" onclick={openResolveDialog} disabled={isBusy}>Resolve conflicts</button>
{/if}
<button type="button" onclick={continueRebase} disabled={isBusy || hasConflicts}>Continue</button>
<button type="button" onclick={abortRebase} disabled={isBusy}>Abort</button>
</section>
{/if}
{#if activeView === "management"}
<section class="repo-management" aria-label="Repository Management">
<div class="repo-management-head">
@@ -2073,6 +2142,7 @@
{isBusy}
onCheckout={checkout}
onMerge={merge}
onRebase={rebaseOnto}
onCreateBranch={createNewBranch}
onRenameBranch={renameLocalBranch}
onDeleteBranch={deleteLocalBranch}
+54 -1
View File
@@ -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; }
+10
View File
@@ -49,6 +49,7 @@
isBusy: boolean;
onCheckout: (branch: GitBranchInfo) => void;
onMerge: (branch: GitBranchInfo) => void;
onRebase: (branch: GitBranchInfo) => void;
onCreateBranch: (branchName: string) => void | Promise<void>;
onRenameBranch: (branch: GitBranchInfo) => void | Promise<void>;
onDeleteBranch: (branch: GitBranchInfo) => void | Promise<void>;
@@ -62,6 +63,7 @@
isBusy = false,
onCheckout = () => {},
onMerge = () => {},
onRebase = () => {},
onCreateBranch = () => {},
onRenameBranch = () => {},
onDeleteBranch = () => {},
@@ -370,6 +372,10 @@
<GitMerge size={15} aria-hidden="true" />
Merge
</button>
<button class="btn-sm" type="button" onclick={() => onRebase(row.branch)} disabled={isBusy} title="Rebase current branch onto this branch">
<GitBranch size={15} aria-hidden="true" />
Rebase
</button>
</div>
{/if}
</article>
@@ -446,6 +452,10 @@
<GitMerge size={15} aria-hidden="true" />
Merge
</button>
<button class="btn-sm" type="button" onclick={() => onRebase(row.branch)} disabled={isBusy} title="Rebase current branch onto this branch">
<GitBranch size={15} aria-hidden="true" />
Rebase
</button>
</div>
{/if}
</article>
+3 -3
View File
@@ -261,13 +261,13 @@
style="grid-template-rows: auto minmax(0,1fr) auto;"
role="dialog"
aria-modal="true"
aria-label="Resolve merge conflicts"
aria-label="Resolve conflicts"
tabindex="-1"
>
<header class="dialog-header">
<div>
<span class="eyebrow">Resolve</span>
<h2 class="dialog-title">Merge conflicts</h2>
<h2 class="dialog-title">Conflicts</h2>
</div>
<button class="dialog-close" type="button" onclick={onClose} title="Close">
<X size={18} aria-hidden="true" />
@@ -275,7 +275,7 @@
</header>
{#if conflictedFiles.length === 0}
<div class="blank-state">All conflicts resolved. You can commit the merge now.</div>
<div class="blank-state">All conflicts resolved. Continue the current operation when ready.</div>
{:else}
<div class="dialog-body">
<aside class="dialog-files" aria-label="Conflicted files">
+22 -4
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { Archive, Download, Trash2, Upload } from "@lucide/svelte";
import { Archive, ChevronDown, ChevronRight, Download, Trash2, Upload } from "@lucide/svelte";
import type { GitStash } from "../types";
interface Props {
@@ -26,6 +26,7 @@
let message = $state("");
let includeUntracked = $state(true);
let open = $state(false);
function submitPush() {
onPush(message, includeUntracked);
@@ -37,16 +38,33 @@
}
</script>
<section class="panel stash-panel overflow-hidden" aria-label="Git stash">
<section class="panel stash-panel overflow-hidden" class:collapsed={!open} aria-label="Git stash">
<div class="section-head">
<div>
<span class="eyebrow">Stash</span>
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Shelved changes</h2>
</div>
<span class="pill pill-count">{stashes.length}</span>
<div class="stash-head-actions">
<button
class="stash-toggle"
type="button"
onclick={() => { open = !open; }}
aria-expanded={open}
title={open ? "Collapse stash panel" : "Expand stash panel"}
>
{#if open}
<ChevronDown size={14} aria-hidden="true" />
{:else}
<ChevronRight size={14} aria-hidden="true" />
{/if}
</button>
<span class="pill pill-count">{stashes.length}</span>
</div>
</div>
{#if !hasRepository}
{#if !open}
<!-- collapsed -->
{:else if !hasRepository}
<div class="blank-state">No repository loaded.</div>
{:else}
<div class="stash-create">
+12
View File
@@ -219,6 +219,18 @@ export function mergeBranch(path: string, branch: string): Promise<GitStatus> {
return invoke<GitStatus>("merge_branch", { path, branch });
}
export function rebaseBranch(path: string, branch: string): Promise<GitStatus> {
return invoke<GitStatus>("rebase_branch", { path, branch });
}
export function rebaseContinue(path: string): Promise<GitStatus> {
return invoke<GitStatus>("rebase_continue", { path });
}
export function rebaseAbort(path: string): Promise<GitStatus> {
return invoke<GitStatus>("rebase_abort", { path });
}
export function listRepositoryFiles(path: string): Promise<GitRepositoryFile[]> {
return invoke<GitRepositoryFile[]>("list_repository_files", { path });
}
+1
View File
@@ -41,6 +41,7 @@ export interface GitStatus {
behind: number;
files: GitFileStatus[];
clean: boolean;
rebase_in_progress: boolean;
}
export interface GitFileStatus {