refactor: simplify worktree management by removing legacy API
- Remove legacy worktree API usage and related state - Add Manage Branches button in the Git header for quick access - Introduce worktree status utilities to derive root branch hints
This commit is contained in:
@@ -1,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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user