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
@@ -1,52 +1,28 @@
import React from 'react';
import { RiAddLine, RiCloseLine, RiDeleteBinLine, RiInformationLine } from '@remixicon/react';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectSeparator,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useSessionStore } from '@/stores/useSessionStore';
import { useGitBranches } from '@/stores/useGitStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { checkIsGitRepository, getGitBranches } from '@/lib/gitApi';
import { checkIsGitRepository } from '@/lib/gitApi';
import { getWorktreeSetupCommands, saveWorktreeSetupCommands } from '@/lib/openchamberConfig';
import { listProjectWorktrees } from '@/lib/worktrees/worktreeManager';
import { sessionEvents } from '@/lib/sessionEvents';
import type { WorktreeMetadata } from '@/types/worktree';
import { formatPathForDisplay } from '@/lib/utils';
type BranchOption = {
value: string;
label: string;
group: 'special' | 'local' | 'remote';
};
export const WorktreeSectionContent: React.FC = () => {
const activeProject = useProjectsStore((state) => state.getActiveProject());
const updateWorktreeDefaults = useProjectsStore((state) => state.updateWorktreeDefaults);
const projectPath = activeProject?.path ?? null;
const worktreeDefaults = activeProject?.worktreeDefaults;
const branchesFromStore = useGitBranches(projectPath);
const { sessions, getWorktreeMetadata } = useSessionStore();
const homeDirectory = useDirectoryStore((state) => state.homeDirectory);
const [baseBranch, setBaseBranch] = React.useState(worktreeDefaults?.baseBranch ?? 'HEAD');
const [setupCommands, setSetupCommands] = React.useState<string[]>([]);
const [isLoadingCommands, setIsLoadingCommands] = React.useState(false);
const [isLoadingGit, setIsLoadingGit] = React.useState(false);
const [isGitRepoLocal, setIsGitRepoLocal] = React.useState<boolean | null>(null);
const [branchesLocal, setBranchesLocal] = React.useState<{ all: string[]; current: string } | null>(null);
const [availableWorktrees, setAvailableWorktrees] = React.useState<WorktreeMetadata[]>([]);
const [isLoadingWorktrees, setIsLoadingWorktrees] = React.useState(false);
@@ -68,34 +44,20 @@ export const WorktreeSectionContent: React.FC = () => {
}
}, [projectRef, isGitRepoLocal]);
// Load repo + branch info
// Load repo info
React.useEffect(() => {
if (!projectPath) return;
let cancelled = false;
setIsLoadingGit(true);
setIsGitRepoLocal(null);
setBranchesLocal(null);
(async () => {
try {
const repoStatus = await checkIsGitRepository(projectPath);
if (cancelled) return;
setIsGitRepoLocal(repoStatus);
if (!repoStatus) {
return;
}
const branchData = await getGitBranches(projectPath);
if (cancelled) return;
setBranchesLocal({ all: branchData.all, current: branchData.current });
} catch {
// Ignore errors
} finally {
if (!cancelled) {
setIsLoadingGit(false);
}
}
})();
@@ -170,49 +132,6 @@ export const WorktreeSectionContent: React.FC = () => {
};
}, [projectRef]);
// Sync local state with store when project changes
React.useEffect(() => {
setBaseBranch(worktreeDefaults?.baseBranch ?? 'HEAD');
}, [worktreeDefaults]);
// Use local branches if available, otherwise fall back to store
const branches = branchesLocal ?? branchesFromStore;
const branchOptions = React.useMemo<BranchOption[]>(() => {
const options: BranchOption[] = [];
const headLabel = branches?.current
? `Current (HEAD: ${branches.current})`
: 'Current (HEAD)';
options.push({ value: 'HEAD', label: headLabel, group: 'special' });
if (branches) {
const localBranches = branches.all
.filter((name: string) => !name.startsWith('remotes/'))
.sort((a: string, b: string) => a.localeCompare(b));
localBranches.forEach((name: string) => {
options.push({ value: name, label: name, group: 'local' });
});
const remoteBranches = branches.all
.filter((name: string) => name.startsWith('remotes/'))
.map((name: string) => name.replace(/^remotes\//, ''))
.sort((a: string, b: string) => a.localeCompare(b));
remoteBranches.forEach((name: string) => {
options.push({ value: name, label: name, group: 'remote' });
});
}
return options;
}, [branches]);
const handleBaseBranchChange = React.useCallback((value: string) => {
setBaseBranch(value);
if (!activeProject?.id) return;
updateWorktreeDefaults(activeProject.id, { baseBranch: value });
}, [activeProject?.id, updateWorktreeDefaults]);
const handleSetupCommandChange = React.useCallback((index: number, value: string) => {
setSetupCommands((prev) => {
const next = [...prev];
@@ -320,7 +239,7 @@ export const WorktreeSectionContent: React.FC = () => {
if (!projectPath) {
return (
<p className="typography-meta text-muted-foreground">
Select a project to configure worktree defaults.
Select a project to manage worktrees.
</p>
);
}
@@ -335,82 +254,8 @@ export const WorktreeSectionContent: React.FC = () => {
return (
<div className="space-y-6">
{/* Default base branch */}
<div className="space-y-4">
<div className="space-y-1">
<div className="flex items-center gap-2">
<h3 className="typography-ui-header font-semibold text-foreground">Base branch</h3>
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
Default branch to create new worktrees from.
</TooltipContent>
</Tooltip>
</div>
<p className="typography-meta text-muted-foreground">
Default branch for new worktree branches
</p>
</div>
{isLoadingGit ? (
<p className="typography-meta text-muted-foreground">Loading...</p>
) : (
<Select value={baseBranch} onValueChange={handleBaseBranchChange}>
<SelectTrigger className="w-auto max-w-xs typography-meta text-foreground">
<SelectValue placeholder="Select a branch" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Default</SelectLabel>
{branchOptions
.filter((option) => option.group === 'special')
.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectGroup>
{branchOptions.some((option) => option.group === 'local') && (
<>
<SelectSeparator />
<SelectGroup>
<SelectLabel>Local branches</SelectLabel>
{branchOptions
.filter((option) => option.group === 'local')
.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectGroup>
</>
)}
{branchOptions.some((option) => option.group === 'remote') && (
<>
<SelectSeparator />
<SelectGroup>
<SelectLabel>Remote branches</SelectLabel>
{branchOptions
.filter((option) => option.group === 'remote')
.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectGroup>
</>
)}
</SelectContent>
</Select>
)}
</div>
{/* Setup commands */}
<div className="space-y-4 border-t border-border/40 pt-6">
<div className="space-y-4">
<div className="space-y-1">
<h3 className="typography-ui-header font-semibold text-foreground">Setup commands</h3>
<p className="typography-meta text-muted-foreground">
@@ -467,8 +312,7 @@ export const WorktreeSectionContent: React.FC = () => {
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
SDK worktrees live outside the repo (OpenCode-managed). Legacy <code className="font-mono text-xs">.openchamber</code> worktrees are still supported.
Deleting a worktree also removes linked sessions.
Worktrees live outside the repo (OpenCode-managed). Deleting a worktree also removes linked sessions.
</TooltipContent>
</Tooltip>
</div>
@@ -496,13 +340,11 @@ export const WorktreeSectionContent: React.FC = () => {
{worktree.label || worktree.branch || 'Detached HEAD'}
</p>
<span className="typography-micro text-muted-foreground/60 px-1.5 py-[1px] rounded bg-sidebar-accent/40 flex-shrink-0 self-center leading-none">
{worktree.source === 'sdk' ? 'OpenCode' : 'OpenChamber'}
OpenCode
</span>
</div>
<p className="typography-micro text-muted-foreground/60 truncate">
{worktree.source === 'sdk'
? formatPathForDisplay(worktree.path, homeDirectory)
: (worktree.relativePath || worktree.path)}
{formatPathForDisplay(worktree.path, homeDirectory)}
</p>
</div>
<button
@@ -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) => {
+84 -15
View File
@@ -1,10 +1,10 @@
import React from 'react';
import { useSessionStore } from '@/stores/useSessionStore';
import { useConfigStore } from '@/stores/useConfigStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useFireworksCelebration } from '@/contexts/FireworksContext';
import type { GitIdentityProfile, CommitFileEntry } from '@/lib/api/types';
import { useGitIdentitiesStore } from '@/stores/useGitIdentitiesStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import {
useGitStore,
@@ -43,6 +43,8 @@ import { ChangesSection } from './git/ChangesSection';
import { CommitSection } from './git/CommitSection';
import { HistorySection } from './git/HistorySection';
import { PullRequestSection } from './git/PullRequestSection';
import { BranchPickerDialog } from '@/components/session/BranchPickerDialog';
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
type SyncAction = 'fetch' | 'pull' | 'push' | null;
type CommitAction = 'commit' | 'commitAndPush' | null;
@@ -185,6 +187,9 @@ const matchGitmojiFromSubject = (subject: string, gitmojis: GitmojiEntry[]): Git
const gitViewSnapshots = new Map<string, GitViewSnapshot>();
const normalizePath = (value?: string | null): string =>
(value || '').replace(/\\/g, '/').replace(/\/+$/, '');
export const GitView: React.FC = () => {
const { git } = useRuntimeAPIs();
const currentDirectory = useEffectiveDirectory();
@@ -220,15 +225,56 @@ export const GitView: React.FC = () => {
}, [currentDirectory]);
const settingsGitmojiEnabled = useConfigStore((state) => state.settingsGitmojiEnabled);
const projects = useProjectsStore((state) => state.projects);
const [isBranchPickerOpen, setIsBranchPickerOpen] = React.useState(false);
const [rootBranchHint, setRootBranchHint] = React.useState<string | null>(null);
const activeProject = useProjectsStore((state) => state.getActiveProject());
const baseBranch = React.useMemo(() => {
const fromProject = activeProject?.worktreeDefaults?.baseBranch;
if (typeof fromProject === 'string' && fromProject.trim().length > 0) {
return fromProject.trim();
const baseBranch = worktreeMetadata?.createdFromBranch || status?.current || 'HEAD';
React.useEffect(() => {
const projectRoot = worktreeMetadata?.projectDirectory;
if (!projectRoot) {
setRootBranchHint(null);
return;
}
return 'main';
}, [activeProject?.worktreeDefaults?.baseBranch]);
let cancelled = false;
void getRootBranch(projectRoot)
.then((branch) => {
if (cancelled) return;
const normalized = branch.trim();
setRootBranchHint(normalized && normalized !== 'HEAD' ? normalized : null);
})
.catch(() => {
if (!cancelled) {
setRootBranchHint(null);
}
});
return () => {
cancelled = true;
};
}, [worktreeMetadata?.projectDirectory]);
const branchPickerProject = React.useMemo(() => {
const current = normalizePath(currentDirectory);
const worktreeRoot = normalizePath(worktreeMetadata?.projectDirectory);
const best = projects
.map((project) => ({
id: project.id,
path: project.path,
label: project.label,
normalizedPath: normalizePath(project.path),
}))
.sort((a, b) => b.normalizedPath.length - a.normalizedPath.length)
.find((project) => {
if (!project.normalizedPath) return false;
if (worktreeRoot && project.normalizedPath === worktreeRoot) return true;
return current === project.normalizedPath || current.startsWith(`${project.normalizedPath}/`);
});
return best ?? null;
}, [currentDirectory, projects, worktreeMetadata?.projectDirectory]);
const [commitMessage, setCommitMessage] = React.useState(
initialSnapshot?.commitMessage ?? ''
@@ -277,15 +323,31 @@ export const GitView: React.FC = () => {
}, [status?.tracking]);
const defaultTargetBranch = React.useMemo(() => {
const fromMeta = worktreeMetadata?.createdFromBranch;
if (typeof fromMeta === 'string' && fromMeta.trim().length > 0) {
return fromMeta.trim();
const normalizedFromMeta = typeof fromMeta === 'string' ? fromMeta.trim() : '';
const current = typeof status?.current === 'string' ? status.current.trim() : '';
const normalizedRoot = typeof rootBranchHint === 'string' ? rootBranchHint.trim() : '';
if (normalizedFromMeta) {
const looksLikeCorruptedSelfTarget =
normalizedFromMeta === current &&
normalizedFromMeta.startsWith('opencode/') &&
normalizedRoot.length > 0 &&
normalizedRoot !== normalizedFromMeta;
if (looksLikeCorruptedSelfTarget) {
return normalizedRoot;
}
return normalizedFromMeta;
}
const fromProject = activeProject?.worktreeDefaults?.baseBranch;
if (typeof fromProject === 'string' && fromProject.trim().length > 0) {
return fromProject.trim();
if (normalizedRoot) {
return normalizedRoot;
}
return 'main';
}, [worktreeMetadata?.createdFromBranch, activeProject?.worktreeDefaults?.baseBranch]);
if (current) {
return current;
}
return 'HEAD';
}, [worktreeMetadata?.createdFromBranch, status, rootBranchHint]);
const clearGeneratedHighlights = React.useCallback(() => {
setGeneratedHighlights([]);
}, []);
@@ -1009,6 +1071,7 @@ export const GitView: React.FC = () => {
onSelectIdentity={handleApplyIdentity}
isApplyingIdentity={isSettingIdentity}
isWorktreeMode={!!worktreeMetadata}
onOpenBranchPicker={branchPickerProject ? () => setIsBranchPickerOpen(true) : undefined}
/>
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="p-3">
@@ -1138,6 +1201,12 @@ export const GitView: React.FC = () => {
</DialogContent>
</Dialog>
<BranchPickerDialog
open={isBranchPickerOpen}
onOpenChange={setIsBranchPickerOpen}
project={branchPickerProject}
/>
</div>
);
};
@@ -10,6 +10,7 @@ import {
RiGraduationCapLine,
RiCodeLine,
RiHeartLine,
RiGitRepositoryLine,
RiUser3Line,
} from '@remixicon/react';
import { Button } from '@/components/ui/button';
@@ -44,6 +45,7 @@ interface GitHeaderProps {
onSelectIdentity: (profile: GitIdentityProfile) => void;
isApplyingIdentity: boolean;
isWorktreeMode: boolean;
onOpenBranchPicker?: () => void;
}
const IDENTITY_ICON_MAP: Record<
@@ -195,6 +197,7 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
onSelectIdentity,
isApplyingIdentity,
isWorktreeMode,
onOpenBranchPicker,
}) => {
if (!status) {
return null;
@@ -252,6 +255,23 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
<div className="flex-1" />
{onOpenBranchPicker ? (
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="gap-1.5 px-2 py-1 h-8 typography-ui-label"
onClick={onOpenBranchPicker}
>
<RiGitRepositoryLine className="size-4" />
<span className="hidden sm:inline">Manage Branches</span>
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={8}>Manage branches</TooltipContent>
</Tooltip>
) : null}
<IdentityDropdown
activeProfile={activeIdentityProfile}
identities={availableIdentities}
-21
View File
@@ -230,18 +230,6 @@ export interface GitWorktreeInfo {
branch?: string;
}
export interface GitAddWorktreePayload {
path: string;
branch: string;
createBranch?: boolean;
startPoint?: string;
}
export interface GitRemoveWorktreePayload {
path: string;
force?: boolean;
}
export interface GitDeleteBranchPayload {
branch: string;
force?: boolean;
@@ -290,9 +278,6 @@ export interface GitAPI {
payload: { base: string; head: string; context?: string }
): Promise<GeneratedPullRequestDescription>;
listGitWorktrees(directory: string): Promise<GitWorktreeInfo[]>;
addGitWorktree(directory: string, payload: GitAddWorktreePayload): Promise<{ success: boolean; path: string; branch: string }>;
removeGitWorktree(directory: string, payload: GitRemoveWorktreePayload): Promise<{ success: boolean }>;
ensureOpenChamberIgnored(directory: string): Promise<void>;
createGitCommit(directory: string, message: string, options?: CreateGitCommitOptions): Promise<GitCommitResult>;
gitPush(directory: string, options?: { remote?: string; branch?: string; options?: string[] | Record<string, unknown> }): Promise<GitPushResult>;
gitPull(directory: string, options?: { remote?: string; branch?: string }): Promise<GitPullResult>;
@@ -366,18 +351,12 @@ export interface FilesAPI {
execCommands?(commands: string[], cwd: string): Promise<{ success: boolean; results: CommandExecResult[] }>;
}
export interface WorktreeDefaults {
baseBranch?: string; // e.g. "main", "develop", or "HEAD"
autoCreateWorktree?: boolean; // future: skip dialog, create worktree automatically
}
export interface ProjectEntry {
id: string;
path: string;
label?: string;
addedAt?: number;
lastOpenedAt?: number;
worktreeDefaults?: WorktreeDefaults;
sidebarCollapsed?: boolean;
}
-344
View File
@@ -1,344 +0,0 @@
import { addGitWorktree, deleteGitBranch, deleteRemoteBranch, getGitStatus, listGitWorktrees, removeGitWorktree, type GitAddWorktreePayload, type GitWorktreeInfo } from '@/lib/gitApi';
import { opencodeClient } from '@/lib/opencode/client';
import type { WorktreeMetadata } from '@/types/worktree';
import type { FilesAPI, RuntimeAPIs } from '@/lib/api/types';
import { substituteCommandVariables } from '@/lib/openchamberConfig';
const WORKTREE_ROOT = '.openchamber';
const DEFAULT_BASE_URL = import.meta.env.VITE_OPENCODE_URL || '/api';
/**
* Get the runtime Files API if available (Desktop/VSCode).
*/
function getRuntimeFilesAPI(): FilesAPI | null {
if (typeof window === 'undefined') return null;
const apis = (window as typeof window & { __OPENCHAMBER_RUNTIME_APIS__?: RuntimeAPIs }).__OPENCHAMBER_RUNTIME_APIS__;
if (apis?.files) {
return apis.files;
}
return null;
}
const normalize = (value: string): string => {
if (!value) {
return '';
}
const replaced = value.replace(/\\/g, '/');
if (replaced === '/') {
return '/';
}
return replaced.replace(/\/+$/, '');
};
const joinPath = (base: string, segment: string): string => {
const normalizedBase = normalize(base);
const sanitizedSegment = segment.replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+$/, '');
if (!normalizedBase || normalizedBase === '/') {
return `/${sanitizedSegment}`;
}
return `${normalizedBase}/${sanitizedSegment}`;
};
const shortBranchLabel = (branch?: string): string => {
if (!branch) {
return '';
}
if (branch.startsWith('refs/heads/')) {
return branch.substring('refs/heads/'.length);
}
if (branch.startsWith('heads/')) {
return branch.substring('heads/'.length);
}
if (branch.startsWith('refs/')) {
return branch.substring('refs/'.length);
}
return branch;
};
const ensureDirectory = async (path: string) => {
try {
await opencodeClient.createDirectory(path);
} catch (error) {
if (error instanceof Error) {
if (/exist/i.test(error.message)) {
return;
}
}
throw error;
}
};
export interface CreateWorktreeOptions {
projectDirectory: string;
worktreeSlug: string;
branch: string;
createBranch?: boolean;
startPoint?: string;
}
export interface RemoveWorktreeOptions {
projectDirectory: string;
path: string;
force?: boolean;
}
export interface ArchiveWorktreeOptions {
projectDirectory: string;
path: string;
branch: string;
force?: boolean;
deleteRemote?: boolean;
remote?: string;
}
export async function resolveWorktreePath(projectDirectory: string, worktreeSlug: string): Promise<string> {
const normalizedProject = normalize(projectDirectory);
const root = joinPath(normalizedProject, WORKTREE_ROOT);
await ensureDirectory(root);
return joinPath(root, worktreeSlug);
}
export async function createWorktree(options: CreateWorktreeOptions): Promise<WorktreeMetadata> {
// LEGACY_WORKTREES: creates <project>/.openchamber/<slug> git worktrees.
const { projectDirectory, worktreeSlug, branch, createBranch, startPoint } = options;
const normalizedProject = normalize(projectDirectory);
const worktreePath = await resolveWorktreePath(normalizedProject, worktreeSlug);
const payload: GitAddWorktreePayload = {
path: worktreePath,
branch,
createBranch: Boolean(createBranch),
startPoint: startPoint?.trim() || undefined,
};
await addGitWorktree(normalizedProject, payload);
return {
source: 'legacy',
path: worktreePath,
branch,
label: shortBranchLabel(branch),
projectDirectory: normalizedProject,
relativePath: worktreePath.startsWith(`${normalizedProject}/`)
? worktreePath.slice(normalizedProject.length + 1)
: worktreePath,
};
}
export async function removeWorktree(options: RemoveWorktreeOptions): Promise<void> {
const { projectDirectory, path, force } = options;
const normalizedProject = normalize(projectDirectory);
await removeGitWorktree(normalizedProject, { path, force });
}
export async function archiveWorktree(options: ArchiveWorktreeOptions): Promise<void> {
const { projectDirectory, path, branch, force, deleteRemote, remote } = options;
const normalizedProject = normalize(projectDirectory);
const normalizedBranch = branch.startsWith('refs/heads/')
? branch.substring('refs/heads/'.length)
: branch;
await removeGitWorktree(normalizedProject, { path, force });
if (normalizedBranch) {
await deleteGitBranch(normalizedProject, { branch: normalizedBranch, force: true });
if (deleteRemote) {
try {
await deleteRemoteBranch(normalizedProject, {
branch: normalizedBranch,
remote,
});
} catch (error) {
console.warn('Failed to delete remote branch during worktree archive:', error);
}
}
}
}
export async function listWorktrees(projectDirectory: string): Promise<GitWorktreeInfo[]> {
const normalizedProject = normalize(projectDirectory);
return listGitWorktrees(normalizedProject);
}
export async function getWorktreeStatus(worktreePath: string): Promise<WorktreeMetadata['status']> {
const normalizedPath = normalize(worktreePath);
const status = await getGitStatus(normalizedPath);
return {
isDirty: !status.isClean,
ahead: status.ahead,
behind: status.behind,
upstream: status.tracking,
};
}
export function mapWorktreeToMetadata(projectDirectory: string, info: GitWorktreeInfo): WorktreeMetadata {
const normalizedProject = normalize(projectDirectory);
const normalizedPath = normalize(info.worktree);
const legacyRoot = `${normalizedProject}/${WORKTREE_ROOT}/`;
const source: WorktreeMetadata['source'] = normalizedPath.startsWith(legacyRoot) ? 'legacy' : 'sdk';
return {
source,
path: normalizedPath,
branch: info.branch ?? '',
label: shortBranchLabel(info.branch ?? ''),
projectDirectory: normalizedProject,
relativePath: normalizedPath.startsWith(`${normalizedProject}/`)
? normalizedPath.slice(normalizedProject.length + 1)
: normalizedPath,
};
}
export interface WorktreeSetupResult {
success: boolean;
results: Array<{
command: string;
success: boolean;
exitCode?: number;
stdout?: string;
stderr?: string;
error?: string;
}>;
}
/**
* Run worktree setup commands in the background.
* This does not block - it returns a promise that resolves when all commands complete.
*
* @param worktreePath - The path to the new worktree where commands will run
* @param projectDirectory - The root project directory (for $ROOT_PROJECT_PATH substitution)
* @param commands - Commands to run.
* @returns Promise resolving to setup results
*/
export async function runWorktreeSetupCommands(
worktreePath: string,
projectDirectory: string,
commands: string[]
): Promise<WorktreeSetupResult> {
const commandsToRun = Array.isArray(commands) ? commands : [];
if (commandsToRun.length === 0) {
return { success: true, results: [] };
}
// Substitute variables in commands
const substitutedCommands = commandsToRun.map(cmd =>
substituteCommandVariables(cmd, { rootWorktreePath: projectDirectory })
);
console.log('[worktreeService] Running setup commands:', { worktreePath, projectDirectory, commands: substitutedCommands });
try {
// Try runtime API first (Desktop/VSCode)
const runtimeFiles = getRuntimeFilesAPI();
if (runtimeFiles?.execCommands) {
console.log('[worktreeService] Using runtime API for exec');
try {
// Don't use background mode - we want actual results for toast notifications
// The bridge now uses async exec (not execSync) so it won't block other operations
const result = await runtimeFiles.execCommands(substitutedCommands, worktreePath);
console.log('[worktreeService] Runtime exec result:', result);
return result as WorktreeSetupResult;
} catch (error) {
console.error('[worktreeService] Runtime exec error:', error);
return {
success: false,
results: substitutedCommands.map(cmd => ({
command: cmd,
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
})),
};
}
}
// Fall back to web API
console.log('[worktreeService] Using web API for exec');
const startResponse = await fetch(`${DEFAULT_BASE_URL}/fs/exec`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
// Use background job so we don't hold long-lived HTTP connections.
body: JSON.stringify({
commands: substitutedCommands,
cwd: worktreePath,
background: true,
}),
});
const startPayload = await startResponse.json().catch(() => null);
if (startResponse.status === 202 && startPayload && typeof startPayload.jobId === 'string') {
const jobId = startPayload.jobId as string;
const pollIntervalMs = 800;
const timeoutMs = Math.max(5 * 60_000, substitutedCommands.length * 60_000);
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
const pollResponse = await fetch(`${DEFAULT_BASE_URL}/fs/exec/${jobId}`, {
method: 'GET',
});
const pollPayload = await pollResponse.json().catch(() => null);
if (!pollResponse.ok) {
return {
success: false,
results: substitutedCommands.map((cmd) => ({
command: cmd,
success: false,
error: (pollPayload && pollPayload.error) || 'Failed to poll exec job',
})),
};
}
const status = pollPayload?.status;
if (status === 'done') {
const results = Array.isArray(pollPayload?.results) ? pollPayload.results : [];
const success = pollPayload?.success === true;
return { success, results } as WorktreeSetupResult;
}
}
return {
success: false,
results: substitutedCommands.map((cmd) => ({
command: cmd,
success: false,
error: 'Setup commands timed out',
})),
};
}
if (!startResponse.ok) {
const error = (startPayload && startPayload.error) || 'Request failed';
console.error('[worktreeService] Web exec failed:', startPayload);
return {
success: false,
results: substitutedCommands.map((cmd) => ({
command: cmd,
success: false,
error,
})),
};
}
// Back-compat: older servers may still return results synchronously.
console.log('[worktreeService] Web exec result:', startPayload);
return startPayload as WorktreeSetupResult;
} catch (error) {
console.error('[worktreeService] Exec exception:', error);
return {
success: false,
results: substitutedCommands.map(cmd => ({
command: cmd,
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
})),
};
}
}
// (intentionally no `hasWorktreeSetupCommands`; setup commands now run via SDK worktree startCommand)
-21
View File
@@ -18,8 +18,6 @@ export type {
GitLogEntry,
GitLogResponse,
GitWorktreeInfo,
GitAddWorktreePayload,
GitRemoveWorktreePayload,
GitDeleteBranchPayload,
GitDeleteRemoteBranchPayload,
DiscoveredGitCredential,
@@ -121,25 +119,6 @@ export async function listGitWorktrees(directory: string): Promise<import('./api
return gitHttp.listGitWorktrees(directory);
}
export async function addGitWorktree(directory: string, payload: import('./api/types').GitAddWorktreePayload): Promise<{ success: boolean; path: string; branch: string }> {
const runtime = getRuntimeGit();
if (runtime) return runtime.addGitWorktree(directory, payload);
return gitHttp.addGitWorktree(directory, payload);
}
export async function removeGitWorktree(directory: string, payload: import('./api/types').GitRemoveWorktreePayload): Promise<{ success: boolean }> {
const runtime = getRuntimeGit();
if (runtime) return runtime.removeGitWorktree(directory, payload);
return gitHttp.removeGitWorktree(directory, payload);
}
export async function ensureOpenChamberIgnored(directory: string): Promise<void> {
// LEGACY_WORKTREES: only needed for <project>/.openchamber era. Safe to remove after legacy support dropped.
const runtime = getRuntimeGit();
if (runtime) return runtime.ensureOpenChamberIgnored(directory);
return gitHttp.ensureOpenChamberIgnored(directory);
}
export async function createGitCommit(
directory: string,
message: string,
-52
View File
@@ -11,8 +11,6 @@ import type {
GitDeleteRemoteBranchPayload,
GeneratedCommitMessage,
GitWorktreeInfo,
GitAddWorktreePayload,
GitRemoveWorktreePayload,
CreateGitCommitOptions,
GitCommitResult,
GitPushResult,
@@ -291,56 +289,6 @@ export async function listGitWorktrees(directory: string): Promise<GitWorktreeIn
return response.json();
}
export async function addGitWorktree(directory: string, payload: GitAddWorktreePayload): Promise<{ success: boolean; path: string; branch: string }> {
if (!payload?.path || !payload?.branch) {
throw new Error('path and branch are required to add a worktree');
}
const response = await fetch(buildUrl(`${API_BASE}/worktrees`, directory), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to add worktree');
}
return response.json();
}
export async function removeGitWorktree(directory: string, payload: GitRemoveWorktreePayload): Promise<{ success: boolean }> {
if (!payload?.path) {
throw new Error('path is required to remove a worktree');
}
const response = await fetch(buildUrl(`${API_BASE}/worktrees`, directory), {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to remove worktree');
}
return response.json();
}
export async function ensureOpenChamberIgnored(directory: string): Promise<void> {
// LEGACY_WORKTREES: only needed for <project>/.openchamber era. Safe to remove after legacy support dropped.
const response = await fetch(buildUrl(`${API_BASE}/ignore-openchamber`, directory), {
method: 'POST',
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to update git ignore');
}
}
export async function createGitCommit(
directory: string,
message: string,
-15
View File
@@ -157,21 +157,6 @@ const sanitizeProjects = (value: unknown): DesktopSettings['projects'] | undefin
if (typeof candidate.sidebarCollapsed === 'boolean') {
(project as unknown as Record<string, unknown>).sidebarCollapsed = candidate.sidebarCollapsed;
}
// Preserve worktreeDefaults
if (candidate.worktreeDefaults && typeof candidate.worktreeDefaults === 'object') {
const wt = candidate.worktreeDefaults as Record<string, unknown>;
const defaults: Record<string, unknown> = {};
if (typeof wt.baseBranch === 'string' && wt.baseBranch.trim()) {
defaults.baseBranch = wt.baseBranch.trim();
}
if (typeof wt.autoCreateWorktree === 'boolean') {
defaults.autoCreateWorktree = wt.autoCreateWorktree;
}
if (Object.keys(defaults).length > 0) {
(project as unknown as Record<string, unknown>).worktreeDefaults = defaults;
}
}
result.push(project);
}
+61 -58
View File
@@ -12,9 +12,7 @@ import { useContextStore } from '@/stores/contextStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { checkIsGitRepository } from '@/lib/gitApi';
import { generateBranchName } from '@/lib/git/branchNameGenerator';
import {
getWorktreeStatus,
} from '@/lib/git/worktreeService';
import { getRootBranch, getWorktreeStatus } from '@/lib/worktrees/worktreeStatus';
import { getWorktreeSetupCommands } from '@/lib/openchamberConfig';
import {
createSdkWorktree,
@@ -28,11 +26,26 @@ const normalizePath = (value: string): string => value.replace(/\\/g, '/').repla
const resolveProjectRef = (directory: string): ProjectRef | null => {
const normalized = normalizePath(directory);
const projects = useProjectsStore.getState().projects;
const match = projects.find((project) => normalizePath(project.path) === normalized);
if (!match) {
if (projects.length === 0) {
return null;
}
return { id: match.id, path: match.path };
const activeProject = useProjectsStore.getState().getActiveProject();
if (activeProject?.path) {
const activePath = normalizePath(activeProject.path);
if (normalized === activePath || normalized.startsWith(`${activePath}/`)) {
return { id: activeProject.id, path: activeProject.path };
}
}
const matches = projects.filter((project) => {
const projectPath = normalizePath(project.path);
return normalized === projectPath || normalized.startsWith(`${projectPath}/`);
});
const match = matches.sort((a, b) => normalizePath(b.path).length - normalizePath(a.path).length)[0];
return match ? { id: match.id, path: match.path } : null;
};
// Track if we're currently creating a worktree session
@@ -40,7 +53,7 @@ let isCreatingWorktreeSession = false;
/**
* Create a new session with an auto-generated worktree.
* Uses project's worktree defaults (branch prefix, base branch) from settings.
* Uses project's worktree defaults for naming/metadata.
*
* @returns The created session, or null if creation failed
*/
@@ -78,27 +91,21 @@ export async function createWorktreeSession(): Promise<{ id: string } | null> {
startConfigUpdate("Creating new worktree session...");
try {
// Get worktree defaults from project settings
const worktreeDefaults = activeProject.worktreeDefaults;
const baseBranch = worktreeDefaults?.baseBranch;
const projectRef: ProjectRef = { id: activeProject.id, path: projectDirectory };
// Generate a friendly name (SDK will slugify + ensure uniqueness).
const preferredName = generateBranchName();
const startPoint = baseBranch && baseBranch !== 'HEAD' ? baseBranch : undefined;
const setupCommands = await getWorktreeSetupCommands(projectRef);
const rootBranch = await getRootBranch(projectRef.path);
const metadata = await createSdkWorktree(projectRef, {
preferredName,
setupCommands,
startPoint,
});
const createdMetadata = {
...metadata,
createdFromBranch: startPoint ?? 'HEAD',
createdFromBranch: rootBranch,
kind: 'standard' as const,
};
@@ -238,21 +245,6 @@ export async function createWorktreeSessionForBranch(
return null;
}
// Check if it's a git repo
let isGitRepo = false;
try {
isGitRepo = await checkIsGitRepository(projectDirectory);
} catch {
// Ignore errors, treat as not a git repo
}
if (!isGitRepo) {
toast.error('Not a Git repository', {
description: 'Worktrees can only be created in Git repositories.',
});
return null;
}
isCreatingWorktreeSession = true;
startConfigUpdate("Creating worktree session...");
@@ -262,16 +254,31 @@ export async function createWorktreeSessionForBranch(
throw new Error('Project is not registered in OpenChamber');
}
// Check if it's a git repo (root project path)
let isGitRepo = false;
try {
isGitRepo = await checkIsGitRepository(projectRef.path);
} catch {
// Ignore errors, treat as not a git repo
}
if (!isGitRepo) {
toast.error('Not a Git repository', {
description: 'Worktrees can only be created in Git repositories.',
});
return null;
}
const setupCommands = await getWorktreeSetupCommands(projectRef);
const rootBranch = await getRootBranch(projectRef.path);
const metadata = await createSdkWorktree(projectRef, {
preferredName: branchName,
setupCommands,
startPoint: branchName,
});
const createdMetadata = {
...metadata,
createdFromBranch: branchName,
createdFromBranch: rootBranch,
kind: 'standard' as const,
};
@@ -389,33 +396,19 @@ export async function createWorktreeSessionForBranch(
}
/**
* Create a worktree session for a new branch (created at startPoint).
* This avoids checking out the branch in the main worktree.
* Create a worktree session for a new branch name.
* Callers can still use startPoint for metadata or follow-up git operations.
*/
export async function createWorktreeSessionForNewBranch(
projectDirectory: string,
preferredBranchName: string,
startPoint: string,
options?: { allowSuffix?: boolean; kind?: 'pr' | 'standard' }
startPoint?: string,
options?: { kind?: 'pr' | 'standard' }
): Promise<{ id: string; branch: string } | null> {
if (isCreatingWorktreeSession) {
return null;
}
let isGitRepo = false;
try {
isGitRepo = await checkIsGitRepository(projectDirectory);
} catch {
// ignore
}
if (!isGitRepo) {
toast.error('Not a Git repository', {
description: 'Worktrees can only be created in Git repositories.',
});
return null;
}
isCreatingWorktreeSession = true;
startConfigUpdate('Creating worktree session...');
@@ -426,7 +419,6 @@ export async function createWorktreeSessionForNewBranch(
throw new Error('Branch name is required');
}
const allowSuffix = options?.allowSuffix !== false;
const kind = options?.kind ?? 'standard';
const projectRef = resolveProjectRef(projectDirectory);
@@ -434,19 +426,31 @@ export async function createWorktreeSessionForNewBranch(
throw new Error('Project is not registered in OpenChamber');
}
let isGitRepo = false;
try {
isGitRepo = await checkIsGitRepository(projectRef.path);
} catch {
// ignore
}
if (!isGitRepo) {
toast.error('Not a Git repository', {
description: 'Worktrees can only be created in Git repositories.',
});
return null;
}
const setupCommands = await getWorktreeSetupCommands(projectRef);
const rootBranch = await getRootBranch(projectRef.path);
try {
const metadata = await createSdkWorktree(projectRef, {
preferredName: base,
setupCommands,
startPoint: start,
allowSuffix,
});
const createdMetadata = {
...metadata,
createdFromBranch: start,
createdFromBranch: rootBranch || start,
kind,
};
@@ -541,8 +545,8 @@ export async function createWorktreeSessionForNewBranch(
}
/**
* Same as createWorktreeSessionForNewBranch, but does NOT suffix the branch name.
* Use when the worktree must be created on an exact branch name (e.g. PR head ref).
* Same as createWorktreeSessionForNewBranch, but preserves the exact branch name.
* Use when the worktree must be tied to a specific ref (e.g. PR head ref).
*/
export async function createWorktreeSessionForNewBranchExact(
projectDirectory: string,
@@ -551,7 +555,6 @@ export async function createWorktreeSessionForNewBranchExact(
options?: { kind?: 'pr' | 'standard' }
): Promise<{ id: string; branch: string } | null> {
return createWorktreeSessionForNewBranch(projectDirectory, branchName, startPoint, {
allowSuffix: false,
kind: options?.kind,
});
}
+96 -184
View File
@@ -1,17 +1,10 @@
import { opencodeClient } from '@/lib/opencode/client';
import { substituteCommandVariables } from '@/lib/openchamberConfig';
import type { WorktreeMetadata } from '@/types/worktree';
import {
listWorktrees as listLegacyGitWorktrees,
mapWorktreeToMetadata,
removeWorktree as removeLegacyWorktree,
} from '@/lib/git/worktreeService';
import { deleteGitBranch, deleteRemoteBranch, removeGitWorktree } from '@/lib/gitApi';
import { deleteRemoteBranch } from '@/lib/gitApi';
export type ProjectRef = { id: string; path: string };
const WORKTREE_LEGACY_ROOT = '.openchamber';
const normalizePath = (value: string): string => {
const replaced = value.replace(/\\/g, '/');
if (replaced === '/') {
@@ -20,13 +13,6 @@ const normalizePath = (value: string): string => {
return replaced.length > 1 ? replaced.replace(/\/+$/, '') : replaced;
};
const isLegacyWorktreePath = (projectDirectory: string, candidatePath: string): boolean => {
const project = normalizePath(projectDirectory);
const candidate = normalizePath(candidatePath);
const root = `${project}/${WORKTREE_LEGACY_ROOT}/`;
return candidate.startsWith(root);
};
const slugifyWorktreeName = (value: string): string => {
return value
.trim()
@@ -36,14 +22,6 @@ const slugifyWorktreeName = (value: string): string => {
.slice(0, 80);
};
const shellQuote = (value: string): string => {
const v = value.trim();
if (!v) {
return "''";
}
return `'${v.replace(/'/g, `'\\''`)}'`;
};
const unwrapSdkData = (value: unknown): unknown => {
if (!value || typeof value !== 'object') {
return value;
@@ -61,36 +39,12 @@ const deriveSdkWorktreeNameFromDirectory = (directory: string): string => {
return parts[parts.length - 1] ?? normalized;
};
type WorktreeRemovalParams = Record<string, unknown>;
type WorktreeRemovalMethod = (params?: WorktreeRemovalParams) => Promise<unknown>;
const getWorktreeMethod = (client: unknown, key: string): WorktreeRemovalMethod | null => {
if (!client || (typeof client !== 'object' && typeof client !== 'function')) {
return null;
}
const record = client as Record<string, unknown>;
const candidate = record[key];
if (typeof candidate !== 'function') {
return null;
}
// Keep method binding; SDK methods use `this.client`.
return (params?: WorktreeRemovalParams) => (candidate as (this: unknown, p?: WorktreeRemovalParams) => Promise<unknown>).call(client, params);
};
export const buildSdkStartCommand = (args: {
projectDirectory: string;
setupCommands: string[];
startPoint?: string | null;
}): string | undefined => {
const commands: string[] = [];
const startPoint = typeof args.startPoint === 'string' ? args.startPoint.trim() : '';
if (startPoint && startPoint !== 'HEAD') {
commands.push(`git reset --hard ${shellQuote(startPoint)}`);
} else {
commands.push('git reset --hard HEAD');
}
for (const raw of args.setupCommands) {
const trimmed = raw.trim();
if (!trimmed) continue;
@@ -103,13 +57,69 @@ export const buildSdkStartCommand = (args: {
return joined.trim().length > 0 ? joined : undefined;
};
const waitForSdkWorktreeReady = async (directory: string, timeoutMs = 60_000): Promise<void> => {
const target = normalizePath(directory);
if (!target) {
return;
}
await new Promise<void>((resolve, reject) => {
let done = false;
let unsubscribe = () => {};
let timeout: ReturnType<typeof setTimeout> | null = null;
const cleanup = () => {
if (timeout) {
clearTimeout(timeout);
}
try {
unsubscribe();
} catch {
// ignore
}
};
const finish = (result?: { error?: string }) => {
if (done) return;
done = true;
cleanup();
if (result?.error) {
reject(new Error(result.error));
} else {
resolve();
}
};
timeout = setTimeout(() => {
finish({ error: 'Worktree startup timed out' });
}, timeoutMs);
unsubscribe = opencodeClient.subscribeToGlobalEvents(
(event) => {
const payload = event.payload as { type?: string; properties?: Record<string, unknown> };
if (payload?.type === 'worktree.ready') {
finish();
return;
}
if (payload?.type === 'worktree.failed') {
const message = typeof payload.properties?.message === 'string'
? payload.properties.message
: 'Worktree failed to start';
finish({ error: message });
}
},
undefined,
undefined,
{ directory: target }
);
});
};
export async function listProjectWorktrees(project: ProjectRef): Promise<WorktreeMetadata[]> {
const projectDirectory = project.path;
const scoped = opencodeClient.getScopedApiClient(projectDirectory);
const results: WorktreeMetadata[] = [];
// SDK worktrees (new)
// SDK worktrees
try {
const raw = await scoped.worktree.list();
const data = unwrapSdkData(raw);
@@ -126,42 +136,14 @@ export async function listProjectWorktrees(project: ProjectRef): Promise<Worktre
name,
path: directory,
projectDirectory,
branch: `opencode/${name}`,
branch: '',
label: name,
});
}
} catch {
// ignore
}
// Legacy worktrees (<project>/.openchamber/*)
// LEGACY_WORKTREES: list legacy git worktrees rooted under <project>/.openchamber
try {
const legacy = await listLegacyGitWorktrees(projectDirectory);
const mapped = legacy
.map((info) => mapWorktreeToMetadata(projectDirectory, info))
.filter((meta) => isLegacyWorktreePath(projectDirectory, meta.path))
.map((meta) => ({ ...meta, source: 'legacy' as const }));
results.push(...mapped);
} catch {
// ignore
}
// Dedupe by path, prefer SDK entry on collision.
const byPath = new Map<string, WorktreeMetadata>();
for (const meta of results) {
const key = normalizePath(meta.path);
const existing = byPath.get(key);
if (!existing) {
byPath.set(key, meta);
continue;
}
if (existing.source !== 'sdk' && meta.source === 'sdk') {
byPath.set(key, meta);
}
}
return Array.from(byPath.values()).sort((a, b) => {
return results.sort((a, b) => {
const aLabel = (a.label || a.branch || a.path).toLowerCase();
const bLabel = (b.label || b.branch || b.path).toLowerCase();
return aLabel.localeCompare(bLabel);
@@ -171,8 +153,6 @@ export async function listProjectWorktrees(project: ProjectRef): Promise<Worktre
export async function createSdkWorktree(project: ProjectRef, args: {
preferredName?: string;
setupCommands?: string[];
startPoint?: string | null;
allowSuffix?: boolean;
}): Promise<WorktreeMetadata> {
const projectDirectory = project.path;
const scoped = opencodeClient.getScopedApiClient(projectDirectory);
@@ -184,52 +164,42 @@ export async function createSdkWorktree(project: ProjectRef, args: {
const startCommand = buildSdkStartCommand({
projectDirectory,
setupCommands: commands,
startPoint: args.startPoint,
});
let lastError: unknown = null;
const allowSuffix = args.allowSuffix !== false;
const maxAttempts = seed ? (allowSuffix ? 6 : 1) : 1;
const name = seed || undefined;
const raw = await scoped.worktree.create({
worktreeCreateInput: {
...(name ? { name } : {}),
...(startCommand ? { startCommand } : {}),
},
});
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
const name = seed ? (attempt === 0 ? seed : `${seed}-${attempt + 1}`) : undefined;
try {
const raw = await scoped.worktree.create({
worktreeCreateInput: {
...(name ? { name } : {}),
...(startCommand ? { startCommand } : {}),
},
});
const data = unwrapSdkData(raw);
if (!data || typeof data !== 'object') {
throw new Error('Invalid worktree.create response');
}
const record = data as Record<string, unknown>;
const returnedName = typeof record.name === 'string' ? record.name : name;
const returnedBranch = typeof record.branch === 'string' ? record.branch : (returnedName ? `opencode/${returnedName}` : '');
const returnedDirectory = typeof record.directory === 'string' ? record.directory : '';
if (!returnedName || !returnedDirectory) {
throw new Error('Worktree create missing name/directory');
}
return {
source: 'sdk',
name: returnedName,
path: normalizePath(returnedDirectory),
projectDirectory,
branch: returnedBranch,
label: returnedName,
};
} catch (err) {
lastError = err;
}
const data = unwrapSdkData(raw);
if (!data || typeof data !== 'object') {
throw new Error('Invalid worktree.create response');
}
const message = lastError instanceof Error ? lastError.message : 'Failed to create worktree';
throw new Error(message);
const record = data as Record<string, unknown>;
const returnedName = typeof record.name === 'string' ? record.name : name;
const returnedBranch = typeof record.branch === 'string' ? record.branch : (returnedName ? `opencode/${returnedName}` : '');
const returnedDirectory = typeof record.directory === 'string' ? record.directory : '';
if (!returnedName || !returnedDirectory) {
throw new Error('Worktree create missing name/directory');
}
const metadata: WorktreeMetadata = {
source: 'sdk',
name: returnedName,
path: normalizePath(returnedDirectory),
projectDirectory,
branch: returnedBranch,
label: returnedName,
};
await waitForSdkWorktreeReady(metadata.path);
return metadata;
}
export async function removeProjectWorktree(project: ProjectRef, worktree: WorktreeMetadata, options?: {
@@ -239,74 +209,16 @@ export async function removeProjectWorktree(project: ProjectRef, worktree: Workt
}): Promise<void> {
const projectDirectory = project.path;
const deleteLocalBranch = true;
const deleteRemote = Boolean(options?.deleteRemoteBranch);
const remoteName = options?.remoteName;
if (worktree.source === 'sdk') {
const scoped = opencodeClient.getScopedApiClient(projectDirectory);
const worktreeClient = scoped.worktree as unknown;
const force = Boolean(options?.force ?? true);
const fallbackRemoveViaGit = async () => {
await removeGitWorktree(projectDirectory, { path: worktree.path, force });
};
const removeMethod = getWorktreeMethod(worktreeClient, 'remove');
if (removeMethod) {
const raw = await removeMethod({ worktreeRemoveInput: { directory: worktree.path } });
const ok = unwrapSdkData(raw);
if (ok !== true) {
await fallbackRemoveViaGit();
}
} else {
const deleteMethod = getWorktreeMethod(worktreeClient, 'delete');
if (deleteMethod) {
const raw = await deleteMethod({ worktreeDeleteInput: { directory: worktree.path } });
const ok = unwrapSdkData(raw);
if (ok !== true) {
await fallbackRemoveViaGit();
}
} else {
const archiveMethod = getWorktreeMethod(worktreeClient, 'archive');
if (archiveMethod) {
const raw = await archiveMethod({ worktreeArchiveInput: { directory: worktree.path } });
const ok = unwrapSdkData(raw);
if (ok !== true) {
await fallbackRemoveViaGit();
}
} else {
throw new Error('Worktree removal is not supported by this SDK version.');
}
}
}
// Some OpenCode builds only update internal state; remove git worktree best-effort.
await fallbackRemoveViaGit().catch(() => undefined);
// Best-effort branch cleanup. Some OpenCode builds may keep the branch.
const branchName = (worktree.branch || '').replace(/^refs\/heads\//, '').trim();
if (deleteLocalBranch && branchName) {
await deleteGitBranch(projectDirectory, { branch: branchName, force: true }).catch(() => undefined);
}
if (deleteRemote && branchName) {
await deleteRemoteBranch(projectDirectory, { branch: branchName, remote: remoteName }).catch(() => undefined);
}
return;
const scoped = opencodeClient.getScopedApiClient(projectDirectory);
const raw = await scoped.worktree.remove({ worktreeRemoveInput: { directory: worktree.path } });
const ok = unwrapSdkData(raw);
if (ok !== true) {
throw new Error('Worktree removal failed');
}
// LEGACY_WORKTREES: delete legacy git worktree under <project>/.openchamber
const statusIsDirty = Boolean(worktree.status?.isDirty);
const force = Boolean(options?.force ?? statusIsDirty);
await removeGitWorktree(projectDirectory, { path: worktree.path, force }).catch(async () => {
await removeLegacyWorktree({ projectDirectory, path: worktree.path, force: true });
});
const branchName = (worktree.branch || '').replace(/^refs\/heads\//, '').trim();
if (deleteLocalBranch && branchName) {
await deleteGitBranch(projectDirectory, { branch: branchName, force: true }).catch(() => undefined);
}
if (deleteRemote && branchName) {
await deleteRemoteBranch(projectDirectory, { branch: branchName, remote: remoteName }).catch(() => undefined);
}
@@ -0,0 +1,97 @@
import { getGitStatus } from '@/lib/gitApi';
import { execCommand } from '@/lib/execCommands';
import type { WorktreeMetadata } from '@/types/worktree';
const normalizePath = (value: string): string => {
if (!value) {
return '';
}
const replaced = value.replace(/\\/g, '/');
if (replaced === '/') {
return '/';
}
return replaced.replace(/\/+$/, '');
};
const toAbsolutePath = (baseDir: string, maybeRelativePath: string): string => {
const normalizedBase = normalizePath(baseDir);
const normalizedInput = normalizePath(maybeRelativePath);
if (!normalizedInput) return normalizedBase;
if (normalizedInput.startsWith('/')) return normalizedInput;
const stack = normalizedBase.split('/').filter(Boolean);
const parts = normalizedInput.split('/').filter(Boolean);
for (const part of parts) {
if (part === '.') continue;
if (part === '..') {
stack.pop();
continue;
}
stack.push(part);
}
return `/${stack.join('/')}`;
};
const derivePrimaryWorktreeRootFromGitDir = (gitDir: string): string | null => {
const normalized = normalizePath(gitDir);
if (!normalized) return null;
if (normalized.endsWith('/.git')) {
return normalized.slice(0, -'/.git'.length) || null;
}
const worktreesMarker = '/.git/worktrees/';
const markerIndex = normalized.indexOf(worktreesMarker);
if (markerIndex > 0) {
return normalized.slice(0, markerIndex) || null;
}
return null;
};
export async function getWorktreeStatus(worktreePath: string): Promise<WorktreeMetadata['status']> {
const normalizedPath = normalizePath(worktreePath);
const status = await getGitStatus(normalizedPath);
return {
isDirty: !status.isClean,
ahead: status.ahead,
behind: status.behind,
upstream: status.tracking,
};
}
export async function getRootBranch(projectDirectory: string): Promise<string> {
const normalizedPath = normalizePath(projectDirectory);
if (!normalizedPath) {
return 'HEAD';
}
const resolveProjectRoot = async (directory: string): Promise<string> => {
const absoluteGitDirResult = await execCommand('git rev-parse --absolute-git-dir', directory);
const absoluteGitDir = normalizePath((absoluteGitDirResult.stdout || '').trim());
if (absoluteGitDirResult.success && absoluteGitDir) {
const rootFromAbsoluteGitDir = derivePrimaryWorktreeRootFromGitDir(absoluteGitDir);
if (rootFromAbsoluteGitDir) {
return rootFromAbsoluteGitDir;
}
}
const commonDirResult = await execCommand('git rev-parse --git-common-dir', directory);
const rawCommonDir = normalizePath((commonDirResult.stdout || '').trim());
if (!commonDirResult.success || !rawCommonDir) return directory;
const commonDir = toAbsolutePath(directory, rawCommonDir);
const rootFromCommonDir = derivePrimaryWorktreeRootFromGitDir(commonDir);
if (rootFromCommonDir) {
return rootFromCommonDir;
}
return directory;
};
try {
const projectRoot = await resolveProjectRoot(normalizedPath).catch(() => normalizedPath);
const status = await getGitStatus(projectRoot);
const branch = typeof status.current === 'string' ? status.current.trim() : '';
return branch || 'HEAD';
} catch {
return 'HEAD';
}
}
+38 -30
View File
@@ -4,7 +4,7 @@ import type { Session } from "@opencode-ai/sdk/v2";
import { opencodeClient } from "@/lib/opencode/client";
import { getSafeStorage } from "./utils/safeStorage";
import type { WorktreeMetadata } from "@/types/worktree";
import { getWorktreeStatus, listWorktrees, mapWorktreeToMetadata } from "@/lib/git/worktreeService";
import { getWorktreeStatus } from "@/lib/worktrees/worktreeStatus";
import { listProjectWorktrees, removeProjectWorktree } from "@/lib/worktrees/worktreeManager";
import { useDirectoryStore } from "./useDirectoryStore";
import { useProjectsStore } from "./useProjectsStore";
@@ -52,8 +52,6 @@ type SessionStore = SessionState & SessionActions;
const safeStorage = getSafeStorage();
const SESSION_SELECTION_STORAGE_KEY = "oc.sessionSelectionByDirectory";
const WORKTREE_ROOT = ".openchamber";
type SessionSelectionMap = Record<string, string>;
const readSessionSelectionMap = (): SessionSelectionMap => {
@@ -278,11 +276,11 @@ const hydrateSessionWorktreeMetadata = async (
return null;
}
let worktreeEntries;
let worktreeEntries: WorktreeMetadata[];
try {
worktreeEntries = await listWorktrees(normalizedProject);
worktreeEntries = await listProjectWorktrees({ id: `path:${normalizedProject}`, path: normalizedProject });
} catch (error) {
console.debug("Failed to hydrate worktree metadata from git worktree list:", error);
console.debug("Failed to hydrate worktree metadata from worktree list:", error);
return null;
}
@@ -298,8 +296,7 @@ const hydrateSessionWorktreeMetadata = async (
}
const worktreeMapByPath = new Map<string, WorktreeMetadata>();
worktreeEntries.forEach((info) => {
const metadata = mapWorktreeToMetadata(normalizedProject, info);
worktreeEntries.forEach((metadata) => {
const normalizedPath = normalizePath(metadata.path) ?? metadata.path;
if (normalizedPath === normalizedProject) {
@@ -312,6 +309,26 @@ const hydrateSessionWorktreeMetadata = async (
let mutated = false;
const next = new Map(existingMetadata);
const mergeHydratedMetadata = (
hydrated: WorktreeMetadata,
previous?: WorktreeMetadata
): WorktreeMetadata => {
if (!previous) {
return hydrated;
}
return {
...previous,
...hydrated,
branch: hydrated.branch || previous.branch,
label: hydrated.label || previous.label,
name: hydrated.name || previous.name,
projectDirectory: hydrated.projectDirectory || previous.projectDirectory,
createdFromBranch: hydrated.createdFromBranch || previous.createdFromBranch,
kind: hydrated.kind || previous.kind,
status: hydrated.status || previous.status,
};
};
sessionsWithDirectory.forEach(({ id, directory }) => {
const metadata = worktreeMapByPath.get(directory);
if (!metadata) {
@@ -322,8 +339,19 @@ const hydrateSessionWorktreeMetadata = async (
}
const previous = next.get(id);
if (!previous || previous.path !== metadata.path || previous.branch !== metadata.branch || previous.label !== metadata.label) {
next.set(id, metadata);
const merged = mergeHydratedMetadata(metadata, previous);
if (
!previous ||
previous.path !== merged.path ||
previous.branch !== merged.branch ||
previous.label !== merged.label ||
previous.name !== merged.name ||
previous.projectDirectory !== merged.projectDirectory ||
previous.createdFromBranch !== merged.createdFromBranch ||
previous.kind !== merged.kind ||
previous.source !== merged.source
) {
next.set(id, merged);
mutated = true;
}
});
@@ -569,7 +597,6 @@ export const useSessionStore = create<SessionStore>()(
validPaths.add(normalizedProject);
if (isGitRepo) {
const worktreeRoot = `${normalizedProject}/${WORKTREE_ROOT}`;
try {
const candidates = new Set<string>();
@@ -584,25 +611,6 @@ export const useSessionStore = create<SessionStore>()(
}
});
// LEGACY_WORKTREES: check if .openchamber directory exists before listing it
// LEGACY_WORKTREES: filesystem scan fallback for legacy <project>/.openchamber/*
const projectEntriesList = await opencodeClient.listLocalDirectory(normalizedProject);
const worktreeDirExists = projectEntriesList.some(
(entry) => entry.isDirectory && entry.name === WORKTREE_ROOT
);
if (worktreeDirExists) {
const entries = await opencodeClient.listLocalDirectory(worktreeRoot);
entries
.filter((entry) => entry.isDirectory)
.forEach((entry) => {
const isAbsolutePath = /^([A-Za-z]:)?\//.test(entry.path);
const resolvedPath = isAbsolutePath ? entry.path : `${worktreeRoot}/${entry.name}`;
const normalizedPath = normalizePath(resolvedPath) ?? resolvedPath;
candidates.add(normalizedPath);
});
}
candidates.forEach((candidate) => {
const normalizedCandidate = normalizePath(candidate) ?? candidate;
validPaths.add(normalizedCandidate);
+11 -82
View File
@@ -6,11 +6,8 @@ import { useDirectoryStore } from './useDirectoryStore';
import { useProjectsStore } from './useProjectsStore';
import { useSessionStore } from './useSessionStore';
import type { WorktreeMetadata } from '@/types/worktree';
import { listWorktrees, mapWorktreeToMetadata } from '@/lib/git/worktreeService';
import type { Session } from '@opencode-ai/sdk/v2';
// LEGACY_WORKTREES: legacy worktree root inside project.
const OPENCHAMBER_DIR = '.openchamber';
const resolveProjectDirectory = (currentDirectory: string | null | undefined): string | null => {
const projectsState = useProjectsStore.getState();
@@ -109,48 +106,6 @@ const normalize = (value: string): string => {
return replaced.replace(/\/+$/, '');
};
const buildOpenChamberRoot = (projectDirectory: string): string => {
const normalizedProject = normalize(projectDirectory);
if (!normalizedProject || normalizedProject === '/') {
return `/${OPENCHAMBER_DIR}`;
}
return `${normalizedProject}/${OPENCHAMBER_DIR}`;
};
const resolveDirectoryListingPaths = (root: string, entries: Array<{ name?: string; path?: string }>): string[] => {
const normalizedRoot = normalize(root);
return entries
.map((entry) => {
const entryPath = typeof entry.path === 'string' && entry.path.trim().length > 0 ? entry.path : null;
if (entryPath) {
const normalizedEntry = normalize(entryPath);
if (normalizedEntry) {
return normalizedEntry;
}
}
const name = typeof entry.name === 'string' ? entry.name.trim() : '';
if (!name || !normalizedRoot) {
return null;
}
return `${normalizedRoot}/${name}`;
})
.filter((value): value is string => Boolean(value));
};
const listOpenChamberDirectories = async (root: string): Promise<string[]> => {
const normalizedRoot = normalize(root);
if (!normalizedRoot) {
return [];
}
try {
const entries = await opencodeClient.listLocalDirectory(normalizedRoot);
const directories = entries.filter((entry) => entry.isDirectory);
return resolveDirectoryListingPaths(normalizedRoot, directories);
} catch {
return [];
}
};
const startsWithDirectory = (candidate: string, root: string): boolean => {
const normalizedCandidate = normalize(candidate);
@@ -253,12 +208,12 @@ const buildWorktreeMetadataByPath = async (group: AgentGroup, projectDirectory:
}
try {
const infos = await listWorktrees(projectDirectory);
const infoByPath = new Map(infos.map((info) => [normalize(info.worktree), info]));
const worktrees = await listProjectWorktrees({ id: `path:${projectDirectory}`, path: projectDirectory });
const infoByPath = new Map(worktrees.map((meta) => [normalize(meta.path), meta]));
missingPaths.forEach((path) => {
const info = infoByPath.get(path);
if (info) {
map.set(path, mapWorktreeToMetadata(projectDirectory, info));
map.set(path, info);
}
});
} catch {
@@ -419,24 +374,17 @@ export const useAgentGroupsStore = create<AgentGroupsStore>()(
: [];
const worktreeDirectorySet = new Set<string>();
const worktreeMetadataMap = new Map<string, WorktreeMetadata>();
[...managedWorktrees, ...managedWorktreesCanonical].forEach((meta) => {
if (meta?.path) {
worktreeDirectorySet.add(normalize(meta.path));
const key = normalize(meta.path);
worktreeDirectorySet.add(key);
if (!worktreeMetadataMap.has(key)) {
worktreeMetadataMap.set(key, meta);
}
}
});
// Get git worktree info first - we need to query each worktree separately
let worktreeInfoMap = new Map<string, Awaited<ReturnType<typeof listWorktrees>>[number]>();
let worktreeInfoList: Awaited<ReturnType<typeof listWorktrees>> = [];
try {
worktreeInfoList = await listWorktrees(normalizedProject);
worktreeInfoMap = new Map(
worktreeInfoList.map((info) => [normalize(info.worktree), info])
);
} catch {
console.debug('Failed to list git worktrees');
}
const fetchCandidateSessions = async (): Promise<Session[]> => {
try {
const scoped = await apiClient.session.list({ directory: normalizedProject });
@@ -497,23 +445,6 @@ export const useAgentGroupsStore = create<AgentGroupsStore>()(
// 1) Known worktree directories for this project
worktreeDirectorySet.forEach((dir) => candidates.add(dir));
// 2) Git worktree list (covers SDK + legacy)
worktreeInfoList
.map((info) => normalize(info.worktree))
.filter(Boolean)
.forEach((worktreePath) => candidates.add(worktreePath));
// LEGACY_WORKTREES: optional filesystem scan for legacy <project>/.openchamber/*
const roots = [buildOpenChamberRoot(normalizedProject), buildOpenChamberRoot(canonicalProject)]
.map((p) => normalize(p))
.filter(Boolean);
await Promise.all(
Array.from(new Set(roots)).map(async (root) => {
const dirs = await listOpenChamberDirectories(root);
dirs.forEach((dir) => candidates.add(dir));
})
);
if (candidates.size > 0) {
allSessions = await fetchSessionsByWorktreeDirectories(Array.from(candidates));
}
@@ -533,7 +464,7 @@ export const useAgentGroupsStore = create<AgentGroupsStore>()(
if (!parsed) continue; // Skip sessions without valid agent group title
const sessionPath = normalize(session.directory);
const worktreeInfo = worktreeInfoMap.get(sessionPath);
const worktreeInfo = worktreeMetadataMap.get(sessionPath);
const agentSession: AgentGroupSession = {
id: session.id,
@@ -543,9 +474,7 @@ export const useAgentGroupsStore = create<AgentGroupsStore>()(
instanceNumber: parsed.index,
branch: worktreeInfo?.branch ?? '',
displayLabel: `${parsed.provider}/${parsed.model}`,
worktreeMetadata: worktreeInfo
? mapWorktreeToMetadata(normalizedProject, worktreeInfo)
: undefined,
worktreeMetadata: worktreeInfo,
};
const existing = groupsMap.get(parsed.groupSlug);
+3 -7
View File
@@ -4,6 +4,7 @@ import type { CreateMultiRunParams, CreateMultiRunResult } from '@/types/multiru
import { opencodeClient } from '@/lib/opencode/client';
import { saveWorktreeSetupCommands } from '@/lib/openchamberConfig';
import { createSdkWorktree, type ProjectRef } from '@/lib/worktrees/worktreeManager';
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
import { checkIsGitRepository } from '@/lib/gitApi';
import { useSessionStore } from './sessionStore';
import { useDirectoryStore } from './useDirectoryStore';
@@ -121,11 +122,7 @@ export const useMultiRunStore = create<MultiRunStore>()(
}
const groupSlug = toGitSafeSlug(groupName);
const worktreeBaseBranch =
typeof params.worktreeBaseBranch === 'string' && params.worktreeBaseBranch.trim().length > 0
? params.worktreeBaseBranch.trim()
: 'HEAD';
const startPoint = worktreeBaseBranch !== 'HEAD' ? worktreeBaseBranch : undefined;
const rootBranch = await getRootBranch(directory);
const createdRuns: Array<{
sessionId: string;
@@ -164,12 +161,11 @@ export const useMultiRunStore = create<MultiRunStore>()(
const worktreeMetadata = await createSdkWorktree(project, {
preferredName,
setupCommands: commandsToRun,
startPoint: startPoint ?? null,
});
const enrichedMetadata = {
...worktreeMetadata,
createdFromBranch: startPoint ?? 'HEAD',
createdFromBranch: rootBranch,
kind: 'standard' as const,
};
+1 -47
View File
@@ -1,7 +1,7 @@
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import { opencodeClient } from '@/lib/opencode/client';
import type { ProjectEntry, WorktreeDefaults } from '@/lib/api/types';
import type { ProjectEntry } from '@/lib/api/types';
import type { DesktopSettings } from '@/lib/desktop';
import { updateDesktopSettings } from '@/lib/persistence';
import { getSafeStorage } from './utils/safeStorage';
@@ -27,7 +27,6 @@ interface ProjectsStore {
validateProjectPath: (path: string) => ProjectPathValidationResult;
synchronizeFromSettings: (settings: DesktopSettings) => void;
getActiveProject: () => ProjectEntry | null;
updateWorktreeDefaults: (projectId: string, defaults: Partial<WorktreeDefaults>) => void;
}
const safeStorage = getSafeStorage();
@@ -124,20 +123,6 @@ const sanitizeProjects = (value: unknown): ProjectEntry[] => {
if (typeof candidate.sidebarCollapsed === 'boolean') {
project.sidebarCollapsed = candidate.sidebarCollapsed;
}
if (candidate.worktreeDefaults && typeof candidate.worktreeDefaults === 'object') {
const wt = candidate.worktreeDefaults as Record<string, unknown>;
const defaults: WorktreeDefaults = {};
if (typeof wt.baseBranch === 'string') {
defaults.baseBranch = wt.baseBranch;
}
if (typeof wt.autoCreateWorktree === 'boolean') {
defaults.autoCreateWorktree = wt.autoCreateWorktree;
}
if (Object.keys(defaults).length > 0) {
project.worktreeDefaults = defaults;
}
}
result.push(project);
}
@@ -452,37 +437,6 @@ export const useProjectsStore = create<ProjectsStore>()(
return projects.find((project) => project.id === activeProjectId) ?? null;
},
updateWorktreeDefaults: (projectId: string, defaults: Partial<WorktreeDefaults>) => {
if (vscodeWorkspace) {
return;
}
const { projects, activeProjectId } = get();
const target = projects.find((project) => project.id === projectId);
if (!target) {
return;
}
const merged: WorktreeDefaults = { ...target.worktreeDefaults };
if (defaults.baseBranch !== undefined) {
if (defaults.baseBranch.trim()) {
merged.baseBranch = defaults.baseBranch.trim();
} else {
delete merged.baseBranch;
}
}
if (defaults.autoCreateWorktree !== undefined) {
merged.autoCreateWorktree = defaults.autoCreateWorktree;
}
const nextProjects = projects.map((project) =>
project.id === projectId
? { ...project, worktreeDefaults: Object.keys(merged).length > 0 ? merged : undefined }
: project
);
set({ projects: nextProjects });
persistProjects(nextProjects, activeProjectId);
},
}), { name: 'projects-store' })
);
+1 -2
View File
@@ -3,9 +3,8 @@ export interface WorktreeMetadata {
/**
* Worktree origin.
* - sdk: created/managed by OpenCode SDK worktrees
* - legacy: git worktree under <project>/.openchamber
*/
source?: 'sdk' | 'legacy';
source?: 'sdk';
path: string;
+3 -43
View File
@@ -2171,42 +2171,12 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
}
case 'api:git/worktrees': {
const { directory, method, path: worktreePath, branch, createBranch, force } = (payload || {}) as {
directory?: string;
method?: string;
path?: string;
branch?: string;
createBranch?: boolean;
force?: boolean;
};
const { directory } = (payload || {}) as { directory?: string };
if (!directory) {
return { id, type, success: false, error: 'Directory is required' };
}
const normalizedMethod = typeof method === 'string' ? method.toUpperCase() : 'GET';
if (normalizedMethod === 'GET') {
const worktrees = await gitService.listGitWorktrees(directory);
return { id, type, success: true, data: worktrees };
}
if (normalizedMethod === 'POST') {
if (!worktreePath || !branch) {
return { id, type, success: false, error: 'Path and branch are required' };
}
const result = await gitService.addGitWorktree(directory, worktreePath, branch, createBranch);
return { id, type, success: true, data: result };
}
if (normalizedMethod === 'DELETE') {
if (!worktreePath) {
return { id, type, success: false, error: 'Path is required' };
}
const result = await gitService.removeGitWorktree(directory, worktreePath, force);
return { id, type, success: true, data: result };
}
return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` };
const worktrees = await gitService.listGitWorktrees(directory);
return { id, type, success: true, data: worktrees };
}
case 'api:git/diff': {
@@ -2438,16 +2408,6 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` };
}
case 'api:git/ignore-openchamber': {
// LEGACY_WORKTREES: only needed for <project>/.openchamber era. Safe to remove after legacy support dropped.
const { directory } = (payload || {}) as { directory?: string };
if (!directory) {
return { id, type, success: false, error: 'Directory is required' };
}
await gitService.ensureOpenChamberIgnored(directory);
return { id, type, success: true, data: { success: true } };
}
default:
return { id, type, success: false, error: `Unknown message type: ${type}` };
}
-71
View File
@@ -694,48 +694,6 @@ export async function getAvailableBranchesForWorktree(directory: string): Promis
return availableBranches;
}
/**
* Add a new worktree
*/
export async function addGitWorktree(
directory: string,
worktreePath: string,
branch: string,
createBranch = false
): Promise<{ success: boolean; path: string; branch: string }> {
const args = ['worktree', 'add'];
if (createBranch) {
args.push('-b', branch, worktreePath);
} else {
args.push(worktreePath, branch);
}
const result = await execGit(args, directory);
return {
success: result.exitCode === 0,
path: worktreePath,
branch,
};
}
/**
* Remove a worktree
*/
export async function removeGitWorktree(
directory: string,
worktreePath: string,
force = false
): Promise<{ success: boolean }> {
const args = ['worktree', 'remove'];
if (force) args.push('--force');
args.push(worktreePath);
const result = await execGit(args, directory);
return { success: result.exitCode === 0 };
}
// ============== Diff Operations ==============
/**
@@ -1361,32 +1319,3 @@ export async function setGitIdentity(
return { success: true };
}
// ============== Utility Operations ==============
/**
* Ensure .openchamber is in git exclude
*/
export async function ensureOpenChamberIgnored(directory: string): Promise<void> {
// LEGACY_WORKTREES: only needed for <project>/.openchamber era. Safe to remove after legacy support dropped.
const excludeFile = path.join(directory, '.git', 'info', 'exclude');
try {
const uri = vscode.Uri.file(excludeFile);
let content = '';
try {
const bytes = await vscode.workspace.fs.readFile(uri);
content = Buffer.from(bytes).toString('utf8');
} catch {
// File doesn't exist, we'll create it
}
if (!content.includes('.openchamber')) {
const newContent = content.trimEnd() + '\n.openchamber\n';
await vscode.workspace.fs.writeFile(uri, Buffer.from(newContent, 'utf8'));
}
} catch (error) {
console.warn('[GitService] Failed to update git exclude:', error);
}
}
-26
View File
@@ -17,8 +17,6 @@ import type {
GeneratedCommitMessage,
GeneratedPullRequestDescription,
GitWorktreeInfo,
GitAddWorktreePayload,
GitRemoveWorktreePayload,
GitCommitResult,
CreateGitCommitOptions,
GitPushResult,
@@ -112,30 +110,6 @@ export const createVSCodeGitAPI = (): GitAPI => ({
return sendBridgeMessage<GitWorktreeInfo[]>('api:git/worktrees', { directory, method: 'GET' });
},
addGitWorktree: async (directory: string, payload: GitAddWorktreePayload): Promise<{ success: boolean; path: string; branch: string }> => {
return sendBridgeMessage<{ success: boolean; path: string; branch: string }>('api:git/worktrees', {
directory,
method: 'POST',
path: payload.path,
branch: payload.branch,
createBranch: payload.createBranch,
startPoint: payload.startPoint,
});
},
removeGitWorktree: async (directory: string, payload: GitRemoveWorktreePayload): Promise<{ success: boolean }> => {
return sendBridgeMessage<{ success: boolean }>('api:git/worktrees', {
directory,
method: 'DELETE',
path: payload.path,
force: payload.force,
});
},
ensureOpenChamberIgnored: async (directory: string): Promise<void> => {
await sendBridgeMessage('api:git/ignore-openchamber', { directory });
},
createGitCommit: async (directory: string, message: string, options?: CreateGitCommitOptions): Promise<GitCommitResult> => {
return sendBridgeMessage<GitCommitResult>('api:git/commit', {
directory,
-77
View File
@@ -891,24 +891,6 @@ const sanitizeProjects = (input) => {
...(Number.isFinite(lastOpenedAt) && lastOpenedAt >= 0 ? { lastOpenedAt } : {}),
};
// Preserve worktreeDefaults
if (candidate.worktreeDefaults && typeof candidate.worktreeDefaults === 'object') {
const wt = candidate.worktreeDefaults;
const defaults = {};
if (typeof wt.branchPrefix === 'string' && wt.branchPrefix.trim()) {
defaults.branchPrefix = wt.branchPrefix.trim();
}
if (typeof wt.baseBranch === 'string' && wt.baseBranch.trim()) {
defaults.baseBranch = wt.baseBranch.trim();
}
if (typeof wt.autoCreateWorktree === 'boolean') {
defaults.autoCreateWorktree = wt.autoCreateWorktree;
}
if (Object.keys(defaults).length > 0) {
project.worktreeDefaults = defaults;
}
}
if (typeof candidate.sidebarCollapsed === 'boolean') {
project.sidebarCollapsed = candidate.sidebarCollapsed;
}
@@ -7111,65 +7093,6 @@ Context:
}
});
app.post('/api/git/worktrees', async (req, res) => {
const { addWorktree } = await getGitLibraries();
try {
const directory = req.query.directory;
if (!directory) {
return res.status(400).json({ error: 'directory parameter is required' });
}
const { path, branch, createBranch, startPoint } = req.body;
if (!path || !branch) {
return res.status(400).json({ error: 'path and branch are required' });
}
const result = await addWorktree(directory, path, branch, { createBranch, startPoint });
res.json(result);
} catch (error) {
console.error('Failed to add worktree:', error);
res.status(500).json({ error: error.message || 'Failed to add worktree' });
}
});
app.delete('/api/git/worktrees', async (req, res) => {
const { removeWorktree } = await getGitLibraries();
try {
const directory = req.query.directory;
if (!directory) {
return res.status(400).json({ error: 'directory parameter is required' });
}
const { path, force } = req.body;
if (!path) {
return res.status(400).json({ error: 'path is required' });
}
const result = await removeWorktree(directory, path, { force });
res.json(result);
} catch (error) {
console.error('Failed to remove worktree:', error);
res.status(500).json({ error: error.message || 'Failed to remove worktree' });
}
});
app.post('/api/git/ignore-openchamber', async (req, res) => {
// LEGACY_WORKTREES: only needed for <project>/.openchamber era. Safe to remove after legacy support dropped.
const { ensureOpenChamberIgnored } = await getGitLibraries();
try {
const directory = req.query.directory;
if (!directory) {
return res.status(400).json({ error: 'directory parameter is required' });
}
await ensureOpenChamberIgnored(directory);
res.json({ success: true });
} catch (error) {
console.error('Failed to ignore .openchamber directory:', error);
res.status(500).json({ error: error.message || 'Failed to update git ignore' });
}
});
app.get('/api/git/worktree-type', async (req, res) => {
const { isLinkedWorktree } = await getGitLibraries();
try {
-97
View File
@@ -130,46 +130,6 @@ export async function isGitRepository(directory) {
return fs.existsSync(gitDir);
}
export async function ensureOpenChamberIgnored(directory) {
// LEGACY_WORKTREES: only needed for <project>/.openchamber era. Safe to remove after legacy support dropped.
const directoryPath = normalizeDirectoryPath(directory);
if (!directoryPath || !fs.existsSync(directoryPath)) {
return false;
}
const gitDir = path.join(directoryPath, '.git');
if (!fs.existsSync(gitDir)) {
return false;
}
const infoDir = path.join(gitDir, 'info');
const excludePath = path.join(infoDir, 'exclude');
const entry = '/.openchamber/';
try {
await fsp.mkdir(infoDir, { recursive: true });
let contents = '';
try {
contents = await fsp.readFile(excludePath, 'utf8');
} catch (readError) {
if (readError && readError.code !== 'ENOENT') {
throw readError;
}
}
const lines = contents.split(/\r?\n/).map((line) => line.trim());
if (!lines.includes(entry)) {
const prefix = contents.length > 0 && !contents.endsWith('\n') ? '\n' : '';
await fsp.appendFile(excludePath, `${prefix}${entry}\n`, 'utf8');
}
return true;
} catch (error) {
console.error('Failed to ensure .openchamber ignore:', error);
throw error;
}
}
export async function getGlobalIdentity() {
const git = await createGit();
@@ -1018,63 +978,6 @@ export async function getWorktrees(directory) {
}
}
export async function addWorktree(directory, worktreePath, branch, options = {}) {
const git = await createGit(directory);
try {
const args = ['worktree', 'add'];
const startPoint = typeof options.startPoint === 'string' ? options.startPoint.trim() : '';
if (options.createBranch) {
args.push('-b', branch);
}
args.push(worktreePath);
if (!options.createBranch) {
args.push(branch);
} else if (startPoint) {
args.push(startPoint);
}
await git.raw(args);
return {
success: true,
path: worktreePath,
branch
};
} catch (error) {
console.error('Failed to add worktree:', error);
throw error;
}
}
export async function removeWorktree(directory, worktreePath, options = {}) {
const git = await createGit(directory);
try {
const args = ['worktree', 'remove', worktreePath];
if (options.force) {
args.push('--force');
}
await git.raw(args);
return { success: true };
} catch (error) {
// If the worktree doesn't exist or isn't recognized by git, treat as success
// since the goal (removing the worktree) is already achieved.
const errorMessage = String(error?.message || error || '');
if (errorMessage.includes('is not a working tree') || errorMessage.includes('is not a valid path')) {
return { success: true };
}
console.error('Failed to remove worktree:', error);
throw error;
}
}
export async function deleteBranch(directory, branch, options = {}) {
const git = await createGit(directory);
-3
View File
@@ -18,9 +18,6 @@ export const createWebGitAPI = (): GitAPI => ({
generateCommitMessage: gitApiHttp.generateCommitMessage,
generatePullRequestDescription: gitApiHttp.generatePullRequestDescription,
listGitWorktrees: gitApiHttp.listGitWorktrees,
addGitWorktree: gitApiHttp.addGitWorktree as GitAPI['addGitWorktree'],
removeGitWorktree: gitApiHttp.removeGitWorktree as GitAPI['removeGitWorktree'],
ensureOpenChamberIgnored: gitApiHttp.ensureOpenChamberIgnored,
createGitCommit(directory: string, message: string, options?: CreateGitCommitOptions) {
return gitApiHttp.createGitCommit(directory, message, options);
},