refactor: simplify worktree management by removing legacy API

- Remove legacy worktree API usage and related state
- Add Manage Branches button in the Git header for quick access
- Introduce worktree status utilities to derive root branch hints
This commit is contained in:
Bohdan Triapitsyn
2026-02-06 12:38:09 +02:00
parent 2f336a0716
commit 0503f51357
26 changed files with 504 additions and 1450 deletions
-21
View File
@@ -230,18 +230,6 @@ export interface GitWorktreeInfo {
branch?: string;
}
export interface GitAddWorktreePayload {
path: string;
branch: string;
createBranch?: boolean;
startPoint?: string;
}
export interface GitRemoveWorktreePayload {
path: string;
force?: boolean;
}
export interface GitDeleteBranchPayload {
branch: string;
force?: boolean;
@@ -290,9 +278,6 @@ export interface GitAPI {
payload: { base: string; head: string; context?: string }
): Promise<GeneratedPullRequestDescription>;
listGitWorktrees(directory: string): Promise<GitWorktreeInfo[]>;
addGitWorktree(directory: string, payload: GitAddWorktreePayload): Promise<{ success: boolean; path: string; branch: string }>;
removeGitWorktree(directory: string, payload: GitRemoveWorktreePayload): Promise<{ success: boolean }>;
ensureOpenChamberIgnored(directory: string): Promise<void>;
createGitCommit(directory: string, message: string, options?: CreateGitCommitOptions): Promise<GitCommitResult>;
gitPush(directory: string, options?: { remote?: string; branch?: string; options?: string[] | Record<string, unknown> }): Promise<GitPushResult>;
gitPull(directory: string, options?: { remote?: string; branch?: string }): Promise<GitPullResult>;
@@ -366,18 +351,12 @@ export interface FilesAPI {
execCommands?(commands: string[], cwd: string): Promise<{ success: boolean; results: CommandExecResult[] }>;
}
export interface WorktreeDefaults {
baseBranch?: string; // e.g. "main", "develop", or "HEAD"
autoCreateWorktree?: boolean; // future: skip dialog, create worktree automatically
}
export interface ProjectEntry {
id: string;
path: string;
label?: string;
addedAt?: number;
lastOpenedAt?: number;
worktreeDefaults?: WorktreeDefaults;
sidebarCollapsed?: boolean;
}
-344
View File
@@ -1,344 +0,0 @@
import { addGitWorktree, deleteGitBranch, deleteRemoteBranch, getGitStatus, listGitWorktrees, removeGitWorktree, type GitAddWorktreePayload, type GitWorktreeInfo } from '@/lib/gitApi';
import { opencodeClient } from '@/lib/opencode/client';
import type { WorktreeMetadata } from '@/types/worktree';
import type { FilesAPI, RuntimeAPIs } from '@/lib/api/types';
import { substituteCommandVariables } from '@/lib/openchamberConfig';
const WORKTREE_ROOT = '.openchamber';
const DEFAULT_BASE_URL = import.meta.env.VITE_OPENCODE_URL || '/api';
/**
* Get the runtime Files API if available (Desktop/VSCode).
*/
function getRuntimeFilesAPI(): FilesAPI | null {
if (typeof window === 'undefined') return null;
const apis = (window as typeof window & { __OPENCHAMBER_RUNTIME_APIS__?: RuntimeAPIs }).__OPENCHAMBER_RUNTIME_APIS__;
if (apis?.files) {
return apis.files;
}
return null;
}
const normalize = (value: string): string => {
if (!value) {
return '';
}
const replaced = value.replace(/\\/g, '/');
if (replaced === '/') {
return '/';
}
return replaced.replace(/\/+$/, '');
};
const joinPath = (base: string, segment: string): string => {
const normalizedBase = normalize(base);
const sanitizedSegment = segment.replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+$/, '');
if (!normalizedBase || normalizedBase === '/') {
return `/${sanitizedSegment}`;
}
return `${normalizedBase}/${sanitizedSegment}`;
};
const shortBranchLabel = (branch?: string): string => {
if (!branch) {
return '';
}
if (branch.startsWith('refs/heads/')) {
return branch.substring('refs/heads/'.length);
}
if (branch.startsWith('heads/')) {
return branch.substring('heads/'.length);
}
if (branch.startsWith('refs/')) {
return branch.substring('refs/'.length);
}
return branch;
};
const ensureDirectory = async (path: string) => {
try {
await opencodeClient.createDirectory(path);
} catch (error) {
if (error instanceof Error) {
if (/exist/i.test(error.message)) {
return;
}
}
throw error;
}
};
export interface CreateWorktreeOptions {
projectDirectory: string;
worktreeSlug: string;
branch: string;
createBranch?: boolean;
startPoint?: string;
}
export interface RemoveWorktreeOptions {
projectDirectory: string;
path: string;
force?: boolean;
}
export interface ArchiveWorktreeOptions {
projectDirectory: string;
path: string;
branch: string;
force?: boolean;
deleteRemote?: boolean;
remote?: string;
}
export async function resolveWorktreePath(projectDirectory: string, worktreeSlug: string): Promise<string> {
const normalizedProject = normalize(projectDirectory);
const root = joinPath(normalizedProject, WORKTREE_ROOT);
await ensureDirectory(root);
return joinPath(root, worktreeSlug);
}
export async function createWorktree(options: CreateWorktreeOptions): Promise<WorktreeMetadata> {
// LEGACY_WORKTREES: creates <project>/.openchamber/<slug> git worktrees.
const { projectDirectory, worktreeSlug, branch, createBranch, startPoint } = options;
const normalizedProject = normalize(projectDirectory);
const worktreePath = await resolveWorktreePath(normalizedProject, worktreeSlug);
const payload: GitAddWorktreePayload = {
path: worktreePath,
branch,
createBranch: Boolean(createBranch),
startPoint: startPoint?.trim() || undefined,
};
await addGitWorktree(normalizedProject, payload);
return {
source: 'legacy',
path: worktreePath,
branch,
label: shortBranchLabel(branch),
projectDirectory: normalizedProject,
relativePath: worktreePath.startsWith(`${normalizedProject}/`)
? worktreePath.slice(normalizedProject.length + 1)
: worktreePath,
};
}
export async function removeWorktree(options: RemoveWorktreeOptions): Promise<void> {
const { projectDirectory, path, force } = options;
const normalizedProject = normalize(projectDirectory);
await removeGitWorktree(normalizedProject, { path, force });
}
export async function archiveWorktree(options: ArchiveWorktreeOptions): Promise<void> {
const { projectDirectory, path, branch, force, deleteRemote, remote } = options;
const normalizedProject = normalize(projectDirectory);
const normalizedBranch = branch.startsWith('refs/heads/')
? branch.substring('refs/heads/'.length)
: branch;
await removeGitWorktree(normalizedProject, { path, force });
if (normalizedBranch) {
await deleteGitBranch(normalizedProject, { branch: normalizedBranch, force: true });
if (deleteRemote) {
try {
await deleteRemoteBranch(normalizedProject, {
branch: normalizedBranch,
remote,
});
} catch (error) {
console.warn('Failed to delete remote branch during worktree archive:', error);
}
}
}
}
export async function listWorktrees(projectDirectory: string): Promise<GitWorktreeInfo[]> {
const normalizedProject = normalize(projectDirectory);
return listGitWorktrees(normalizedProject);
}
export async function getWorktreeStatus(worktreePath: string): Promise<WorktreeMetadata['status']> {
const normalizedPath = normalize(worktreePath);
const status = await getGitStatus(normalizedPath);
return {
isDirty: !status.isClean,
ahead: status.ahead,
behind: status.behind,
upstream: status.tracking,
};
}
export function mapWorktreeToMetadata(projectDirectory: string, info: GitWorktreeInfo): WorktreeMetadata {
const normalizedProject = normalize(projectDirectory);
const normalizedPath = normalize(info.worktree);
const legacyRoot = `${normalizedProject}/${WORKTREE_ROOT}/`;
const source: WorktreeMetadata['source'] = normalizedPath.startsWith(legacyRoot) ? 'legacy' : 'sdk';
return {
source,
path: normalizedPath,
branch: info.branch ?? '',
label: shortBranchLabel(info.branch ?? ''),
projectDirectory: normalizedProject,
relativePath: normalizedPath.startsWith(`${normalizedProject}/`)
? normalizedPath.slice(normalizedProject.length + 1)
: normalizedPath,
};
}
export interface WorktreeSetupResult {
success: boolean;
results: Array<{
command: string;
success: boolean;
exitCode?: number;
stdout?: string;
stderr?: string;
error?: string;
}>;
}
/**
* Run worktree setup commands in the background.
* This does not block - it returns a promise that resolves when all commands complete.
*
* @param worktreePath - The path to the new worktree where commands will run
* @param projectDirectory - The root project directory (for $ROOT_PROJECT_PATH substitution)
* @param commands - Commands to run.
* @returns Promise resolving to setup results
*/
export async function runWorktreeSetupCommands(
worktreePath: string,
projectDirectory: string,
commands: string[]
): Promise<WorktreeSetupResult> {
const commandsToRun = Array.isArray(commands) ? commands : [];
if (commandsToRun.length === 0) {
return { success: true, results: [] };
}
// Substitute variables in commands
const substitutedCommands = commandsToRun.map(cmd =>
substituteCommandVariables(cmd, { rootWorktreePath: projectDirectory })
);
console.log('[worktreeService] Running setup commands:', { worktreePath, projectDirectory, commands: substitutedCommands });
try {
// Try runtime API first (Desktop/VSCode)
const runtimeFiles = getRuntimeFilesAPI();
if (runtimeFiles?.execCommands) {
console.log('[worktreeService] Using runtime API for exec');
try {
// Don't use background mode - we want actual results for toast notifications
// The bridge now uses async exec (not execSync) so it won't block other operations
const result = await runtimeFiles.execCommands(substitutedCommands, worktreePath);
console.log('[worktreeService] Runtime exec result:', result);
return result as WorktreeSetupResult;
} catch (error) {
console.error('[worktreeService] Runtime exec error:', error);
return {
success: false,
results: substitutedCommands.map(cmd => ({
command: cmd,
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
})),
};
}
}
// Fall back to web API
console.log('[worktreeService] Using web API for exec');
const startResponse = await fetch(`${DEFAULT_BASE_URL}/fs/exec`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
// Use background job so we don't hold long-lived HTTP connections.
body: JSON.stringify({
commands: substitutedCommands,
cwd: worktreePath,
background: true,
}),
});
const startPayload = await startResponse.json().catch(() => null);
if (startResponse.status === 202 && startPayload && typeof startPayload.jobId === 'string') {
const jobId = startPayload.jobId as string;
const pollIntervalMs = 800;
const timeoutMs = Math.max(5 * 60_000, substitutedCommands.length * 60_000);
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
const pollResponse = await fetch(`${DEFAULT_BASE_URL}/fs/exec/${jobId}`, {
method: 'GET',
});
const pollPayload = await pollResponse.json().catch(() => null);
if (!pollResponse.ok) {
return {
success: false,
results: substitutedCommands.map((cmd) => ({
command: cmd,
success: false,
error: (pollPayload && pollPayload.error) || 'Failed to poll exec job',
})),
};
}
const status = pollPayload?.status;
if (status === 'done') {
const results = Array.isArray(pollPayload?.results) ? pollPayload.results : [];
const success = pollPayload?.success === true;
return { success, results } as WorktreeSetupResult;
}
}
return {
success: false,
results: substitutedCommands.map((cmd) => ({
command: cmd,
success: false,
error: 'Setup commands timed out',
})),
};
}
if (!startResponse.ok) {
const error = (startPayload && startPayload.error) || 'Request failed';
console.error('[worktreeService] Web exec failed:', startPayload);
return {
success: false,
results: substitutedCommands.map((cmd) => ({
command: cmd,
success: false,
error,
})),
};
}
// Back-compat: older servers may still return results synchronously.
console.log('[worktreeService] Web exec result:', startPayload);
return startPayload as WorktreeSetupResult;
} catch (error) {
console.error('[worktreeService] Exec exception:', error);
return {
success: false,
results: substitutedCommands.map(cmd => ({
command: cmd,
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
})),
};
}
}
// (intentionally no `hasWorktreeSetupCommands`; setup commands now run via SDK worktree startCommand)
-21
View File
@@ -18,8 +18,6 @@ export type {
GitLogEntry,
GitLogResponse,
GitWorktreeInfo,
GitAddWorktreePayload,
GitRemoveWorktreePayload,
GitDeleteBranchPayload,
GitDeleteRemoteBranchPayload,
DiscoveredGitCredential,
@@ -121,25 +119,6 @@ export async function listGitWorktrees(directory: string): Promise<import('./api
return gitHttp.listGitWorktrees(directory);
}
export async function addGitWorktree(directory: string, payload: import('./api/types').GitAddWorktreePayload): Promise<{ success: boolean; path: string; branch: string }> {
const runtime = getRuntimeGit();
if (runtime) return runtime.addGitWorktree(directory, payload);
return gitHttp.addGitWorktree(directory, payload);
}
export async function removeGitWorktree(directory: string, payload: import('./api/types').GitRemoveWorktreePayload): Promise<{ success: boolean }> {
const runtime = getRuntimeGit();
if (runtime) return runtime.removeGitWorktree(directory, payload);
return gitHttp.removeGitWorktree(directory, payload);
}
export async function ensureOpenChamberIgnored(directory: string): Promise<void> {
// LEGACY_WORKTREES: only needed for <project>/.openchamber era. Safe to remove after legacy support dropped.
const runtime = getRuntimeGit();
if (runtime) return runtime.ensureOpenChamberIgnored(directory);
return gitHttp.ensureOpenChamberIgnored(directory);
}
export async function createGitCommit(
directory: string,
message: string,
-52
View File
@@ -11,8 +11,6 @@ import type {
GitDeleteRemoteBranchPayload,
GeneratedCommitMessage,
GitWorktreeInfo,
GitAddWorktreePayload,
GitRemoveWorktreePayload,
CreateGitCommitOptions,
GitCommitResult,
GitPushResult,
@@ -291,56 +289,6 @@ export async function listGitWorktrees(directory: string): Promise<GitWorktreeIn
return response.json();
}
export async function addGitWorktree(directory: string, payload: GitAddWorktreePayload): Promise<{ success: boolean; path: string; branch: string }> {
if (!payload?.path || !payload?.branch) {
throw new Error('path and branch are required to add a worktree');
}
const response = await fetch(buildUrl(`${API_BASE}/worktrees`, directory), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to add worktree');
}
return response.json();
}
export async function removeGitWorktree(directory: string, payload: GitRemoveWorktreePayload): Promise<{ success: boolean }> {
if (!payload?.path) {
throw new Error('path is required to remove a worktree');
}
const response = await fetch(buildUrl(`${API_BASE}/worktrees`, directory), {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to remove worktree');
}
return response.json();
}
export async function ensureOpenChamberIgnored(directory: string): Promise<void> {
// LEGACY_WORKTREES: only needed for <project>/.openchamber era. Safe to remove after legacy support dropped.
const response = await fetch(buildUrl(`${API_BASE}/ignore-openchamber`, directory), {
method: 'POST',
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to update git ignore');
}
}
export async function createGitCommit(
directory: string,
message: string,
-15
View File
@@ -157,21 +157,6 @@ const sanitizeProjects = (value: unknown): DesktopSettings['projects'] | undefin
if (typeof candidate.sidebarCollapsed === 'boolean') {
(project as unknown as Record<string, unknown>).sidebarCollapsed = candidate.sidebarCollapsed;
}
// Preserve worktreeDefaults
if (candidate.worktreeDefaults && typeof candidate.worktreeDefaults === 'object') {
const wt = candidate.worktreeDefaults as Record<string, unknown>;
const defaults: Record<string, unknown> = {};
if (typeof wt.baseBranch === 'string' && wt.baseBranch.trim()) {
defaults.baseBranch = wt.baseBranch.trim();
}
if (typeof wt.autoCreateWorktree === 'boolean') {
defaults.autoCreateWorktree = wt.autoCreateWorktree;
}
if (Object.keys(defaults).length > 0) {
(project as unknown as Record<string, unknown>).worktreeDefaults = defaults;
}
}
result.push(project);
}
+61 -58
View File
@@ -12,9 +12,7 @@ import { useContextStore } from '@/stores/contextStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { checkIsGitRepository } from '@/lib/gitApi';
import { generateBranchName } from '@/lib/git/branchNameGenerator';
import {
getWorktreeStatus,
} from '@/lib/git/worktreeService';
import { getRootBranch, getWorktreeStatus } from '@/lib/worktrees/worktreeStatus';
import { getWorktreeSetupCommands } from '@/lib/openchamberConfig';
import {
createSdkWorktree,
@@ -28,11 +26,26 @@ const normalizePath = (value: string): string => value.replace(/\\/g, '/').repla
const resolveProjectRef = (directory: string): ProjectRef | null => {
const normalized = normalizePath(directory);
const projects = useProjectsStore.getState().projects;
const match = projects.find((project) => normalizePath(project.path) === normalized);
if (!match) {
if (projects.length === 0) {
return null;
}
return { id: match.id, path: match.path };
const activeProject = useProjectsStore.getState().getActiveProject();
if (activeProject?.path) {
const activePath = normalizePath(activeProject.path);
if (normalized === activePath || normalized.startsWith(`${activePath}/`)) {
return { id: activeProject.id, path: activeProject.path };
}
}
const matches = projects.filter((project) => {
const projectPath = normalizePath(project.path);
return normalized === projectPath || normalized.startsWith(`${projectPath}/`);
});
const match = matches.sort((a, b) => normalizePath(b.path).length - normalizePath(a.path).length)[0];
return match ? { id: match.id, path: match.path } : null;
};
// Track if we're currently creating a worktree session
@@ -40,7 +53,7 @@ let isCreatingWorktreeSession = false;
/**
* Create a new session with an auto-generated worktree.
* Uses project's worktree defaults (branch prefix, base branch) from settings.
* Uses project's worktree defaults for naming/metadata.
*
* @returns The created session, or null if creation failed
*/
@@ -78,27 +91,21 @@ export async function createWorktreeSession(): Promise<{ id: string } | null> {
startConfigUpdate("Creating new worktree session...");
try {
// Get worktree defaults from project settings
const worktreeDefaults = activeProject.worktreeDefaults;
const baseBranch = worktreeDefaults?.baseBranch;
const projectRef: ProjectRef = { id: activeProject.id, path: projectDirectory };
// Generate a friendly name (SDK will slugify + ensure uniqueness).
const preferredName = generateBranchName();
const startPoint = baseBranch && baseBranch !== 'HEAD' ? baseBranch : undefined;
const setupCommands = await getWorktreeSetupCommands(projectRef);
const rootBranch = await getRootBranch(projectRef.path);
const metadata = await createSdkWorktree(projectRef, {
preferredName,
setupCommands,
startPoint,
});
const createdMetadata = {
...metadata,
createdFromBranch: startPoint ?? 'HEAD',
createdFromBranch: rootBranch,
kind: 'standard' as const,
};
@@ -238,21 +245,6 @@ export async function createWorktreeSessionForBranch(
return null;
}
// Check if it's a git repo
let isGitRepo = false;
try {
isGitRepo = await checkIsGitRepository(projectDirectory);
} catch {
// Ignore errors, treat as not a git repo
}
if (!isGitRepo) {
toast.error('Not a Git repository', {
description: 'Worktrees can only be created in Git repositories.',
});
return null;
}
isCreatingWorktreeSession = true;
startConfigUpdate("Creating worktree session...");
@@ -262,16 +254,31 @@ export async function createWorktreeSessionForBranch(
throw new Error('Project is not registered in OpenChamber');
}
// Check if it's a git repo (root project path)
let isGitRepo = false;
try {
isGitRepo = await checkIsGitRepository(projectRef.path);
} catch {
// Ignore errors, treat as not a git repo
}
if (!isGitRepo) {
toast.error('Not a Git repository', {
description: 'Worktrees can only be created in Git repositories.',
});
return null;
}
const setupCommands = await getWorktreeSetupCommands(projectRef);
const rootBranch = await getRootBranch(projectRef.path);
const metadata = await createSdkWorktree(projectRef, {
preferredName: branchName,
setupCommands,
startPoint: branchName,
});
const createdMetadata = {
...metadata,
createdFromBranch: branchName,
createdFromBranch: rootBranch,
kind: 'standard' as const,
};
@@ -389,33 +396,19 @@ export async function createWorktreeSessionForBranch(
}
/**
* Create a worktree session for a new branch (created at startPoint).
* This avoids checking out the branch in the main worktree.
* Create a worktree session for a new branch name.
* Callers can still use startPoint for metadata or follow-up git operations.
*/
export async function createWorktreeSessionForNewBranch(
projectDirectory: string,
preferredBranchName: string,
startPoint: string,
options?: { allowSuffix?: boolean; kind?: 'pr' | 'standard' }
startPoint?: string,
options?: { kind?: 'pr' | 'standard' }
): Promise<{ id: string; branch: string } | null> {
if (isCreatingWorktreeSession) {
return null;
}
let isGitRepo = false;
try {
isGitRepo = await checkIsGitRepository(projectDirectory);
} catch {
// ignore
}
if (!isGitRepo) {
toast.error('Not a Git repository', {
description: 'Worktrees can only be created in Git repositories.',
});
return null;
}
isCreatingWorktreeSession = true;
startConfigUpdate('Creating worktree session...');
@@ -426,7 +419,6 @@ export async function createWorktreeSessionForNewBranch(
throw new Error('Branch name is required');
}
const allowSuffix = options?.allowSuffix !== false;
const kind = options?.kind ?? 'standard';
const projectRef = resolveProjectRef(projectDirectory);
@@ -434,19 +426,31 @@ export async function createWorktreeSessionForNewBranch(
throw new Error('Project is not registered in OpenChamber');
}
let isGitRepo = false;
try {
isGitRepo = await checkIsGitRepository(projectRef.path);
} catch {
// ignore
}
if (!isGitRepo) {
toast.error('Not a Git repository', {
description: 'Worktrees can only be created in Git repositories.',
});
return null;
}
const setupCommands = await getWorktreeSetupCommands(projectRef);
const rootBranch = await getRootBranch(projectRef.path);
try {
const metadata = await createSdkWorktree(projectRef, {
preferredName: base,
setupCommands,
startPoint: start,
allowSuffix,
});
const createdMetadata = {
...metadata,
createdFromBranch: start,
createdFromBranch: rootBranch || start,
kind,
};
@@ -541,8 +545,8 @@ export async function createWorktreeSessionForNewBranch(
}
/**
* Same as createWorktreeSessionForNewBranch, but does NOT suffix the branch name.
* Use when the worktree must be created on an exact branch name (e.g. PR head ref).
* Same as createWorktreeSessionForNewBranch, but preserves the exact branch name.
* Use when the worktree must be tied to a specific ref (e.g. PR head ref).
*/
export async function createWorktreeSessionForNewBranchExact(
projectDirectory: string,
@@ -551,7 +555,6 @@ export async function createWorktreeSessionForNewBranchExact(
options?: { kind?: 'pr' | 'standard' }
): Promise<{ id: string; branch: string } | null> {
return createWorktreeSessionForNewBranch(projectDirectory, branchName, startPoint, {
allowSuffix: false,
kind: options?.kind,
});
}
+96 -184
View File
@@ -1,17 +1,10 @@
import { opencodeClient } from '@/lib/opencode/client';
import { substituteCommandVariables } from '@/lib/openchamberConfig';
import type { WorktreeMetadata } from '@/types/worktree';
import {
listWorktrees as listLegacyGitWorktrees,
mapWorktreeToMetadata,
removeWorktree as removeLegacyWorktree,
} from '@/lib/git/worktreeService';
import { deleteGitBranch, deleteRemoteBranch, removeGitWorktree } from '@/lib/gitApi';
import { deleteRemoteBranch } from '@/lib/gitApi';
export type ProjectRef = { id: string; path: string };
const WORKTREE_LEGACY_ROOT = '.openchamber';
const normalizePath = (value: string): string => {
const replaced = value.replace(/\\/g, '/');
if (replaced === '/') {
@@ -20,13 +13,6 @@ const normalizePath = (value: string): string => {
return replaced.length > 1 ? replaced.replace(/\/+$/, '') : replaced;
};
const isLegacyWorktreePath = (projectDirectory: string, candidatePath: string): boolean => {
const project = normalizePath(projectDirectory);
const candidate = normalizePath(candidatePath);
const root = `${project}/${WORKTREE_LEGACY_ROOT}/`;
return candidate.startsWith(root);
};
const slugifyWorktreeName = (value: string): string => {
return value
.trim()
@@ -36,14 +22,6 @@ const slugifyWorktreeName = (value: string): string => {
.slice(0, 80);
};
const shellQuote = (value: string): string => {
const v = value.trim();
if (!v) {
return "''";
}
return `'${v.replace(/'/g, `'\\''`)}'`;
};
const unwrapSdkData = (value: unknown): unknown => {
if (!value || typeof value !== 'object') {
return value;
@@ -61,36 +39,12 @@ const deriveSdkWorktreeNameFromDirectory = (directory: string): string => {
return parts[parts.length - 1] ?? normalized;
};
type WorktreeRemovalParams = Record<string, unknown>;
type WorktreeRemovalMethod = (params?: WorktreeRemovalParams) => Promise<unknown>;
const getWorktreeMethod = (client: unknown, key: string): WorktreeRemovalMethod | null => {
if (!client || (typeof client !== 'object' && typeof client !== 'function')) {
return null;
}
const record = client as Record<string, unknown>;
const candidate = record[key];
if (typeof candidate !== 'function') {
return null;
}
// Keep method binding; SDK methods use `this.client`.
return (params?: WorktreeRemovalParams) => (candidate as (this: unknown, p?: WorktreeRemovalParams) => Promise<unknown>).call(client, params);
};
export const buildSdkStartCommand = (args: {
projectDirectory: string;
setupCommands: string[];
startPoint?: string | null;
}): string | undefined => {
const commands: string[] = [];
const startPoint = typeof args.startPoint === 'string' ? args.startPoint.trim() : '';
if (startPoint && startPoint !== 'HEAD') {
commands.push(`git reset --hard ${shellQuote(startPoint)}`);
} else {
commands.push('git reset --hard HEAD');
}
for (const raw of args.setupCommands) {
const trimmed = raw.trim();
if (!trimmed) continue;
@@ -103,13 +57,69 @@ export const buildSdkStartCommand = (args: {
return joined.trim().length > 0 ? joined : undefined;
};
const waitForSdkWorktreeReady = async (directory: string, timeoutMs = 60_000): Promise<void> => {
const target = normalizePath(directory);
if (!target) {
return;
}
await new Promise<void>((resolve, reject) => {
let done = false;
let unsubscribe = () => {};
let timeout: ReturnType<typeof setTimeout> | null = null;
const cleanup = () => {
if (timeout) {
clearTimeout(timeout);
}
try {
unsubscribe();
} catch {
// ignore
}
};
const finish = (result?: { error?: string }) => {
if (done) return;
done = true;
cleanup();
if (result?.error) {
reject(new Error(result.error));
} else {
resolve();
}
};
timeout = setTimeout(() => {
finish({ error: 'Worktree startup timed out' });
}, timeoutMs);
unsubscribe = opencodeClient.subscribeToGlobalEvents(
(event) => {
const payload = event.payload as { type?: string; properties?: Record<string, unknown> };
if (payload?.type === 'worktree.ready') {
finish();
return;
}
if (payload?.type === 'worktree.failed') {
const message = typeof payload.properties?.message === 'string'
? payload.properties.message
: 'Worktree failed to start';
finish({ error: message });
}
},
undefined,
undefined,
{ directory: target }
);
});
};
export async function listProjectWorktrees(project: ProjectRef): Promise<WorktreeMetadata[]> {
const projectDirectory = project.path;
const scoped = opencodeClient.getScopedApiClient(projectDirectory);
const results: WorktreeMetadata[] = [];
// SDK worktrees (new)
// SDK worktrees
try {
const raw = await scoped.worktree.list();
const data = unwrapSdkData(raw);
@@ -126,42 +136,14 @@ export async function listProjectWorktrees(project: ProjectRef): Promise<Worktre
name,
path: directory,
projectDirectory,
branch: `opencode/${name}`,
branch: '',
label: name,
});
}
} catch {
// ignore
}
// Legacy worktrees (<project>/.openchamber/*)
// LEGACY_WORKTREES: list legacy git worktrees rooted under <project>/.openchamber
try {
const legacy = await listLegacyGitWorktrees(projectDirectory);
const mapped = legacy
.map((info) => mapWorktreeToMetadata(projectDirectory, info))
.filter((meta) => isLegacyWorktreePath(projectDirectory, meta.path))
.map((meta) => ({ ...meta, source: 'legacy' as const }));
results.push(...mapped);
} catch {
// ignore
}
// Dedupe by path, prefer SDK entry on collision.
const byPath = new Map<string, WorktreeMetadata>();
for (const meta of results) {
const key = normalizePath(meta.path);
const existing = byPath.get(key);
if (!existing) {
byPath.set(key, meta);
continue;
}
if (existing.source !== 'sdk' && meta.source === 'sdk') {
byPath.set(key, meta);
}
}
return Array.from(byPath.values()).sort((a, b) => {
return results.sort((a, b) => {
const aLabel = (a.label || a.branch || a.path).toLowerCase();
const bLabel = (b.label || b.branch || b.path).toLowerCase();
return aLabel.localeCompare(bLabel);
@@ -171,8 +153,6 @@ export async function listProjectWorktrees(project: ProjectRef): Promise<Worktre
export async function createSdkWorktree(project: ProjectRef, args: {
preferredName?: string;
setupCommands?: string[];
startPoint?: string | null;
allowSuffix?: boolean;
}): Promise<WorktreeMetadata> {
const projectDirectory = project.path;
const scoped = opencodeClient.getScopedApiClient(projectDirectory);
@@ -184,52 +164,42 @@ export async function createSdkWorktree(project: ProjectRef, args: {
const startCommand = buildSdkStartCommand({
projectDirectory,
setupCommands: commands,
startPoint: args.startPoint,
});
let lastError: unknown = null;
const allowSuffix = args.allowSuffix !== false;
const maxAttempts = seed ? (allowSuffix ? 6 : 1) : 1;
const name = seed || undefined;
const raw = await scoped.worktree.create({
worktreeCreateInput: {
...(name ? { name } : {}),
...(startCommand ? { startCommand } : {}),
},
});
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
const name = seed ? (attempt === 0 ? seed : `${seed}-${attempt + 1}`) : undefined;
try {
const raw = await scoped.worktree.create({
worktreeCreateInput: {
...(name ? { name } : {}),
...(startCommand ? { startCommand } : {}),
},
});
const data = unwrapSdkData(raw);
if (!data || typeof data !== 'object') {
throw new Error('Invalid worktree.create response');
}
const record = data as Record<string, unknown>;
const returnedName = typeof record.name === 'string' ? record.name : name;
const returnedBranch = typeof record.branch === 'string' ? record.branch : (returnedName ? `opencode/${returnedName}` : '');
const returnedDirectory = typeof record.directory === 'string' ? record.directory : '';
if (!returnedName || !returnedDirectory) {
throw new Error('Worktree create missing name/directory');
}
return {
source: 'sdk',
name: returnedName,
path: normalizePath(returnedDirectory),
projectDirectory,
branch: returnedBranch,
label: returnedName,
};
} catch (err) {
lastError = err;
}
const data = unwrapSdkData(raw);
if (!data || typeof data !== 'object') {
throw new Error('Invalid worktree.create response');
}
const message = lastError instanceof Error ? lastError.message : 'Failed to create worktree';
throw new Error(message);
const record = data as Record<string, unknown>;
const returnedName = typeof record.name === 'string' ? record.name : name;
const returnedBranch = typeof record.branch === 'string' ? record.branch : (returnedName ? `opencode/${returnedName}` : '');
const returnedDirectory = typeof record.directory === 'string' ? record.directory : '';
if (!returnedName || !returnedDirectory) {
throw new Error('Worktree create missing name/directory');
}
const metadata: WorktreeMetadata = {
source: 'sdk',
name: returnedName,
path: normalizePath(returnedDirectory),
projectDirectory,
branch: returnedBranch,
label: returnedName,
};
await waitForSdkWorktreeReady(metadata.path);
return metadata;
}
export async function removeProjectWorktree(project: ProjectRef, worktree: WorktreeMetadata, options?: {
@@ -239,74 +209,16 @@ export async function removeProjectWorktree(project: ProjectRef, worktree: Workt
}): Promise<void> {
const projectDirectory = project.path;
const deleteLocalBranch = true;
const deleteRemote = Boolean(options?.deleteRemoteBranch);
const remoteName = options?.remoteName;
if (worktree.source === 'sdk') {
const scoped = opencodeClient.getScopedApiClient(projectDirectory);
const worktreeClient = scoped.worktree as unknown;
const force = Boolean(options?.force ?? true);
const fallbackRemoveViaGit = async () => {
await removeGitWorktree(projectDirectory, { path: worktree.path, force });
};
const removeMethod = getWorktreeMethod(worktreeClient, 'remove');
if (removeMethod) {
const raw = await removeMethod({ worktreeRemoveInput: { directory: worktree.path } });
const ok = unwrapSdkData(raw);
if (ok !== true) {
await fallbackRemoveViaGit();
}
} else {
const deleteMethod = getWorktreeMethod(worktreeClient, 'delete');
if (deleteMethod) {
const raw = await deleteMethod({ worktreeDeleteInput: { directory: worktree.path } });
const ok = unwrapSdkData(raw);
if (ok !== true) {
await fallbackRemoveViaGit();
}
} else {
const archiveMethod = getWorktreeMethod(worktreeClient, 'archive');
if (archiveMethod) {
const raw = await archiveMethod({ worktreeArchiveInput: { directory: worktree.path } });
const ok = unwrapSdkData(raw);
if (ok !== true) {
await fallbackRemoveViaGit();
}
} else {
throw new Error('Worktree removal is not supported by this SDK version.');
}
}
}
// Some OpenCode builds only update internal state; remove git worktree best-effort.
await fallbackRemoveViaGit().catch(() => undefined);
// Best-effort branch cleanup. Some OpenCode builds may keep the branch.
const branchName = (worktree.branch || '').replace(/^refs\/heads\//, '').trim();
if (deleteLocalBranch && branchName) {
await deleteGitBranch(projectDirectory, { branch: branchName, force: true }).catch(() => undefined);
}
if (deleteRemote && branchName) {
await deleteRemoteBranch(projectDirectory, { branch: branchName, remote: remoteName }).catch(() => undefined);
}
return;
const scoped = opencodeClient.getScopedApiClient(projectDirectory);
const raw = await scoped.worktree.remove({ worktreeRemoveInput: { directory: worktree.path } });
const ok = unwrapSdkData(raw);
if (ok !== true) {
throw new Error('Worktree removal failed');
}
// LEGACY_WORKTREES: delete legacy git worktree under <project>/.openchamber
const statusIsDirty = Boolean(worktree.status?.isDirty);
const force = Boolean(options?.force ?? statusIsDirty);
await removeGitWorktree(projectDirectory, { path: worktree.path, force }).catch(async () => {
await removeLegacyWorktree({ projectDirectory, path: worktree.path, force: true });
});
const branchName = (worktree.branch || '').replace(/^refs\/heads\//, '').trim();
if (deleteLocalBranch && branchName) {
await deleteGitBranch(projectDirectory, { branch: branchName, force: true }).catch(() => undefined);
}
if (deleteRemote && branchName) {
await deleteRemoteBranch(projectDirectory, { branch: branchName, remote: remoteName }).catch(() => undefined);
}
@@ -0,0 +1,97 @@
import { getGitStatus } from '@/lib/gitApi';
import { execCommand } from '@/lib/execCommands';
import type { WorktreeMetadata } from '@/types/worktree';
const normalizePath = (value: string): string => {
if (!value) {
return '';
}
const replaced = value.replace(/\\/g, '/');
if (replaced === '/') {
return '/';
}
return replaced.replace(/\/+$/, '');
};
const toAbsolutePath = (baseDir: string, maybeRelativePath: string): string => {
const normalizedBase = normalizePath(baseDir);
const normalizedInput = normalizePath(maybeRelativePath);
if (!normalizedInput) return normalizedBase;
if (normalizedInput.startsWith('/')) return normalizedInput;
const stack = normalizedBase.split('/').filter(Boolean);
const parts = normalizedInput.split('/').filter(Boolean);
for (const part of parts) {
if (part === '.') continue;
if (part === '..') {
stack.pop();
continue;
}
stack.push(part);
}
return `/${stack.join('/')}`;
};
const derivePrimaryWorktreeRootFromGitDir = (gitDir: string): string | null => {
const normalized = normalizePath(gitDir);
if (!normalized) return null;
if (normalized.endsWith('/.git')) {
return normalized.slice(0, -'/.git'.length) || null;
}
const worktreesMarker = '/.git/worktrees/';
const markerIndex = normalized.indexOf(worktreesMarker);
if (markerIndex > 0) {
return normalized.slice(0, markerIndex) || null;
}
return null;
};
export async function getWorktreeStatus(worktreePath: string): Promise<WorktreeMetadata['status']> {
const normalizedPath = normalizePath(worktreePath);
const status = await getGitStatus(normalizedPath);
return {
isDirty: !status.isClean,
ahead: status.ahead,
behind: status.behind,
upstream: status.tracking,
};
}
export async function getRootBranch(projectDirectory: string): Promise<string> {
const normalizedPath = normalizePath(projectDirectory);
if (!normalizedPath) {
return 'HEAD';
}
const resolveProjectRoot = async (directory: string): Promise<string> => {
const absoluteGitDirResult = await execCommand('git rev-parse --absolute-git-dir', directory);
const absoluteGitDir = normalizePath((absoluteGitDirResult.stdout || '').trim());
if (absoluteGitDirResult.success && absoluteGitDir) {
const rootFromAbsoluteGitDir = derivePrimaryWorktreeRootFromGitDir(absoluteGitDir);
if (rootFromAbsoluteGitDir) {
return rootFromAbsoluteGitDir;
}
}
const commonDirResult = await execCommand('git rev-parse --git-common-dir', directory);
const rawCommonDir = normalizePath((commonDirResult.stdout || '').trim());
if (!commonDirResult.success || !rawCommonDir) return directory;
const commonDir = toAbsolutePath(directory, rawCommonDir);
const rootFromCommonDir = derivePrimaryWorktreeRootFromGitDir(commonDir);
if (rootFromCommonDir) {
return rootFromCommonDir;
}
return directory;
};
try {
const projectRoot = await resolveProjectRoot(normalizedPath).catch(() => normalizedPath);
const status = await getGitStatus(projectRoot);
const branch = typeof status.current === 'string' ? status.current.trim() : '';
return branch || 'HEAD';
} catch {
return 'HEAD';
}
}