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:
+184
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user