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 React from 'react';
|
||||||
import { RiAddLine, RiCloseLine, RiDeleteBinLine, RiInformationLine } from '@remixicon/react';
|
import { RiAddLine, RiCloseLine, RiDeleteBinLine, RiInformationLine } from '@remixicon/react';
|
||||||
import { Input } from '@/components/ui/input';
|
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 { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionStore } from '@/stores/useSessionStore';
|
||||||
import { useGitBranches } from '@/stores/useGitStore';
|
|
||||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||||
import { checkIsGitRepository, getGitBranches } from '@/lib/gitApi';
|
import { checkIsGitRepository } from '@/lib/gitApi';
|
||||||
import { getWorktreeSetupCommands, saveWorktreeSetupCommands } from '@/lib/openchamberConfig';
|
import { getWorktreeSetupCommands, saveWorktreeSetupCommands } from '@/lib/openchamberConfig';
|
||||||
import { listProjectWorktrees } from '@/lib/worktrees/worktreeManager';
|
import { listProjectWorktrees } from '@/lib/worktrees/worktreeManager';
|
||||||
import { sessionEvents } from '@/lib/sessionEvents';
|
import { sessionEvents } from '@/lib/sessionEvents';
|
||||||
import type { WorktreeMetadata } from '@/types/worktree';
|
import type { WorktreeMetadata } from '@/types/worktree';
|
||||||
import { formatPathForDisplay } from '@/lib/utils';
|
import { formatPathForDisplay } from '@/lib/utils';
|
||||||
|
|
||||||
type BranchOption = {
|
|
||||||
value: string;
|
|
||||||
label: string;
|
|
||||||
group: 'special' | 'local' | 'remote';
|
|
||||||
};
|
|
||||||
|
|
||||||
export const WorktreeSectionContent: React.FC = () => {
|
export const WorktreeSectionContent: React.FC = () => {
|
||||||
const activeProject = useProjectsStore((state) => state.getActiveProject());
|
const activeProject = useProjectsStore((state) => state.getActiveProject());
|
||||||
const updateWorktreeDefaults = useProjectsStore((state) => state.updateWorktreeDefaults);
|
|
||||||
|
|
||||||
const projectPath = activeProject?.path ?? null;
|
const projectPath = activeProject?.path ?? null;
|
||||||
const worktreeDefaults = activeProject?.worktreeDefaults;
|
|
||||||
|
|
||||||
const branchesFromStore = useGitBranches(projectPath);
|
|
||||||
|
|
||||||
const { sessions, getWorktreeMetadata } = useSessionStore();
|
const { sessions, getWorktreeMetadata } = useSessionStore();
|
||||||
const homeDirectory = useDirectoryStore((state) => state.homeDirectory);
|
const homeDirectory = useDirectoryStore((state) => state.homeDirectory);
|
||||||
|
|
||||||
const [baseBranch, setBaseBranch] = React.useState(worktreeDefaults?.baseBranch ?? 'HEAD');
|
|
||||||
const [setupCommands, setSetupCommands] = React.useState<string[]>([]);
|
const [setupCommands, setSetupCommands] = React.useState<string[]>([]);
|
||||||
const [isLoadingCommands, setIsLoadingCommands] = React.useState(false);
|
const [isLoadingCommands, setIsLoadingCommands] = React.useState(false);
|
||||||
const [isLoadingGit, setIsLoadingGit] = React.useState(false);
|
|
||||||
const [isGitRepoLocal, setIsGitRepoLocal] = React.useState<boolean | null>(null);
|
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 [availableWorktrees, setAvailableWorktrees] = React.useState<WorktreeMetadata[]>([]);
|
||||||
const [isLoadingWorktrees, setIsLoadingWorktrees] = React.useState(false);
|
const [isLoadingWorktrees, setIsLoadingWorktrees] = React.useState(false);
|
||||||
|
|
||||||
@@ -68,34 +44,20 @@ export const WorktreeSectionContent: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [projectRef, isGitRepoLocal]);
|
}, [projectRef, isGitRepoLocal]);
|
||||||
|
|
||||||
// Load repo + branch info
|
// Load repo info
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!projectPath) return;
|
if (!projectPath) return;
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setIsLoadingGit(true);
|
|
||||||
setIsGitRepoLocal(null);
|
setIsGitRepoLocal(null);
|
||||||
setBranchesLocal(null);
|
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const repoStatus = await checkIsGitRepository(projectPath);
|
const repoStatus = await checkIsGitRepository(projectPath);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setIsGitRepoLocal(repoStatus);
|
setIsGitRepoLocal(repoStatus);
|
||||||
|
|
||||||
if (!repoStatus) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const branchData = await getGitBranches(projectPath);
|
|
||||||
if (cancelled) return;
|
|
||||||
setBranchesLocal({ all: branchData.all, current: branchData.current });
|
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore errors
|
// Ignore errors
|
||||||
} finally {
|
|
||||||
if (!cancelled) {
|
|
||||||
setIsLoadingGit(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
@@ -170,49 +132,6 @@ export const WorktreeSectionContent: React.FC = () => {
|
|||||||
};
|
};
|
||||||
}, [projectRef]);
|
}, [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) => {
|
const handleSetupCommandChange = React.useCallback((index: number, value: string) => {
|
||||||
setSetupCommands((prev) => {
|
setSetupCommands((prev) => {
|
||||||
const next = [...prev];
|
const next = [...prev];
|
||||||
@@ -320,7 +239,7 @@ export const WorktreeSectionContent: React.FC = () => {
|
|||||||
if (!projectPath) {
|
if (!projectPath) {
|
||||||
return (
|
return (
|
||||||
<p className="typography-meta text-muted-foreground">
|
<p className="typography-meta text-muted-foreground">
|
||||||
Select a project to configure worktree defaults.
|
Select a project to manage worktrees.
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -335,82 +254,8 @@ export const WorktreeSectionContent: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<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 */}
|
{/* Setup commands */}
|
||||||
<div className="space-y-4 border-t border-border/40 pt-6">
|
<div className="space-y-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h3 className="typography-ui-header font-semibold text-foreground">Setup commands</h3>
|
<h3 className="typography-ui-header font-semibold text-foreground">Setup commands</h3>
|
||||||
<p className="typography-meta text-muted-foreground">
|
<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" />
|
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
<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.
|
Worktrees live outside the repo (OpenCode-managed). Deleting a worktree also removes linked sessions.
|
||||||
Deleting a worktree also removes linked sessions.
|
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
@@ -496,13 +340,11 @@ export const WorktreeSectionContent: React.FC = () => {
|
|||||||
{worktree.label || worktree.branch || 'Detached HEAD'}
|
{worktree.label || worktree.branch || 'Detached HEAD'}
|
||||||
</p>
|
</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">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="typography-micro text-muted-foreground/60 truncate">
|
<p className="typography-micro text-muted-foreground/60 truncate">
|
||||||
{worktree.source === 'sdk'
|
{formatPathForDisplay(worktree.path, homeDirectory)}
|
||||||
? formatPathForDisplay(worktree.path, homeDirectory)
|
|
||||||
: (worktree.relativePath || worktree.path)}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import {
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { deleteGitBranch, getGitBranches, listGitWorktrees, renameBranch } from '@/lib/gitApi';
|
import { deleteGitBranch, getGitBranches, listGitWorktrees, renameBranch } from '@/lib/gitApi';
|
||||||
import type { GitBranch, GitWorktreeInfo } from '@/lib/api/types';
|
import type { GitBranch, GitWorktreeInfo } from '@/lib/api/types';
|
||||||
import { createWorktreeSessionForBranch } from '@/lib/worktreeSessionCreator';
|
|
||||||
|
|
||||||
export interface BranchPickerProject {
|
export interface BranchPickerProject {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -46,7 +45,6 @@ export function BranchPickerDialog({ open, onOpenChange, project }: BranchPicker
|
|||||||
const [loading, setLoading] = React.useState(false);
|
const [loading, setLoading] = React.useState(false);
|
||||||
const [error, setError] = React.useState<string | null>(null);
|
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 [deletingBranch, setDeletingBranch] = React.useState<string | null>(null);
|
||||||
const [confirmingDelete, setConfirmingDelete] = React.useState<string | null>(null);
|
const [confirmingDelete, setConfirmingDelete] = React.useState<string | null>(null);
|
||||||
const [forceDeleteBranch, setForceDeleteBranch] = 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));
|
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) => {
|
const beginRename = React.useCallback((branchName: string) => {
|
||||||
setEditingBranch(branchName);
|
setEditingBranch(branchName);
|
||||||
setEditValue(branchName);
|
setEditValue(branchName);
|
||||||
@@ -222,7 +205,6 @@ export function BranchPickerDialog({ open, onOpenChange, project }: BranchPicker
|
|||||||
localBranches.map((branchName) => {
|
localBranches.map((branchName) => {
|
||||||
const details = branches?.branches[branchName];
|
const details = branches?.branches[branchName];
|
||||||
const isCurrent = Boolean(details?.current);
|
const isCurrent = Boolean(details?.current);
|
||||||
const isCreating = creatingWorktree === branchName;
|
|
||||||
const isDeleting = deletingBranch === branchName;
|
const isDeleting = deletingBranch === branchName;
|
||||||
const isRenaming = renamingBranchKey === branchName;
|
const isRenaming = renamingBranchKey === branchName;
|
||||||
const hasAttachedWorktree = worktreeBranches.has(branchName);
|
const hasAttachedWorktree = worktreeBranches.has(branchName);
|
||||||
@@ -302,25 +284,6 @@ export function BranchPickerDialog({ open, onOpenChange, project }: BranchPicker
|
|||||||
|
|
||||||
{!isEditing && !isConfirming ? (
|
{!isEditing && !isConfirming ? (
|
||||||
<div className="flex items-center gap-1 flex-shrink-0">
|
<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}>
|
<Tooltip delayDuration={700}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -78,7 +78,6 @@ export function GitHubIssuePickerDialog({
|
|||||||
const activeProject = useProjectsStore((state) => state.getActiveProject());
|
const activeProject = useProjectsStore((state) => state.getActiveProject());
|
||||||
|
|
||||||
const projectDirectory = activeProject?.path ?? null;
|
const projectDirectory = activeProject?.path ?? null;
|
||||||
const baseBranch = activeProject?.worktreeDefaults?.baseBranch || 'main';
|
|
||||||
|
|
||||||
const [query, setQuery] = React.useState('');
|
const [query, setQuery] = React.useState('');
|
||||||
const [createInWorktree, setCreateInWorktree] = React.useState(false);
|
const [createInWorktree, setCreateInWorktree] = React.useState(false);
|
||||||
@@ -302,8 +301,7 @@ export function GitHubIssuePickerDialog({
|
|||||||
const preferred = `issue-${issue.number}-${generateBranchSlug()}`;
|
const preferred = `issue-${issue.number}-${generateBranchSlug()}`;
|
||||||
const created = await createWorktreeSessionForNewBranch(
|
const created = await createWorktreeSessionForNewBranch(
|
||||||
projectDirectory,
|
projectDirectory,
|
||||||
preferred,
|
preferred
|
||||||
baseBranch || 'main'
|
|
||||||
);
|
);
|
||||||
if (!created?.id) {
|
if (!created?.id) {
|
||||||
throw new Error('Failed to create worktree session');
|
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 {
|
} finally {
|
||||||
setStartingIssueNumber(null);
|
setStartingIssueNumber(null);
|
||||||
}
|
}
|
||||||
}, [createInWorktree, github, onOpenChange, projectDirectory, baseBranch, resolveDefaultAgentName, resolveDefaultModelSelection, resolveDefaultVariant, startingIssueNumber]);
|
}, [createInWorktree, github, onOpenChange, projectDirectory, resolveDefaultAgentName, resolveDefaultModelSelection, resolveDefaultVariant, startingIssueNumber]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
|||||||
@@ -16,9 +16,7 @@ import { DirectoryExplorerDialog } from './DirectoryExplorerDialog';
|
|||||||
import { cn, formatPathForDisplay } from '@/lib/utils';
|
import { cn, formatPathForDisplay } from '@/lib/utils';
|
||||||
import type { Session } from '@opencode-ai/sdk/v2';
|
import type { Session } from '@opencode-ai/sdk/v2';
|
||||||
import type { WorktreeMetadata } from '@/types/worktree';
|
import type { WorktreeMetadata } from '@/types/worktree';
|
||||||
import {
|
import { getWorktreeStatus } from '@/lib/worktrees/worktreeStatus';
|
||||||
getWorktreeStatus,
|
|
||||||
} from '@/lib/git/worktreeService';
|
|
||||||
import { removeProjectWorktree } from '@/lib/worktrees/worktreeManager';
|
import { removeProjectWorktree } from '@/lib/worktrees/worktreeManager';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionStore } from '@/stores/useSessionStore';
|
||||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
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 [deleteDialogSummaries, setDeleteDialogSummaries] = React.useState<Array<{ session: Session; metadata: WorktreeMetadata }>>([]);
|
||||||
const [deleteDialogShouldRemoveRemote, setDeleteDialogShouldRemoveRemote] = React.useState(false);
|
const [deleteDialogShouldRemoveRemote, setDeleteDialogShouldRemoveRemote] = React.useState(false);
|
||||||
const [isProcessingDelete, setIsProcessingDelete] = 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 {
|
const {
|
||||||
deleteSession,
|
deleteSession,
|
||||||
@@ -84,12 +84,7 @@ export const SessionDialogs: React.FC = () => {
|
|||||||
return { id: match?.id ?? `path:${fallbackPath}`, path: fallbackPath };
|
return { id: match?.id ?? `path:${fallbackPath}`, path: fallbackPath };
|
||||||
}, [projectDirectory, projects]);
|
}, [projectDirectory, projects]);
|
||||||
|
|
||||||
const hasDirtyWorktrees = React.useMemo(
|
const hasDirtyWorktrees = hasCompletedDirtyCheck && dirtyWorktreePaths.size > 0;
|
||||||
() =>
|
|
||||||
(deleteDialog?.worktree?.status?.isDirty ?? false) ||
|
|
||||||
deleteDialogSummaries.some((entry) => entry.metadata.status?.isDirty),
|
|
||||||
[deleteDialog?.worktree?.status?.isDirty, deleteDialogSummaries],
|
|
||||||
);
|
|
||||||
const canRemoveRemoteBranches = React.useMemo(
|
const canRemoveRemoteBranches = React.useMemo(
|
||||||
() => {
|
() => {
|
||||||
const targetWorktree = deleteDialog?.worktree;
|
const targetWorktree = deleteDialog?.worktree;
|
||||||
@@ -108,8 +103,6 @@ export const SessionDialogs: React.FC = () => {
|
|||||||
const removeRemoteOptionDisabled =
|
const removeRemoteOptionDisabled =
|
||||||
isProcessingDelete || !isWorktreeDelete || !canRemoveRemoteBranches;
|
isProcessingDelete || !isWorktreeDelete || !canRemoveRemoteBranches;
|
||||||
|
|
||||||
// NOTE: stop auto-modifying .gitignore for legacy `.openchamber`.
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
loadSessions();
|
loadSessions();
|
||||||
}, [loadSessions, currentDirectory]);
|
}, [loadSessions, currentDirectory]);
|
||||||
@@ -194,6 +187,8 @@ export const SessionDialogs: React.FC = () => {
|
|||||||
setDeleteDialogSummaries([]);
|
setDeleteDialogSummaries([]);
|
||||||
setDeleteDialogShouldRemoveRemote(false);
|
setDeleteDialogShouldRemoveRemote(false);
|
||||||
setIsProcessingDelete(false);
|
setIsProcessingDelete(false);
|
||||||
|
setHasCompletedDirtyCheck(false);
|
||||||
|
setDirtyWorktreePaths(new Set());
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -212,6 +207,8 @@ export const SessionDialogs: React.FC = () => {
|
|||||||
if (!deleteDialog) {
|
if (!deleteDialog) {
|
||||||
setDeleteDialogSummaries([]);
|
setDeleteDialogSummaries([]);
|
||||||
setDeleteDialogShouldRemoveRemote(false);
|
setDeleteDialogShouldRemoveRemote(false);
|
||||||
|
setHasCompletedDirtyCheck(false);
|
||||||
|
setDirtyWorktreePaths(new Set());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,39 +221,97 @@ export const SessionDialogs: React.FC = () => {
|
|||||||
|
|
||||||
setDeleteDialogSummaries(summaries);
|
setDeleteDialogSummaries(summaries);
|
||||||
setDeleteDialogShouldRemoveRemote(false);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
const statuses = await Promise.all(
|
const statusByPath = new Map<string, WorktreeMetadata['status']>();
|
||||||
summaries.map(async ({ metadata }) => {
|
const nextDirtyPaths = new Set<string>();
|
||||||
if (metadata.status && typeof metadata.status.isDirty === 'boolean') {
|
|
||||||
return metadata.status;
|
await Promise.all(
|
||||||
}
|
Array.from(metadataByPath.entries()).map(async ([pathKey, metadata]) => {
|
||||||
try {
|
try {
|
||||||
return await getWorktreeStatus(metadata.path);
|
const status = await getWorktreeStatus(metadata.path);
|
||||||
|
statusByPath.set(pathKey, status);
|
||||||
|
if (status?.isDirty) {
|
||||||
|
nextDirtyPaths.add(pathKey);
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return metadata.status;
|
if (metadata.status) {
|
||||||
|
statusByPath.set(pathKey, metadata.status);
|
||||||
|
if (metadata.status.isDirty) {
|
||||||
|
nextDirtyPaths.add(pathKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
).catch((error) => {
|
).catch((error) => {
|
||||||
console.warn('Failed to inspect worktree status before deletion:', 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;
|
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) =>
|
setDeleteDialogSummaries((prev) =>
|
||||||
prev.map((entry, index) => ({
|
prev.map((entry) => {
|
||||||
session: entry.session,
|
const pathKey = normalizeProjectDirectory(entry.metadata.path);
|
||||||
metadata: { ...entry.metadata, status: statuses[index] ?? entry.metadata.status },
|
const nextStatus = statusByPath.get(pathKey);
|
||||||
}))
|
if (!nextStatus) {
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
session: entry.session,
|
||||||
|
metadata: { ...entry.metadata, status: nextStatus },
|
||||||
|
};
|
||||||
|
})
|
||||||
);
|
);
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ import {
|
|||||||
RiFolderAddLine,
|
RiFolderAddLine,
|
||||||
RiGitBranchLine,
|
RiGitBranchLine,
|
||||||
RiGitPullRequestLine,
|
RiGitPullRequestLine,
|
||||||
RiGitRepositoryLine,
|
|
||||||
RiLinkUnlinkM,
|
RiLinkUnlinkM,
|
||||||
|
|
||||||
RiGithubLine,
|
RiGithubLine,
|
||||||
@@ -66,7 +65,6 @@ import { getSafeStorage } from '@/stores/utils/safeStorage';
|
|||||||
import { createWorktreeSession } from '@/lib/worktreeSessionCreator';
|
import { createWorktreeSession } from '@/lib/worktreeSessionCreator';
|
||||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||||
import { updateDesktopSettings } from '@/lib/persistence';
|
import { updateDesktopSettings } from '@/lib/persistence';
|
||||||
import { BranchPickerDialog } from './BranchPickerDialog';
|
|
||||||
import { GitHubIssuePickerDialog } from './GitHubIssuePickerDialog';
|
import { GitHubIssuePickerDialog } from './GitHubIssuePickerDialog';
|
||||||
import { GitHubPullRequestPickerDialog } from './GitHubPullRequestPickerDialog';
|
import { GitHubPullRequestPickerDialog } from './GitHubPullRequestPickerDialog';
|
||||||
|
|
||||||
@@ -145,7 +143,6 @@ interface SortableProjectItemProps {
|
|||||||
onHoverChange: (hovered: boolean) => void;
|
onHoverChange: (hovered: boolean) => void;
|
||||||
onNewSession: () => void;
|
onNewSession: () => void;
|
||||||
onNewWorktreeSession?: () => void;
|
onNewWorktreeSession?: () => void;
|
||||||
onOpenBranchPicker?: () => void;
|
|
||||||
onNewSessionFromGitHubIssue?: () => void;
|
onNewSessionFromGitHubIssue?: () => void;
|
||||||
onNewSessionFromGitHubPR?: () => void;
|
onNewSessionFromGitHubPR?: () => void;
|
||||||
onOpenMultiRunLauncher: () => void;
|
onOpenMultiRunLauncher: () => void;
|
||||||
@@ -177,7 +174,6 @@ const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
|||||||
onHoverChange,
|
onHoverChange,
|
||||||
onNewSession,
|
onNewSession,
|
||||||
onNewWorktreeSession,
|
onNewWorktreeSession,
|
||||||
onOpenBranchPicker,
|
|
||||||
onNewSessionFromGitHubIssue,
|
onNewSessionFromGitHubIssue,
|
||||||
onNewSessionFromGitHubPR,
|
onNewSessionFromGitHubPR,
|
||||||
onOpenMultiRunLauncher,
|
onOpenMultiRunLauncher,
|
||||||
@@ -346,12 +342,6 @@ const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
|||||||
New Multi-Run
|
New Multi-Run
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
{isRepo && !hideDirectoryControls && onOpenBranchPicker && (
|
|
||||||
<DropdownMenuItem onClick={onOpenBranchPicker}>
|
|
||||||
<RiGitRepositoryLine className="mr-1.5 h-4 w-4" />
|
|
||||||
Manage Branches
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
<DropdownMenuItem onClick={onRenameStart}>
|
<DropdownMenuItem onClick={onRenameStart}>
|
||||||
<RiPencilAiLine className="mr-1.5 h-4 w-4" />
|
<RiPencilAiLine className="mr-1.5 h-4 w-4" />
|
||||||
Rename
|
Rename
|
||||||
@@ -478,8 +468,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
const [projectRepoStatus, setProjectRepoStatus] = React.useState<Map<string, boolean | null>>(new Map());
|
const [projectRepoStatus, setProjectRepoStatus] = React.useState<Map<string, boolean | null>>(new Map());
|
||||||
const [expandedSessionGroups, setExpandedSessionGroups] = React.useState<Set<string>>(new Set());
|
const [expandedSessionGroups, setExpandedSessionGroups] = React.useState<Set<string>>(new Set());
|
||||||
const [hoveredProjectId, setHoveredProjectId] = React.useState<string | null>(null);
|
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 [issuePickerOpen, setIssuePickerOpen] = React.useState(false);
|
||||||
const [pullRequestPickerOpen, setPullRequestPickerOpen] = React.useState(false);
|
const [pullRequestPickerOpen, setPullRequestPickerOpen] = React.useState(false);
|
||||||
const [activeDragId, setActiveDragId] = React.useState<string | null>(null);
|
const [activeDragId, setActiveDragId] = React.useState<string | null>(null);
|
||||||
@@ -1792,10 +1780,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
}
|
}
|
||||||
createWorktreeSession();
|
createWorktreeSession();
|
||||||
}}
|
}}
|
||||||
onOpenBranchPicker={() => {
|
|
||||||
setBranchPickerProjectId(projectKey);
|
|
||||||
setBranchPickerOpen(true);
|
|
||||||
}}
|
|
||||||
onNewSessionFromGitHubIssue={() => {
|
onNewSessionFromGitHubIssue={() => {
|
||||||
if (projectKey !== activeProjectId) {
|
if (projectKey !== activeProjectId) {
|
||||||
setActiveProject(projectKey);
|
setActiveProject(projectKey);
|
||||||
@@ -1852,14 +1836,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
)}
|
)}
|
||||||
</ScrollableOverlay>
|
</ScrollableOverlay>
|
||||||
|
|
||||||
<BranchPickerDialog
|
|
||||||
open={branchPickerOpen}
|
|
||||||
onOpenChange={setBranchPickerOpen}
|
|
||||||
project={branchPickerProjectId
|
|
||||||
? normalizedProjects.find((p) => p.id === branchPickerProjectId) ?? null
|
|
||||||
: null}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<GitHubIssuePickerDialog
|
<GitHubIssuePickerDialog
|
||||||
open={issuePickerOpen}
|
open={issuePickerOpen}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionStore } from '@/stores/useSessionStore';
|
||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
|
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||||
import { useFireworksCelebration } from '@/contexts/FireworksContext';
|
import { useFireworksCelebration } from '@/contexts/FireworksContext';
|
||||||
import type { GitIdentityProfile, CommitFileEntry } from '@/lib/api/types';
|
import type { GitIdentityProfile, CommitFileEntry } from '@/lib/api/types';
|
||||||
import { useGitIdentitiesStore } from '@/stores/useGitIdentitiesStore';
|
import { useGitIdentitiesStore } from '@/stores/useGitIdentitiesStore';
|
||||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
|
||||||
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||||
import {
|
import {
|
||||||
useGitStore,
|
useGitStore,
|
||||||
@@ -43,6 +43,8 @@ import { ChangesSection } from './git/ChangesSection';
|
|||||||
import { CommitSection } from './git/CommitSection';
|
import { CommitSection } from './git/CommitSection';
|
||||||
import { HistorySection } from './git/HistorySection';
|
import { HistorySection } from './git/HistorySection';
|
||||||
import { PullRequestSection } from './git/PullRequestSection';
|
import { PullRequestSection } from './git/PullRequestSection';
|
||||||
|
import { BranchPickerDialog } from '@/components/session/BranchPickerDialog';
|
||||||
|
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
|
||||||
|
|
||||||
type SyncAction = 'fetch' | 'pull' | 'push' | null;
|
type SyncAction = 'fetch' | 'pull' | 'push' | null;
|
||||||
type CommitAction = 'commit' | 'commitAndPush' | null;
|
type CommitAction = 'commit' | 'commitAndPush' | null;
|
||||||
@@ -185,6 +187,9 @@ const matchGitmojiFromSubject = (subject: string, gitmojis: GitmojiEntry[]): Git
|
|||||||
|
|
||||||
const gitViewSnapshots = new Map<string, GitViewSnapshot>();
|
const gitViewSnapshots = new Map<string, GitViewSnapshot>();
|
||||||
|
|
||||||
|
const normalizePath = (value?: string | null): string =>
|
||||||
|
(value || '').replace(/\\/g, '/').replace(/\/+$/, '');
|
||||||
|
|
||||||
export const GitView: React.FC = () => {
|
export const GitView: React.FC = () => {
|
||||||
const { git } = useRuntimeAPIs();
|
const { git } = useRuntimeAPIs();
|
||||||
const currentDirectory = useEffectiveDirectory();
|
const currentDirectory = useEffectiveDirectory();
|
||||||
@@ -220,15 +225,56 @@ export const GitView: React.FC = () => {
|
|||||||
}, [currentDirectory]);
|
}, [currentDirectory]);
|
||||||
|
|
||||||
const settingsGitmojiEnabled = useConfigStore((state) => state.settingsGitmojiEnabled);
|
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 = worktreeMetadata?.createdFromBranch || status?.current || 'HEAD';
|
||||||
const baseBranch = React.useMemo(() => {
|
|
||||||
const fromProject = activeProject?.worktreeDefaults?.baseBranch;
|
React.useEffect(() => {
|
||||||
if (typeof fromProject === 'string' && fromProject.trim().length > 0) {
|
const projectRoot = worktreeMetadata?.projectDirectory;
|
||||||
return fromProject.trim();
|
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(
|
const [commitMessage, setCommitMessage] = React.useState(
|
||||||
initialSnapshot?.commitMessage ?? ''
|
initialSnapshot?.commitMessage ?? ''
|
||||||
@@ -277,15 +323,31 @@ export const GitView: React.FC = () => {
|
|||||||
}, [status?.tracking]);
|
}, [status?.tracking]);
|
||||||
const defaultTargetBranch = React.useMemo(() => {
|
const defaultTargetBranch = React.useMemo(() => {
|
||||||
const fromMeta = worktreeMetadata?.createdFromBranch;
|
const fromMeta = worktreeMetadata?.createdFromBranch;
|
||||||
if (typeof fromMeta === 'string' && fromMeta.trim().length > 0) {
|
const normalizedFromMeta = typeof fromMeta === 'string' ? fromMeta.trim() : '';
|
||||||
return 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 (normalizedRoot) {
|
||||||
if (typeof fromProject === 'string' && fromProject.trim().length > 0) {
|
return normalizedRoot;
|
||||||
return fromProject.trim();
|
|
||||||
}
|
}
|
||||||
return 'main';
|
if (current) {
|
||||||
}, [worktreeMetadata?.createdFromBranch, activeProject?.worktreeDefaults?.baseBranch]);
|
return current;
|
||||||
|
}
|
||||||
|
return 'HEAD';
|
||||||
|
}, [worktreeMetadata?.createdFromBranch, status, rootBranchHint]);
|
||||||
const clearGeneratedHighlights = React.useCallback(() => {
|
const clearGeneratedHighlights = React.useCallback(() => {
|
||||||
setGeneratedHighlights([]);
|
setGeneratedHighlights([]);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -1009,6 +1071,7 @@ export const GitView: React.FC = () => {
|
|||||||
onSelectIdentity={handleApplyIdentity}
|
onSelectIdentity={handleApplyIdentity}
|
||||||
isApplyingIdentity={isSettingIdentity}
|
isApplyingIdentity={isSettingIdentity}
|
||||||
isWorktreeMode={!!worktreeMetadata}
|
isWorktreeMode={!!worktreeMetadata}
|
||||||
|
onOpenBranchPicker={branchPickerProject ? () => setIsBranchPickerOpen(true) : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="p-3">
|
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="p-3">
|
||||||
@@ -1138,6 +1201,12 @@ export const GitView: React.FC = () => {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<BranchPickerDialog
|
||||||
|
open={isBranchPickerOpen}
|
||||||
|
onOpenChange={setIsBranchPickerOpen}
|
||||||
|
project={branchPickerProject}
|
||||||
|
/>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
RiGraduationCapLine,
|
RiGraduationCapLine,
|
||||||
RiCodeLine,
|
RiCodeLine,
|
||||||
RiHeartLine,
|
RiHeartLine,
|
||||||
|
RiGitRepositoryLine,
|
||||||
RiUser3Line,
|
RiUser3Line,
|
||||||
} from '@remixicon/react';
|
} from '@remixicon/react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -44,6 +45,7 @@ interface GitHeaderProps {
|
|||||||
onSelectIdentity: (profile: GitIdentityProfile) => void;
|
onSelectIdentity: (profile: GitIdentityProfile) => void;
|
||||||
isApplyingIdentity: boolean;
|
isApplyingIdentity: boolean;
|
||||||
isWorktreeMode: boolean;
|
isWorktreeMode: boolean;
|
||||||
|
onOpenBranchPicker?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const IDENTITY_ICON_MAP: Record<
|
const IDENTITY_ICON_MAP: Record<
|
||||||
@@ -195,6 +197,7 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
|
|||||||
onSelectIdentity,
|
onSelectIdentity,
|
||||||
isApplyingIdentity,
|
isApplyingIdentity,
|
||||||
isWorktreeMode,
|
isWorktreeMode,
|
||||||
|
onOpenBranchPicker,
|
||||||
}) => {
|
}) => {
|
||||||
if (!status) {
|
if (!status) {
|
||||||
return null;
|
return null;
|
||||||
@@ -252,6 +255,23 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
|
|||||||
|
|
||||||
<div className="flex-1" />
|
<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
|
<IdentityDropdown
|
||||||
activeProfile={activeIdentityProfile}
|
activeProfile={activeIdentityProfile}
|
||||||
identities={availableIdentities}
|
identities={availableIdentities}
|
||||||
|
|||||||
@@ -230,18 +230,6 @@ export interface GitWorktreeInfo {
|
|||||||
branch?: string;
|
branch?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GitAddWorktreePayload {
|
|
||||||
path: string;
|
|
||||||
branch: string;
|
|
||||||
createBranch?: boolean;
|
|
||||||
startPoint?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GitRemoveWorktreePayload {
|
|
||||||
path: string;
|
|
||||||
force?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GitDeleteBranchPayload {
|
export interface GitDeleteBranchPayload {
|
||||||
branch: string;
|
branch: string;
|
||||||
force?: boolean;
|
force?: boolean;
|
||||||
@@ -290,9 +278,6 @@ export interface GitAPI {
|
|||||||
payload: { base: string; head: string; context?: string }
|
payload: { base: string; head: string; context?: string }
|
||||||
): Promise<GeneratedPullRequestDescription>;
|
): Promise<GeneratedPullRequestDescription>;
|
||||||
listGitWorktrees(directory: string): Promise<GitWorktreeInfo[]>;
|
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>;
|
createGitCommit(directory: string, message: string, options?: CreateGitCommitOptions): Promise<GitCommitResult>;
|
||||||
gitPush(directory: string, options?: { remote?: string; branch?: string; options?: string[] | Record<string, unknown> }): Promise<GitPushResult>;
|
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>;
|
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[] }>;
|
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 {
|
export interface ProjectEntry {
|
||||||
id: string;
|
id: string;
|
||||||
path: string;
|
path: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
addedAt?: number;
|
addedAt?: number;
|
||||||
lastOpenedAt?: number;
|
lastOpenedAt?: number;
|
||||||
worktreeDefaults?: WorktreeDefaults;
|
|
||||||
sidebarCollapsed?: boolean;
|
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,
|
GitLogEntry,
|
||||||
GitLogResponse,
|
GitLogResponse,
|
||||||
GitWorktreeInfo,
|
GitWorktreeInfo,
|
||||||
GitAddWorktreePayload,
|
|
||||||
GitRemoveWorktreePayload,
|
|
||||||
GitDeleteBranchPayload,
|
GitDeleteBranchPayload,
|
||||||
GitDeleteRemoteBranchPayload,
|
GitDeleteRemoteBranchPayload,
|
||||||
DiscoveredGitCredential,
|
DiscoveredGitCredential,
|
||||||
@@ -121,25 +119,6 @@ export async function listGitWorktrees(directory: string): Promise<import('./api
|
|||||||
return gitHttp.listGitWorktrees(directory);
|
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(
|
export async function createGitCommit(
|
||||||
directory: string,
|
directory: string,
|
||||||
message: string,
|
message: string,
|
||||||
|
|||||||
@@ -11,8 +11,6 @@ import type {
|
|||||||
GitDeleteRemoteBranchPayload,
|
GitDeleteRemoteBranchPayload,
|
||||||
GeneratedCommitMessage,
|
GeneratedCommitMessage,
|
||||||
GitWorktreeInfo,
|
GitWorktreeInfo,
|
||||||
GitAddWorktreePayload,
|
|
||||||
GitRemoveWorktreePayload,
|
|
||||||
CreateGitCommitOptions,
|
CreateGitCommitOptions,
|
||||||
GitCommitResult,
|
GitCommitResult,
|
||||||
GitPushResult,
|
GitPushResult,
|
||||||
@@ -291,56 +289,6 @@ export async function listGitWorktrees(directory: string): Promise<GitWorktreeIn
|
|||||||
return response.json();
|
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(
|
export async function createGitCommit(
|
||||||
directory: string,
|
directory: string,
|
||||||
message: string,
|
message: string,
|
||||||
|
|||||||
@@ -157,21 +157,6 @@ const sanitizeProjects = (value: unknown): DesktopSettings['projects'] | undefin
|
|||||||
if (typeof candidate.sidebarCollapsed === 'boolean') {
|
if (typeof candidate.sidebarCollapsed === 'boolean') {
|
||||||
(project as unknown as Record<string, unknown>).sidebarCollapsed = candidate.sidebarCollapsed;
|
(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);
|
result.push(project);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,9 +12,7 @@ import { useContextStore } from '@/stores/contextStore';
|
|||||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||||
import { checkIsGitRepository } from '@/lib/gitApi';
|
import { checkIsGitRepository } from '@/lib/gitApi';
|
||||||
import { generateBranchName } from '@/lib/git/branchNameGenerator';
|
import { generateBranchName } from '@/lib/git/branchNameGenerator';
|
||||||
import {
|
import { getRootBranch, getWorktreeStatus } from '@/lib/worktrees/worktreeStatus';
|
||||||
getWorktreeStatus,
|
|
||||||
} from '@/lib/git/worktreeService';
|
|
||||||
import { getWorktreeSetupCommands } from '@/lib/openchamberConfig';
|
import { getWorktreeSetupCommands } from '@/lib/openchamberConfig';
|
||||||
import {
|
import {
|
||||||
createSdkWorktree,
|
createSdkWorktree,
|
||||||
@@ -28,11 +26,26 @@ const normalizePath = (value: string): string => value.replace(/\\/g, '/').repla
|
|||||||
const resolveProjectRef = (directory: string): ProjectRef | null => {
|
const resolveProjectRef = (directory: string): ProjectRef | null => {
|
||||||
const normalized = normalizePath(directory);
|
const normalized = normalizePath(directory);
|
||||||
const projects = useProjectsStore.getState().projects;
|
const projects = useProjectsStore.getState().projects;
|
||||||
const match = projects.find((project) => normalizePath(project.path) === normalized);
|
if (projects.length === 0) {
|
||||||
if (!match) {
|
|
||||||
return null;
|
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
|
// 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.
|
* 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
|
* @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...");
|
startConfigUpdate("Creating new worktree session...");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get worktree defaults from project settings
|
|
||||||
const worktreeDefaults = activeProject.worktreeDefaults;
|
|
||||||
const baseBranch = worktreeDefaults?.baseBranch;
|
|
||||||
|
|
||||||
const projectRef: ProjectRef = { id: activeProject.id, path: projectDirectory };
|
const projectRef: ProjectRef = { id: activeProject.id, path: projectDirectory };
|
||||||
|
|
||||||
// Generate a friendly name (SDK will slugify + ensure uniqueness).
|
// Generate a friendly name (SDK will slugify + ensure uniqueness).
|
||||||
const preferredName = generateBranchName();
|
const preferredName = generateBranchName();
|
||||||
|
|
||||||
const startPoint = baseBranch && baseBranch !== 'HEAD' ? baseBranch : undefined;
|
|
||||||
|
|
||||||
const setupCommands = await getWorktreeSetupCommands(projectRef);
|
const setupCommands = await getWorktreeSetupCommands(projectRef);
|
||||||
|
const rootBranch = await getRootBranch(projectRef.path);
|
||||||
const metadata = await createSdkWorktree(projectRef, {
|
const metadata = await createSdkWorktree(projectRef, {
|
||||||
preferredName,
|
preferredName,
|
||||||
setupCommands,
|
setupCommands,
|
||||||
startPoint,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const createdMetadata = {
|
const createdMetadata = {
|
||||||
...metadata,
|
...metadata,
|
||||||
createdFromBranch: startPoint ?? 'HEAD',
|
createdFromBranch: rootBranch,
|
||||||
kind: 'standard' as const,
|
kind: 'standard' as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -238,21 +245,6 @@ export async function createWorktreeSessionForBranch(
|
|||||||
return null;
|
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;
|
isCreatingWorktreeSession = true;
|
||||||
startConfigUpdate("Creating worktree session...");
|
startConfigUpdate("Creating worktree session...");
|
||||||
|
|
||||||
@@ -262,16 +254,31 @@ export async function createWorktreeSessionForBranch(
|
|||||||
throw new Error('Project is not registered in OpenChamber');
|
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 setupCommands = await getWorktreeSetupCommands(projectRef);
|
||||||
|
const rootBranch = await getRootBranch(projectRef.path);
|
||||||
const metadata = await createSdkWorktree(projectRef, {
|
const metadata = await createSdkWorktree(projectRef, {
|
||||||
preferredName: branchName,
|
preferredName: branchName,
|
||||||
setupCommands,
|
setupCommands,
|
||||||
startPoint: branchName,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const createdMetadata = {
|
const createdMetadata = {
|
||||||
...metadata,
|
...metadata,
|
||||||
createdFromBranch: branchName,
|
createdFromBranch: rootBranch,
|
||||||
kind: 'standard' as const,
|
kind: 'standard' as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -389,33 +396,19 @@ export async function createWorktreeSessionForBranch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a worktree session for a new branch (created at startPoint).
|
* Create a worktree session for a new branch name.
|
||||||
* This avoids checking out the branch in the main worktree.
|
* Callers can still use startPoint for metadata or follow-up git operations.
|
||||||
*/
|
*/
|
||||||
export async function createWorktreeSessionForNewBranch(
|
export async function createWorktreeSessionForNewBranch(
|
||||||
projectDirectory: string,
|
projectDirectory: string,
|
||||||
preferredBranchName: string,
|
preferredBranchName: string,
|
||||||
startPoint: string,
|
startPoint?: string,
|
||||||
options?: { allowSuffix?: boolean; kind?: 'pr' | 'standard' }
|
options?: { kind?: 'pr' | 'standard' }
|
||||||
): Promise<{ id: string; branch: string } | null> {
|
): Promise<{ id: string; branch: string } | null> {
|
||||||
if (isCreatingWorktreeSession) {
|
if (isCreatingWorktreeSession) {
|
||||||
return null;
|
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;
|
isCreatingWorktreeSession = true;
|
||||||
startConfigUpdate('Creating worktree session...');
|
startConfigUpdate('Creating worktree session...');
|
||||||
|
|
||||||
@@ -426,7 +419,6 @@ export async function createWorktreeSessionForNewBranch(
|
|||||||
throw new Error('Branch name is required');
|
throw new Error('Branch name is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
const allowSuffix = options?.allowSuffix !== false;
|
|
||||||
const kind = options?.kind ?? 'standard';
|
const kind = options?.kind ?? 'standard';
|
||||||
|
|
||||||
const projectRef = resolveProjectRef(projectDirectory);
|
const projectRef = resolveProjectRef(projectDirectory);
|
||||||
@@ -434,19 +426,31 @@ export async function createWorktreeSessionForNewBranch(
|
|||||||
throw new Error('Project is not registered in OpenChamber');
|
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 setupCommands = await getWorktreeSetupCommands(projectRef);
|
||||||
|
const rootBranch = await getRootBranch(projectRef.path);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const metadata = await createSdkWorktree(projectRef, {
|
const metadata = await createSdkWorktree(projectRef, {
|
||||||
preferredName: base,
|
preferredName: base,
|
||||||
setupCommands,
|
setupCommands,
|
||||||
startPoint: start,
|
|
||||||
allowSuffix,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const createdMetadata = {
|
const createdMetadata = {
|
||||||
...metadata,
|
...metadata,
|
||||||
createdFromBranch: start,
|
createdFromBranch: rootBranch || start,
|
||||||
kind,
|
kind,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -541,8 +545,8 @@ export async function createWorktreeSessionForNewBranch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Same as createWorktreeSessionForNewBranch, but does NOT suffix the branch name.
|
* Same as createWorktreeSessionForNewBranch, but preserves the exact branch name.
|
||||||
* Use when the worktree must be created on an exact branch name (e.g. PR head ref).
|
* Use when the worktree must be tied to a specific ref (e.g. PR head ref).
|
||||||
*/
|
*/
|
||||||
export async function createWorktreeSessionForNewBranchExact(
|
export async function createWorktreeSessionForNewBranchExact(
|
||||||
projectDirectory: string,
|
projectDirectory: string,
|
||||||
@@ -551,7 +555,6 @@ export async function createWorktreeSessionForNewBranchExact(
|
|||||||
options?: { kind?: 'pr' | 'standard' }
|
options?: { kind?: 'pr' | 'standard' }
|
||||||
): Promise<{ id: string; branch: string } | null> {
|
): Promise<{ id: string; branch: string } | null> {
|
||||||
return createWorktreeSessionForNewBranch(projectDirectory, branchName, startPoint, {
|
return createWorktreeSessionForNewBranch(projectDirectory, branchName, startPoint, {
|
||||||
allowSuffix: false,
|
|
||||||
kind: options?.kind,
|
kind: options?.kind,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,10 @@
|
|||||||
import { opencodeClient } from '@/lib/opencode/client';
|
import { opencodeClient } from '@/lib/opencode/client';
|
||||||
import { substituteCommandVariables } from '@/lib/openchamberConfig';
|
import { substituteCommandVariables } from '@/lib/openchamberConfig';
|
||||||
import type { WorktreeMetadata } from '@/types/worktree';
|
import type { WorktreeMetadata } from '@/types/worktree';
|
||||||
import {
|
import { deleteRemoteBranch } from '@/lib/gitApi';
|
||||||
listWorktrees as listLegacyGitWorktrees,
|
|
||||||
mapWorktreeToMetadata,
|
|
||||||
removeWorktree as removeLegacyWorktree,
|
|
||||||
} from '@/lib/git/worktreeService';
|
|
||||||
import { deleteGitBranch, deleteRemoteBranch, removeGitWorktree } from '@/lib/gitApi';
|
|
||||||
|
|
||||||
export type ProjectRef = { id: string; path: string };
|
export type ProjectRef = { id: string; path: string };
|
||||||
|
|
||||||
const WORKTREE_LEGACY_ROOT = '.openchamber';
|
|
||||||
|
|
||||||
const normalizePath = (value: string): string => {
|
const normalizePath = (value: string): string => {
|
||||||
const replaced = value.replace(/\\/g, '/');
|
const replaced = value.replace(/\\/g, '/');
|
||||||
if (replaced === '/') {
|
if (replaced === '/') {
|
||||||
@@ -20,13 +13,6 @@ const normalizePath = (value: string): string => {
|
|||||||
return replaced.length > 1 ? replaced.replace(/\/+$/, '') : replaced;
|
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 => {
|
const slugifyWorktreeName = (value: string): string => {
|
||||||
return value
|
return value
|
||||||
.trim()
|
.trim()
|
||||||
@@ -36,14 +22,6 @@ const slugifyWorktreeName = (value: string): string => {
|
|||||||
.slice(0, 80);
|
.slice(0, 80);
|
||||||
};
|
};
|
||||||
|
|
||||||
const shellQuote = (value: string): string => {
|
|
||||||
const v = value.trim();
|
|
||||||
if (!v) {
|
|
||||||
return "''";
|
|
||||||
}
|
|
||||||
return `'${v.replace(/'/g, `'\\''`)}'`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const unwrapSdkData = (value: unknown): unknown => {
|
const unwrapSdkData = (value: unknown): unknown => {
|
||||||
if (!value || typeof value !== 'object') {
|
if (!value || typeof value !== 'object') {
|
||||||
return value;
|
return value;
|
||||||
@@ -61,36 +39,12 @@ const deriveSdkWorktreeNameFromDirectory = (directory: string): string => {
|
|||||||
return parts[parts.length - 1] ?? normalized;
|
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: {
|
export const buildSdkStartCommand = (args: {
|
||||||
projectDirectory: string;
|
projectDirectory: string;
|
||||||
setupCommands: string[];
|
setupCommands: string[];
|
||||||
startPoint?: string | null;
|
|
||||||
}): string | undefined => {
|
}): string | undefined => {
|
||||||
const commands: string[] = [];
|
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) {
|
for (const raw of args.setupCommands) {
|
||||||
const trimmed = raw.trim();
|
const trimmed = raw.trim();
|
||||||
if (!trimmed) continue;
|
if (!trimmed) continue;
|
||||||
@@ -103,13 +57,69 @@ export const buildSdkStartCommand = (args: {
|
|||||||
return joined.trim().length > 0 ? joined : undefined;
|
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[]> {
|
export async function listProjectWorktrees(project: ProjectRef): Promise<WorktreeMetadata[]> {
|
||||||
const projectDirectory = project.path;
|
const projectDirectory = project.path;
|
||||||
const scoped = opencodeClient.getScopedApiClient(projectDirectory);
|
const scoped = opencodeClient.getScopedApiClient(projectDirectory);
|
||||||
|
|
||||||
const results: WorktreeMetadata[] = [];
|
const results: WorktreeMetadata[] = [];
|
||||||
|
|
||||||
// SDK worktrees (new)
|
// SDK worktrees
|
||||||
try {
|
try {
|
||||||
const raw = await scoped.worktree.list();
|
const raw = await scoped.worktree.list();
|
||||||
const data = unwrapSdkData(raw);
|
const data = unwrapSdkData(raw);
|
||||||
@@ -126,42 +136,14 @@ export async function listProjectWorktrees(project: ProjectRef): Promise<Worktre
|
|||||||
name,
|
name,
|
||||||
path: directory,
|
path: directory,
|
||||||
projectDirectory,
|
projectDirectory,
|
||||||
branch: `opencode/${name}`,
|
branch: '',
|
||||||
label: name,
|
label: name,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
|
return results.sort((a, b) => {
|
||||||
// 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) => {
|
|
||||||
const aLabel = (a.label || a.branch || a.path).toLowerCase();
|
const aLabel = (a.label || a.branch || a.path).toLowerCase();
|
||||||
const bLabel = (b.label || b.branch || b.path).toLowerCase();
|
const bLabel = (b.label || b.branch || b.path).toLowerCase();
|
||||||
return aLabel.localeCompare(bLabel);
|
return aLabel.localeCompare(bLabel);
|
||||||
@@ -171,8 +153,6 @@ export async function listProjectWorktrees(project: ProjectRef): Promise<Worktre
|
|||||||
export async function createSdkWorktree(project: ProjectRef, args: {
|
export async function createSdkWorktree(project: ProjectRef, args: {
|
||||||
preferredName?: string;
|
preferredName?: string;
|
||||||
setupCommands?: string[];
|
setupCommands?: string[];
|
||||||
startPoint?: string | null;
|
|
||||||
allowSuffix?: boolean;
|
|
||||||
}): Promise<WorktreeMetadata> {
|
}): Promise<WorktreeMetadata> {
|
||||||
const projectDirectory = project.path;
|
const projectDirectory = project.path;
|
||||||
const scoped = opencodeClient.getScopedApiClient(projectDirectory);
|
const scoped = opencodeClient.getScopedApiClient(projectDirectory);
|
||||||
@@ -184,52 +164,42 @@ export async function createSdkWorktree(project: ProjectRef, args: {
|
|||||||
const startCommand = buildSdkStartCommand({
|
const startCommand = buildSdkStartCommand({
|
||||||
projectDirectory,
|
projectDirectory,
|
||||||
setupCommands: commands,
|
setupCommands: commands,
|
||||||
startPoint: args.startPoint,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let lastError: unknown = null;
|
const name = seed || undefined;
|
||||||
const allowSuffix = args.allowSuffix !== false;
|
const raw = await scoped.worktree.create({
|
||||||
const maxAttempts = seed ? (allowSuffix ? 6 : 1) : 1;
|
worktreeCreateInput: {
|
||||||
|
...(name ? { name } : {}),
|
||||||
|
...(startCommand ? { startCommand } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
const data = unwrapSdkData(raw);
|
||||||
const name = seed ? (attempt === 0 ? seed : `${seed}-${attempt + 1}`) : undefined;
|
if (!data || typeof data !== 'object') {
|
||||||
try {
|
throw new Error('Invalid worktree.create response');
|
||||||
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 message = lastError instanceof Error ? lastError.message : 'Failed to create worktree';
|
const record = data as Record<string, unknown>;
|
||||||
throw new Error(message);
|
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?: {
|
export async function removeProjectWorktree(project: ProjectRef, worktree: WorktreeMetadata, options?: {
|
||||||
@@ -239,74 +209,16 @@ export async function removeProjectWorktree(project: ProjectRef, worktree: Workt
|
|||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const projectDirectory = project.path;
|
const projectDirectory = project.path;
|
||||||
|
|
||||||
const deleteLocalBranch = true;
|
|
||||||
const deleteRemote = Boolean(options?.deleteRemoteBranch);
|
const deleteRemote = Boolean(options?.deleteRemoteBranch);
|
||||||
const remoteName = options?.remoteName;
|
const remoteName = options?.remoteName;
|
||||||
|
const scoped = opencodeClient.getScopedApiClient(projectDirectory);
|
||||||
if (worktree.source === 'sdk') {
|
const raw = await scoped.worktree.remove({ worktreeRemoveInput: { directory: worktree.path } });
|
||||||
const scoped = opencodeClient.getScopedApiClient(projectDirectory);
|
const ok = unwrapSdkData(raw);
|
||||||
const worktreeClient = scoped.worktree as unknown;
|
if (ok !== true) {
|
||||||
const force = Boolean(options?.force ?? true);
|
throw new Error('Worktree removal failed');
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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();
|
const branchName = (worktree.branch || '').replace(/^refs\/heads\//, '').trim();
|
||||||
if (deleteLocalBranch && branchName) {
|
|
||||||
await deleteGitBranch(projectDirectory, { branch: branchName, force: true }).catch(() => undefined);
|
|
||||||
}
|
|
||||||
if (deleteRemote && branchName) {
|
if (deleteRemote && branchName) {
|
||||||
await deleteRemoteBranch(projectDirectory, { branch: branchName, remote: remoteName }).catch(() => undefined);
|
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 { opencodeClient } from "@/lib/opencode/client";
|
||||||
import { getSafeStorage } from "./utils/safeStorage";
|
import { getSafeStorage } from "./utils/safeStorage";
|
||||||
import type { WorktreeMetadata } from "@/types/worktree";
|
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 { listProjectWorktrees, removeProjectWorktree } from "@/lib/worktrees/worktreeManager";
|
||||||
import { useDirectoryStore } from "./useDirectoryStore";
|
import { useDirectoryStore } from "./useDirectoryStore";
|
||||||
import { useProjectsStore } from "./useProjectsStore";
|
import { useProjectsStore } from "./useProjectsStore";
|
||||||
@@ -52,8 +52,6 @@ type SessionStore = SessionState & SessionActions;
|
|||||||
|
|
||||||
const safeStorage = getSafeStorage();
|
const safeStorage = getSafeStorage();
|
||||||
const SESSION_SELECTION_STORAGE_KEY = "oc.sessionSelectionByDirectory";
|
const SESSION_SELECTION_STORAGE_KEY = "oc.sessionSelectionByDirectory";
|
||||||
const WORKTREE_ROOT = ".openchamber";
|
|
||||||
|
|
||||||
type SessionSelectionMap = Record<string, string>;
|
type SessionSelectionMap = Record<string, string>;
|
||||||
|
|
||||||
const readSessionSelectionMap = (): SessionSelectionMap => {
|
const readSessionSelectionMap = (): SessionSelectionMap => {
|
||||||
@@ -278,11 +276,11 @@ const hydrateSessionWorktreeMetadata = async (
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let worktreeEntries;
|
let worktreeEntries: WorktreeMetadata[];
|
||||||
try {
|
try {
|
||||||
worktreeEntries = await listWorktrees(normalizedProject);
|
worktreeEntries = await listProjectWorktrees({ id: `path:${normalizedProject}`, path: normalizedProject });
|
||||||
} catch (error) {
|
} 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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,8 +296,7 @@ const hydrateSessionWorktreeMetadata = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const worktreeMapByPath = new Map<string, WorktreeMetadata>();
|
const worktreeMapByPath = new Map<string, WorktreeMetadata>();
|
||||||
worktreeEntries.forEach((info) => {
|
worktreeEntries.forEach((metadata) => {
|
||||||
const metadata = mapWorktreeToMetadata(normalizedProject, info);
|
|
||||||
const normalizedPath = normalizePath(metadata.path) ?? metadata.path;
|
const normalizedPath = normalizePath(metadata.path) ?? metadata.path;
|
||||||
|
|
||||||
if (normalizedPath === normalizedProject) {
|
if (normalizedPath === normalizedProject) {
|
||||||
@@ -312,6 +309,26 @@ const hydrateSessionWorktreeMetadata = async (
|
|||||||
let mutated = false;
|
let mutated = false;
|
||||||
const next = new Map(existingMetadata);
|
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 }) => {
|
sessionsWithDirectory.forEach(({ id, directory }) => {
|
||||||
const metadata = worktreeMapByPath.get(directory);
|
const metadata = worktreeMapByPath.get(directory);
|
||||||
if (!metadata) {
|
if (!metadata) {
|
||||||
@@ -322,8 +339,19 @@ const hydrateSessionWorktreeMetadata = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const previous = next.get(id);
|
const previous = next.get(id);
|
||||||
if (!previous || previous.path !== metadata.path || previous.branch !== metadata.branch || previous.label !== metadata.label) {
|
const merged = mergeHydratedMetadata(metadata, previous);
|
||||||
next.set(id, metadata);
|
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;
|
mutated = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -569,7 +597,6 @@ export const useSessionStore = create<SessionStore>()(
|
|||||||
validPaths.add(normalizedProject);
|
validPaths.add(normalizedProject);
|
||||||
|
|
||||||
if (isGitRepo) {
|
if (isGitRepo) {
|
||||||
const worktreeRoot = `${normalizedProject}/${WORKTREE_ROOT}`;
|
|
||||||
try {
|
try {
|
||||||
const candidates = new Set<string>();
|
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) => {
|
candidates.forEach((candidate) => {
|
||||||
const normalizedCandidate = normalizePath(candidate) ?? candidate;
|
const normalizedCandidate = normalizePath(candidate) ?? candidate;
|
||||||
validPaths.add(normalizedCandidate);
|
validPaths.add(normalizedCandidate);
|
||||||
|
|||||||
@@ -6,11 +6,8 @@ import { useDirectoryStore } from './useDirectoryStore';
|
|||||||
import { useProjectsStore } from './useProjectsStore';
|
import { useProjectsStore } from './useProjectsStore';
|
||||||
import { useSessionStore } from './useSessionStore';
|
import { useSessionStore } from './useSessionStore';
|
||||||
import type { WorktreeMetadata } from '@/types/worktree';
|
import type { WorktreeMetadata } from '@/types/worktree';
|
||||||
import { listWorktrees, mapWorktreeToMetadata } from '@/lib/git/worktreeService';
|
|
||||||
import type { Session } from '@opencode-ai/sdk/v2';
|
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 resolveProjectDirectory = (currentDirectory: string | null | undefined): string | null => {
|
||||||
const projectsState = useProjectsStore.getState();
|
const projectsState = useProjectsStore.getState();
|
||||||
@@ -109,48 +106,6 @@ const normalize = (value: string): string => {
|
|||||||
return replaced.replace(/\/+$/, '');
|
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 startsWithDirectory = (candidate: string, root: string): boolean => {
|
||||||
const normalizedCandidate = normalize(candidate);
|
const normalizedCandidate = normalize(candidate);
|
||||||
@@ -253,12 +208,12 @@ const buildWorktreeMetadataByPath = async (group: AgentGroup, projectDirectory:
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const infos = await listWorktrees(projectDirectory);
|
const worktrees = await listProjectWorktrees({ id: `path:${projectDirectory}`, path: projectDirectory });
|
||||||
const infoByPath = new Map(infos.map((info) => [normalize(info.worktree), info]));
|
const infoByPath = new Map(worktrees.map((meta) => [normalize(meta.path), meta]));
|
||||||
missingPaths.forEach((path) => {
|
missingPaths.forEach((path) => {
|
||||||
const info = infoByPath.get(path);
|
const info = infoByPath.get(path);
|
||||||
if (info) {
|
if (info) {
|
||||||
map.set(path, mapWorktreeToMetadata(projectDirectory, info));
|
map.set(path, info);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
@@ -419,24 +374,17 @@ export const useAgentGroupsStore = create<AgentGroupsStore>()(
|
|||||||
: [];
|
: [];
|
||||||
|
|
||||||
const worktreeDirectorySet = new Set<string>();
|
const worktreeDirectorySet = new Set<string>();
|
||||||
|
const worktreeMetadataMap = new Map<string, WorktreeMetadata>();
|
||||||
[...managedWorktrees, ...managedWorktreesCanonical].forEach((meta) => {
|
[...managedWorktrees, ...managedWorktreesCanonical].forEach((meta) => {
|
||||||
if (meta?.path) {
|
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[]> => {
|
const fetchCandidateSessions = async (): Promise<Session[]> => {
|
||||||
try {
|
try {
|
||||||
const scoped = await apiClient.session.list({ directory: normalizedProject });
|
const scoped = await apiClient.session.list({ directory: normalizedProject });
|
||||||
@@ -497,23 +445,6 @@ export const useAgentGroupsStore = create<AgentGroupsStore>()(
|
|||||||
// 1) Known worktree directories for this project
|
// 1) Known worktree directories for this project
|
||||||
worktreeDirectorySet.forEach((dir) => candidates.add(dir));
|
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) {
|
if (candidates.size > 0) {
|
||||||
allSessions = await fetchSessionsByWorktreeDirectories(Array.from(candidates));
|
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
|
if (!parsed) continue; // Skip sessions without valid agent group title
|
||||||
|
|
||||||
const sessionPath = normalize(session.directory);
|
const sessionPath = normalize(session.directory);
|
||||||
const worktreeInfo = worktreeInfoMap.get(sessionPath);
|
const worktreeInfo = worktreeMetadataMap.get(sessionPath);
|
||||||
|
|
||||||
const agentSession: AgentGroupSession = {
|
const agentSession: AgentGroupSession = {
|
||||||
id: session.id,
|
id: session.id,
|
||||||
@@ -543,9 +474,7 @@ export const useAgentGroupsStore = create<AgentGroupsStore>()(
|
|||||||
instanceNumber: parsed.index,
|
instanceNumber: parsed.index,
|
||||||
branch: worktreeInfo?.branch ?? '',
|
branch: worktreeInfo?.branch ?? '',
|
||||||
displayLabel: `${parsed.provider}/${parsed.model}`,
|
displayLabel: `${parsed.provider}/${parsed.model}`,
|
||||||
worktreeMetadata: worktreeInfo
|
worktreeMetadata: worktreeInfo,
|
||||||
? mapWorktreeToMetadata(normalizedProject, worktreeInfo)
|
|
||||||
: undefined,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const existing = groupsMap.get(parsed.groupSlug);
|
const existing = groupsMap.get(parsed.groupSlug);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { CreateMultiRunParams, CreateMultiRunResult } from '@/types/multiru
|
|||||||
import { opencodeClient } from '@/lib/opencode/client';
|
import { opencodeClient } from '@/lib/opencode/client';
|
||||||
import { saveWorktreeSetupCommands } from '@/lib/openchamberConfig';
|
import { saveWorktreeSetupCommands } from '@/lib/openchamberConfig';
|
||||||
import { createSdkWorktree, type ProjectRef } from '@/lib/worktrees/worktreeManager';
|
import { createSdkWorktree, type ProjectRef } from '@/lib/worktrees/worktreeManager';
|
||||||
|
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
|
||||||
import { checkIsGitRepository } from '@/lib/gitApi';
|
import { checkIsGitRepository } from '@/lib/gitApi';
|
||||||
import { useSessionStore } from './sessionStore';
|
import { useSessionStore } from './sessionStore';
|
||||||
import { useDirectoryStore } from './useDirectoryStore';
|
import { useDirectoryStore } from './useDirectoryStore';
|
||||||
@@ -121,11 +122,7 @@ export const useMultiRunStore = create<MultiRunStore>()(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const groupSlug = toGitSafeSlug(groupName);
|
const groupSlug = toGitSafeSlug(groupName);
|
||||||
const worktreeBaseBranch =
|
const rootBranch = await getRootBranch(directory);
|
||||||
typeof params.worktreeBaseBranch === 'string' && params.worktreeBaseBranch.trim().length > 0
|
|
||||||
? params.worktreeBaseBranch.trim()
|
|
||||||
: 'HEAD';
|
|
||||||
const startPoint = worktreeBaseBranch !== 'HEAD' ? worktreeBaseBranch : undefined;
|
|
||||||
|
|
||||||
const createdRuns: Array<{
|
const createdRuns: Array<{
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
@@ -164,12 +161,11 @@ export const useMultiRunStore = create<MultiRunStore>()(
|
|||||||
const worktreeMetadata = await createSdkWorktree(project, {
|
const worktreeMetadata = await createSdkWorktree(project, {
|
||||||
preferredName,
|
preferredName,
|
||||||
setupCommands: commandsToRun,
|
setupCommands: commandsToRun,
|
||||||
startPoint: startPoint ?? null,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const enrichedMetadata = {
|
const enrichedMetadata = {
|
||||||
...worktreeMetadata,
|
...worktreeMetadata,
|
||||||
createdFromBranch: startPoint ?? 'HEAD',
|
createdFromBranch: rootBranch,
|
||||||
kind: 'standard' as const,
|
kind: 'standard' as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { devtools } from 'zustand/middleware';
|
import { devtools } from 'zustand/middleware';
|
||||||
import { opencodeClient } from '@/lib/opencode/client';
|
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 type { DesktopSettings } from '@/lib/desktop';
|
||||||
import { updateDesktopSettings } from '@/lib/persistence';
|
import { updateDesktopSettings } from '@/lib/persistence';
|
||||||
import { getSafeStorage } from './utils/safeStorage';
|
import { getSafeStorage } from './utils/safeStorage';
|
||||||
@@ -27,7 +27,6 @@ interface ProjectsStore {
|
|||||||
validateProjectPath: (path: string) => ProjectPathValidationResult;
|
validateProjectPath: (path: string) => ProjectPathValidationResult;
|
||||||
synchronizeFromSettings: (settings: DesktopSettings) => void;
|
synchronizeFromSettings: (settings: DesktopSettings) => void;
|
||||||
getActiveProject: () => ProjectEntry | null;
|
getActiveProject: () => ProjectEntry | null;
|
||||||
updateWorktreeDefaults: (projectId: string, defaults: Partial<WorktreeDefaults>) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const safeStorage = getSafeStorage();
|
const safeStorage = getSafeStorage();
|
||||||
@@ -124,20 +123,6 @@ const sanitizeProjects = (value: unknown): ProjectEntry[] => {
|
|||||||
if (typeof candidate.sidebarCollapsed === 'boolean') {
|
if (typeof candidate.sidebarCollapsed === 'boolean') {
|
||||||
project.sidebarCollapsed = candidate.sidebarCollapsed;
|
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);
|
result.push(project);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -452,37 +437,6 @@ export const useProjectsStore = create<ProjectsStore>()(
|
|||||||
return projects.find((project) => project.id === activeProjectId) ?? null;
|
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' })
|
}), { name: 'projects-store' })
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,8 @@ export interface WorktreeMetadata {
|
|||||||
/**
|
/**
|
||||||
* Worktree origin.
|
* Worktree origin.
|
||||||
* - sdk: created/managed by OpenCode SDK worktrees
|
* - sdk: created/managed by OpenCode SDK worktrees
|
||||||
* - legacy: git worktree under <project>/.openchamber
|
|
||||||
*/
|
*/
|
||||||
source?: 'sdk' | 'legacy';
|
source?: 'sdk';
|
||||||
|
|
||||||
path: string;
|
path: string;
|
||||||
|
|
||||||
|
|||||||
@@ -2171,42 +2171,12 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'api:git/worktrees': {
|
case 'api:git/worktrees': {
|
||||||
const { directory, method, path: worktreePath, branch, createBranch, force } = (payload || {}) as {
|
const { directory } = (payload || {}) as { directory?: string };
|
||||||
directory?: string;
|
|
||||||
method?: string;
|
|
||||||
path?: string;
|
|
||||||
branch?: string;
|
|
||||||
createBranch?: boolean;
|
|
||||||
force?: boolean;
|
|
||||||
};
|
|
||||||
if (!directory) {
|
if (!directory) {
|
||||||
return { id, type, success: false, error: 'Directory is required' };
|
return { id, type, success: false, error: 'Directory is required' };
|
||||||
}
|
}
|
||||||
|
const worktrees = await gitService.listGitWorktrees(directory);
|
||||||
const normalizedMethod = typeof method === 'string' ? method.toUpperCase() : 'GET';
|
return { id, type, success: true, data: worktrees };
|
||||||
|
|
||||||
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}` };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'api:git/diff': {
|
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}` };
|
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:
|
default:
|
||||||
return { id, type, success: false, error: `Unknown message type: ${type}` };
|
return { id, type, success: false, error: `Unknown message type: ${type}` };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -694,48 +694,6 @@ export async function getAvailableBranchesForWorktree(directory: string): Promis
|
|||||||
return availableBranches;
|
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 ==============
|
// ============== Diff Operations ==============
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1361,32 +1319,3 @@ export async function setGitIdentity(
|
|||||||
|
|
||||||
return { success: true };
|
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,
|
GeneratedCommitMessage,
|
||||||
GeneratedPullRequestDescription,
|
GeneratedPullRequestDescription,
|
||||||
GitWorktreeInfo,
|
GitWorktreeInfo,
|
||||||
GitAddWorktreePayload,
|
|
||||||
GitRemoveWorktreePayload,
|
|
||||||
GitCommitResult,
|
GitCommitResult,
|
||||||
CreateGitCommitOptions,
|
CreateGitCommitOptions,
|
||||||
GitPushResult,
|
GitPushResult,
|
||||||
@@ -112,30 +110,6 @@ export const createVSCodeGitAPI = (): GitAPI => ({
|
|||||||
return sendBridgeMessage<GitWorktreeInfo[]>('api:git/worktrees', { directory, method: 'GET' });
|
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> => {
|
createGitCommit: async (directory: string, message: string, options?: CreateGitCommitOptions): Promise<GitCommitResult> => {
|
||||||
return sendBridgeMessage<GitCommitResult>('api:git/commit', {
|
return sendBridgeMessage<GitCommitResult>('api:git/commit', {
|
||||||
directory,
|
directory,
|
||||||
|
|||||||
@@ -891,24 +891,6 @@ const sanitizeProjects = (input) => {
|
|||||||
...(Number.isFinite(lastOpenedAt) && lastOpenedAt >= 0 ? { lastOpenedAt } : {}),
|
...(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') {
|
if (typeof candidate.sidebarCollapsed === 'boolean') {
|
||||||
project.sidebarCollapsed = candidate.sidebarCollapsed;
|
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) => {
|
app.get('/api/git/worktree-type', async (req, res) => {
|
||||||
const { isLinkedWorktree } = await getGitLibraries();
|
const { isLinkedWorktree } = await getGitLibraries();
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -130,46 +130,6 @@ export async function isGitRepository(directory) {
|
|||||||
return fs.existsSync(gitDir);
|
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() {
|
export async function getGlobalIdentity() {
|
||||||
const git = await createGit();
|
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 = {}) {
|
export async function deleteBranch(directory, branch, options = {}) {
|
||||||
const git = await createGit(directory);
|
const git = await createGit(directory);
|
||||||
|
|
||||||
|
|||||||
@@ -18,9 +18,6 @@ export const createWebGitAPI = (): GitAPI => ({
|
|||||||
generateCommitMessage: gitApiHttp.generateCommitMessage,
|
generateCommitMessage: gitApiHttp.generateCommitMessage,
|
||||||
generatePullRequestDescription: gitApiHttp.generatePullRequestDescription,
|
generatePullRequestDescription: gitApiHttp.generatePullRequestDescription,
|
||||||
listGitWorktrees: gitApiHttp.listGitWorktrees,
|
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) {
|
createGitCommit(directory: string, message: string, options?: CreateGitCommitOptions) {
|
||||||
return gitApiHttp.createGitCommit(directory, message, options);
|
return gitApiHttp.createGitCommit(directory, message, options);
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user