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