agent-ecosystem/src/main/services/team/TeamMemberWorktreeManager.ts
2026-04-28 16:08:05 +03:00

219 lines
6.6 KiB
TypeScript

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<string> {
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<string | null> {
try {
return await fs.promises.realpath(candidate);
} catch {
return null;
}
}
async function resolveGitPath(cwd: string, raw: string): Promise<string> {
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<TeamMemberWorktreeResolution> {
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<string> {
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<void> {
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<void> {
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
);
}
}