diff --git a/packages/ui/src/components/sections/openchamber/WorktreeSectionContent.tsx b/packages/ui/src/components/sections/openchamber/WorktreeSectionContent.tsx index e22714a2..9db03c99 100644 --- a/packages/ui/src/components/sections/openchamber/WorktreeSectionContent.tsx +++ b/packages/ui/src/components/sections/openchamber/WorktreeSectionContent.tsx @@ -1,52 +1,28 @@ import React from 'react'; import { RiAddLine, RiCloseLine, RiDeleteBinLine, RiInformationLine } from '@remixicon/react'; import { Input } from '@/components/ui/input'; -import { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectLabel, - SelectSeparator, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { useSessionStore } from '@/stores/useSessionStore'; -import { useGitBranches } from '@/stores/useGitStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; -import { checkIsGitRepository, getGitBranches } from '@/lib/gitApi'; +import { checkIsGitRepository } from '@/lib/gitApi'; import { getWorktreeSetupCommands, saveWorktreeSetupCommands } from '@/lib/openchamberConfig'; import { listProjectWorktrees } from '@/lib/worktrees/worktreeManager'; import { sessionEvents } from '@/lib/sessionEvents'; import type { WorktreeMetadata } from '@/types/worktree'; import { formatPathForDisplay } from '@/lib/utils'; -type BranchOption = { - value: string; - label: string; - group: 'special' | 'local' | 'remote'; -}; - export const WorktreeSectionContent: React.FC = () => { const activeProject = useProjectsStore((state) => state.getActiveProject()); - const updateWorktreeDefaults = useProjectsStore((state) => state.updateWorktreeDefaults); const projectPath = activeProject?.path ?? null; - const worktreeDefaults = activeProject?.worktreeDefaults; - - const branchesFromStore = useGitBranches(projectPath); const { sessions, getWorktreeMetadata } = useSessionStore(); const homeDirectory = useDirectoryStore((state) => state.homeDirectory); - const [baseBranch, setBaseBranch] = React.useState(worktreeDefaults?.baseBranch ?? 'HEAD'); const [setupCommands, setSetupCommands] = React.useState([]); const [isLoadingCommands, setIsLoadingCommands] = React.useState(false); - const [isLoadingGit, setIsLoadingGit] = React.useState(false); const [isGitRepoLocal, setIsGitRepoLocal] = React.useState(null); - const [branchesLocal, setBranchesLocal] = React.useState<{ all: string[]; current: string } | null>(null); const [availableWorktrees, setAvailableWorktrees] = React.useState([]); const [isLoadingWorktrees, setIsLoadingWorktrees] = React.useState(false); @@ -68,34 +44,20 @@ export const WorktreeSectionContent: React.FC = () => { } }, [projectRef, isGitRepoLocal]); - // Load repo + branch info + // Load repo info React.useEffect(() => { if (!projectPath) return; let cancelled = false; - setIsLoadingGit(true); setIsGitRepoLocal(null); - setBranchesLocal(null); (async () => { try { const repoStatus = await checkIsGitRepository(projectPath); if (cancelled) return; setIsGitRepoLocal(repoStatus); - - if (!repoStatus) { - return; - } - - const branchData = await getGitBranches(projectPath); - if (cancelled) return; - setBranchesLocal({ all: branchData.all, current: branchData.current }); } catch { // Ignore errors - } finally { - if (!cancelled) { - setIsLoadingGit(false); - } } })(); @@ -170,49 +132,6 @@ export const WorktreeSectionContent: React.FC = () => { }; }, [projectRef]); - // Sync local state with store when project changes - React.useEffect(() => { - setBaseBranch(worktreeDefaults?.baseBranch ?? 'HEAD'); - }, [worktreeDefaults]); - - // Use local branches if available, otherwise fall back to store - const branches = branchesLocal ?? branchesFromStore; - - const branchOptions = React.useMemo(() => { - const options: BranchOption[] = []; - const headLabel = branches?.current - ? `Current (HEAD: ${branches.current})` - : 'Current (HEAD)'; - options.push({ value: 'HEAD', label: headLabel, group: 'special' }); - - if (branches) { - const localBranches = branches.all - .filter((name: string) => !name.startsWith('remotes/')) - .sort((a: string, b: string) => a.localeCompare(b)); - - localBranches.forEach((name: string) => { - options.push({ value: name, label: name, group: 'local' }); - }); - - const remoteBranches = branches.all - .filter((name: string) => name.startsWith('remotes/')) - .map((name: string) => name.replace(/^remotes\//, '')) - .sort((a: string, b: string) => a.localeCompare(b)); - - remoteBranches.forEach((name: string) => { - options.push({ value: name, label: name, group: 'remote' }); - }); - } - - return options; - }, [branches]); - - const handleBaseBranchChange = React.useCallback((value: string) => { - setBaseBranch(value); - if (!activeProject?.id) return; - updateWorktreeDefaults(activeProject.id, { baseBranch: value }); - }, [activeProject?.id, updateWorktreeDefaults]); - const handleSetupCommandChange = React.useCallback((index: number, value: string) => { setSetupCommands((prev) => { const next = [...prev]; @@ -320,7 +239,7 @@ export const WorktreeSectionContent: React.FC = () => { if (!projectPath) { return (

- Select a project to configure worktree defaults. + Select a project to manage worktrees.

); } @@ -335,82 +254,8 @@ export const WorktreeSectionContent: React.FC = () => { return (
- {/* Default base branch */} -
-
-
-

Base branch

- - - - - - Default branch to create new worktrees from. - - -
-

- Default branch for new worktree branches -

-
- - {isLoadingGit ? ( -

Loading...

- ) : ( - - )} -
- {/* Setup commands */} -
+

Setup commands

@@ -467,8 +312,7 @@ export const WorktreeSectionContent: React.FC = () => { - SDK worktrees live outside the repo (OpenCode-managed). Legacy .openchamber worktrees are still supported. - Deleting a worktree also removes linked sessions. + Worktrees live outside the repo (OpenCode-managed). Deleting a worktree also removes linked sessions.

@@ -496,13 +340,11 @@ export const WorktreeSectionContent: React.FC = () => { {worktree.label || worktree.branch || 'Detached HEAD'}

- {worktree.source === 'sdk' ? 'OpenCode' : 'OpenChamber'} + OpenCode

- {worktree.source === 'sdk' - ? formatPathForDisplay(worktree.path, homeDirectory) - : (worktree.relativePath || worktree.path)} + {formatPathForDisplay(worktree.path, homeDirectory)}

- - Create worktree from - -
); }; diff --git a/packages/ui/src/components/views/git/GitHeader.tsx b/packages/ui/src/components/views/git/GitHeader.tsx index 582bac5f..f758e8b1 100644 --- a/packages/ui/src/components/views/git/GitHeader.tsx +++ b/packages/ui/src/components/views/git/GitHeader.tsx @@ -10,6 +10,7 @@ import { RiGraduationCapLine, RiCodeLine, RiHeartLine, + RiGitRepositoryLine, RiUser3Line, } from '@remixicon/react'; import { Button } from '@/components/ui/button'; @@ -44,6 +45,7 @@ interface GitHeaderProps { onSelectIdentity: (profile: GitIdentityProfile) => void; isApplyingIdentity: boolean; isWorktreeMode: boolean; + onOpenBranchPicker?: () => void; } const IDENTITY_ICON_MAP: Record< @@ -195,6 +197,7 @@ export const GitHeader: React.FC = ({ onSelectIdentity, isApplyingIdentity, isWorktreeMode, + onOpenBranchPicker, }) => { if (!status) { return null; @@ -252,6 +255,23 @@ export const GitHeader: React.FC = ({
+ {onOpenBranchPicker ? ( + + + + + Manage branches + + ) : null} + ; listGitWorktrees(directory: string): Promise; - addGitWorktree(directory: string, payload: GitAddWorktreePayload): Promise<{ success: boolean; path: string; branch: string }>; - removeGitWorktree(directory: string, payload: GitRemoveWorktreePayload): Promise<{ success: boolean }>; - ensureOpenChamberIgnored(directory: string): Promise; createGitCommit(directory: string, message: string, options?: CreateGitCommitOptions): Promise; gitPush(directory: string, options?: { remote?: string; branch?: string; options?: string[] | Record }): Promise; gitPull(directory: string, options?: { remote?: string; branch?: string }): Promise; @@ -366,18 +351,12 @@ export interface FilesAPI { execCommands?(commands: string[], cwd: string): Promise<{ success: boolean; results: CommandExecResult[] }>; } -export interface WorktreeDefaults { - baseBranch?: string; // e.g. "main", "develop", or "HEAD" - autoCreateWorktree?: boolean; // future: skip dialog, create worktree automatically -} - export interface ProjectEntry { id: string; path: string; label?: string; addedAt?: number; lastOpenedAt?: number; - worktreeDefaults?: WorktreeDefaults; sidebarCollapsed?: boolean; } diff --git a/packages/ui/src/lib/git/worktreeService.ts b/packages/ui/src/lib/git/worktreeService.ts deleted file mode 100644 index c7f46ec8..00000000 --- a/packages/ui/src/lib/git/worktreeService.ts +++ /dev/null @@ -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 { - const normalizedProject = normalize(projectDirectory); - const root = joinPath(normalizedProject, WORKTREE_ROOT); - await ensureDirectory(root); - return joinPath(root, worktreeSlug); -} - -export async function createWorktree(options: CreateWorktreeOptions): Promise { - // LEGACY_WORKTREES: creates /.openchamber/ 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 { - const { projectDirectory, path, force } = options; - const normalizedProject = normalize(projectDirectory); - await removeGitWorktree(normalizedProject, { path, force }); -} - -export async function archiveWorktree(options: ArchiveWorktreeOptions): Promise { - 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 { - const normalizedProject = normalize(projectDirectory); - return listGitWorktrees(normalizedProject); -} - -export async function getWorktreeStatus(worktreePath: string): Promise { - 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 { - 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) diff --git a/packages/ui/src/lib/gitApi.ts b/packages/ui/src/lib/gitApi.ts index f90d4bbe..b73334c0 100644 --- a/packages/ui/src/lib/gitApi.ts +++ b/packages/ui/src/lib/gitApi.ts @@ -18,8 +18,6 @@ export type { GitLogEntry, GitLogResponse, GitWorktreeInfo, - GitAddWorktreePayload, - GitRemoveWorktreePayload, GitDeleteBranchPayload, GitDeleteRemoteBranchPayload, DiscoveredGitCredential, @@ -121,25 +119,6 @@ export async function listGitWorktrees(directory: string): Promise { - 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 { - // LEGACY_WORKTREES: only needed for /.openchamber era. Safe to remove after legacy support dropped. - const runtime = getRuntimeGit(); - if (runtime) return runtime.ensureOpenChamberIgnored(directory); - return gitHttp.ensureOpenChamberIgnored(directory); -} - export async function createGitCommit( directory: string, message: string, diff --git a/packages/ui/src/lib/gitApiHttp.ts b/packages/ui/src/lib/gitApiHttp.ts index 06e0a2b2..685bbc24 100644 --- a/packages/ui/src/lib/gitApiHttp.ts +++ b/packages/ui/src/lib/gitApiHttp.ts @@ -11,8 +11,6 @@ import type { GitDeleteRemoteBranchPayload, GeneratedCommitMessage, GitWorktreeInfo, - GitAddWorktreePayload, - GitRemoveWorktreePayload, CreateGitCommitOptions, GitCommitResult, GitPushResult, @@ -291,56 +289,6 @@ export async function listGitWorktrees(directory: string): Promise { - 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 { - // LEGACY_WORKTREES: only needed for /.openchamber era. Safe to remove after legacy support dropped. - const response = await fetch(buildUrl(`${API_BASE}/ignore-openchamber`, directory), { - method: 'POST', - }); - - if (!response.ok) { - const error = await response.json().catch(() => ({ error: response.statusText })); - throw new Error(error.error || 'Failed to update git ignore'); - } -} - export async function createGitCommit( directory: string, message: string, diff --git a/packages/ui/src/lib/persistence.ts b/packages/ui/src/lib/persistence.ts index d3f07efd..7460e2ad 100644 --- a/packages/ui/src/lib/persistence.ts +++ b/packages/ui/src/lib/persistence.ts @@ -157,21 +157,6 @@ const sanitizeProjects = (value: unknown): DesktopSettings['projects'] | undefin if (typeof candidate.sidebarCollapsed === 'boolean') { (project as unknown as Record).sidebarCollapsed = candidate.sidebarCollapsed; } - // Preserve worktreeDefaults - if (candidate.worktreeDefaults && typeof candidate.worktreeDefaults === 'object') { - const wt = candidate.worktreeDefaults as Record; - const defaults: Record = {}; - 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).worktreeDefaults = defaults; - } - } - result.push(project); } diff --git a/packages/ui/src/lib/worktreeSessionCreator.ts b/packages/ui/src/lib/worktreeSessionCreator.ts index 349a9191..5a285bae 100644 --- a/packages/ui/src/lib/worktreeSessionCreator.ts +++ b/packages/ui/src/lib/worktreeSessionCreator.ts @@ -12,9 +12,7 @@ import { useContextStore } from '@/stores/contextStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { checkIsGitRepository } from '@/lib/gitApi'; import { generateBranchName } from '@/lib/git/branchNameGenerator'; -import { - getWorktreeStatus, -} from '@/lib/git/worktreeService'; +import { getRootBranch, getWorktreeStatus } from '@/lib/worktrees/worktreeStatus'; import { getWorktreeSetupCommands } from '@/lib/openchamberConfig'; import { createSdkWorktree, @@ -28,11 +26,26 @@ const normalizePath = (value: string): string => value.replace(/\\/g, '/').repla const resolveProjectRef = (directory: string): ProjectRef | null => { const normalized = normalizePath(directory); const projects = useProjectsStore.getState().projects; - const match = projects.find((project) => normalizePath(project.path) === normalized); - if (!match) { + if (projects.length === 0) { return null; } - return { id: match.id, path: match.path }; + + const activeProject = useProjectsStore.getState().getActiveProject(); + if (activeProject?.path) { + const activePath = normalizePath(activeProject.path); + if (normalized === activePath || normalized.startsWith(`${activePath}/`)) { + return { id: activeProject.id, path: activeProject.path }; + } + } + + const matches = projects.filter((project) => { + const projectPath = normalizePath(project.path); + return normalized === projectPath || normalized.startsWith(`${projectPath}/`); + }); + + const match = matches.sort((a, b) => normalizePath(b.path).length - normalizePath(a.path).length)[0]; + + return match ? { id: match.id, path: match.path } : null; }; // Track if we're currently creating a worktree session @@ -40,7 +53,7 @@ let isCreatingWorktreeSession = false; /** * Create a new session with an auto-generated worktree. - * Uses project's worktree defaults (branch prefix, base branch) from settings. + * Uses project's worktree defaults for naming/metadata. * * @returns The created session, or null if creation failed */ @@ -78,27 +91,21 @@ export async function createWorktreeSession(): Promise<{ id: string } | null> { startConfigUpdate("Creating new worktree session..."); try { - // Get worktree defaults from project settings - const worktreeDefaults = activeProject.worktreeDefaults; - const baseBranch = worktreeDefaults?.baseBranch; - const projectRef: ProjectRef = { id: activeProject.id, path: projectDirectory }; // Generate a friendly name (SDK will slugify + ensure uniqueness). const preferredName = generateBranchName(); - const startPoint = baseBranch && baseBranch !== 'HEAD' ? baseBranch : undefined; - const setupCommands = await getWorktreeSetupCommands(projectRef); + const rootBranch = await getRootBranch(projectRef.path); const metadata = await createSdkWorktree(projectRef, { preferredName, setupCommands, - startPoint, }); const createdMetadata = { ...metadata, - createdFromBranch: startPoint ?? 'HEAD', + createdFromBranch: rootBranch, kind: 'standard' as const, }; @@ -238,21 +245,6 @@ export async function createWorktreeSessionForBranch( return null; } - // Check if it's a git repo - let isGitRepo = false; - try { - isGitRepo = await checkIsGitRepository(projectDirectory); - } catch { - // Ignore errors, treat as not a git repo - } - - if (!isGitRepo) { - toast.error('Not a Git repository', { - description: 'Worktrees can only be created in Git repositories.', - }); - return null; - } - isCreatingWorktreeSession = true; startConfigUpdate("Creating worktree session..."); @@ -262,16 +254,31 @@ export async function createWorktreeSessionForBranch( throw new Error('Project is not registered in OpenChamber'); } + // Check if it's a git repo (root project path) + let isGitRepo = false; + try { + isGitRepo = await checkIsGitRepository(projectRef.path); + } catch { + // Ignore errors, treat as not a git repo + } + + if (!isGitRepo) { + toast.error('Not a Git repository', { + description: 'Worktrees can only be created in Git repositories.', + }); + return null; + } + const setupCommands = await getWorktreeSetupCommands(projectRef); + const rootBranch = await getRootBranch(projectRef.path); const metadata = await createSdkWorktree(projectRef, { preferredName: branchName, setupCommands, - startPoint: branchName, }); const createdMetadata = { ...metadata, - createdFromBranch: branchName, + createdFromBranch: rootBranch, kind: 'standard' as const, }; @@ -389,33 +396,19 @@ export async function createWorktreeSessionForBranch( } /** - * Create a worktree session for a new branch (created at startPoint). - * This avoids checking out the branch in the main worktree. + * Create a worktree session for a new branch name. + * Callers can still use startPoint for metadata or follow-up git operations. */ export async function createWorktreeSessionForNewBranch( projectDirectory: string, preferredBranchName: string, - startPoint: string, - options?: { allowSuffix?: boolean; kind?: 'pr' | 'standard' } + startPoint?: string, + options?: { kind?: 'pr' | 'standard' } ): Promise<{ id: string; branch: string } | null> { if (isCreatingWorktreeSession) { return null; } - let isGitRepo = false; - try { - isGitRepo = await checkIsGitRepository(projectDirectory); - } catch { - // ignore - } - - if (!isGitRepo) { - toast.error('Not a Git repository', { - description: 'Worktrees can only be created in Git repositories.', - }); - return null; - } - isCreatingWorktreeSession = true; startConfigUpdate('Creating worktree session...'); @@ -426,7 +419,6 @@ export async function createWorktreeSessionForNewBranch( throw new Error('Branch name is required'); } - const allowSuffix = options?.allowSuffix !== false; const kind = options?.kind ?? 'standard'; const projectRef = resolveProjectRef(projectDirectory); @@ -434,19 +426,31 @@ export async function createWorktreeSessionForNewBranch( throw new Error('Project is not registered in OpenChamber'); } + let isGitRepo = false; + try { + isGitRepo = await checkIsGitRepository(projectRef.path); + } catch { + // ignore + } + + if (!isGitRepo) { + toast.error('Not a Git repository', { + description: 'Worktrees can only be created in Git repositories.', + }); + return null; + } + const setupCommands = await getWorktreeSetupCommands(projectRef); + const rootBranch = await getRootBranch(projectRef.path); try { const metadata = await createSdkWorktree(projectRef, { preferredName: base, setupCommands, - startPoint: start, - allowSuffix, }); - const createdMetadata = { ...metadata, - createdFromBranch: start, + createdFromBranch: rootBranch || start, kind, }; @@ -541,8 +545,8 @@ export async function createWorktreeSessionForNewBranch( } /** - * Same as createWorktreeSessionForNewBranch, but does NOT suffix the branch name. - * Use when the worktree must be created on an exact branch name (e.g. PR head ref). + * Same as createWorktreeSessionForNewBranch, but preserves the exact branch name. + * Use when the worktree must be tied to a specific ref (e.g. PR head ref). */ export async function createWorktreeSessionForNewBranchExact( projectDirectory: string, @@ -551,7 +555,6 @@ export async function createWorktreeSessionForNewBranchExact( options?: { kind?: 'pr' | 'standard' } ): Promise<{ id: string; branch: string } | null> { return createWorktreeSessionForNewBranch(projectDirectory, branchName, startPoint, { - allowSuffix: false, kind: options?.kind, }); } diff --git a/packages/ui/src/lib/worktrees/worktreeManager.ts b/packages/ui/src/lib/worktrees/worktreeManager.ts index 94aad900..eb284581 100644 --- a/packages/ui/src/lib/worktrees/worktreeManager.ts +++ b/packages/ui/src/lib/worktrees/worktreeManager.ts @@ -1,17 +1,10 @@ import { opencodeClient } from '@/lib/opencode/client'; import { substituteCommandVariables } from '@/lib/openchamberConfig'; import type { WorktreeMetadata } from '@/types/worktree'; -import { - listWorktrees as listLegacyGitWorktrees, - mapWorktreeToMetadata, - removeWorktree as removeLegacyWorktree, -} from '@/lib/git/worktreeService'; -import { deleteGitBranch, deleteRemoteBranch, removeGitWorktree } from '@/lib/gitApi'; +import { deleteRemoteBranch } from '@/lib/gitApi'; export type ProjectRef = { id: string; path: string }; -const WORKTREE_LEGACY_ROOT = '.openchamber'; - const normalizePath = (value: string): string => { const replaced = value.replace(/\\/g, '/'); if (replaced === '/') { @@ -20,13 +13,6 @@ const normalizePath = (value: string): string => { return replaced.length > 1 ? replaced.replace(/\/+$/, '') : replaced; }; -const isLegacyWorktreePath = (projectDirectory: string, candidatePath: string): boolean => { - const project = normalizePath(projectDirectory); - const candidate = normalizePath(candidatePath); - const root = `${project}/${WORKTREE_LEGACY_ROOT}/`; - return candidate.startsWith(root); -}; - const slugifyWorktreeName = (value: string): string => { return value .trim() @@ -36,14 +22,6 @@ const slugifyWorktreeName = (value: string): string => { .slice(0, 80); }; -const shellQuote = (value: string): string => { - const v = value.trim(); - if (!v) { - return "''"; - } - return `'${v.replace(/'/g, `'\\''`)}'`; -}; - const unwrapSdkData = (value: unknown): unknown => { if (!value || typeof value !== 'object') { return value; @@ -61,36 +39,12 @@ const deriveSdkWorktreeNameFromDirectory = (directory: string): string => { return parts[parts.length - 1] ?? normalized; }; -type WorktreeRemovalParams = Record; -type WorktreeRemovalMethod = (params?: WorktreeRemovalParams) => Promise; - -const getWorktreeMethod = (client: unknown, key: string): WorktreeRemovalMethod | null => { - if (!client || (typeof client !== 'object' && typeof client !== 'function')) { - return null; - } - const record = client as Record; - 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).call(client, params); -}; - export const buildSdkStartCommand = (args: { projectDirectory: string; setupCommands: string[]; - startPoint?: string | null; }): string | undefined => { const commands: string[] = []; - const startPoint = typeof args.startPoint === 'string' ? args.startPoint.trim() : ''; - if (startPoint && startPoint !== 'HEAD') { - commands.push(`git reset --hard ${shellQuote(startPoint)}`); - } else { - commands.push('git reset --hard HEAD'); - } - for (const raw of args.setupCommands) { const trimmed = raw.trim(); if (!trimmed) continue; @@ -103,13 +57,69 @@ export const buildSdkStartCommand = (args: { return joined.trim().length > 0 ? joined : undefined; }; +const waitForSdkWorktreeReady = async (directory: string, timeoutMs = 60_000): Promise => { + const target = normalizePath(directory); + if (!target) { + return; + } + + await new Promise((resolve, reject) => { + let done = false; + let unsubscribe = () => {}; + let timeout: ReturnType | 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 }; + 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 { const projectDirectory = project.path; const scoped = opencodeClient.getScopedApiClient(projectDirectory); const results: WorktreeMetadata[] = []; - // SDK worktrees (new) + // SDK worktrees try { const raw = await scoped.worktree.list(); const data = unwrapSdkData(raw); @@ -126,42 +136,14 @@ export async function listProjectWorktrees(project: ProjectRef): Promise/.openchamber/*) - // LEGACY_WORKTREES: list legacy git worktrees rooted under /.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(); - for (const meta of results) { - const key = normalizePath(meta.path); - const existing = byPath.get(key); - if (!existing) { - byPath.set(key, meta); - continue; - } - if (existing.source !== 'sdk' && meta.source === 'sdk') { - byPath.set(key, meta); - } - } - - return Array.from(byPath.values()).sort((a, b) => { + return results.sort((a, b) => { const aLabel = (a.label || a.branch || a.path).toLowerCase(); const bLabel = (b.label || b.branch || b.path).toLowerCase(); return aLabel.localeCompare(bLabel); @@ -171,8 +153,6 @@ export async function listProjectWorktrees(project: ProjectRef): Promise { const projectDirectory = project.path; const scoped = opencodeClient.getScopedApiClient(projectDirectory); @@ -184,52 +164,42 @@ export async function createSdkWorktree(project: ProjectRef, args: { const startCommand = buildSdkStartCommand({ projectDirectory, setupCommands: commands, - startPoint: args.startPoint, }); - let lastError: unknown = null; - const allowSuffix = args.allowSuffix !== false; - const maxAttempts = seed ? (allowSuffix ? 6 : 1) : 1; + const name = seed || undefined; + const raw = await scoped.worktree.create({ + worktreeCreateInput: { + ...(name ? { name } : {}), + ...(startCommand ? { startCommand } : {}), + }, + }); - for (let attempt = 0; attempt < maxAttempts; attempt += 1) { - const name = seed ? (attempt === 0 ? seed : `${seed}-${attempt + 1}`) : undefined; - try { - const raw = await scoped.worktree.create({ - worktreeCreateInput: { - ...(name ? { name } : {}), - ...(startCommand ? { startCommand } : {}), - }, - }); - - const data = unwrapSdkData(raw); - if (!data || typeof data !== 'object') { - throw new Error('Invalid worktree.create response'); - } - - const record = data as Record; - const returnedName = typeof record.name === 'string' ? record.name : name; - const returnedBranch = typeof record.branch === 'string' ? record.branch : (returnedName ? `opencode/${returnedName}` : ''); - const returnedDirectory = typeof record.directory === 'string' ? record.directory : ''; - - if (!returnedName || !returnedDirectory) { - throw new Error('Worktree create missing name/directory'); - } - - return { - source: 'sdk', - name: returnedName, - path: normalizePath(returnedDirectory), - projectDirectory, - branch: returnedBranch, - label: returnedName, - }; - } catch (err) { - lastError = err; - } + const data = unwrapSdkData(raw); + if (!data || typeof data !== 'object') { + throw new Error('Invalid worktree.create response'); } - const message = lastError instanceof Error ? lastError.message : 'Failed to create worktree'; - throw new Error(message); + const record = data as Record; + const returnedName = typeof record.name === 'string' ? record.name : name; + const returnedBranch = typeof record.branch === 'string' ? record.branch : (returnedName ? `opencode/${returnedName}` : ''); + const returnedDirectory = typeof record.directory === 'string' ? record.directory : ''; + + if (!returnedName || !returnedDirectory) { + throw new Error('Worktree create missing name/directory'); + } + + const metadata: WorktreeMetadata = { + source: 'sdk', + name: returnedName, + path: normalizePath(returnedDirectory), + projectDirectory, + branch: returnedBranch, + label: returnedName, + }; + + await waitForSdkWorktreeReady(metadata.path); + + return metadata; } export async function removeProjectWorktree(project: ProjectRef, worktree: WorktreeMetadata, options?: { @@ -239,74 +209,16 @@ export async function removeProjectWorktree(project: ProjectRef, worktree: Workt }): Promise { const projectDirectory = project.path; - const deleteLocalBranch = true; const deleteRemote = Boolean(options?.deleteRemoteBranch); const remoteName = options?.remoteName; - - if (worktree.source === 'sdk') { - const scoped = opencodeClient.getScopedApiClient(projectDirectory); - const worktreeClient = scoped.worktree as unknown; - const force = Boolean(options?.force ?? true); - - const fallbackRemoveViaGit = async () => { - await removeGitWorktree(projectDirectory, { path: worktree.path, force }); - }; - - const removeMethod = getWorktreeMethod(worktreeClient, 'remove'); - if (removeMethod) { - const raw = await removeMethod({ worktreeRemoveInput: { directory: worktree.path } }); - const ok = unwrapSdkData(raw); - if (ok !== true) { - await fallbackRemoveViaGit(); - } - } else { - const deleteMethod = getWorktreeMethod(worktreeClient, 'delete'); - if (deleteMethod) { - const raw = await deleteMethod({ worktreeDeleteInput: { directory: worktree.path } }); - const ok = unwrapSdkData(raw); - if (ok !== true) { - await fallbackRemoveViaGit(); - } - } else { - const archiveMethod = getWorktreeMethod(worktreeClient, 'archive'); - if (archiveMethod) { - const raw = await archiveMethod({ worktreeArchiveInput: { directory: worktree.path } }); - const ok = unwrapSdkData(raw); - if (ok !== true) { - await fallbackRemoveViaGit(); - } - } else { - throw new Error('Worktree removal is not supported by this SDK version.'); - } - } - } - - // Some OpenCode builds only update internal state; remove git worktree best-effort. - await fallbackRemoveViaGit().catch(() => undefined); - - // Best-effort branch cleanup. Some OpenCode builds may keep the branch. - const branchName = (worktree.branch || '').replace(/^refs\/heads\//, '').trim(); - if (deleteLocalBranch && branchName) { - await deleteGitBranch(projectDirectory, { branch: branchName, force: true }).catch(() => undefined); - } - if (deleteRemote && branchName) { - await deleteRemoteBranch(projectDirectory, { branch: branchName, remote: remoteName }).catch(() => undefined); - } - return; + const scoped = opencodeClient.getScopedApiClient(projectDirectory); + const raw = await scoped.worktree.remove({ worktreeRemoveInput: { directory: worktree.path } }); + const ok = unwrapSdkData(raw); + if (ok !== true) { + throw new Error('Worktree removal failed'); } - // LEGACY_WORKTREES: delete legacy git worktree under /.openchamber - const statusIsDirty = Boolean(worktree.status?.isDirty); - const force = Boolean(options?.force ?? statusIsDirty); - - await removeGitWorktree(projectDirectory, { path: worktree.path, force }).catch(async () => { - await removeLegacyWorktree({ projectDirectory, path: worktree.path, force: true }); - }); - const branchName = (worktree.branch || '').replace(/^refs\/heads\//, '').trim(); - if (deleteLocalBranch && branchName) { - await deleteGitBranch(projectDirectory, { branch: branchName, force: true }).catch(() => undefined); - } if (deleteRemote && branchName) { await deleteRemoteBranch(projectDirectory, { branch: branchName, remote: remoteName }).catch(() => undefined); } diff --git a/packages/ui/src/lib/worktrees/worktreeStatus.ts b/packages/ui/src/lib/worktrees/worktreeStatus.ts new file mode 100644 index 00000000..aed667be --- /dev/null +++ b/packages/ui/src/lib/worktrees/worktreeStatus.ts @@ -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 { + 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 { + const normalizedPath = normalizePath(projectDirectory); + if (!normalizedPath) { + return 'HEAD'; + } + + const resolveProjectRoot = async (directory: string): Promise => { + 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'; + } +} diff --git a/packages/ui/src/stores/sessionStore.ts b/packages/ui/src/stores/sessionStore.ts index b185c11d..2a7c1226 100644 --- a/packages/ui/src/stores/sessionStore.ts +++ b/packages/ui/src/stores/sessionStore.ts @@ -4,7 +4,7 @@ import type { Session } from "@opencode-ai/sdk/v2"; import { opencodeClient } from "@/lib/opencode/client"; import { getSafeStorage } from "./utils/safeStorage"; import type { WorktreeMetadata } from "@/types/worktree"; -import { getWorktreeStatus, listWorktrees, mapWorktreeToMetadata } from "@/lib/git/worktreeService"; +import { getWorktreeStatus } from "@/lib/worktrees/worktreeStatus"; import { listProjectWorktrees, removeProjectWorktree } from "@/lib/worktrees/worktreeManager"; import { useDirectoryStore } from "./useDirectoryStore"; import { useProjectsStore } from "./useProjectsStore"; @@ -52,8 +52,6 @@ type SessionStore = SessionState & SessionActions; const safeStorage = getSafeStorage(); const SESSION_SELECTION_STORAGE_KEY = "oc.sessionSelectionByDirectory"; -const WORKTREE_ROOT = ".openchamber"; - type SessionSelectionMap = Record; const readSessionSelectionMap = (): SessionSelectionMap => { @@ -278,11 +276,11 @@ const hydrateSessionWorktreeMetadata = async ( return null; } - let worktreeEntries; + let worktreeEntries: WorktreeMetadata[]; try { - worktreeEntries = await listWorktrees(normalizedProject); + worktreeEntries = await listProjectWorktrees({ id: `path:${normalizedProject}`, path: normalizedProject }); } catch (error) { - console.debug("Failed to hydrate worktree metadata from git worktree list:", error); + console.debug("Failed to hydrate worktree metadata from worktree list:", error); return null; } @@ -298,8 +296,7 @@ const hydrateSessionWorktreeMetadata = async ( } const worktreeMapByPath = new Map(); - worktreeEntries.forEach((info) => { - const metadata = mapWorktreeToMetadata(normalizedProject, info); + worktreeEntries.forEach((metadata) => { const normalizedPath = normalizePath(metadata.path) ?? metadata.path; if (normalizedPath === normalizedProject) { @@ -312,6 +309,26 @@ const hydrateSessionWorktreeMetadata = async ( let mutated = false; const next = new Map(existingMetadata); + const mergeHydratedMetadata = ( + hydrated: WorktreeMetadata, + previous?: WorktreeMetadata + ): WorktreeMetadata => { + if (!previous) { + return hydrated; + } + return { + ...previous, + ...hydrated, + branch: hydrated.branch || previous.branch, + label: hydrated.label || previous.label, + name: hydrated.name || previous.name, + projectDirectory: hydrated.projectDirectory || previous.projectDirectory, + createdFromBranch: hydrated.createdFromBranch || previous.createdFromBranch, + kind: hydrated.kind || previous.kind, + status: hydrated.status || previous.status, + }; + }; + sessionsWithDirectory.forEach(({ id, directory }) => { const metadata = worktreeMapByPath.get(directory); if (!metadata) { @@ -322,8 +339,19 @@ const hydrateSessionWorktreeMetadata = async ( } const previous = next.get(id); - if (!previous || previous.path !== metadata.path || previous.branch !== metadata.branch || previous.label !== metadata.label) { - next.set(id, metadata); + const merged = mergeHydratedMetadata(metadata, previous); + if ( + !previous || + previous.path !== merged.path || + previous.branch !== merged.branch || + previous.label !== merged.label || + previous.name !== merged.name || + previous.projectDirectory !== merged.projectDirectory || + previous.createdFromBranch !== merged.createdFromBranch || + previous.kind !== merged.kind || + previous.source !== merged.source + ) { + next.set(id, merged); mutated = true; } }); @@ -569,7 +597,6 @@ export const useSessionStore = create()( validPaths.add(normalizedProject); if (isGitRepo) { - const worktreeRoot = `${normalizedProject}/${WORKTREE_ROOT}`; try { const candidates = new Set(); @@ -584,25 +611,6 @@ export const useSessionStore = create()( } }); - // LEGACY_WORKTREES: check if .openchamber directory exists before listing it - // LEGACY_WORKTREES: filesystem scan fallback for legacy /.openchamber/* - const projectEntriesList = await opencodeClient.listLocalDirectory(normalizedProject); - const worktreeDirExists = projectEntriesList.some( - (entry) => entry.isDirectory && entry.name === WORKTREE_ROOT - ); - - if (worktreeDirExists) { - const entries = await opencodeClient.listLocalDirectory(worktreeRoot); - entries - .filter((entry) => entry.isDirectory) - .forEach((entry) => { - const isAbsolutePath = /^([A-Za-z]:)?\//.test(entry.path); - const resolvedPath = isAbsolutePath ? entry.path : `${worktreeRoot}/${entry.name}`; - const normalizedPath = normalizePath(resolvedPath) ?? resolvedPath; - candidates.add(normalizedPath); - }); - } - candidates.forEach((candidate) => { const normalizedCandidate = normalizePath(candidate) ?? candidate; validPaths.add(normalizedCandidate); diff --git a/packages/ui/src/stores/useAgentGroupsStore.ts b/packages/ui/src/stores/useAgentGroupsStore.ts index b00af151..03f91354 100644 --- a/packages/ui/src/stores/useAgentGroupsStore.ts +++ b/packages/ui/src/stores/useAgentGroupsStore.ts @@ -6,11 +6,8 @@ import { useDirectoryStore } from './useDirectoryStore'; import { useProjectsStore } from './useProjectsStore'; import { useSessionStore } from './useSessionStore'; import type { WorktreeMetadata } from '@/types/worktree'; -import { listWorktrees, mapWorktreeToMetadata } from '@/lib/git/worktreeService'; import type { Session } from '@opencode-ai/sdk/v2'; -// LEGACY_WORKTREES: legacy worktree root inside project. -const OPENCHAMBER_DIR = '.openchamber'; const resolveProjectDirectory = (currentDirectory: string | null | undefined): string | null => { const projectsState = useProjectsStore.getState(); @@ -109,48 +106,6 @@ const normalize = (value: string): string => { return replaced.replace(/\/+$/, ''); }; -const buildOpenChamberRoot = (projectDirectory: string): string => { - const normalizedProject = normalize(projectDirectory); - if (!normalizedProject || normalizedProject === '/') { - return `/${OPENCHAMBER_DIR}`; - } - return `${normalizedProject}/${OPENCHAMBER_DIR}`; -}; - -const resolveDirectoryListingPaths = (root: string, entries: Array<{ name?: string; path?: string }>): string[] => { - const normalizedRoot = normalize(root); - return entries - .map((entry) => { - const entryPath = typeof entry.path === 'string' && entry.path.trim().length > 0 ? entry.path : null; - if (entryPath) { - const normalizedEntry = normalize(entryPath); - if (normalizedEntry) { - return normalizedEntry; - } - } - const name = typeof entry.name === 'string' ? entry.name.trim() : ''; - if (!name || !normalizedRoot) { - return null; - } - return `${normalizedRoot}/${name}`; - }) - .filter((value): value is string => Boolean(value)); -}; - -const listOpenChamberDirectories = async (root: string): Promise => { - const normalizedRoot = normalize(root); - if (!normalizedRoot) { - return []; - } - - try { - const entries = await opencodeClient.listLocalDirectory(normalizedRoot); - const directories = entries.filter((entry) => entry.isDirectory); - return resolveDirectoryListingPaths(normalizedRoot, directories); - } catch { - return []; - } -}; const startsWithDirectory = (candidate: string, root: string): boolean => { const normalizedCandidate = normalize(candidate); @@ -253,12 +208,12 @@ const buildWorktreeMetadataByPath = async (group: AgentGroup, projectDirectory: } try { - const infos = await listWorktrees(projectDirectory); - const infoByPath = new Map(infos.map((info) => [normalize(info.worktree), info])); + const worktrees = await listProjectWorktrees({ id: `path:${projectDirectory}`, path: projectDirectory }); + const infoByPath = new Map(worktrees.map((meta) => [normalize(meta.path), meta])); missingPaths.forEach((path) => { const info = infoByPath.get(path); if (info) { - map.set(path, mapWorktreeToMetadata(projectDirectory, info)); + map.set(path, info); } }); } catch { @@ -419,24 +374,17 @@ export const useAgentGroupsStore = create()( : []; const worktreeDirectorySet = new Set(); + const worktreeMetadataMap = new Map(); [...managedWorktrees, ...managedWorktreesCanonical].forEach((meta) => { if (meta?.path) { - worktreeDirectorySet.add(normalize(meta.path)); + const key = normalize(meta.path); + worktreeDirectorySet.add(key); + if (!worktreeMetadataMap.has(key)) { + worktreeMetadataMap.set(key, meta); + } } }); - // Get git worktree info first - we need to query each worktree separately - let worktreeInfoMap = new Map>[number]>(); - let worktreeInfoList: Awaited> = []; - 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 => { try { const scoped = await apiClient.session.list({ directory: normalizedProject }); @@ -497,23 +445,6 @@ export const useAgentGroupsStore = create()( // 1) Known worktree directories for this project worktreeDirectorySet.forEach((dir) => candidates.add(dir)); - // 2) Git worktree list (covers SDK + legacy) - worktreeInfoList - .map((info) => normalize(info.worktree)) - .filter(Boolean) - .forEach((worktreePath) => candidates.add(worktreePath)); - - // LEGACY_WORKTREES: optional filesystem scan for legacy /.openchamber/* - const roots = [buildOpenChamberRoot(normalizedProject), buildOpenChamberRoot(canonicalProject)] - .map((p) => normalize(p)) - .filter(Boolean); - await Promise.all( - Array.from(new Set(roots)).map(async (root) => { - const dirs = await listOpenChamberDirectories(root); - dirs.forEach((dir) => candidates.add(dir)); - }) - ); - if (candidates.size > 0) { allSessions = await fetchSessionsByWorktreeDirectories(Array.from(candidates)); } @@ -533,7 +464,7 @@ export const useAgentGroupsStore = create()( if (!parsed) continue; // Skip sessions without valid agent group title const sessionPath = normalize(session.directory); - const worktreeInfo = worktreeInfoMap.get(sessionPath); + const worktreeInfo = worktreeMetadataMap.get(sessionPath); const agentSession: AgentGroupSession = { id: session.id, @@ -543,9 +474,7 @@ export const useAgentGroupsStore = create()( instanceNumber: parsed.index, branch: worktreeInfo?.branch ?? '', displayLabel: `${parsed.provider}/${parsed.model}`, - worktreeMetadata: worktreeInfo - ? mapWorktreeToMetadata(normalizedProject, worktreeInfo) - : undefined, + worktreeMetadata: worktreeInfo, }; const existing = groupsMap.get(parsed.groupSlug); diff --git a/packages/ui/src/stores/useMultiRunStore.ts b/packages/ui/src/stores/useMultiRunStore.ts index 810cd510..24f9326e 100644 --- a/packages/ui/src/stores/useMultiRunStore.ts +++ b/packages/ui/src/stores/useMultiRunStore.ts @@ -4,6 +4,7 @@ import type { CreateMultiRunParams, CreateMultiRunResult } from '@/types/multiru import { opencodeClient } from '@/lib/opencode/client'; import { saveWorktreeSetupCommands } from '@/lib/openchamberConfig'; import { createSdkWorktree, type ProjectRef } from '@/lib/worktrees/worktreeManager'; +import { getRootBranch } from '@/lib/worktrees/worktreeStatus'; import { checkIsGitRepository } from '@/lib/gitApi'; import { useSessionStore } from './sessionStore'; import { useDirectoryStore } from './useDirectoryStore'; @@ -121,11 +122,7 @@ export const useMultiRunStore = create()( } const groupSlug = toGitSafeSlug(groupName); - const worktreeBaseBranch = - typeof params.worktreeBaseBranch === 'string' && params.worktreeBaseBranch.trim().length > 0 - ? params.worktreeBaseBranch.trim() - : 'HEAD'; - const startPoint = worktreeBaseBranch !== 'HEAD' ? worktreeBaseBranch : undefined; + const rootBranch = await getRootBranch(directory); const createdRuns: Array<{ sessionId: string; @@ -164,12 +161,11 @@ export const useMultiRunStore = create()( const worktreeMetadata = await createSdkWorktree(project, { preferredName, setupCommands: commandsToRun, - startPoint: startPoint ?? null, }); const enrichedMetadata = { ...worktreeMetadata, - createdFromBranch: startPoint ?? 'HEAD', + createdFromBranch: rootBranch, kind: 'standard' as const, }; diff --git a/packages/ui/src/stores/useProjectsStore.ts b/packages/ui/src/stores/useProjectsStore.ts index 419d37a4..f3adf343 100644 --- a/packages/ui/src/stores/useProjectsStore.ts +++ b/packages/ui/src/stores/useProjectsStore.ts @@ -1,7 +1,7 @@ import { create } from 'zustand'; import { devtools } from 'zustand/middleware'; import { opencodeClient } from '@/lib/opencode/client'; -import type { ProjectEntry, WorktreeDefaults } from '@/lib/api/types'; +import type { ProjectEntry } from '@/lib/api/types'; import type { DesktopSettings } from '@/lib/desktop'; import { updateDesktopSettings } from '@/lib/persistence'; import { getSafeStorage } from './utils/safeStorage'; @@ -27,7 +27,6 @@ interface ProjectsStore { validateProjectPath: (path: string) => ProjectPathValidationResult; synchronizeFromSettings: (settings: DesktopSettings) => void; getActiveProject: () => ProjectEntry | null; - updateWorktreeDefaults: (projectId: string, defaults: Partial) => void; } const safeStorage = getSafeStorage(); @@ -124,20 +123,6 @@ const sanitizeProjects = (value: unknown): ProjectEntry[] => { if (typeof candidate.sidebarCollapsed === 'boolean') { project.sidebarCollapsed = candidate.sidebarCollapsed; } - if (candidate.worktreeDefaults && typeof candidate.worktreeDefaults === 'object') { - const wt = candidate.worktreeDefaults as Record; - const defaults: WorktreeDefaults = {}; - if (typeof wt.baseBranch === 'string') { - defaults.baseBranch = wt.baseBranch; - } - if (typeof wt.autoCreateWorktree === 'boolean') { - defaults.autoCreateWorktree = wt.autoCreateWorktree; - } - if (Object.keys(defaults).length > 0) { - project.worktreeDefaults = defaults; - } - } - result.push(project); } @@ -452,37 +437,6 @@ export const useProjectsStore = create()( return projects.find((project) => project.id === activeProjectId) ?? null; }, - updateWorktreeDefaults: (projectId: string, defaults: Partial) => { - 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' }) ); diff --git a/packages/ui/src/types/worktree.ts b/packages/ui/src/types/worktree.ts index 171c557e..66a2e9a7 100644 --- a/packages/ui/src/types/worktree.ts +++ b/packages/ui/src/types/worktree.ts @@ -3,9 +3,8 @@ export interface WorktreeMetadata { /** * Worktree origin. * - sdk: created/managed by OpenCode SDK worktrees - * - legacy: git worktree under /.openchamber */ - source?: 'sdk' | 'legacy'; + source?: 'sdk'; path: string; diff --git a/packages/vscode/src/bridge.ts b/packages/vscode/src/bridge.ts index 4596a559..d0b5c3a0 100644 --- a/packages/vscode/src/bridge.ts +++ b/packages/vscode/src/bridge.ts @@ -2171,42 +2171,12 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo } case 'api:git/worktrees': { - const { directory, method, path: worktreePath, branch, createBranch, force } = (payload || {}) as { - directory?: string; - method?: string; - path?: string; - branch?: string; - createBranch?: boolean; - force?: boolean; - }; + const { directory } = (payload || {}) as { directory?: string }; if (!directory) { return { id, type, success: false, error: 'Directory is required' }; } - - const normalizedMethod = typeof method === 'string' ? method.toUpperCase() : 'GET'; - - if (normalizedMethod === 'GET') { - const worktrees = await gitService.listGitWorktrees(directory); - return { id, type, success: true, data: worktrees }; - } - - if (normalizedMethod === 'POST') { - if (!worktreePath || !branch) { - return { id, type, success: false, error: 'Path and branch are required' }; - } - const result = await gitService.addGitWorktree(directory, worktreePath, branch, createBranch); - return { id, type, success: true, data: result }; - } - - if (normalizedMethod === 'DELETE') { - if (!worktreePath) { - return { id, type, success: false, error: 'Path is required' }; - } - const result = await gitService.removeGitWorktree(directory, worktreePath, force); - return { id, type, success: true, data: result }; - } - - return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` }; + const worktrees = await gitService.listGitWorktrees(directory); + return { id, type, success: true, data: worktrees }; } case 'api:git/diff': { @@ -2438,16 +2408,6 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` }; } - case 'api:git/ignore-openchamber': { - // LEGACY_WORKTREES: only needed for /.openchamber era. Safe to remove after legacy support dropped. - const { directory } = (payload || {}) as { directory?: string }; - if (!directory) { - return { id, type, success: false, error: 'Directory is required' }; - } - await gitService.ensureOpenChamberIgnored(directory); - return { id, type, success: true, data: { success: true } }; - } - default: return { id, type, success: false, error: `Unknown message type: ${type}` }; } diff --git a/packages/vscode/src/gitService.ts b/packages/vscode/src/gitService.ts index 1878fccf..3b2bc7d4 100644 --- a/packages/vscode/src/gitService.ts +++ b/packages/vscode/src/gitService.ts @@ -694,48 +694,6 @@ export async function getAvailableBranchesForWorktree(directory: string): Promis return availableBranches; } -/** - * Add a new worktree - */ -export async function addGitWorktree( - directory: string, - worktreePath: string, - branch: string, - createBranch = false -): Promise<{ success: boolean; path: string; branch: string }> { - const args = ['worktree', 'add']; - - if (createBranch) { - args.push('-b', branch, worktreePath); - } else { - args.push(worktreePath, branch); - } - - const result = await execGit(args, directory); - - return { - success: result.exitCode === 0, - path: worktreePath, - branch, - }; -} - -/** - * Remove a worktree - */ -export async function removeGitWorktree( - directory: string, - worktreePath: string, - force = false -): Promise<{ success: boolean }> { - const args = ['worktree', 'remove']; - if (force) args.push('--force'); - args.push(worktreePath); - - const result = await execGit(args, directory); - return { success: result.exitCode === 0 }; -} - // ============== Diff Operations ============== /** @@ -1361,32 +1319,3 @@ export async function setGitIdentity( return { success: true }; } - -// ============== Utility Operations ============== - -/** - * Ensure .openchamber is in git exclude - */ -export async function ensureOpenChamberIgnored(directory: string): Promise { - // LEGACY_WORKTREES: only needed for /.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); - } -} diff --git a/packages/vscode/webview/api/git.ts b/packages/vscode/webview/api/git.ts index e406efd6..ab9f80d1 100644 --- a/packages/vscode/webview/api/git.ts +++ b/packages/vscode/webview/api/git.ts @@ -17,8 +17,6 @@ import type { GeneratedCommitMessage, GeneratedPullRequestDescription, GitWorktreeInfo, - GitAddWorktreePayload, - GitRemoveWorktreePayload, GitCommitResult, CreateGitCommitOptions, GitPushResult, @@ -112,30 +110,6 @@ export const createVSCodeGitAPI = (): GitAPI => ({ return sendBridgeMessage('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 => { - await sendBridgeMessage('api:git/ignore-openchamber', { directory }); - }, - createGitCommit: async (directory: string, message: string, options?: CreateGitCommitOptions): Promise => { return sendBridgeMessage('api:git/commit', { directory, diff --git a/packages/web/server/index.js b/packages/web/server/index.js index a90cfb68..4ee6d7b1 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -891,24 +891,6 @@ const sanitizeProjects = (input) => { ...(Number.isFinite(lastOpenedAt) && lastOpenedAt >= 0 ? { lastOpenedAt } : {}), }; - // Preserve worktreeDefaults - if (candidate.worktreeDefaults && typeof candidate.worktreeDefaults === 'object') { - const wt = candidate.worktreeDefaults; - const defaults = {}; - if (typeof wt.branchPrefix === 'string' && wt.branchPrefix.trim()) { - defaults.branchPrefix = wt.branchPrefix.trim(); - } - if (typeof wt.baseBranch === 'string' && wt.baseBranch.trim()) { - defaults.baseBranch = wt.baseBranch.trim(); - } - if (typeof wt.autoCreateWorktree === 'boolean') { - defaults.autoCreateWorktree = wt.autoCreateWorktree; - } - if (Object.keys(defaults).length > 0) { - project.worktreeDefaults = defaults; - } - } - if (typeof candidate.sidebarCollapsed === 'boolean') { project.sidebarCollapsed = candidate.sidebarCollapsed; } @@ -7111,65 +7093,6 @@ Context: } }); - app.post('/api/git/worktrees', async (req, res) => { - const { addWorktree } = await getGitLibraries(); - try { - const directory = req.query.directory; - if (!directory) { - return res.status(400).json({ error: 'directory parameter is required' }); - } - - const { path, branch, createBranch, startPoint } = req.body; - if (!path || !branch) { - return res.status(400).json({ error: 'path and branch are required' }); - } - - const result = await addWorktree(directory, path, branch, { createBranch, startPoint }); - res.json(result); - } catch (error) { - console.error('Failed to add worktree:', error); - res.status(500).json({ error: error.message || 'Failed to add worktree' }); - } - }); - - app.delete('/api/git/worktrees', async (req, res) => { - const { removeWorktree } = await getGitLibraries(); - try { - const directory = req.query.directory; - if (!directory) { - return res.status(400).json({ error: 'directory parameter is required' }); - } - - const { path, force } = req.body; - if (!path) { - return res.status(400).json({ error: 'path is required' }); - } - - const result = await removeWorktree(directory, path, { force }); - res.json(result); - } catch (error) { - console.error('Failed to remove worktree:', error); - res.status(500).json({ error: error.message || 'Failed to remove worktree' }); - } - }); - - app.post('/api/git/ignore-openchamber', async (req, res) => { - // LEGACY_WORKTREES: only needed for /.openchamber era. Safe to remove after legacy support dropped. - const { ensureOpenChamberIgnored } = await getGitLibraries(); - try { - const directory = req.query.directory; - if (!directory) { - return res.status(400).json({ error: 'directory parameter is required' }); - } - - await ensureOpenChamberIgnored(directory); - res.json({ success: true }); - } catch (error) { - console.error('Failed to ignore .openchamber directory:', error); - res.status(500).json({ error: error.message || 'Failed to update git ignore' }); - } - }); - app.get('/api/git/worktree-type', async (req, res) => { const { isLinkedWorktree } = await getGitLibraries(); try { diff --git a/packages/web/server/lib/git-service.js b/packages/web/server/lib/git-service.js index c1189c67..522fd0ac 100644 --- a/packages/web/server/lib/git-service.js +++ b/packages/web/server/lib/git-service.js @@ -130,46 +130,6 @@ export async function isGitRepository(directory) { return fs.existsSync(gitDir); } -export async function ensureOpenChamberIgnored(directory) { - // LEGACY_WORKTREES: only needed for /.openchamber era. Safe to remove after legacy support dropped. - const directoryPath = normalizeDirectoryPath(directory); - if (!directoryPath || !fs.existsSync(directoryPath)) { - return false; - } - - const gitDir = path.join(directoryPath, '.git'); - if (!fs.existsSync(gitDir)) { - return false; - } - - const infoDir = path.join(gitDir, 'info'); - const excludePath = path.join(infoDir, 'exclude'); - const entry = '/.openchamber/'; - - try { - await fsp.mkdir(infoDir, { recursive: true }); - let contents = ''; - try { - contents = await fsp.readFile(excludePath, 'utf8'); - } catch (readError) { - if (readError && readError.code !== 'ENOENT') { - throw readError; - } - } - - const lines = contents.split(/\r?\n/).map((line) => line.trim()); - if (!lines.includes(entry)) { - const prefix = contents.length > 0 && !contents.endsWith('\n') ? '\n' : ''; - await fsp.appendFile(excludePath, `${prefix}${entry}\n`, 'utf8'); - } - - return true; - } catch (error) { - console.error('Failed to ensure .openchamber ignore:', error); - throw error; - } -} - export async function getGlobalIdentity() { const git = await createGit(); @@ -1018,63 +978,6 @@ export async function getWorktrees(directory) { } } -export async function addWorktree(directory, worktreePath, branch, options = {}) { - const git = await createGit(directory); - - try { - const args = ['worktree', 'add']; - const startPoint = typeof options.startPoint === 'string' ? options.startPoint.trim() : ''; - - if (options.createBranch) { - args.push('-b', branch); - } - - args.push(worktreePath); - - if (!options.createBranch) { - args.push(branch); - } else if (startPoint) { - args.push(startPoint); - } - - await git.raw(args); - - return { - success: true, - path: worktreePath, - branch - }; - } catch (error) { - console.error('Failed to add worktree:', error); - throw error; - } -} - -export async function removeWorktree(directory, worktreePath, options = {}) { - const git = await createGit(directory); - - try { - const args = ['worktree', 'remove', worktreePath]; - - if (options.force) { - args.push('--force'); - } - - await git.raw(args); - - return { success: true }; - } catch (error) { - // If the worktree doesn't exist or isn't recognized by git, treat as success - // since the goal (removing the worktree) is already achieved. - const errorMessage = String(error?.message || error || ''); - if (errorMessage.includes('is not a working tree') || errorMessage.includes('is not a valid path')) { - return { success: true }; - } - console.error('Failed to remove worktree:', error); - throw error; - } -} - export async function deleteBranch(directory, branch, options = {}) { const git = await createGit(directory); diff --git a/packages/web/src/api/git.ts b/packages/web/src/api/git.ts index f46288fe..1552ecf6 100644 --- a/packages/web/src/api/git.ts +++ b/packages/web/src/api/git.ts @@ -18,9 +18,6 @@ export const createWebGitAPI = (): GitAPI => ({ generateCommitMessage: gitApiHttp.generateCommitMessage, generatePullRequestDescription: gitApiHttp.generatePullRequestDescription, listGitWorktrees: gitApiHttp.listGitWorktrees, - addGitWorktree: gitApiHttp.addGitWorktree as GitAPI['addGitWorktree'], - removeGitWorktree: gitApiHttp.removeGitWorktree as GitAPI['removeGitWorktree'], - ensureOpenChamberIgnored: gitApiHttp.ensureOpenChamberIgnored, createGitCommit(directory: string, message: string, options?: CreateGitCommitOptions) { return gitApiHttp.createGitCommit(directory, message, options); },