| import { execFile as execFileCallback } from 'node:child_process' |
| import * as fs from 'node:fs/promises' |
| import * as path from 'node:path' |
| import { promisify } from 'node:util' |
| import { ApiError } from '../middleware/errorHandler.js' |
| import { findCanonicalGitRoot, findGitRoot } from '../../utils/git.js' |
| import { registerFilesystemAccessRoot } from './filesystemAccessRoots.js' |
| import { |
| ensureWorktreesDirExcluded, |
| performPostCreationSetup, |
| validateWorktreeSlug, |
| worktreeBranchName, |
| } from '../../utils/worktree.js' |
|
|
| const execFile = promisify(execFileCallback) |
| const GIT_TIMEOUT_MS = 10_000 |
| const WORKTREE_TIMEOUT_MS = 60_000 |
| const MAX_GIT_BUFFER_BYTES = 2_000_000 |
| const GIT_NO_PROMPT_ENV = { |
| GIT_TERMINAL_PROMPT: '0', |
| GIT_ASKPASS: '', |
| } |
|
|
| const REPOSITORY_ERROR = { |
| workdirMissing: 'WORKDIR_MISSING', |
| workdirNotDirectory: 'WORKDIR_NOT_DIRECTORY', |
| notGit: 'REPOSITORY_NOT_GIT', |
| contextFailed: 'REPOSITORY_CONTEXT_ERROR', |
| branchNotFound: 'REPOSITORY_BRANCH_NOT_FOUND', |
| dirtyWorktree: 'REPOSITORY_DIRTY_WORKTREE', |
| branchCheckedOut: 'REPOSITORY_BRANCH_CHECKED_OUT', |
| worktreeCreateFailed: 'REPOSITORY_WORKTREE_CREATE_FAILED', |
| switchFailed: 'REPOSITORY_SWITCH_FAILED', |
| } as const |
|
|
| type RepositoryErrorCode = typeof REPOSITORY_ERROR[keyof typeof REPOSITORY_ERROR] |
|
|
| export type RepositoryBranchInfo = { |
| name: string |
| current: boolean |
| local: boolean |
| remote: boolean |
| remoteRef?: string |
| checkedOut: boolean |
| worktreePath?: string |
| } |
|
|
| export type RepositoryWorktreeInfo = { |
| path: string |
| branch: string | null |
| current: boolean |
| } |
|
|
| export type RepositoryContextResult = { |
| state: 'ok' | 'not_git_repo' | 'missing_workdir' | 'error' |
| workDir: string |
| repoRoot: string | null |
| repoName: string | null |
| currentBranch: string | null |
| defaultBranch: string | null |
| dirty: boolean |
| branches: RepositoryBranchInfo[] |
| worktrees: RepositoryWorktreeInfo[] |
| error?: string |
| } |
|
|
| export type CreateSessionRepositoryOptions = { |
| branch?: string | null |
| worktree?: boolean |
| } |
|
|
| export type PreparedSessionWorkspace = { |
| workDir: string |
| repository?: { |
| requestedWorkDir: string |
| repoRoot: string |
| branch: string |
| worktree: boolean |
| baseRef: string |
| worktreePath?: string |
| worktreeBranch?: string |
| worktreeSlug?: string |
| } |
| } |
|
|
| export type RepositorySessionLaunchState = { |
| workDir: string |
| repository?: PreparedSessionWorkspace['repository'] |
| worktreeSession?: { worktreePath?: string | null } | null |
| transcriptMessageCount: number |
| } |
|
|
| function samePath(left: string | null | undefined, right: string | null | undefined): boolean { |
| if (!left || !right) return false |
| return path.resolve(left) === path.resolve(right) |
| } |
|
|
| export function isMaterializedWorktreeLaunch( |
| launchInfo: RepositorySessionLaunchState, |
| ): boolean { |
| const worktreePath = launchInfo.repository?.worktreePath |
| return ( |
| samePath(launchInfo.workDir, worktreePath) || |
| samePath(launchInfo.workDir, launchInfo.worktreeSession?.worktreePath) || |
| samePath(worktreePath, launchInfo.worktreeSession?.worktreePath) |
| ) |
| } |
|
|
| export function shouldCreateWorktreeForSessionLaunch( |
| launchInfo: RepositorySessionLaunchState, |
| ): boolean { |
| return !!( |
| launchInfo.repository?.worktree && |
| launchInfo.transcriptMessageCount === 0 && |
| !isMaterializedWorktreeLaunch(launchInfo) |
| ) |
| } |
|
|
| type GitResult = { |
| stdout: string |
| stderr: string |
| code: number |
| } |
|
|
| type GitWorktreeRecord = { |
| path: string |
| branch: string | null |
| } |
|
|
| type ResolvedBranch = RepositoryBranchInfo & { |
| baseRef: string |
| } |
|
|
| function repositoryBadRequest(code: RepositoryErrorCode, message: string): ApiError { |
| return new ApiError(400, message, code) |
| } |
|
|
| async function runGit( |
| cwd: string, |
| args: string[], |
| timeout = GIT_TIMEOUT_MS, |
| ): Promise<GitResult> { |
| try { |
| const { stdout, stderr } = await execFile('git', args, { |
| cwd, |
| timeout, |
| maxBuffer: MAX_GIT_BUFFER_BYTES, |
| env: { ...process.env, ...GIT_NO_PROMPT_ENV }, |
| }) |
| return { |
| stdout: String(stdout ?? ''), |
| stderr: String(stderr ?? ''), |
| code: 0, |
| } |
| } catch (error) { |
| const err = error as { |
| stdout?: string | Buffer |
| stderr?: string | Buffer |
| code?: unknown |
| message?: string |
| } |
| return { |
| stdout: String(err.stdout ?? ''), |
| stderr: String(err.stderr ?? err.message ?? ''), |
| code: typeof err.code === 'number' ? err.code : 1, |
| } |
| } |
| } |
|
|
| async function resolveDirectory(workDir: string): Promise<string> { |
| const resolved = path.resolve(workDir) |
| let realPath: string |
| try { |
| realPath = await fs.realpath(resolved) |
| } catch { |
| throw repositoryBadRequest( |
| REPOSITORY_ERROR.workdirMissing, |
| `Working directory does not exist: ${resolved}`, |
| ) |
| } |
|
|
| const stat = await fs.stat(realPath) |
| if (!stat.isDirectory()) { |
| throw repositoryBadRequest( |
| REPOSITORY_ERROR.workdirNotDirectory, |
| `Working directory is not a directory: ${realPath}`, |
| ) |
| } |
|
|
| return realPath |
| } |
|
|
| async function canonicalizeKnownPath(candidate: string): Promise<string> { |
| try { |
| return (await fs.realpath(candidate)).normalize('NFC') |
| } catch { |
| return path.resolve(candidate).normalize('NFC') |
| } |
| } |
|
|
| function isSameOrInsidePath(root: string, candidate: string): boolean { |
| const relative = path.relative(root, candidate) |
| return relative === '' || (!!relative && !relative.startsWith('..') && !path.isAbsolute(relative)) |
| } |
|
|
| function normalizeRemoteBranch(ref: string): { name: string; remoteRef: string } | null { |
| if (!ref || ref.endsWith('/HEAD')) return null |
| const slash = ref.indexOf('/') |
| if (slash < 1) return null |
| const remote = ref.slice(0, slash) |
| const name = ref.slice(slash + 1) |
| if (!name) return null |
| return { |
| name: remote === 'origin' ? name : ref, |
| remoteRef: ref, |
| } |
| } |
|
|
| function parseWorktreeList(stdout: string): GitWorktreeRecord[] { |
| const records: GitWorktreeRecord[] = [] |
| let current: GitWorktreeRecord | null = null |
|
|
| for (const line of stdout.split('\n')) { |
| if (line.startsWith('worktree ')) { |
| if (current) records.push(current) |
| current = { path: line.slice('worktree '.length).normalize('NFC'), branch: null } |
| continue |
| } |
| if (current && line.startsWith('branch ')) { |
| const ref = line.slice('branch '.length) |
| current.branch = ref.startsWith('refs/heads/') ? ref.slice('refs/heads/'.length) : ref |
| } |
| } |
|
|
| if (current) records.push(current) |
| return records |
| } |
|
|
| function branchSort(a: RepositoryBranchInfo, b: RepositoryBranchInfo): number { |
| if (a.current !== b.current) return a.current ? -1 : 1 |
| if (a.local !== b.local) return a.local ? -1 : 1 |
| return a.name.localeCompare(b.name) |
| } |
|
|
| function isDesktopWorktreeBranch(name: string): boolean { |
| return name.startsWith('worktree-desktop-') |
| } |
|
|
| async function listBranches(repoRoot: string, currentBranch: string | null, worktrees: GitWorktreeRecord[]): Promise<RepositoryBranchInfo[]> { |
| const branches = new Map<string, RepositoryBranchInfo>() |
| const checkedOutByBranch = new Map<string, string>() |
| for (const worktree of worktrees) { |
| if (worktree.branch) checkedOutByBranch.set(worktree.branch, worktree.path) |
| } |
|
|
| const localResult = await runGit(repoRoot, ['for-each-ref', '--format=%(refname:short)', 'refs/heads']) |
| if (localResult.code === 0) { |
| for (const name of localResult.stdout.split('\n').map((line) => line.trim()).filter(Boolean)) { |
| const worktreePath = checkedOutByBranch.get(name) |
| branches.set(name, { |
| name, |
| current: name === currentBranch, |
| local: true, |
| remote: false, |
| checkedOut: !!worktreePath, |
| worktreePath, |
| }) |
| } |
| } |
|
|
| const remoteResult = await runGit(repoRoot, ['for-each-ref', '--format=%(refname:short)', 'refs/remotes']) |
| if (remoteResult.code === 0) { |
| for (const ref of remoteResult.stdout.split('\n').map((line) => line.trim()).filter(Boolean)) { |
| const parsed = normalizeRemoteBranch(ref) |
| if (!parsed) continue |
| const existing = branches.get(parsed.name) |
| if (existing) { |
| branches.set(parsed.name, { |
| ...existing, |
| remote: true, |
| remoteRef: parsed.remoteRef, |
| }) |
| } else { |
| branches.set(parsed.name, { |
| name: parsed.name, |
| current: parsed.name === currentBranch, |
| local: false, |
| remote: true, |
| remoteRef: parsed.remoteRef, |
| checkedOut: false, |
| }) |
| } |
| } |
| } |
|
|
| if (currentBranch && !branches.has(currentBranch)) { |
| const worktreePath = checkedOutByBranch.get(currentBranch) |
| branches.set(currentBranch, { |
| name: currentBranch, |
| current: true, |
| local: true, |
| remote: false, |
| checkedOut: !!worktreePath, |
| worktreePath, |
| }) |
| } |
|
|
| return [...branches.values()] |
| .filter((branch) => !isDesktopWorktreeBranch(branch.name)) |
| .sort(branchSort) |
| } |
|
|
| async function getDefaultBranch(repoRoot: string): Promise<string | null> { |
| const originHead = await runGit(repoRoot, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD']) |
| if (originHead.code === 0) { |
| const value = originHead.stdout.trim() |
| if (value.startsWith('origin/')) return value.slice('origin/'.length) |
| if (value) return value |
| } |
|
|
| const current = await runGit(repoRoot, ['branch', '--show-current']) |
| const currentBranch = current.stdout.trim() |
| return currentBranch || null |
| } |
|
|
| export async function getRepositoryContext(workDir: string): Promise<RepositoryContextResult> { |
| let absWorkDir: string |
| try { |
| absWorkDir = await resolveDirectory(workDir) |
| registerFilesystemAccessRoot(workDir) |
| registerFilesystemAccessRoot(absWorkDir) |
| } catch (error) { |
| return { |
| state: 'missing_workdir', |
| workDir: path.resolve(workDir), |
| repoRoot: null, |
| repoName: null, |
| currentBranch: null, |
| defaultBranch: null, |
| dirty: false, |
| branches: [], |
| worktrees: [], |
| error: error instanceof Error ? error.message : String(error), |
| } |
| } |
|
|
| const gitRoot = findGitRoot(absWorkDir) |
| if (!gitRoot) { |
| return { |
| state: 'not_git_repo', |
| workDir: absWorkDir, |
| repoRoot: null, |
| repoName: null, |
| currentBranch: null, |
| defaultBranch: null, |
| dirty: false, |
| branches: [], |
| worktrees: [], |
| } |
| } |
|
|
| try { |
| const repoRoot = findCanonicalGitRoot(gitRoot) ?? gitRoot |
| registerFilesystemAccessRoot(repoRoot) |
| const [branchResult, defaultBranch, statusResult, worktreeResult] = await Promise.all([ |
| runGit(gitRoot, ['branch', '--show-current']), |
| getDefaultBranch(gitRoot), |
| runGit(gitRoot, ['--no-optional-locks', 'status', '--porcelain']), |
| runGit(repoRoot, ['worktree', 'list', '--porcelain']), |
| ]) |
|
|
| const currentBranch = branchResult.stdout.trim() || null |
| const rawWorktreeRecords = worktreeResult.code === 0 ? parseWorktreeList(worktreeResult.stdout) : [] |
| const worktreeRecords = await Promise.all( |
| rawWorktreeRecords.map(async (worktree) => ({ |
| ...worktree, |
| path: await canonicalizeKnownPath(worktree.path), |
| })), |
| ) |
| const worktrees = worktreeRecords.map((worktree) => ({ |
| path: worktree.path, |
| branch: worktree.branch, |
| current: isSameOrInsidePath(worktree.path, absWorkDir), |
| })) |
|
|
| return { |
| state: 'ok', |
| workDir: absWorkDir, |
| repoRoot, |
| repoName: path.basename(repoRoot), |
| currentBranch, |
| defaultBranch, |
| dirty: statusResult.code === 0 && statusResult.stdout.trim().length > 0, |
| branches: await listBranches(repoRoot, currentBranch, worktreeRecords), |
| worktrees, |
| } |
| } catch (error) { |
| return { |
| state: 'error', |
| workDir: absWorkDir, |
| repoRoot: gitRoot, |
| repoName: path.basename(gitRoot), |
| currentBranch: null, |
| defaultBranch: null, |
| dirty: false, |
| branches: [], |
| worktrees: [], |
| error: error instanceof Error ? error.message : String(error), |
| } |
| } |
| } |
|
|
| function resolveBranch(context: RepositoryContextResult, requestedBranch?: string | null): ResolvedBranch | null { |
| if (context.state !== 'ok') return null |
| const selectedName = requestedBranch || [ |
| context.currentBranch, |
| context.defaultBranch, |
| context.branches[0]?.name, |
| ].find((name) => name && context.branches.some((candidate) => candidate.name === name)) |
| if (!selectedName) return null |
| const branch = context.branches.find((candidate) => candidate.name === selectedName) |
| if (!branch) return null |
| return { |
| ...branch, |
| baseRef: branch.local ? branch.name : branch.remoteRef ?? branch.name, |
| } |
| } |
|
|
| function safeWorktreeSlug(branchName: string, sessionId: string): string { |
| const safeBranch = branchName |
| .replace(/[^a-zA-Z0-9._-]+/g, '-') |
| .replace(/^-+|-+$/g, '') |
| .slice(0, 38) || 'branch' |
| const slug = `desktop-${safeBranch}-${sessionId.slice(0, 8)}` |
| validateWorktreeSlug(slug) |
| return slug |
| } |
|
|
| async function createDesktopWorktree( |
| context: RepositoryContextResult, |
| branch: ResolvedBranch, |
| sessionId: string, |
| ): Promise<PreparedSessionWorkspace> { |
| if (context.state !== 'ok' || !context.repoRoot) { |
| throw repositoryBadRequest( |
| REPOSITORY_ERROR.notGit, |
| 'Cannot create a worktree outside a Git repository', |
| ) |
| } |
|
|
| const slug = safeWorktreeSlug(branch.name, sessionId) |
| const worktreePath = path.join(context.repoRoot, '.claude', 'worktrees', slug) |
| const branchName = worktreeBranchName(slug) |
|
|
| await ensureWorktreesDirExcluded(context.repoRoot) |
| await fs.mkdir(path.dirname(worktreePath), { recursive: true }) |
| const result = await runGit( |
| context.repoRoot, |
| ['worktree', 'add', '-b', branchName, worktreePath, branch.baseRef], |
| WORKTREE_TIMEOUT_MS, |
| ) |
| if (result.code !== 0) { |
| throw repositoryBadRequest( |
| REPOSITORY_ERROR.worktreeCreateFailed, |
| `Failed to create worktree: ${result.stderr.trim() || result.stdout.trim() || 'git worktree add failed'}`, |
| ) |
| } |
|
|
| await performPostCreationSetup(context.repoRoot, worktreePath) |
|
|
| return { |
| workDir: worktreePath, |
| repository: { |
| requestedWorkDir: context.workDir, |
| repoRoot: context.repoRoot, |
| branch: branch.name, |
| worktree: true, |
| baseRef: branch.baseRef, |
| worktreePath, |
| worktreeBranch: branchName, |
| worktreeSlug: slug, |
| }, |
| } |
| } |
|
|
| function planIsolatedWorktree( |
| context: RepositoryContextResult, |
| branch: ResolvedBranch, |
| sessionId: string, |
| ): PreparedSessionWorkspace { |
| if (context.state !== 'ok' || !context.repoRoot) { |
| throw repositoryBadRequest( |
| REPOSITORY_ERROR.notGit, |
| 'Cannot create a worktree outside a Git repository', |
| ) |
| } |
|
|
| const slug = safeWorktreeSlug(branch.name, sessionId) |
| const worktreePath = path.join(context.repoRoot, '.claude', 'worktrees', slug) |
| const branchName = worktreeBranchName(slug) |
|
|
| return { |
| workDir: context.workDir, |
| repository: { |
| requestedWorkDir: context.workDir, |
| repoRoot: context.repoRoot, |
| branch: branch.name, |
| worktree: true, |
| baseRef: branch.baseRef, |
| worktreePath, |
| worktreeBranch: branchName, |
| worktreeSlug: slug, |
| }, |
| } |
| } |
|
|
| async function switchExistingCheckout( |
| context: RepositoryContextResult, |
| branch: ResolvedBranch, |
| ): Promise<PreparedSessionWorkspace> { |
| if (context.state !== 'ok' || !context.repoRoot) { |
| throw repositoryBadRequest( |
| REPOSITORY_ERROR.notGit, |
| 'Cannot switch branches outside a Git repository', |
| ) |
| } |
|
|
| if (branch.name === context.currentBranch) { |
| return { |
| workDir: context.workDir, |
| repository: { |
| requestedWorkDir: context.workDir, |
| repoRoot: context.repoRoot, |
| branch: branch.name, |
| worktree: false, |
| baseRef: branch.baseRef, |
| }, |
| } |
| } |
|
|
| if (branch.checkedOut) { |
| throw repositoryBadRequest( |
| REPOSITORY_ERROR.branchCheckedOut, |
| `Branch "${branch.name}" is already checked out in another worktree.`, |
| ) |
| } |
|
|
| const args = branch.local |
| ? ['switch', branch.name] |
| : ['switch', '--track', '-c', branch.name, branch.baseRef] |
| const result = await runGit(context.workDir, args) |
| if (result.code !== 0) { |
| throw repositoryBadRequest( |
| REPOSITORY_ERROR.switchFailed, |
| `Failed to switch branch: ${result.stderr.trim() || result.stdout.trim() || 'git switch failed'}`, |
| ) |
| } |
|
|
| return { |
| workDir: context.workDir, |
| repository: { |
| requestedWorkDir: context.workDir, |
| repoRoot: context.repoRoot, |
| branch: branch.name, |
| worktree: false, |
| baseRef: branch.baseRef, |
| }, |
| } |
| } |
|
|
| export async function prepareSessionWorkspace( |
| workDir: string, |
| options: CreateSessionRepositoryOptions | undefined, |
| sessionId: string, |
| ): Promise<PreparedSessionWorkspace> { |
| const absWorkDir = await resolveDirectory(workDir) |
|
|
| if (!options?.branch && !options?.worktree) { |
| return { workDir: absWorkDir } |
| } |
|
|
| const context = await getRepositoryContext(absWorkDir) |
| if (context.state !== 'ok') { |
| if (context.state === 'not_git_repo') { |
| throw repositoryBadRequest( |
| REPOSITORY_ERROR.notGit, |
| 'Selected directory is not a Git repository', |
| ) |
| } |
| if (context.state === 'missing_workdir') { |
| throw repositoryBadRequest( |
| REPOSITORY_ERROR.workdirMissing, |
| context.error || 'Working directory does not exist', |
| ) |
| } |
| throw repositoryBadRequest( |
| REPOSITORY_ERROR.contextFailed, |
| context.error || 'Failed to inspect Git repository', |
| ) |
| } |
|
|
| const branch = resolveBranch(context, options.branch) |
| if (!branch) { |
| throw repositoryBadRequest( |
| REPOSITORY_ERROR.branchNotFound, |
| `Branch not found: ${options.branch || 'default branch'}`, |
| ) |
| } |
|
|
| return options.worktree |
| ? createDesktopWorktree(context, branch, sessionId) |
| : switchExistingCheckout(context, branch) |
| } |
|
|
| export async function resolveSessionWorkspaceLaunch( |
| workDir: string, |
| options: CreateSessionRepositoryOptions | undefined, |
| sessionId: string, |
| ): Promise<PreparedSessionWorkspace> { |
| const absWorkDir = await resolveDirectory(workDir) |
|
|
| if (!options?.branch && !options?.worktree) { |
| return { workDir: absWorkDir } |
| } |
|
|
| const context = await getRepositoryContext(absWorkDir) |
| if (context.state !== 'ok') { |
| if (context.state === 'not_git_repo') { |
| throw repositoryBadRequest( |
| REPOSITORY_ERROR.notGit, |
| 'Selected directory is not a Git repository', |
| ) |
| } |
| if (context.state === 'missing_workdir') { |
| throw repositoryBadRequest( |
| REPOSITORY_ERROR.workdirMissing, |
| context.error || 'Working directory does not exist', |
| ) |
| } |
| throw repositoryBadRequest( |
| REPOSITORY_ERROR.contextFailed, |
| context.error || 'Failed to inspect Git repository', |
| ) |
| } |
|
|
| const branch = resolveBranch(context, options.branch) |
| if (!branch) { |
| throw repositoryBadRequest( |
| REPOSITORY_ERROR.branchNotFound, |
| `Branch not found: ${options.branch || 'default branch'}`, |
| ) |
| } |
|
|
| return options.worktree |
| ? planIsolatedWorktree(context, branch, sessionId) |
| : { |
| workDir: context.workDir, |
| repository: { |
| requestedWorkDir: context.workDir, |
| repoRoot: context.repoRoot, |
| branch: branch.name, |
| worktree: false, |
| baseRef: branch.baseRef, |
| }, |
| } |
| } |
|
|