import { getAppDataPath, getClaudeBasePath } from '@main/utils/pathDecoder'; import { execFile } from 'child_process'; import { createHash } from 'crypto'; import * as fs from 'fs'; import * as path from 'path'; export interface TeamMemberWorktreeRequest { teamName: string; memberName: string; baseCwd: string; } export interface TeamMemberWorktreeResolution { baseRepoPath: string; worktreePath: string; branchName: string; } interface GitWorktreeEntry { worktree: string; branch?: string; } const GIT_TIMEOUT_MS = 15_000; function execGit(args: string[], cwd: string): Promise { return new Promise((resolve, reject) => { execFile( 'git', args, { cwd, timeout: GIT_TIMEOUT_MS, maxBuffer: 1024 * 1024 }, (error, stdout, stderr) => { if (error) { const message = String(stderr || error.message || 'git command failed').trim(); reject(new Error(message)); return; } resolve(String(stdout).trim()); } ); }); } function slugify(value: string): string { return ( value .trim() .toLowerCase() .replace(/[^a-z0-9._-]+/g, '-') .replace(/^-+|-+$/g, '') .slice(0, 48) || 'member' ); } function shortHash(value: string): string { return createHash('sha256').update(value).digest('hex').slice(0, 10); } async function realpathIfExists(candidate: string): Promise { try { return await fs.promises.realpath(candidate); } catch { return null; } } async function resolveGitPath(cwd: string, raw: string): Promise { const resolved = path.isAbsolute(raw) ? raw : path.resolve(cwd, raw); return (await realpathIfExists(resolved)) ?? resolved; } function parseGitWorktreeList(raw: string): GitWorktreeEntry[] { const entries: GitWorktreeEntry[] = []; let current: GitWorktreeEntry | null = null; for (const line of raw.split(/\r?\n/g)) { if (!line.trim()) { if (current) entries.push(current); current = null; continue; } const [key, ...rest] = line.split(' '); const value = rest.join(' ').trim(); if (key === 'worktree') { if (current) entries.push(current); current = { worktree: value }; continue; } if (key === 'branch' && current) { current.branch = value.replace(/^refs\/heads\//, ''); } } if (current) entries.push(current); return entries; } export class TeamMemberWorktreeManager { async ensureMemberWorktree( request: TeamMemberWorktreeRequest ): Promise { const baseRepoPath = await this.resolveBaseRepoPath(request.baseCwd); const repoHash = shortHash(baseRepoPath); const projectSlug = slugify(path.basename(baseRepoPath)); const teamSlug = slugify(request.teamName); const memberSlug = slugify(request.memberName); const branchName = `agent-teams/${teamSlug}/${memberSlug}-${repoHash}`; const worktreePath = path.join( getAppDataPath(), 'team-worktrees', `${projectSlug}-${repoHash}`, teamSlug, memberSlug ); const legacyWorktreePath = path.join( getClaudeBasePath(), 'team-worktrees', repoHash, teamSlug, memberSlug ); const existingStat = await fs.promises.stat(worktreePath).catch(() => null); if (existingStat) { if (!existingStat.isDirectory()) { throw new Error(`Worktree path exists but is not a directory: ${worktreePath}`); } await this.assertExistingWorktreeMatchesRepo(worktreePath, baseRepoPath, branchName); return { baseRepoPath, worktreePath, branchName }; } const legacyStat = await fs.promises.stat(legacyWorktreePath).catch(() => null); if (legacyStat) { if (!legacyStat.isDirectory()) { throw new Error(`Worktree path exists but is not a directory: ${legacyWorktreePath}`); } await this.assertExistingWorktreeMatchesRepo(legacyWorktreePath, baseRepoPath, branchName); return { baseRepoPath, worktreePath: legacyWorktreePath, branchName }; } await fs.promises.mkdir(path.dirname(worktreePath), { recursive: true }); await this.createWorktree({ baseRepoPath, worktreePath, branchName }); return { baseRepoPath, worktreePath, branchName }; } private async resolveBaseRepoPath(baseCwd: string): Promise { if (!path.isAbsolute(baseCwd)) { throw new Error('OpenCode worktree isolation requires an absolute project path.'); } const root = await execGit(['rev-parse', '--show-toplevel'], baseCwd).catch((error) => { throw new Error( `OpenCode worktree isolation requires a git repository: ${ error instanceof Error ? error.message : String(error) }` ); }); return (await realpathIfExists(root)) ?? root; } private async assertExistingWorktreeMatchesRepo( worktreePath: string, baseRepoPath: string, branchName: string ): Promise { const [baseCommonRaw, targetCommonRaw, targetBranchRaw] = await Promise.all([ execGit(['rev-parse', '--git-common-dir'], baseRepoPath), execGit(['rev-parse', '--git-common-dir'], worktreePath), execGit(['rev-parse', '--abbrev-ref', 'HEAD'], worktreePath), ]); const [baseCommon, targetCommon] = await Promise.all([ resolveGitPath(baseRepoPath, baseCommonRaw), resolveGitPath(worktreePath, targetCommonRaw), ]); if (baseCommon !== targetCommon) { throw new Error(`Worktree path belongs to a different git repository: ${worktreePath}`); } if (targetBranchRaw !== branchName) { throw new Error( `Worktree path is checked out on "${targetBranchRaw}", expected "${branchName}": ${worktreePath}` ); } } private async createWorktree(params: { baseRepoPath: string; worktreePath: string; branchName: string; }): Promise { const branchExists = await execGit( ['rev-parse', '--verify', `refs/heads/${params.branchName}`], params.baseRepoPath ) .then(() => true) .catch(() => false); const listRaw = await execGit(['worktree', 'list', '--porcelain'], params.baseRepoPath); const branchInUse = parseGitWorktreeList(listRaw).some( (entry) => entry.branch === params.branchName ); if (branchInUse) { throw new Error( `OpenCode worktree branch is already checked out elsewhere: ${params.branchName}` ); } if (branchExists) { await execGit( ['worktree', 'add', params.worktreePath, params.branchName], params.baseRepoPath ); return; } await execGit( ['worktree', 'add', '-b', params.branchName, params.worktreePath, 'HEAD'], params.baseRepoPath ); } }