feat(commit): add amend and undo last commit support

This change introduces new Tauri commands to amend the last commit with
an optional message, fetch the last commit message, and undo the most
recent commit safely. The UI now offers an amend toggle and an undo
button only when the last commit hasn’t been pushed upstream, reducing
the risk of rewriting shared history.

- Add amend/undo/last-message git commands in Rust
- Wire new operations into the Svelte commit panel UI
- Add styling and state handling for amend mode
This commit is contained in:
Christoph Brandau
2026-07-06 17:34:05 +02:00
parent 0bad722c7a
commit ce0cbc0786
6 changed files with 226 additions and 18 deletions
+57
View File
@@ -1040,6 +1040,63 @@ pub fn commit(path: String, message: String) -> Result<GitStatus, String> {
status_for_repo(&repo)
}
#[tauri::command]
pub fn amend_commit(path: String, message: Option<String>) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
if verify_commit(&repo, "HEAD").is_err() {
return Err("There is no commit to amend.".to_string());
}
let current_status = status_for_repo(&repo)?;
if has_unresolved_conflicts(&current_status) {
return Err("Merge conflicts must be resolved before you can commit.".to_string());
}
let message = message
.map(|message| message.trim().to_string())
.filter(|message| !message.is_empty());
match message {
Some(message) => {
run_git(&repo, ["commit", "--amend", "-m", message.as_str()])?;
}
None => {
run_git(&repo, ["commit", "--amend", "--no-edit"])?;
}
}
status_for_repo(&repo)
}
#[tauri::command]
pub fn last_commit_message(path: String) -> Result<Option<String>, String> {
let repo = resolve_repo(&path)?;
if verify_commit(&repo, "HEAD").is_err() {
return Ok(None);
}
let output = run_git(&repo, ["log", "-1", "--format=%B", "HEAD"])?;
let message = String::from_utf8_lossy(&output).trim_end().to_string();
Ok(if message.is_empty() { None } else { Some(message) })
}
#[tauri::command]
pub fn undo_last_commit(path: String) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
if verify_commit(&repo, "HEAD").is_err() {
return Err("There is no commit to undo.".to_string());
}
if verify_commit(&repo, "HEAD~1").is_err() {
return Err("This is the first commit; there is nothing to undo to.".to_string());
}
// Mixed reset: moves HEAD back one commit and unstages the difference, but
// leaves the working tree files untouched, so the undone commit's changes
// reappear as ordinary uncommitted changes instead of being discarded.
run_git(&repo, ["reset", "HEAD~1"])?;
status_for_repo(&repo)
}
#[tauri::command]
pub fn pull(
path: String,
+16 -12
View File
@@ -5,18 +5,19 @@ mod git;
use badge::set_sync_badge;
use git::{
SearchCancellationState, apply_file_patch, cancel_code_search, cancel_file_history,
checkout_branch, cherry_pick_abort, cherry_pick_commit, cherry_pick_continue,
clone_repository, commit, commit_ai_generate, commit_ai_load, commit_ai_local_models,
commit_ai_status, compare_commits, compare_file_to_head, compare_file_to_parent,
create_branch, create_tag, cred_delete, cred_load, cred_save, delete_branch, delete_tag,
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,
list_tags, merge_branch, open_repo_in_explorer, open_repository, open_repository_bundle,
open_repository_file, pull, push, push_tag, 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,
SearchCancellationState, amend_commit, apply_file_patch, cancel_code_search,
cancel_file_history, checkout_branch, cherry_pick_abort, cherry_pick_commit,
cherry_pick_continue, clone_repository, commit, commit_ai_generate, commit_ai_load,
commit_ai_local_models, commit_ai_status, compare_commits, compare_file_to_head,
compare_file_to_parent, create_branch, create_tag, cred_delete, cred_load, cred_save,
delete_branch, delete_tag, diff_file_against_working_tree, fetch, get_file_patch,
get_remote_url, get_status, last_commit_message, list_branches, list_commits,
list_file_history, list_repository_files, list_stashes, list_tags, merge_branch,
open_repo_in_explorer, open_repository, open_repository_bundle, open_repository_file, pull,
push, push_tag, 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, undo_last_commit, unstage_files,
};
fn main() {
@@ -54,6 +55,9 @@ fn main() {
get_file_patch,
apply_file_patch,
commit,
amend_commit,
undo_last_commit,
last_commit_message,
commit_ai_status,
commit_ai_load,
commit_ai_local_models,
+83 -2
View File
@@ -28,6 +28,7 @@
import UpdateToast from "./lib/components/UpdateToast.svelte";
import {
amendCommit,
checkoutBranch,
cherryPickAbort,
cherryPickCommit,
@@ -50,6 +51,7 @@
compareFileToParent,
fetchRemote,
getStatus,
lastCommitMessage,
listBranches,
listStashes,
listTags,
@@ -85,6 +87,7 @@
stashDrop,
stashPop,
stashPush,
undoLastCommit,
unstageFiles,
} from "./lib/git";
@@ -182,6 +185,8 @@
let activeFileHistoryRequestId = "";
let lastFileHistoryHeadHash = "";
let commitMessage = "";
let amendMode = false;
let preAmendDraftMessage = "";
let lastLocalAiGeneratedMessage = "";
let commitAiPhase: CommitAiPhase = "idle";
let commitAiGenerating = false;
@@ -270,7 +275,18 @@
$: hasConflicts = conflictedFiles.length > 0;
$: rebaseInProgress = status?.rebase_in_progress ?? false;
$: cherryPickInProgress = status?.cherry_pick_in_progress ?? false;
$: canCommit = hasRepository && stagedCount > 0 && commitMessage.trim().length > 0 && !hasConflicts && !rebaseInProgress && !cherryPickInProgress && !isBusy;
// Amending/undoing is only offered while the last commit hasn't reached a
// remote yet: no upstream at all, or the branch is still ahead of it.
$: canAmend = hasRepository && commits.length > 0 && !hasConflicts && !rebaseInProgress && !cherryPickInProgress
&& (!status?.upstream || (status?.ahead ?? 0) > 0);
// Safety net: if the last commit gets pushed elsewhere (or a conflict/rebase starts)
// while amend mode is active, drop out of it instead of leaving a stale, hidden toggle.
$: if (!canAmend && amendMode) {
amendMode = false;
commitMessage = preAmendDraftMessage;
preAmendDraftMessage = "";
}
$: canCommit = hasRepository && (amendMode || stagedCount > 0) && commitMessage.trim().length > 0 && !hasConflicts && !rebaseInProgress && !cherryPickInProgress && !isBusy;
$: commitBlockReason = rebaseInProgress
? "A rebase is in progress. Resolve conflicts and use Rebase continue or abort the rebase."
: cherryPickInProgress
@@ -1871,7 +1887,8 @@
async function commitChanges() {
const message = commitMessage.trim();
if (!message || !activeRepoPath) return;
if (!activeRepoPath) return;
if (!amendMode && !message) return;
if (hasConflicts) {
errorMessage = "Resolve all conflicts before committing.";
return;
@@ -1884,6 +1901,22 @@
errorMessage = "A cherry-pick is in progress. Use Cherry-pick continue or abort it.";
return;
}
if (amendMode) {
await runOperation("Amending", async () => {
applyStatus(await amendCommit(activeRepoPath, message));
commitMessage = "";
amendMode = false;
preAmendDraftMessage = "";
lastLocalAiGeneratedMessage = "";
await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
});
return;
}
await runOperation("Committing", async () => {
applyStatus(await commit(activeRepoPath, message));
commitMessage = "";
@@ -1895,6 +1928,50 @@
});
}
// Only offered when the last commit hasn't reached a remote yet (no upstream,
// or the branch is ahead of it) — amending/undoing a pushed commit rewrites
// history other clones already have, which needs a force-push to reconcile.
async function toggleAmendMode(checked: boolean) {
if (!activeRepoPath || isBusy) return;
if (!checked) {
amendMode = false;
commitMessage = preAmendDraftMessage;
preAmendDraftMessage = "";
return;
}
if (!canAmend) return;
try {
const message = await lastCommitMessage(activeRepoPath);
preAmendDraftMessage = commitMessage;
commitMessage = message ?? "";
amendMode = true;
} catch (error) {
errorMessage = errorToMessage(error);
}
}
async function undoLastCommitChange() {
if (!activeRepoPath || !canAmend || isBusy) return;
const confirmed = window.confirm(
"Undo the last commit?\n\nIts changes come back as uncommitted changes in the working tree — nothing is discarded.",
);
if (!confirmed) return;
await runOperation("Undoing last commit", async () => {
applyStatus(await undoLastCommit(activeRepoPath));
if (amendMode) {
amendMode = false;
commitMessage = preAmendDraftMessage;
preAmendDraftMessage = "";
}
await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
});
}
// ── Commit restore ─────────────────────────────────────────────────────────
async function restoreCommit(target: GitCommit) {
@@ -2575,10 +2652,14 @@
commitAiProvider={aiSettings.provider}
{commitAiPhase}
{commitAiGenerating}
{canAmend}
{amendMode}
onCommit={commitChanges}
onCommitMessageChange={updateCommitMessage}
onGenerateCommitMessage={generateCommitMessageWithAi}
onOpenAiSettings={() => { aiSettingsOpen = true; }}
onToggleAmend={toggleAmendMode}
onUndoLastCommit={undoLastCommitChange}
/>
</div>
</section>
+23
View File
@@ -1807,6 +1807,29 @@
resize: none;
overflow: auto;
}
.commit-amend-row {
display: flex;
align-items: center;
justify-content: space-between;
flex: 0 0 auto;
gap: 8px;
min-width: 0;
}
.commit-amend-toggle {
display: flex;
align-items: center;
gap: 6px;
color: var(--color-ink-dim);
font-size: 12px;
white-space: nowrap;
}
.commit-undo-button {
flex: 0 0 auto;
min-height: 26px;
padding: 0 8px;
font-size: 11px;
}
.commit-actions-row {
display: flex;
flex: 0 0 auto;
+35 -4
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { Check, LoaderCircle, Settings, Sparkles } from "@lucide/svelte";
import { Check, LoaderCircle, RotateCcw, Settings, Sparkles } from "@lucide/svelte";
import type { CommitAiPhase, CommitAiProvider } from "../types";
interface Props {
@@ -13,10 +13,14 @@
commitAiProvider: CommitAiProvider;
commitAiPhase: CommitAiPhase;
commitAiGenerating: boolean;
canAmend: boolean;
amendMode: boolean;
onCommit: () => void;
onCommitMessageChange: (msg: string) => void;
onGenerateCommitMessage: () => void;
onOpenAiSettings: () => void;
onToggleAmend: (checked: boolean) => void;
onUndoLastCommit: () => void;
}
let {
@@ -30,10 +34,14 @@
commitAiProvider = "local",
commitAiPhase = "idle",
commitAiGenerating = false,
canAmend = false,
amendMode = false,
onCommit = () => {},
onCommitMessageChange = () => {},
onGenerateCommitMessage = () => {},
onOpenAiSettings = () => {},
onToggleAmend = () => {},
onUndoLastCommit = () => {},
}: Props = $props();
function handleSubmit(event: SubmitEvent) {
@@ -77,14 +85,37 @@
{#if commitBlockReason}
<p class="commit-block-reason">{commitBlockReason}</p>
{/if}
{#if canAmend}
<div class="commit-amend-row">
<label class="commit-amend-toggle">
<input
type="checkbox"
checked={amendMode}
disabled={isBusy}
onchange={(e) => onToggleAmend((e.target as HTMLInputElement).checked)}
/>
Amend last commit
</label>
<button
class="btn-secondary commit-undo-button"
type="button"
onclick={onUndoLastCommit}
disabled={isBusy}
title="Undo the last commit — its changes come back as uncommitted changes (not pushed yet, so safe)"
>
<RotateCcw size={13} aria-hidden="true" />
Undo last commit
</button>
</div>
{/if}
<div class="commit-actions-row flex-shrink-0">
<button class="btn-primary flex-1" type="submit" disabled={!canCommit} title={commitBlockReason || "Commit staged changes"}>
{#if operation === "Committing"}
<button class="btn-primary flex-1" type="submit" disabled={!canCommit} title={commitBlockReason || (amendMode ? "Amend the last commit" : "Commit staged changes")}>
{#if operation === "Committing" || operation === "Amending"}
<LoaderCircle class="spin" size={16} aria-hidden="true" />
{:else}
<Check size={16} aria-hidden="true" />
{/if}
Commit
{amendMode ? "Amend" : "Commit"}
</button>
<button
class="btn-secondary commit-ai-button"
+12
View File
@@ -171,6 +171,18 @@ export function commit(path: string, message: string): Promise<GitStatus> {
return invoke<GitStatus>("commit", { path, message });
}
export function amendCommit(path: string, message?: string): Promise<GitStatus> {
return invoke<GitStatus>("amend_commit", { path, message: message?.trim() ? message.trim() : null });
}
export function lastCommitMessage(path: string): Promise<string | null> {
return invoke<string | null>("last_commit_message", { path });
}
export function undoLastCommit(path: string): Promise<GitStatus> {
return invoke<GitStatus>("undo_last_commit", { path });
}
export function stashPush(
path: string,
message?: string,