Features/extend git features #20
@@ -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(¤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]
|
||||
pub fn pull(
|
||||
path: String,
|
||||
|
||||
+16
-12
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user