feat(git): add tag management and cherry-pick workflow
This update extends the Git backend and UI to support listing, creating, deleting, and pushing tags. It also adds cherry-pick commands with proper in-progress detection and conflict handling, and prevents other operations while a cherry-pick is active. - Add GitTag model, tag listing, and tag CRUD/push commands - Implement cherry-pick start/continue/abort with status tracking - Update UI to display tags and gate actions during cherry-pick
This commit is contained in:
+129
-4
@@ -2,7 +2,7 @@
|
||||
import { onDestroy, onMount, tick } from "svelte";
|
||||
import { open as openDialog } from "@tauri-apps/plugin-dialog";
|
||||
import { check, type DownloadEvent, type Update } from "@tauri-apps/plugin-updater";
|
||||
import { AlertCircle, BookOpen, Download, FolderOpen, GitBranch, GitMerge, LoaderCircle, Plus, Search, X } from "@lucide/svelte";
|
||||
import { AlertCircle, BookOpen, Cherry, Download, FolderOpen, GitBranch, GitMerge, LoaderCircle, Plus, Search, X } from "@lucide/svelte";
|
||||
|
||||
import TitleBar from "./lib/TitleBar.svelte";
|
||||
import AiSettingsDialog from "./lib/components/AiSettingsDialog.svelte";
|
||||
@@ -29,6 +29,9 @@
|
||||
|
||||
import {
|
||||
checkoutBranch,
|
||||
cherryPickAbort,
|
||||
cherryPickCommit,
|
||||
cherryPickContinue,
|
||||
cloneRepository,
|
||||
commit,
|
||||
commitAiGenerate,
|
||||
@@ -40,13 +43,16 @@
|
||||
cancelFileHistory,
|
||||
applyFilePatch,
|
||||
createBranch,
|
||||
createTag,
|
||||
deleteBranch,
|
||||
deleteTag,
|
||||
diffFileAgainstWorkingTree,
|
||||
compareFileToParent,
|
||||
fetchRemote,
|
||||
getStatus,
|
||||
listBranches,
|
||||
listStashes,
|
||||
listTags,
|
||||
listCommits,
|
||||
listFileHistory,
|
||||
listRepositoryFiles,
|
||||
@@ -56,6 +62,7 @@
|
||||
openRepositoryBundle,
|
||||
pull,
|
||||
push,
|
||||
pushTag,
|
||||
renameBranch,
|
||||
rebaseAbort,
|
||||
rebaseBranch,
|
||||
@@ -97,6 +104,7 @@
|
||||
GitSearchHit,
|
||||
GitStash,
|
||||
GitStatus,
|
||||
GitTag,
|
||||
LocalModelOption,
|
||||
PatchApplyAction,
|
||||
PreparedResolution,
|
||||
@@ -160,6 +168,7 @@
|
||||
let pendingClone: CloneRequest | null = null;
|
||||
let status: GitStatus | null = null;
|
||||
let branches: GitBranchInfo[] = [];
|
||||
let tags: GitTag[] = [];
|
||||
let stashes: GitStash[] = [];
|
||||
let commits: GitCommit[] = [];
|
||||
let repoFiles: GitRepositoryFile[] = [];
|
||||
@@ -260,9 +269,12 @@
|
||||
$: conflictedFiles = changedFiles.filter((f) => f.staged === "conflicted" || f.unstaged === "conflicted");
|
||||
$: hasConflicts = conflictedFiles.length > 0;
|
||||
$: rebaseInProgress = status?.rebase_in_progress ?? false;
|
||||
$: canCommit = hasRepository && stagedCount > 0 && commitMessage.trim().length > 0 && !hasConflicts && !rebaseInProgress && !isBusy;
|
||||
$: cherryPickInProgress = status?.cherry_pick_in_progress ?? false;
|
||||
$: canCommit = hasRepository && 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
|
||||
? "A cherry-pick is in progress. Resolve conflicts and use Cherry-pick continue or abort it."
|
||||
: hasConflicts
|
||||
? `${conflictedFiles.length} ${conflictedFiles.length === 1 ? "conflict must" : "conflicts must"} be resolved before committing.`
|
||||
: "";
|
||||
@@ -360,6 +372,7 @@
|
||||
const bundle = await openRepositoryBundle(activeRepoPath, 100);
|
||||
const previousHeadHash = lastFileHistoryHeadHash;
|
||||
await refreshBranchList(activeRepoPath, bundle.branches);
|
||||
await refreshTags(activeRepoPath, bundle.tags);
|
||||
await refreshStashes(activeRepoPath, bundle.stashes);
|
||||
await refreshCommitHistory(activeRepoPath, bundle.commits);
|
||||
await refreshExplorerFiles(activeRepoPath, bundle.files);
|
||||
@@ -941,6 +954,10 @@
|
||||
branches = prefetched ?? (await listBranches(path));
|
||||
}
|
||||
|
||||
async function refreshTags(path = activeRepoPath, prefetched?: GitTag[]) {
|
||||
tags = prefetched ?? (await listTags(path));
|
||||
}
|
||||
|
||||
async function refreshStashes(path = activeRepoPath, prefetched?: GitStash[]) {
|
||||
stashes = prefetched ?? (await listStashes(path));
|
||||
}
|
||||
@@ -1039,6 +1056,7 @@
|
||||
if (globalSearchBusy) void cancelGlobalSearch();
|
||||
activeView = "repository";
|
||||
await refreshBranchList(activeRepoPath, bundle.branches);
|
||||
await refreshTags(activeRepoPath, bundle.tags);
|
||||
await refreshStashes(activeRepoPath, bundle.stashes);
|
||||
await refreshCommitHistory(activeRepoPath, bundle.commits);
|
||||
await refreshExplorerFiles(activeRepoPath, bundle.files);
|
||||
@@ -1106,6 +1124,7 @@
|
||||
if (globalSearchBusy) void cancelGlobalSearch();
|
||||
activeView = "repository";
|
||||
await refreshBranchList(activeRepoPath, bundle.branches);
|
||||
await refreshTags(activeRepoPath, bundle.tags);
|
||||
await refreshStashes(activeRepoPath, bundle.stashes);
|
||||
await refreshCommitHistory(activeRepoPath, bundle.commits);
|
||||
await refreshExplorerFiles(activeRepoPath, bundle.files);
|
||||
@@ -1327,7 +1346,7 @@
|
||||
}
|
||||
|
||||
async function rebaseOnto(branch: GitBranchInfo) {
|
||||
if (!activeRepoPath || branch.current || rebaseInProgress) return;
|
||||
if (!activeRepoPath || branch.current || rebaseInProgress || cherryPickInProgress) return;
|
||||
await runOperation(`Rebasing onto ${branch.name}`, async () => {
|
||||
applyStatus(await rebaseBranch(activeRepoPath, branch.name));
|
||||
await refreshBranchList(activeRepoPath);
|
||||
@@ -1366,6 +1385,84 @@
|
||||
});
|
||||
}
|
||||
|
||||
async function createNewTag(name: string, message: string) {
|
||||
const trimmed = name.trim();
|
||||
if (!activeRepoPath || !trimmed) return;
|
||||
await runOperation(`Creating tag ${trimmed}`, async () => {
|
||||
await refreshTags(activeRepoPath, await createTag(activeRepoPath, trimmed, undefined, message));
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteLocalTag(tag: GitTag) {
|
||||
if (!activeRepoPath || isBusy) return;
|
||||
const confirmed = window.confirm(`Delete tag '${tag.name}'?\n\nThis only removes the local tag, not any copy already pushed to a remote.`);
|
||||
if (!confirmed) return;
|
||||
|
||||
await runOperation(`Deleting tag ${tag.name}`, async () => {
|
||||
await refreshTags(activeRepoPath, await deleteTag(activeRepoPath, tag.name));
|
||||
});
|
||||
}
|
||||
|
||||
async function pushLocalTag(tag: GitTag) {
|
||||
if (!activeRepoPath || isBusy) return;
|
||||
const key = await currentCredKey();
|
||||
const stored = await loadStoredCredential(key);
|
||||
const credential = stored && !isCredentialExpired(stored) ? stored : null;
|
||||
|
||||
await runOperation(`Pushing tag ${tag.name}`, async () => {
|
||||
try {
|
||||
await pushTag(activeRepoPath, tag.name, credential?.username, credential?.password);
|
||||
} catch (error) {
|
||||
const message = errorToMessage(error);
|
||||
throw new Error(
|
||||
isAuthError(message)
|
||||
? `Sign in via the Push button first, then retry pushing tag '${tag.name}'.`
|
||||
: stripAuthPrefix(message),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function cherryPickFromCommit(commit: GitCommit) {
|
||||
if (!activeRepoPath || rebaseInProgress || cherryPickInProgress) return;
|
||||
await runOperation(`Cherry-picking ${commit.short_hash}`, async () => {
|
||||
applyStatus(await cherryPickCommit(activeRepoPath, commit.hash));
|
||||
await refreshBranchList(activeRepoPath);
|
||||
await refreshCommitHistory(activeRepoPath);
|
||||
await refreshExplorerFiles(activeRepoPath);
|
||||
await refreshFileHistory(activeRepoPath);
|
||||
});
|
||||
}
|
||||
|
||||
async function continueCherryPick() {
|
||||
if (!activeRepoPath || !cherryPickInProgress || hasConflicts) return;
|
||||
await runOperation("Continuing cherry-pick", async () => {
|
||||
applyStatus(await cherryPickContinue(activeRepoPath));
|
||||
await refreshBranchList(activeRepoPath);
|
||||
await refreshCommitHistory(activeRepoPath);
|
||||
await refreshExplorerFiles(activeRepoPath);
|
||||
await refreshFileHistory(activeRepoPath);
|
||||
});
|
||||
}
|
||||
|
||||
async function abortCherryPick() {
|
||||
if (!activeRepoPath || !cherryPickInProgress) return;
|
||||
const confirmed = window.confirm("Abort the current cherry-pick and return to the previous state?");
|
||||
if (!confirmed) return;
|
||||
|
||||
await runOperation("Aborting cherry-pick", async () => {
|
||||
applyStatus(await cherryPickAbort(activeRepoPath));
|
||||
preparedResolutions = {};
|
||||
resolveDialogOpen = false;
|
||||
conflict = null;
|
||||
conflictTarget = "";
|
||||
await refreshBranchList(activeRepoPath);
|
||||
await refreshCommitHistory(activeRepoPath);
|
||||
await refreshExplorerFiles(activeRepoPath);
|
||||
await refreshFileHistory(activeRepoPath);
|
||||
});
|
||||
}
|
||||
|
||||
// Resolve the keychain key (host/org) for the active repo's remote.
|
||||
async function currentCredKey(): Promise<string | null> {
|
||||
if (!activeRepoPath) return null;
|
||||
@@ -1783,6 +1880,10 @@
|
||||
errorMessage = "A rebase is in progress. Use Rebase continue or abort the rebase.";
|
||||
return;
|
||||
}
|
||||
if (cherryPickInProgress) {
|
||||
errorMessage = "A cherry-pick is in progress. Use Cherry-pick continue or abort it.";
|
||||
return;
|
||||
}
|
||||
await runOperation("Committing", async () => {
|
||||
applyStatus(await commit(activeRepoPath, message));
|
||||
commitMessage = "";
|
||||
@@ -2201,7 +2302,7 @@
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if workspaceActive && hasConflicts && !rebaseInProgress}
|
||||
{#if workspaceActive && hasConflicts && !rebaseInProgress && !cherryPickInProgress}
|
||||
<section class="notice conflict" role="alert">
|
||||
<GitMerge size={17} aria-hidden="true" />
|
||||
<span>{conflictedFiles.length} {conflictedFiles.length === 1 ? "file has" : "files have"} conflicts.</span>
|
||||
@@ -2228,6 +2329,25 @@
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if workspaceActive && cherryPickInProgress}
|
||||
<section class="notice rebase" role="status">
|
||||
<Cherry size={17} aria-hidden="true" />
|
||||
<span>
|
||||
Cherry-pick in progress.
|
||||
{#if hasConflicts}
|
||||
Resolve conflicts, then continue.
|
||||
{:else}
|
||||
Continue when the index is ready, or abort to return to the previous state.
|
||||
{/if}
|
||||
</span>
|
||||
{#if hasConflicts}
|
||||
<button type="button" onclick={openResolveDialog} disabled={isBusy}>Resolve conflicts</button>
|
||||
{/if}
|
||||
<button type="button" onclick={continueCherryPick} disabled={isBusy || hasConflicts}>Continue</button>
|
||||
<button type="button" onclick={abortCherryPick} disabled={isBusy}>Abort</button>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if activeView === "management"}
|
||||
<section class="repo-management" aria-label="Repository Management">
|
||||
<div class="repo-management-head">
|
||||
@@ -2354,6 +2474,7 @@
|
||||
{branches}
|
||||
{localBranches}
|
||||
{remoteBranches}
|
||||
{tags}
|
||||
{hasRepository}
|
||||
{isBusy}
|
||||
onCheckout={checkout}
|
||||
@@ -2362,6 +2483,9 @@
|
||||
onCreateBranch={createNewBranch}
|
||||
onRenameBranch={renameLocalBranch}
|
||||
onDeleteBranch={deleteLocalBranch}
|
||||
onCreateTag={createNewTag}
|
||||
onDeleteTag={deleteLocalTag}
|
||||
onPushTag={pushLocalTag}
|
||||
/>
|
||||
<StashPanel
|
||||
{stashes}
|
||||
@@ -2490,6 +2614,7 @@
|
||||
onRestoreCommit={restoreCommit}
|
||||
onPreviewCommitFile={previewCommitFileFromHistory}
|
||||
onCreateBranchFromCommit={openNewBranchDialog}
|
||||
onCherryPickCommit={cherryPickFromCommit}
|
||||
onToggleCommitFiles={(hash) => {
|
||||
const next = new Set(expandedCommitHashes);
|
||||
if (next.has(hash)) next.delete(hash); else next.add(hash);
|
||||
|
||||
Reference in New Issue
Block a user