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:
Christoph Brandau
2026-07-06 15:05:12 +02:00
parent 3e2885f64d
commit 0bad722c7a
9 changed files with 673 additions and 19 deletions
+129 -4
View File
@@ -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);
+6
View File
@@ -1550,6 +1550,12 @@
font-size: 10px;
}
.tag-group-head { display: flex; align-items: center; gap: 6px; }
.tag-group-head .branch-group-toggle { flex: 1; min-width: 0; }
.tag-group-head .branch-create-toggle { flex-shrink: 0; }
.tag-create-form { grid-template-columns: auto minmax(0, 1fr) minmax(0, 1fr) auto auto; }
.branch-empty {
padding: 8px 10px;
color: var(--color-ink-faint);
+183 -5
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { Check, ChevronDown, ChevronRight, Folder, FolderOpen, GitBranch, GitMerge, Pencil, Plus, Trash2, X } from "@lucide/svelte";
import type { GitBranch as GitBranchInfo } from "../types";
import { Check, ChevronDown, ChevronRight, Folder, FolderOpen, GitBranch, GitMerge, Pencil, Plus, Tag as TagIcon, Trash2, Upload, X } from "@lucide/svelte";
import type { GitBranch as GitBranchInfo, GitTag } from "../types";
type BranchTreeNode = BranchFolderNode | BranchLeafNode;
@@ -45,6 +45,7 @@
branches: GitBranchInfo[];
localBranches: GitBranchInfo[];
remoteBranches: GitBranchInfo[];
tags: GitTag[];
hasRepository: boolean;
isBusy: boolean;
onCheckout: (branch: GitBranchInfo) => void;
@@ -53,12 +54,16 @@
onCreateBranch: (branchName: string) => void | Promise<void>;
onRenameBranch: (branch: GitBranchInfo) => void | Promise<void>;
onDeleteBranch: (branch: GitBranchInfo) => void | Promise<void>;
onCreateTag: (name: string, message: string) => void | Promise<void>;
onDeleteTag: (tag: GitTag) => void | Promise<void>;
onPushTag: (tag: GitTag) => void | Promise<void>;
}
let {
branches = [],
localBranches = [],
remoteBranches = [],
tags = [],
hasRepository = false,
isBusy = false,
onCheckout = () => {},
@@ -67,17 +72,28 @@
onCreateBranch = () => {},
onRenameBranch = () => {},
onDeleteBranch = () => {},
onCreateTag = () => {},
onDeleteTag = () => {},
onPushTag = () => {},
}: Props = $props();
let localOpen = $state(true);
let remoteOpen = $state(false);
let tagsOpen = $state(false);
let createOpen = $state(false);
let newBranchName = $state("");
let createInput = $state<HTMLInputElement | null>(null);
let tagCreateOpen = $state(false);
let newTagName = $state("");
let newTagMessage = $state("");
let tagCreateInput = $state<HTMLInputElement | null>(null);
let panelElement = $state<HTMLElement | null>(null);
let contextBranch = $state<GitBranchInfo | null>(null);
let contextMenuX = $state(0);
let contextMenuY = $state(0);
let contextTag = $state<GitTag | null>(null);
let tagContextMenuX = $state(0);
let tagContextMenuY = $state(0);
let collapsedBranchFolders = $state<Set<string>>(new Set());
let localBranchRows = $derived(buildBranchRows("local", localBranches, "local"));
@@ -270,12 +286,74 @@
await onRebase(branch);
}
function openTagCreateForm() {
if (!hasRepository || isBusy) return;
tagCreateOpen = true;
queueMicrotask(() => tagCreateInput?.focus());
}
function closeTagCreateForm() {
tagCreateOpen = false;
newTagName = "";
newTagMessage = "";
}
async function submitCreateTag(event: SubmitEvent) {
event.preventDefault();
const name = newTagName.trim();
if (!name || !hasRepository || isBusy) return;
await onCreateTag(name, newTagMessage.trim());
newTagName = "";
newTagMessage = "";
tagCreateOpen = false;
tagsOpen = true;
}
function openTagContextMenu(event: MouseEvent, tag: GitTag) {
event.preventDefault();
event.stopPropagation();
if (isBusy) return;
const rect = panelElement?.getBoundingClientRect();
const rawX = rect ? event.clientX - rect.left : event.offsetX;
const rawY = rect ? event.clientY - rect.top : event.offsetY;
const maxX = Math.max(8, (rect?.width ?? window.innerWidth) - 192);
const maxY = Math.max(8, (rect?.height ?? window.innerHeight) - 130);
contextTag = tag;
tagContextMenuX = Math.max(8, Math.min(rawX, maxX));
tagContextMenuY = Math.max(8, Math.min(rawY, maxY));
}
function closeTagContextMenu() {
contextTag = null;
}
async function pushContextTag() {
const tag = contextTag;
if (!tag || isBusy) return;
closeTagContextMenu();
await onPushTag(tag);
}
async function deleteContextTag() {
const tag = contextTag;
if (!tag || isBusy) return;
closeTagContextMenu();
await onDeleteTag(tag);
}
function closeAllContextMenus() {
closeBranchContextMenu();
closeTagContextMenu();
}
function handleWindowKeydown(event: KeyboardEvent) {
if (event.key === "Escape") closeBranchContextMenu();
if (event.key === "Escape") closeAllContextMenus();
}
</script>
<svelte:window on:click={closeBranchContextMenu} on:keydown={handleWindowKeydown} on:contextmenu|capture={closeBranchContextMenu} />
<svelte:window on:click={closeAllContextMenus} on:keydown={handleWindowKeydown} on:contextmenu|capture={closeAllContextMenus} />
<section bind:this={panelElement} class="panel branch-panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Branches">
<div class="section-head">
@@ -300,7 +378,7 @@
{#if !hasRepository}
<p class="blank-state">Open a repository to list branches.</p>
{:else if branches.length === 0}
{:else if branches.length === 0 && tags.length === 0}
<p class="blank-state">No branches returned.</p>
{:else}
<div class="branch-list overflow-auto p-2 flex flex-col gap-0">
@@ -458,6 +536,86 @@
{/if}
{/if}
</div>
<div class="branch-group">
<div class="tag-group-head">
<button
class="branch-group-toggle"
type="button"
onclick={() => { tagsOpen = !tagsOpen; }}
aria-expanded={tagsOpen}
>
{#if tagsOpen}
<ChevronDown size={14} aria-hidden="true" />
{:else}
<ChevronRight size={14} aria-hidden="true" />
{/if}
<span>Tags</span>
<span class="branch-group-count">{tags.length}</span>
</button>
<button
class="branch-create-toggle"
type="button"
onclick={openTagCreateForm}
disabled={!hasRepository || isBusy}
title="Create new tag"
aria-label="Create new tag"
>
<Plus size={13} aria-hidden="true" />
</button>
</div>
{#if tagCreateOpen}
<form class="branch-create-form tag-create-form" onsubmit={submitCreateTag}>
<TagIcon size={15} aria-hidden="true" />
<input
bind:this={tagCreateInput}
bind:value={newTagName}
disabled={isBusy}
autocomplete="off"
spellcheck="false"
placeholder="v1.0.0"
aria-label="New tag name"
/>
<input
bind:value={newTagMessage}
disabled={isBusy}
autocomplete="off"
spellcheck="false"
placeholder="Message (optional)"
aria-label="Tag message"
/>
<button class="branch-create-action confirm" type="submit" disabled={isBusy || newTagName.trim().length === 0} title="Create tag">
<Check size={14} aria-hidden="true" />
</button>
<button class="branch-create-action" type="button" onclick={closeTagCreateForm} disabled={isBusy} title="Cancel">
<X size={14} aria-hidden="true" />
</button>
</form>
{/if}
{#if tagsOpen}
{#if tags.length === 0}
<div class="branch-empty">No tags.</div>
{:else}
{#each tags as tag (tag.name)}
<article
class="branch-row"
oncontextmenu={(event) => openTagContextMenu(event, tag)}
title={tag.message ?? tag.name}
>
<div class="branch-info">
<TagIcon size={16} aria-hidden="true" />
<div>
<strong>{tag.name}</strong>
<span>{tag.short_hash}</span>
</div>
</div>
</article>
{/each}
{/if}
{/if}
</div>
</div>
{/if}
@@ -499,4 +657,24 @@
</button>
</div>
{/if}
{#if contextTag}
<div
class="branch-context-menu"
style={`left: ${tagContextMenuX}px; top: ${tagContextMenuY}px;`}
role="menu"
tabindex="-1"
aria-label={`Actions for ${contextTag.name}`}
>
<button type="button" role="menuitem" onclick={pushContextTag} disabled={isBusy}>
<Upload size={14} aria-hidden="true" />
Push to remote
</button>
<div class="menu-separator" role="separator"></div>
<button class="danger" type="button" role="menuitem" onclick={deleteContextTag} disabled={isBusy} title="Delete local tag">
<Trash2 size={14} aria-hidden="true" />
Delete
</button>
</div>
{/if}
</section>
+20 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { ChevronDown, ChevronRight, EllipsisVertical, GitBranch, GitMerge, RotateCcw, X } from "@lucide/svelte";
import { Cherry, ChevronDown, ChevronRight, EllipsisVertical, GitBranch, GitMerge, RotateCcw, X } from "@lucide/svelte";
import type { FileStatusKind, GitCommit, GitCommitFile } from "../types";
interface GraphSegment {
@@ -40,6 +40,7 @@
onPreviewCommitFile: (commit: GitCommit, file: GitCommitFile) => void;
onToggleCommitFiles: (hash: string) => void;
onCreateBranchFromCommit: (commit: GitCommit) => void;
onCherryPickCommit: (commit: GitCommit) => void;
}
let {
@@ -54,6 +55,7 @@
onPreviewCommitFile = () => {},
onToggleCommitFiles = () => {},
onCreateBranchFromCommit = () => {},
onCherryPickCommit = () => {},
}: Props = $props();
let hiddenGraphBranches = $state<Set<string>>(new Set());
@@ -380,6 +382,13 @@
await onRestoreCommit(commit);
}
async function cherryPickContextCommit() {
const commit = contextCommit;
if (!commit || isBusy) return;
closeCommitContextMenu();
await onCherryPickCommit(commit);
}
function handleWindowKeydown(event: KeyboardEvent) {
if (event.key !== "Escape") return;
closeCommitContextMenu();
@@ -656,6 +665,16 @@
<RotateCcw size={14} aria-hidden="true" />
Restore
</button>
<button
type="button"
role="menuitem"
onclick={cherryPickContextCommit}
disabled={isBusy}
title="Apply this commit's changes on top of the current branch"
>
<Cherry size={14} aria-hidden="true" />
Cherry-pick
</button>
</div>
{/if}
</section>
+44
View File
@@ -12,6 +12,7 @@ import type {
GitSearchHit,
GitStash,
GitStatus,
GitTag,
LocalModelOption,
PatchApplyAction,
RepositoryBundle,
@@ -94,6 +95,49 @@ export function deleteBranch(path: string, branch: string, force = false): Promi
return invoke<GitStatus>("delete_branch", { path, branch, force });
}
export function listTags(path: string): Promise<GitTag[]> {
return invoke<GitTag[]>("list_tags", { path });
}
export function createTag(
path: string,
name: string,
target?: string,
message?: string,
): Promise<GitTag[]> {
return invoke<GitTag[]>("create_tag", {
path,
name,
target: target?.trim() ? target.trim() : null,
message: message?.trim() ? message.trim() : null,
});
}
export function deleteTag(path: string, name: string): Promise<GitTag[]> {
return invoke<GitTag[]>("delete_tag", { path, name });
}
export function pushTag(
path: string,
name: string,
username?: string,
password?: string,
): Promise<void> {
return invoke<void>("push_tag", { path, name, username: username ?? null, password: password ?? null });
}
export function cherryPickCommit(path: string, commit: string): Promise<GitStatus> {
return invoke<GitStatus>("cherry_pick_commit", { path, commit });
}
export function cherryPickContinue(path: string): Promise<GitStatus> {
return invoke<GitStatus>("cherry_pick_continue", { path });
}
export function cherryPickAbort(path: string): Promise<GitStatus> {
return invoke<GitStatus>("cherry_pick_abort", { path });
}
export function stageFiles(path: string, files: string[]): Promise<GitStatus> {
return invoke<GitStatus>("stage_files", { path, files });
}
+11
View File
@@ -42,6 +42,7 @@ export interface GitStatus {
files: GitFileStatus[];
clean: boolean;
rebase_in_progress: boolean;
cherry_pick_in_progress: boolean;
}
export interface GitFileStatus {
@@ -59,6 +60,15 @@ export interface GitBranch {
remote: boolean;
}
export interface GitTag {
name: string;
hash: string;
short_hash: string;
message: string | null;
date: string;
annotated: boolean;
}
export interface GitStash {
selector: string;
index: number;
@@ -95,6 +105,7 @@ export interface GitRepositoryFile {
export interface RepositoryBundle {
status: GitStatus;
branches: GitBranch[];
tags: GitTag[];
stashes: GitStash[];
commits: GitCommit[];
files: GitRepositoryFile[];