Features/extend git features #20
@@ -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