feat(git): Add comprehensive worktree management capabilities

This update introduces full support for Git worktrees, allowing users to manage multiple isolated working copies within a single repository. This includes new functionality to list, add, remove, move, lock, and repair worktrees, significantly enhancing the repository's capability to handle parallel development streams.

- Added `GitWorktree` structure definition across API contracts and Rust backend
- Implemented full CRUD operations for worktrees in Tauri commands
- Updated UI components (App.svelte, BranchPanel.svelte) to expose worktree management dialog
This commit is contained in:
Christoph Brandau
2026-07-26 21:47:35 +02:00
parent afa85b97aa
commit 38b7f1a536
9 changed files with 1406 additions and 14 deletions
+184 -1
View File
@@ -37,10 +37,12 @@
import StatusPanel from "./lib/components/StatusPanel.svelte";
import SyncSettingsDialog from "./lib/components/SyncSettingsDialog.svelte";
import UpdateToast from "./lib/components/UpdateToast.svelte";
import WorktreeDialog from "./lib/components/WorktreeDialog.svelte";
import {
amendCommit,
addRemote,
addWorktree,
checkoutBranch,
cherryPickAbort,
cherryPickCommit,
@@ -72,6 +74,7 @@
listRemotes,
listStashes,
listTags,
listWorktrees,
listCommits,
listFileHistory,
listInteractiveRebaseCommits,
@@ -83,10 +86,15 @@
openRepoInExplorer,
openRepositoryFile,
openRepositoryBundle,
lockWorktree,
moveWorktree,
pruneWorktrees,
pull,
push,
pushTag,
removeRemote,
removeWorktree,
repairWorktree,
revertCommit,
setBranchUpstream,
updateRemote,
@@ -115,6 +123,7 @@
stashPop,
stashPush,
undoLastCommit,
unlockWorktree,
unstageFiles,
} from "./lib/git";
@@ -142,6 +151,7 @@
GitStash,
GitStatus,
GitTag,
GitWorktree,
LocalModelOption,
PatchApplyAction,
PreparedResolution,
@@ -304,6 +314,11 @@
let renameBranchTarget: GitBranchInfo | null = null;
let deleteBranchTarget: GitBranchInfo | null = null;
let deleteBranchForce = false;
let worktreeDialogOpen = false;
let worktreeInitialBranch = "";
let worktrees: GitWorktree[] = [];
let worktreesLoading = false;
let worktreeError = "";
let compareSelectOpen = false;
let compareDialogOpen = false;
let interactiveRebaseOpen = false;
@@ -719,7 +734,7 @@
}
async function autoRefreshTick() {
if (!autoRefreshEnabled || activeView !== "repository" || !activeRepoPath || isBusy || autoRefreshInFlight || resolveDialogOpen || compareDialogOpen || compareSelectOpen || interactiveRebaseOpen || reflogOpen || newBranchCommit || globalSearchOpen || helpOpen) return;
if (!autoRefreshEnabled || activeView !== "repository" || !activeRepoPath || isBusy || autoRefreshInFlight || resolveDialogOpen || compareDialogOpen || compareSelectOpen || interactiveRebaseOpen || reflogOpen || worktreeDialogOpen || newBranchCommit || globalSearchOpen || helpOpen) return;
const path = activeRepoPath;
autoRefreshInFlight = true;
try {
@@ -1760,6 +1775,10 @@
globalSearchResults = [];
deleteBranchTarget = null;
deleteBranchForce = false;
worktreeDialogOpen = false;
worktreeInitialBranch = "";
worktrees = [];
worktreeError = "";
globalSearchOpen = false;
globalSearchError = "";
resolveDialogOpen = false;
@@ -2369,6 +2388,146 @@
deleteBranchForce = false;
}
async function openWorktreeDialog(branch = "") {
if (!activeRepoPath || isBusy) return;
worktreeInitialBranch = branch;
worktreeDialogOpen = true;
worktreeError = "";
worktreesLoading = true;
try {
worktrees = await listWorktrees(activeRepoPath);
trackEvent("worktree_dialog_opened", { linked_worktrees: Math.max(0, worktrees.length - 1) });
} catch (error) {
worktreeError = errorToMessage(error);
} finally {
worktreesLoading = false;
}
}
function openBranchInWorktree(branch: GitBranchInfo) {
if (branch.remote) return;
void openWorktreeDialog(branch.name);
}
async function refreshWorktrees() {
if (!activeRepoPath || worktreesLoading) return;
worktreesLoading = true;
worktreeError = "";
try {
worktrees = await listWorktrees(activeRepoPath);
} catch (error) {
worktreeError = errorToMessage(error);
} finally {
worktreesLoading = false;
}
}
async function runWorktreeOperation(
label: string,
task: () => Promise<GitWorktree[]>,
eventName: string,
): Promise<boolean> {
if (!activeRepoPath || isBusy) return false;
operation = label;
worktreeError = "";
try {
worktrees = await task();
await refreshBranchList(activeRepoPath);
trackEvent(eventName, { linked_worktrees: Math.max(0, worktrees.length - 1) });
return true;
} catch (error) {
worktreeError = errorToMessage(error);
return false;
} finally {
operation = "";
}
}
async function createWorktree(request: {
worktreePath: string;
branch?: string;
newBranch?: string;
startPoint?: string;
detached?: boolean;
lock?: boolean;
}): Promise<boolean> {
return runWorktreeOperation(
"Creating worktree",
() => addWorktree(activeRepoPath, request.worktreePath, request),
"worktree_created",
);
}
async function openWorktreeTab(worktree: GitWorktree) {
if (worktree.missing || worktree.bare || isBusy) return;
worktreeDialogOpen = false;
worktreeInitialBranch = "";
await openRepo(worktree.path);
}
async function removeSelectedWorktree(worktree: GitWorktree, force: boolean): Promise<boolean> {
if (repoTabs.some((tab) => sameRepoPath(tab.path, worktree.path))) {
worktreeError = "Close this worktree's repository tab before removing it.";
return false;
}
return runWorktreeOperation(
`Removing ${worktree.branch || "worktree"}`,
() => removeWorktree(activeRepoPath, worktree.path, force),
"worktree_removed",
);
}
async function moveSelectedWorktree(worktree: GitWorktree, destination: string) {
if (repoTabs.some((tab) => sameRepoPath(tab.path, worktree.path))) {
worktreeError = "Close this worktree's repository tab before moving it.";
return;
}
await runWorktreeOperation(
`Moving ${worktree.branch || "worktree"}`,
() => moveWorktree(activeRepoPath, worktree.path, destination),
"worktree_moved",
);
}
function lockSelectedWorktree(worktree: GitWorktree, reason: string): Promise<boolean> {
return runWorktreeOperation(
`Locking ${worktree.branch || "worktree"}`,
() => lockWorktree(activeRepoPath, worktree.path, reason),
"worktree_locked",
);
}
async function unlockSelectedWorktree(worktree: GitWorktree) {
await runWorktreeOperation(
`Unlocking ${worktree.branch || "worktree"}`,
() => unlockWorktree(activeRepoPath, worktree.path),
"worktree_unlocked",
);
}
async function pruneStaleWorktrees() {
await runWorktreeOperation(
"Pruning stale worktrees",
() => pruneWorktrees(activeRepoPath),
"worktrees_pruned",
);
}
async function repairSelectedWorktree(worktree: GitWorktree, location: string) {
await runWorktreeOperation(
`Repairing ${worktree.branch || "worktree"}`,
() => repairWorktree(activeRepoPath, location),
"worktree_repaired",
);
}
function closeWorktreeDialog() {
if (isBusy) return;
worktreeDialogOpen = false;
worktreeInitialBranch = "";
worktreeError = "";
}
function openNewBranchDialog(commit: GitCommit) {
if (!activeRepoPath || isBusy) return;
newBranchCommit = commit;
@@ -3730,6 +3889,7 @@
else if (event.key === "Escape" && newBranchCommit) newBranchCommit = null;
else if (event.key === "Escape" && renameBranchTarget) renameBranchTarget = null;
else if (event.key === "Escape" && deleteBranchTarget) closeDeleteBranchDialog();
else if (event.key === "Escape" && worktreeDialogOpen) closeWorktreeDialog();
else if (event.key === "Escape" && interactiveRebaseOpen && !isBusy) interactiveRebaseOpen = false;
else if (event.key === "Escape" && reflogOpen && !isBusy) reflogOpen = false;
else if (event.key === "Escape" && compareSelectOpen) compareSelectOpen = false;
@@ -4115,6 +4275,8 @@
onCreateTag={createNewTag}
onDeleteTag={deleteLocalTag}
onPushTag={pushLocalTag}
onManageWorktrees={() => { void openWorktreeDialog(); }}
onCreateWorktree={openBranchInWorktree}
collapsed={branchPanelCollapsed}
onToggleCollapsed={toggleBranchPanelCollapsed}
/>
@@ -4480,6 +4642,27 @@
/>
{/if}
{#if worktreeDialogOpen}
<WorktreeDialog
{worktrees}
{branches}
initialBranch={worktreeInitialBranch}
isLoading={worktreesLoading}
{isBusy}
error={worktreeError}
onRefresh={refreshWorktrees}
onOpen={openWorktreeTab}
onAdd={createWorktree}
onRemove={removeSelectedWorktree}
onMove={moveSelectedWorktree}
onLock={lockSelectedWorktree}
onUnlock={unlockSelectedWorktree}
onPrune={pruneStaleWorktrees}
onRepair={repairSelectedWorktree}
onClose={closeWorktreeDialog}
/>
{/if}
<!-- Create a branch from a specific commit in the history -->
{#if newBranchCommit}
<NewBranchDialog