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
+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>