2026-01-27 19:59:48 +02:00
|
|
|
import { substituteCommandVariables } from '@/lib/openchamberConfig';
|
|
|
|
|
import type { WorktreeMetadata } from '@/types/worktree';
|
2026-03-20 16:50:13 +02:00
|
|
|
import { execCommand } from '@/lib/execCommands';
|
2026-02-13 19:18:22 +02:00
|
|
|
import {
|
|
|
|
|
deleteRemoteBranch,
|
|
|
|
|
git,
|
|
|
|
|
} from '@/lib/gitApi';
|
2026-03-22 22:31:29 +02:00
|
|
|
import {
|
|
|
|
|
clearWorktreeBootstrapState,
|
|
|
|
|
markWorktreeBootstrapPending,
|
|
|
|
|
} from '@/lib/worktrees/worktreeBootstrap';
|
2026-02-13 19:18:22 +02:00
|
|
|
import type {
|
|
|
|
|
CreateGitWorktreePayload,
|
|
|
|
|
GitWorktreeValidationResult,
|
|
|
|
|
} from '@/lib/api/types';
|
2026-01-27 19:59:48 +02:00
|
|
|
|
|
|
|
|
export type ProjectRef = { id: string; path: string };
|
|
|
|
|
|
|
|
|
|
const normalizePath = (value: string): string => {
|
|
|
|
|
const replaced = value.replace(/\\/g, '/');
|
|
|
|
|
if (replaced === '/') {
|
|
|
|
|
return '/';
|
|
|
|
|
}
|
|
|
|
|
return replaced.length > 1 ? replaced.replace(/\/+$/, '') : replaced;
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-20 16:50:13 +02:00
|
|
|
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;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const resolvePrimaryWorktreeDirectory = async (directory: string): Promise<string> => {
|
|
|
|
|
const normalizedDirectory = normalizePath(directory);
|
|
|
|
|
|
|
|
|
|
const absoluteGitDirResult = await execCommand('git rev-parse --absolute-git-dir', normalizedDirectory);
|
|
|
|
|
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', normalizedDirectory);
|
|
|
|
|
const rawCommonDir = normalizePath((commonDirResult.stdout || '').trim());
|
|
|
|
|
if (!commonDirResult.success || !rawCommonDir) {
|
|
|
|
|
return normalizedDirectory;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const commonDir = toAbsolutePath(normalizedDirectory, rawCommonDir);
|
|
|
|
|
const rootFromCommonDir = derivePrimaryWorktreeRootFromGitDir(commonDir);
|
|
|
|
|
if (rootFromCommonDir) {
|
|
|
|
|
return rootFromCommonDir;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return normalizedDirectory;
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-27 19:59:48 +02:00
|
|
|
const slugifyWorktreeName = (value: string): string => {
|
|
|
|
|
return value
|
|
|
|
|
.trim()
|
2026-02-13 19:18:22 +02:00
|
|
|
.replace(/^refs\/heads\//, '')
|
|
|
|
|
.replace(/^heads\//, '')
|
|
|
|
|
.replace(/\s+/g, '-')
|
|
|
|
|
.replace(/^\/+|\/+$/g, '')
|
|
|
|
|
.split('/').join('-')
|
|
|
|
|
.replace(/[^A-Za-z0-9._-]+/g, '-')
|
|
|
|
|
.replace(/-+/g, '-')
|
2026-01-27 19:59:48 +02:00
|
|
|
.replace(/^-+|-+$/g, '')
|
|
|
|
|
.slice(0, 80);
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-13 19:18:22 +02:00
|
|
|
const normalizeBranchName = (value: string): string => {
|
|
|
|
|
return value
|
|
|
|
|
.trim()
|
|
|
|
|
.replace(/^refs\/heads\//, '')
|
|
|
|
|
.replace(/^heads\//, '')
|
|
|
|
|
.replace(/\s+/g, '-')
|
|
|
|
|
.replace(/^\/+|\/+$/g, '');
|
2026-01-27 19:59:48 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const deriveSdkWorktreeNameFromDirectory = (directory: string): string => {
|
|
|
|
|
const normalized = normalizePath(directory);
|
|
|
|
|
const parts = normalized.split('/').filter(Boolean);
|
|
|
|
|
return parts[parts.length - 1] ?? normalized;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const buildSdkStartCommand = (args: {
|
|
|
|
|
projectDirectory: string;
|
|
|
|
|
setupCommands: string[];
|
|
|
|
|
}): string | undefined => {
|
|
|
|
|
const commands: string[] = [];
|
|
|
|
|
|
|
|
|
|
for (const raw of args.setupCommands) {
|
|
|
|
|
const trimmed = raw.trim();
|
|
|
|
|
if (!trimmed) continue;
|
|
|
|
|
commands.push(
|
|
|
|
|
substituteCommandVariables(trimmed, { rootWorktreePath: args.projectDirectory })
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const joined = commands.filter(Boolean).join(' && ');
|
|
|
|
|
return joined.trim().length > 0 ? joined : undefined;
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-13 19:18:22 +02:00
|
|
|
const toCreatePayload = (args: {
|
|
|
|
|
preferredName?: string;
|
|
|
|
|
setupCommands?: string[];
|
|
|
|
|
mode?: 'new' | 'existing';
|
|
|
|
|
worktreeName?: string;
|
|
|
|
|
branchName?: string;
|
|
|
|
|
existingBranch?: string;
|
|
|
|
|
startRef?: string;
|
|
|
|
|
setUpstream?: boolean;
|
|
|
|
|
upstreamRemote?: string;
|
|
|
|
|
upstreamBranch?: string;
|
|
|
|
|
ensureRemoteName?: string;
|
|
|
|
|
ensureRemoteUrl?: string;
|
|
|
|
|
}, projectDirectory: string): CreateGitWorktreePayload => {
|
|
|
|
|
const mode = args.mode === 'existing' ? 'existing' : 'new';
|
|
|
|
|
|
|
|
|
|
const worktreeNameSeed = args.worktreeName ?? args.preferredName ?? '';
|
|
|
|
|
const worktreeName = slugifyWorktreeName(worktreeNameSeed);
|
|
|
|
|
|
|
|
|
|
const branchNameSeed = args.branchName ?? (mode === 'new' ? args.preferredName : undefined) ?? '';
|
|
|
|
|
const branchName = normalizeBranchName(branchNameSeed);
|
|
|
|
|
|
|
|
|
|
const existingBranch = normalizeBranchName(args.existingBranch ?? args.branchName ?? '');
|
|
|
|
|
const startRef = (args.startRef || '').trim();
|
2026-02-06 12:31:03 +02:00
|
|
|
|
2026-02-13 19:18:22 +02:00
|
|
|
const commands = Array.isArray(args.setupCommands) ? args.setupCommands : [];
|
|
|
|
|
const startCommand = buildSdkStartCommand({
|
|
|
|
|
projectDirectory,
|
|
|
|
|
setupCommands: commands,
|
2026-02-06 12:31:03 +02:00
|
|
|
});
|
2026-02-13 19:18:22 +02:00
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
mode,
|
|
|
|
|
...(worktreeName ? { worktreeName } : {}),
|
|
|
|
|
...(branchName ? { branchName } : {}),
|
|
|
|
|
...(existingBranch ? { existingBranch } : {}),
|
|
|
|
|
...(startRef ? { startRef } : {}),
|
|
|
|
|
...(startCommand ? { startCommand } : {}),
|
|
|
|
|
...(args.setUpstream ? { setUpstream: true } : {}),
|
|
|
|
|
...(args.upstreamRemote ? { upstreamRemote: args.upstreamRemote } : {}),
|
|
|
|
|
...(args.upstreamBranch ? { upstreamBranch: args.upstreamBranch } : {}),
|
|
|
|
|
...(args.ensureRemoteName ? { ensureRemoteName: args.ensureRemoteName } : {}),
|
|
|
|
|
...(args.ensureRemoteUrl ? { ensureRemoteUrl: args.ensureRemoteUrl } : {}),
|
|
|
|
|
};
|
2026-02-06 12:31:03 +02:00
|
|
|
};
|
|
|
|
|
|
2026-03-31 18:47:00 +03:00
|
|
|
// Cache worktree listings to avoid repeated git worktree list + rev-parse calls
|
|
|
|
|
const _worktreeListCache = new Map<string, { value: WorktreeMetadata[]; at: number }>();
|
|
|
|
|
const _worktreeListInflight = new Map<string, Promise<WorktreeMetadata[]>>();
|
|
|
|
|
const WORKTREE_LIST_CACHE_TTL = 30_000; // 30 seconds
|
|
|
|
|
|
2026-01-27 19:59:48 +02:00
|
|
|
export async function listProjectWorktrees(project: ProjectRef): Promise<WorktreeMetadata[]> {
|
2026-03-20 16:50:13 +02:00
|
|
|
const projectDirectory = normalizePath(project.path);
|
2026-03-31 18:47:00 +03:00
|
|
|
|
|
|
|
|
// Return cached if fresh
|
|
|
|
|
const cached = _worktreeListCache.get(projectDirectory);
|
|
|
|
|
if (cached && Date.now() - cached.at < WORKTREE_LIST_CACHE_TTL) {
|
|
|
|
|
return cached.value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Dedup in-flight requests
|
|
|
|
|
const inflight = _worktreeListInflight.get(projectDirectory);
|
|
|
|
|
if (inflight) return inflight;
|
|
|
|
|
|
|
|
|
|
const promise = (async (): Promise<WorktreeMetadata[]> => {
|
|
|
|
|
const metadataProjectDirectory = await resolvePrimaryWorktreeDirectory(projectDirectory).catch(() => projectDirectory);
|
|
|
|
|
const normalizedProjectDirectory = normalizePath(projectDirectory);
|
|
|
|
|
|
|
|
|
|
const worktrees = await git.worktree.list(projectDirectory).catch(() => []);
|
|
|
|
|
const results: WorktreeMetadata[] = worktrees
|
|
|
|
|
.filter((entry) => typeof entry.path === 'string' && entry.path.trim().length > 0)
|
|
|
|
|
.map((entry) => {
|
|
|
|
|
const worktreePath = normalizePath(entry.path);
|
|
|
|
|
const branch = (entry.branch || '').replace(/^refs\/heads\//, '').trim();
|
|
|
|
|
const name = (entry.name || '').trim();
|
|
|
|
|
return {
|
|
|
|
|
source: 'sdk' as const,
|
|
|
|
|
name: name || deriveSdkWorktreeNameFromDirectory(worktreePath),
|
|
|
|
|
path: worktreePath,
|
|
|
|
|
projectDirectory: metadataProjectDirectory,
|
|
|
|
|
branch,
|
|
|
|
|
label: branch || name || deriveSdkWorktreeNameFromDirectory(worktreePath),
|
|
|
|
|
};
|
|
|
|
|
})
|
|
|
|
|
.filter((entry) => normalizePath(entry.path) !== normalizedProjectDirectory);
|
|
|
|
|
|
|
|
|
|
const sorted = 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);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
_worktreeListCache.set(projectDirectory, { value: sorted, at: Date.now() });
|
|
|
|
|
return sorted;
|
|
|
|
|
})().finally(() => {
|
|
|
|
|
_worktreeListInflight.delete(projectDirectory);
|
2026-01-27 19:59:48 +02:00
|
|
|
});
|
2026-03-31 18:47:00 +03:00
|
|
|
|
|
|
|
|
_worktreeListInflight.set(projectDirectory, promise);
|
|
|
|
|
return promise;
|
2026-01-27 19:59:48 +02:00
|
|
|
}
|
|
|
|
|
|
2026-02-13 19:18:22 +02:00
|
|
|
export type CreateWorktreeArgs = {
|
2026-01-27 19:59:48 +02:00
|
|
|
preferredName?: string;
|
|
|
|
|
setupCommands?: string[];
|
2026-02-13 19:18:22 +02:00
|
|
|
mode?: 'new' | 'existing';
|
|
|
|
|
worktreeName?: string;
|
|
|
|
|
branchName?: string;
|
|
|
|
|
existingBranch?: string;
|
|
|
|
|
startRef?: string;
|
|
|
|
|
setUpstream?: boolean;
|
|
|
|
|
upstreamRemote?: string;
|
|
|
|
|
upstreamBranch?: string;
|
|
|
|
|
ensureRemoteName?: string;
|
|
|
|
|
ensureRemoteUrl?: string;
|
|
|
|
|
};
|
2026-01-27 19:59:48 +02:00
|
|
|
|
2026-02-13 19:18:22 +02:00
|
|
|
export async function createWorktree(project: ProjectRef, args: CreateWorktreeArgs): Promise<WorktreeMetadata> {
|
2026-03-20 16:50:13 +02:00
|
|
|
const projectDirectory = normalizePath(project.path);
|
|
|
|
|
const metadataProjectDirectory = await resolvePrimaryWorktreeDirectory(projectDirectory).catch(() => projectDirectory);
|
2026-02-13 19:18:22 +02:00
|
|
|
const payload = toCreatePayload(args, projectDirectory);
|
2026-01-27 19:59:48 +02:00
|
|
|
|
2026-02-13 19:18:22 +02:00
|
|
|
const created = await git.worktree.create(projectDirectory, payload);
|
|
|
|
|
const returnedName = typeof created?.name === 'string' ? created.name : '';
|
|
|
|
|
const returnedBranch = typeof created?.branch === 'string' ? created.branch : '';
|
|
|
|
|
const returnedPath = typeof created?.path === 'string' ? created.path : '';
|
2026-01-27 19:59:48 +02:00
|
|
|
|
2026-02-13 19:18:22 +02:00
|
|
|
if (!returnedName || !returnedPath) {
|
|
|
|
|
throw new Error('Worktree create missing name/path');
|
2026-02-06 12:31:03 +02:00
|
|
|
}
|
2026-01-27 19:59:48 +02:00
|
|
|
|
2026-02-06 12:31:03 +02:00
|
|
|
const metadata: WorktreeMetadata = {
|
|
|
|
|
source: 'sdk',
|
|
|
|
|
name: returnedName,
|
2026-02-13 19:18:22 +02:00
|
|
|
path: normalizePath(returnedPath),
|
2026-03-20 16:50:13 +02:00
|
|
|
projectDirectory: metadataProjectDirectory,
|
2026-02-06 12:31:03 +02:00
|
|
|
branch: returnedBranch,
|
2026-02-13 19:18:22 +02:00
|
|
|
label: returnedBranch || returnedName,
|
2026-02-06 12:31:03 +02:00
|
|
|
};
|
2026-01-27 19:59:48 +02:00
|
|
|
|
2026-03-22 22:31:29 +02:00
|
|
|
markWorktreeBootstrapPending(metadata.path);
|
|
|
|
|
|
2026-02-06 12:31:03 +02:00
|
|
|
return metadata;
|
2026-01-27 19:59:48 +02:00
|
|
|
}
|
|
|
|
|
|
2026-02-13 19:18:22 +02:00
|
|
|
export async function validateWorktreeCreate(project: ProjectRef, args: CreateWorktreeArgs): Promise<GitWorktreeValidationResult> {
|
|
|
|
|
const projectDirectory = project.path;
|
|
|
|
|
const payload = toCreatePayload(args, projectDirectory);
|
|
|
|
|
return git.worktree.validate(projectDirectory, payload);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-27 19:59:48 +02:00
|
|
|
export async function removeProjectWorktree(project: ProjectRef, worktree: WorktreeMetadata, options?: {
|
|
|
|
|
deleteRemoteBranch?: boolean;
|
2026-02-13 19:18:22 +02:00
|
|
|
deleteLocalBranch?: boolean;
|
2026-01-27 19:59:48 +02:00
|
|
|
remoteName?: string;
|
|
|
|
|
}): Promise<void> {
|
|
|
|
|
const projectDirectory = project.path;
|
|
|
|
|
|
|
|
|
|
const deleteRemote = Boolean(options?.deleteRemoteBranch);
|
2026-02-13 19:18:22 +02:00
|
|
|
const deleteLocalBranch = options?.deleteLocalBranch === true;
|
2026-01-27 19:59:48 +02:00
|
|
|
const remoteName = options?.remoteName;
|
2026-02-13 19:18:22 +02:00
|
|
|
const raw = await git.worktree.remove(projectDirectory, {
|
|
|
|
|
directory: worktree.path,
|
|
|
|
|
deleteLocalBranch,
|
|
|
|
|
});
|
|
|
|
|
if (!raw?.success) {
|
2026-02-06 12:31:03 +02:00
|
|
|
throw new Error('Worktree removal failed');
|
2026-01-27 19:59:48 +02:00
|
|
|
}
|
|
|
|
|
|
2026-03-22 22:31:29 +02:00
|
|
|
clearWorktreeBootstrapState(worktree.path);
|
|
|
|
|
|
2026-01-27 19:59:48 +02:00
|
|
|
const branchName = (worktree.branch || '').replace(/^refs\/heads\//, '').trim();
|
|
|
|
|
if (deleteRemote && branchName) {
|
|
|
|
|
await deleteRemoteBranch(projectDirectory, { branch: branchName, remote: remoteName }).catch(() => undefined);
|
|
|
|
|
}
|
|
|
|
|
}
|