From ce0cbc07861be873d1c87c18f628ced0cf3acf4f Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Mon, 6 Jul 2026 17:34:05 +0200 Subject: [PATCH] feat(commit): add amend and undo last commit support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src-tauri/src/git.rs | 57 ++++++++++++++++++ src-tauri/src/main.rs | 28 +++++---- src/App.svelte | 85 ++++++++++++++++++++++++++- src/app.css | 23 ++++++++ src/lib/components/CommitPanel.svelte | 39 ++++++++++-- src/lib/git.ts | 12 ++++ 6 files changed, 226 insertions(+), 18 deletions(-) diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index 0780ea0..e0a376e 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -1040,6 +1040,63 @@ pub fn commit(path: String, message: String) -> Result { status_for_repo(&repo) } +#[tauri::command] +pub fn amend_commit(path: String, message: Option) -> Result { + 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(¤t_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, 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 { + 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, diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 87cf019..5c77c94 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -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, diff --git a/src/App.svelte b/src/App.svelte index 5adf12b..1006618 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -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} /> diff --git a/src/app.css b/src/app.css index dcd03f2..98d7555 100644 --- a/src/app.css +++ b/src/app.css @@ -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; diff --git a/src/lib/components/CommitPanel.svelte b/src/lib/components/CommitPanel.svelte index e6c4777..c7c4bed 100644 --- a/src/lib/components/CommitPanel.svelte +++ b/src/lib/components/CommitPanel.svelte @@ -1,5 +1,5 @@