refactor: simplify worktree management by removing legacy API

- Remove legacy worktree API usage and related state
- Add Manage Branches button in the Git header for quick access
- Introduce worktree status utilities to derive root branch hints
This commit is contained in:
Bohdan Triapitsyn
2026-02-06 12:38:09 +02:00
parent 2f336a0716
commit 0503f51357
26 changed files with 504 additions and 1450 deletions
@@ -21,7 +21,6 @@ import {
import { cn } from '@/lib/utils';
import { deleteGitBranch, getGitBranches, listGitWorktrees, renameBranch } from '@/lib/gitApi';
import type { GitBranch, GitWorktreeInfo } from '@/lib/api/types';
import { createWorktreeSessionForBranch } from '@/lib/worktreeSessionCreator';
export interface BranchPickerProject {
id: string;
@@ -46,7 +45,6 @@ export function BranchPickerDialog({ open, onOpenChange, project }: BranchPicker
const [loading, setLoading] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const [creatingWorktree, setCreatingWorktree] = React.useState<string | null>(null);
const [deletingBranch, setDeletingBranch] = React.useState<string | null>(null);
const [confirmingDelete, setConfirmingDelete] = React.useState<string | null>(null);
const [forceDeleteBranch, setForceDeleteBranch] = React.useState<string | null>(null);
@@ -93,21 +91,6 @@ export function BranchPickerDialog({ open, onOpenChange, project }: BranchPicker
return list.filter((b) => b.toLowerCase().includes(lower));
};
const handleCreateWorktree = async (branchName: string) => {
if (!project) return;
setCreatingWorktree(branchName);
try {
await createWorktreeSessionForBranch(project.path, branchName);
onOpenChange(false);
} catch (err) {
toast.error('Failed to create worktree', {
description: err instanceof Error ? err.message : 'Create failed',
});
} finally {
setCreatingWorktree(null);
}
};
const beginRename = React.useCallback((branchName: string) => {
setEditingBranch(branchName);
setEditValue(branchName);
@@ -222,7 +205,6 @@ export function BranchPickerDialog({ open, onOpenChange, project }: BranchPicker
localBranches.map((branchName) => {
const details = branches?.branches[branchName];
const isCurrent = Boolean(details?.current);
const isCreating = creatingWorktree === branchName;
const isDeleting = deletingBranch === branchName;
const isRenaming = renamingBranchKey === branchName;
const hasAttachedWorktree = worktreeBranches.has(branchName);
@@ -302,25 +284,6 @@ export function BranchPickerDialog({ open, onOpenChange, project }: BranchPicker
{!isEditing && !isConfirming ? (
<div className="flex items-center gap-1 flex-shrink-0">
<Tooltip delayDuration={700}>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => handleCreateWorktree(branchName)}
disabled={isCreating}
className="inline-flex h-7 w-7 items-center justify-center rounded-md bg-primary/10 hover:bg-primary/20 text-primary transition-colors disabled:opacity-50"
aria-label="Create worktree from"
>
{isCreating ? (
<RiLoader4Line className="h-4 w-4 animate-spin" />
) : (
<RiGitBranchLine className="h-4 w-4" />
)}
</button>
</TooltipTrigger>
<TooltipContent side="left">Create worktree from</TooltipContent>
</Tooltip>
<Tooltip delayDuration={700}>
<TooltipTrigger asChild>
<button
@@ -78,7 +78,6 @@ export function GitHubIssuePickerDialog({
const activeProject = useProjectsStore((state) => state.getActiveProject());
const projectDirectory = activeProject?.path ?? null;
const baseBranch = activeProject?.worktreeDefaults?.baseBranch || 'main';
const [query, setQuery] = React.useState('');
const [createInWorktree, setCreateInWorktree] = React.useState(false);
@@ -302,8 +301,7 @@ export function GitHubIssuePickerDialog({
const preferred = `issue-${issue.number}-${generateBranchSlug()}`;
const created = await createWorktreeSessionForNewBranch(
projectDirectory,
preferred,
baseBranch || 'main'
preferred
);
if (!created?.id) {
throw new Error('Failed to create worktree session');
@@ -446,7 +444,7 @@ Do not implement changes until I confirm; end with: “Next actions: <1 sentence
} finally {
setStartingIssueNumber(null);
}
}, [createInWorktree, github, onOpenChange, projectDirectory, baseBranch, resolveDefaultAgentName, resolveDefaultModelSelection, resolveDefaultVariant, startingIssueNumber]);
}, [createInWorktree, github, onOpenChange, projectDirectory, resolveDefaultAgentName, resolveDefaultModelSelection, resolveDefaultVariant, startingIssueNumber]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -16,9 +16,7 @@ import { DirectoryExplorerDialog } from './DirectoryExplorerDialog';
import { cn, formatPathForDisplay } from '@/lib/utils';
import type { Session } from '@opencode-ai/sdk/v2';
import type { WorktreeMetadata } from '@/types/worktree';
import {
getWorktreeStatus,
} from '@/lib/git/worktreeService';
import { getWorktreeStatus } from '@/lib/worktrees/worktreeStatus';
import { removeProjectWorktree } from '@/lib/worktrees/worktreeManager';
import { useSessionStore } from '@/stores/useSessionStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
@@ -56,6 +54,8 @@ export const SessionDialogs: React.FC = () => {
const [deleteDialogSummaries, setDeleteDialogSummaries] = React.useState<Array<{ session: Session; metadata: WorktreeMetadata }>>([]);
const [deleteDialogShouldRemoveRemote, setDeleteDialogShouldRemoveRemote] = React.useState(false);
const [isProcessingDelete, setIsProcessingDelete] = React.useState(false);
const [hasCompletedDirtyCheck, setHasCompletedDirtyCheck] = React.useState(false);
const [dirtyWorktreePaths, setDirtyWorktreePaths] = React.useState<Set<string>>(new Set());
const {
deleteSession,
@@ -84,12 +84,7 @@ export const SessionDialogs: React.FC = () => {
return { id: match?.id ?? `path:${fallbackPath}`, path: fallbackPath };
}, [projectDirectory, projects]);
const hasDirtyWorktrees = React.useMemo(
() =>
(deleteDialog?.worktree?.status?.isDirty ?? false) ||
deleteDialogSummaries.some((entry) => entry.metadata.status?.isDirty),
[deleteDialog?.worktree?.status?.isDirty, deleteDialogSummaries],
);
const hasDirtyWorktrees = hasCompletedDirtyCheck && dirtyWorktreePaths.size > 0;
const canRemoveRemoteBranches = React.useMemo(
() => {
const targetWorktree = deleteDialog?.worktree;
@@ -108,8 +103,6 @@ export const SessionDialogs: React.FC = () => {
const removeRemoteOptionDisabled =
isProcessingDelete || !isWorktreeDelete || !canRemoveRemoteBranches;
// NOTE: stop auto-modifying .gitignore for legacy `.openchamber`.
React.useEffect(() => {
loadSessions();
}, [loadSessions, currentDirectory]);
@@ -194,6 +187,8 @@ export const SessionDialogs: React.FC = () => {
setDeleteDialogSummaries([]);
setDeleteDialogShouldRemoveRemote(false);
setIsProcessingDelete(false);
setHasCompletedDirtyCheck(false);
setDirtyWorktreePaths(new Set());
}, []);
React.useEffect(() => {
@@ -212,6 +207,8 @@ export const SessionDialogs: React.FC = () => {
if (!deleteDialog) {
setDeleteDialogSummaries([]);
setDeleteDialogShouldRemoveRemote(false);
setHasCompletedDirtyCheck(false);
setDirtyWorktreePaths(new Set());
return;
}
@@ -224,39 +221,97 @@ export const SessionDialogs: React.FC = () => {
setDeleteDialogSummaries(summaries);
setDeleteDialogShouldRemoveRemote(false);
setHasCompletedDirtyCheck(false);
setDirtyWorktreePaths(new Set());
if (summaries.length === 0) {
const metadataByPath = new Map<string, WorktreeMetadata>();
if (deleteDialog.worktree?.path) {
metadataByPath.set(normalizeProjectDirectory(deleteDialog.worktree.path), deleteDialog.worktree);
}
summaries.forEach(({ metadata }) => {
if (metadata.path) {
metadataByPath.set(normalizeProjectDirectory(metadata.path), metadata);
}
});
if (metadataByPath.size === 0) {
setHasCompletedDirtyCheck(true);
return;
}
let cancelled = false;
(async () => {
const statuses = await Promise.all(
summaries.map(async ({ metadata }) => {
if (metadata.status && typeof metadata.status.isDirty === 'boolean') {
return metadata.status;
}
const statusByPath = new Map<string, WorktreeMetadata['status']>();
const nextDirtyPaths = new Set<string>();
await Promise.all(
Array.from(metadataByPath.entries()).map(async ([pathKey, metadata]) => {
try {
return await getWorktreeStatus(metadata.path);
const status = await getWorktreeStatus(metadata.path);
statusByPath.set(pathKey, status);
if (status?.isDirty) {
nextDirtyPaths.add(pathKey);
}
} catch {
return metadata.status;
if (metadata.status) {
statusByPath.set(pathKey, metadata.status);
if (metadata.status.isDirty) {
nextDirtyPaths.add(pathKey);
}
}
}
})
).catch((error) => {
console.warn('Failed to inspect worktree status before deletion:', error);
return summaries.map(({ metadata }) => metadata.status);
});
if (cancelled || !Array.isArray(statuses)) {
if (cancelled) {
return;
}
setDirtyWorktreePaths(nextDirtyPaths);
setHasCompletedDirtyCheck(true);
setDeleteDialog((prev) => {
if (!prev?.worktree?.path) {
return prev;
}
const pathKey = normalizeProjectDirectory(prev.worktree.path);
const nextStatus = statusByPath.get(pathKey);
if (!nextStatus) {
return prev;
}
const prevStatus = prev.worktree.status;
if (
prevStatus?.isDirty === nextStatus.isDirty &&
prevStatus?.ahead === nextStatus.ahead &&
prevStatus?.behind === nextStatus.behind &&
prevStatus?.upstream === nextStatus.upstream
) {
return prev;
}
return {
...prev,
worktree: {
...prev.worktree,
status: nextStatus,
},
};
});
setDeleteDialogSummaries((prev) =>
prev.map((entry, index) => ({
session: entry.session,
metadata: { ...entry.metadata, status: statuses[index] ?? entry.metadata.status },
}))
prev.map((entry) => {
const pathKey = normalizeProjectDirectory(entry.metadata.path);
const nextStatus = statusByPath.get(pathKey);
if (!nextStatus) {
return entry;
}
return {
session: entry.session,
metadata: { ...entry.metadata, status: nextStatus },
};
})
);
})();
@@ -41,7 +41,6 @@ import {
RiFolderAddLine,
RiGitBranchLine,
RiGitPullRequestLine,
RiGitRepositoryLine,
RiLinkUnlinkM,
RiGithubLine,
@@ -66,7 +65,6 @@ import { getSafeStorage } from '@/stores/utils/safeStorage';
import { createWorktreeSession } from '@/lib/worktreeSessionCreator';
import { isVSCodeRuntime } from '@/lib/desktop';
import { updateDesktopSettings } from '@/lib/persistence';
import { BranchPickerDialog } from './BranchPickerDialog';
import { GitHubIssuePickerDialog } from './GitHubIssuePickerDialog';
import { GitHubPullRequestPickerDialog } from './GitHubPullRequestPickerDialog';
@@ -145,7 +143,6 @@ interface SortableProjectItemProps {
onHoverChange: (hovered: boolean) => void;
onNewSession: () => void;
onNewWorktreeSession?: () => void;
onOpenBranchPicker?: () => void;
onNewSessionFromGitHubIssue?: () => void;
onNewSessionFromGitHubPR?: () => void;
onOpenMultiRunLauncher: () => void;
@@ -177,7 +174,6 @@ const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
onHoverChange,
onNewSession,
onNewWorktreeSession,
onOpenBranchPicker,
onNewSessionFromGitHubIssue,
onNewSessionFromGitHubPR,
onOpenMultiRunLauncher,
@@ -346,12 +342,6 @@ const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
New Multi-Run
</DropdownMenuItem>
)}
{isRepo && !hideDirectoryControls && onOpenBranchPicker && (
<DropdownMenuItem onClick={onOpenBranchPicker}>
<RiGitRepositoryLine className="mr-1.5 h-4 w-4" />
Manage Branches
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={onRenameStart}>
<RiPencilAiLine className="mr-1.5 h-4 w-4" />
Rename
@@ -478,8 +468,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
const [projectRepoStatus, setProjectRepoStatus] = React.useState<Map<string, boolean | null>>(new Map());
const [expandedSessionGroups, setExpandedSessionGroups] = React.useState<Set<string>>(new Set());
const [hoveredProjectId, setHoveredProjectId] = React.useState<string | null>(null);
const [branchPickerOpen, setBranchPickerOpen] = React.useState(false);
const [branchPickerProjectId, setBranchPickerProjectId] = React.useState<string | null>(null);
const [issuePickerOpen, setIssuePickerOpen] = React.useState(false);
const [pullRequestPickerOpen, setPullRequestPickerOpen] = React.useState(false);
const [activeDragId, setActiveDragId] = React.useState<string | null>(null);
@@ -1792,10 +1780,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
}
createWorktreeSession();
}}
onOpenBranchPicker={() => {
setBranchPickerProjectId(projectKey);
setBranchPickerOpen(true);
}}
onNewSessionFromGitHubIssue={() => {
if (projectKey !== activeProjectId) {
setActiveProject(projectKey);
@@ -1852,14 +1836,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
)}
</ScrollableOverlay>
<BranchPickerDialog
open={branchPickerOpen}
onOpenChange={setBranchPickerOpen}
project={branchPickerProjectId
? normalizedProjects.find((p) => p.id === branchPickerProjectId) ?? null
: null}
/>
<GitHubIssuePickerDialog
open={issuePickerOpen}
onOpenChange={(open) => {