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:
@@ -1040,6 +1040,63 @@ pub fn commit(path: String, message: String) -> Result<GitStatus, String> {
|
|||||||
status_for_repo(&repo)
|
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(¤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<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]
|
#[tauri::command]
|
||||||
pub fn pull(
|
pub fn pull(
|
||||||
path: String,
|
path: String,
|
||||||
|
|||||||
+16
-12
@@ -5,18 +5,19 @@ mod git;
|
|||||||
|
|
||||||
use badge::set_sync_badge;
|
use badge::set_sync_badge;
|
||||||
use git::{
|
use git::{
|
||||||
SearchCancellationState, apply_file_patch, cancel_code_search, cancel_file_history,
|
SearchCancellationState, amend_commit, apply_file_patch, cancel_code_search,
|
||||||
checkout_branch, cherry_pick_abort, cherry_pick_commit, cherry_pick_continue,
|
cancel_file_history, checkout_branch, cherry_pick_abort, cherry_pick_commit,
|
||||||
clone_repository, commit, commit_ai_generate, commit_ai_load, commit_ai_local_models,
|
cherry_pick_continue, clone_repository, commit, commit_ai_generate, commit_ai_load,
|
||||||
commit_ai_status, compare_commits, compare_file_to_head, compare_file_to_parent,
|
commit_ai_local_models, commit_ai_status, compare_commits, compare_file_to_head,
|
||||||
create_branch, create_tag, cred_delete, cred_load, cred_save, delete_branch, delete_tag,
|
compare_file_to_parent, create_branch, create_tag, cred_delete, cred_load, cred_save,
|
||||||
diff_file_against_working_tree, fetch, get_file_patch, get_remote_url, get_status,
|
delete_branch, delete_tag, diff_file_against_working_tree, fetch, get_file_patch,
|
||||||
list_branches, list_commits, list_file_history, list_repository_files, list_stashes,
|
get_remote_url, get_status, last_commit_message, list_branches, list_commits,
|
||||||
list_tags, merge_branch, open_repo_in_explorer, open_repository, open_repository_bundle,
|
list_file_history, list_repository_files, list_stashes, list_tags, merge_branch,
|
||||||
open_repository_file, pull, push, push_tag, read_conflict, rebase_abort, rebase_branch,
|
open_repo_in_explorer, open_repository, open_repository_bundle, open_repository_file, pull,
|
||||||
rebase_continue, rename_branch, resolve_conflict, resolve_conflict_side,
|
push, push_tag, read_conflict, rebase_abort, rebase_branch, rebase_continue, rename_branch,
|
||||||
restore_file_from_commit, restore_files, restore_to_commit, search_code_introductions,
|
resolve_conflict, resolve_conflict_side, restore_file_from_commit, restore_files,
|
||||||
stage_files, stash_apply, stash_drop, stash_pop, stash_push, unstage_files,
|
restore_to_commit, search_code_introductions, stage_files, stash_apply, stash_drop,
|
||||||
|
stash_pop, stash_push, undo_last_commit, unstage_files,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
@@ -54,6 +55,9 @@ fn main() {
|
|||||||
get_file_patch,
|
get_file_patch,
|
||||||
apply_file_patch,
|
apply_file_patch,
|
||||||
commit,
|
commit,
|
||||||
|
amend_commit,
|
||||||
|
undo_last_commit,
|
||||||
|
last_commit_message,
|
||||||
commit_ai_status,
|
commit_ai_status,
|
||||||
commit_ai_load,
|
commit_ai_load,
|
||||||
commit_ai_local_models,
|
commit_ai_local_models,
|
||||||
|
|||||||
+83
-2
@@ -28,6 +28,7 @@
|
|||||||
import UpdateToast from "./lib/components/UpdateToast.svelte";
|
import UpdateToast from "./lib/components/UpdateToast.svelte";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
amendCommit,
|
||||||
checkoutBranch,
|
checkoutBranch,
|
||||||
cherryPickAbort,
|
cherryPickAbort,
|
||||||
cherryPickCommit,
|
cherryPickCommit,
|
||||||
@@ -50,6 +51,7 @@
|
|||||||
compareFileToParent,
|
compareFileToParent,
|
||||||
fetchRemote,
|
fetchRemote,
|
||||||
getStatus,
|
getStatus,
|
||||||
|
lastCommitMessage,
|
||||||
listBranches,
|
listBranches,
|
||||||
listStashes,
|
listStashes,
|
||||||
listTags,
|
listTags,
|
||||||
@@ -85,6 +87,7 @@
|
|||||||
stashDrop,
|
stashDrop,
|
||||||
stashPop,
|
stashPop,
|
||||||
stashPush,
|
stashPush,
|
||||||
|
undoLastCommit,
|
||||||
unstageFiles,
|
unstageFiles,
|
||||||
} from "./lib/git";
|
} from "./lib/git";
|
||||||
|
|
||||||
@@ -182,6 +185,8 @@
|
|||||||
let activeFileHistoryRequestId = "";
|
let activeFileHistoryRequestId = "";
|
||||||
let lastFileHistoryHeadHash = "";
|
let lastFileHistoryHeadHash = "";
|
||||||
let commitMessage = "";
|
let commitMessage = "";
|
||||||
|
let amendMode = false;
|
||||||
|
let preAmendDraftMessage = "";
|
||||||
let lastLocalAiGeneratedMessage = "";
|
let lastLocalAiGeneratedMessage = "";
|
||||||
let commitAiPhase: CommitAiPhase = "idle";
|
let commitAiPhase: CommitAiPhase = "idle";
|
||||||
let commitAiGenerating = false;
|
let commitAiGenerating = false;
|
||||||
@@ -270,7 +275,18 @@
|
|||||||
$: hasConflicts = conflictedFiles.length > 0;
|
$: hasConflicts = conflictedFiles.length > 0;
|
||||||
$: rebaseInProgress = status?.rebase_in_progress ?? false;
|
$: rebaseInProgress = status?.rebase_in_progress ?? false;
|
||||||
$: cherryPickInProgress = status?.cherry_pick_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
|
$: commitBlockReason = rebaseInProgress
|
||||||
? "A rebase is in progress. Resolve conflicts and use Rebase continue or abort the rebase."
|
? "A rebase is in progress. Resolve conflicts and use Rebase continue or abort the rebase."
|
||||||
: cherryPickInProgress
|
: cherryPickInProgress
|
||||||
@@ -1871,7 +1887,8 @@
|
|||||||
|
|
||||||
async function commitChanges() {
|
async function commitChanges() {
|
||||||
const message = commitMessage.trim();
|
const message = commitMessage.trim();
|
||||||
if (!message || !activeRepoPath) return;
|
if (!activeRepoPath) return;
|
||||||
|
if (!amendMode && !message) return;
|
||||||
if (hasConflicts) {
|
if (hasConflicts) {
|
||||||
errorMessage = "Resolve all conflicts before committing.";
|
errorMessage = "Resolve all conflicts before committing.";
|
||||||
return;
|
return;
|
||||||
@@ -1884,6 +1901,22 @@
|
|||||||
errorMessage = "A cherry-pick is in progress. Use Cherry-pick continue or abort it.";
|
errorMessage = "A cherry-pick is in progress. Use Cherry-pick continue or abort it.";
|
||||||
return;
|
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 () => {
|
await runOperation("Committing", async () => {
|
||||||
applyStatus(await commit(activeRepoPath, message));
|
applyStatus(await commit(activeRepoPath, message));
|
||||||
commitMessage = "";
|
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 ─────────────────────────────────────────────────────────
|
// ── Commit restore ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function restoreCommit(target: GitCommit) {
|
async function restoreCommit(target: GitCommit) {
|
||||||
@@ -2575,10 +2652,14 @@
|
|||||||
commitAiProvider={aiSettings.provider}
|
commitAiProvider={aiSettings.provider}
|
||||||
{commitAiPhase}
|
{commitAiPhase}
|
||||||
{commitAiGenerating}
|
{commitAiGenerating}
|
||||||
|
{canAmend}
|
||||||
|
{amendMode}
|
||||||
onCommit={commitChanges}
|
onCommit={commitChanges}
|
||||||
onCommitMessageChange={updateCommitMessage}
|
onCommitMessageChange={updateCommitMessage}
|
||||||
onGenerateCommitMessage={generateCommitMessageWithAi}
|
onGenerateCommitMessage={generateCommitMessageWithAi}
|
||||||
onOpenAiSettings={() => { aiSettingsOpen = true; }}
|
onOpenAiSettings={() => { aiSettingsOpen = true; }}
|
||||||
|
onToggleAmend={toggleAmendMode}
|
||||||
|
onUndoLastCommit={undoLastCommitChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
+23
@@ -1807,6 +1807,29 @@
|
|||||||
resize: none;
|
resize: none;
|
||||||
overflow: auto;
|
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 {
|
.commit-actions-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<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";
|
import type { CommitAiPhase, CommitAiProvider } from "../types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -13,10 +13,14 @@
|
|||||||
commitAiProvider: CommitAiProvider;
|
commitAiProvider: CommitAiProvider;
|
||||||
commitAiPhase: CommitAiPhase;
|
commitAiPhase: CommitAiPhase;
|
||||||
commitAiGenerating: boolean;
|
commitAiGenerating: boolean;
|
||||||
|
canAmend: boolean;
|
||||||
|
amendMode: boolean;
|
||||||
onCommit: () => void;
|
onCommit: () => void;
|
||||||
onCommitMessageChange: (msg: string) => void;
|
onCommitMessageChange: (msg: string) => void;
|
||||||
onGenerateCommitMessage: () => void;
|
onGenerateCommitMessage: () => void;
|
||||||
onOpenAiSettings: () => void;
|
onOpenAiSettings: () => void;
|
||||||
|
onToggleAmend: (checked: boolean) => void;
|
||||||
|
onUndoLastCommit: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -30,10 +34,14 @@
|
|||||||
commitAiProvider = "local",
|
commitAiProvider = "local",
|
||||||
commitAiPhase = "idle",
|
commitAiPhase = "idle",
|
||||||
commitAiGenerating = false,
|
commitAiGenerating = false,
|
||||||
|
canAmend = false,
|
||||||
|
amendMode = false,
|
||||||
onCommit = () => {},
|
onCommit = () => {},
|
||||||
onCommitMessageChange = () => {},
|
onCommitMessageChange = () => {},
|
||||||
onGenerateCommitMessage = () => {},
|
onGenerateCommitMessage = () => {},
|
||||||
onOpenAiSettings = () => {},
|
onOpenAiSettings = () => {},
|
||||||
|
onToggleAmend = () => {},
|
||||||
|
onUndoLastCommit = () => {},
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
function handleSubmit(event: SubmitEvent) {
|
function handleSubmit(event: SubmitEvent) {
|
||||||
@@ -77,14 +85,37 @@
|
|||||||
{#if commitBlockReason}
|
{#if commitBlockReason}
|
||||||
<p class="commit-block-reason">{commitBlockReason}</p>
|
<p class="commit-block-reason">{commitBlockReason}</p>
|
||||||
{/if}
|
{/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">
|
<div class="commit-actions-row flex-shrink-0">
|
||||||
<button class="btn-primary flex-1" type="submit" disabled={!canCommit} title={commitBlockReason || "Commit staged changes"}>
|
<button class="btn-primary flex-1" type="submit" disabled={!canCommit} title={commitBlockReason || (amendMode ? "Amend the last commit" : "Commit staged changes")}>
|
||||||
{#if operation === "Committing"}
|
{#if operation === "Committing" || operation === "Amending"}
|
||||||
<LoaderCircle class="spin" size={16} aria-hidden="true" />
|
<LoaderCircle class="spin" size={16} aria-hidden="true" />
|
||||||
{:else}
|
{:else}
|
||||||
<Check size={16} aria-hidden="true" />
|
<Check size={16} aria-hidden="true" />
|
||||||
{/if}
|
{/if}
|
||||||
Commit
|
{amendMode ? "Amend" : "Commit"}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="btn-secondary commit-ai-button"
|
class="btn-secondary commit-ai-button"
|
||||||
|
|||||||
@@ -171,6 +171,18 @@ export function commit(path: string, message: string): Promise<GitStatus> {
|
|||||||
return invoke<GitStatus>("commit", { path, message });
|
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(
|
export function stashPush(
|
||||||
path: string,
|
path: string,
|
||||||
message?: string,
|
message?: string,
|
||||||
|
|||||||
Reference in New Issue
Block a user