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:
@@ -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) => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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' })
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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}` };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user