Features/extend git features #20

Merged
Christoph merged 6 commits from features/extend_git_features into main 2026-07-06 19:07:49 +00:00
6 changed files with 226 additions and 18 deletions
Showing only changes of commit ce0cbc0786 - Show all commits
+57
View File
@@ -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(&current_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
View File
@@ -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
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>
+23
View File
@@ -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;
+35 -4
View File
@@ -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"
+12
View File
@@ -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,