import { spawnSync as nodeSpawnSync } from 'node:child_process'; import { existsSync, lstatSync, readlinkSync, symlinkSync } from 'node:fs'; import { copyFile, readFile } from 'node:fs/promises'; import { resolve } from 'node:path'; export type Runtime = 'bun' | 'node'; export type PackageManager = 'bun' | 'npm' | 'pnpm' | 'yarn'; export type CommandResult = { exitCode: number; stderr: string; stdout: string; }; export type GitWorktreeEntry = { branch?: string; path: string; }; export type PullRequestSummary = { baseRefName?: string; body?: string; createdAt?: string; headRefName?: string; number: number; title?: string; url: string; state: string; }; export type PullRequestDetail = PullRequestSummary & { baseRefName?: string; mergedAt?: string | null; }; const LOCK_FILES = [ 'bun.lock', 'pnpm-lock.yaml', 'yarn.lock', 'package-lock.json', ] as const; const WORKTREE_BOOTSTRAP_FILES = [ '.env', '.env.local', '.env.development', '.env.test', '.gitignore', ] as const; export function parseGitWorktreeList(output: string): GitWorktreeEntry[] { const entries: GitWorktreeEntry[] = []; let current: GitWorktreeEntry | undefined; for (const rawLine of output.split('\n')) { const line = rawLine.trim(); if (line.length === 0) { if (current) { entries.push(current); current = undefined; } continue; } if (line.startsWith('worktree ')) { if (current) { entries.push(current); } current = { path: line.slice('worktree '.length), }; continue; } if (line.startsWith('branch ') && current) { current.branch = line.slice('branch '.length); } } if (current) { entries.push(current); } return entries; } export function runProcess( cwd: string, cmd: string[], runtime: Runtime, ): string { const result = runProcessResult(cwd, cmd, runtime); if (result.exitCode !== 0) { throw new Error(formatCommandFailure(cmd, result)); } return result.stdout; } export function runProcessResult( cwd: string, cmd: string[], runtime: Runtime, ): CommandResult { if (runtime === 'bun' && typeof globalThis.Bun !== 'undefined') { const result = Bun.spawnSync(cmd, { cwd, stderr: 'pipe', stdout: 'pipe', env: process.env, }); return { exitCode: result.exitCode, stderr: new TextDecoder().decode(result.stderr).trim(), stdout: new TextDecoder().decode(result.stdout), }; } const result = nodeSpawnSync(cmd[0]!, cmd.slice(1), { cwd, env: process.env, stdio: ['pipe', 'pipe', 'pipe'], }); return { exitCode: result.status ?? 1, stderr: [result.stderr?.toString() ?? '', result.error?.message ?? ''] .filter(Boolean) .join('\n') .trim(), stdout: result.stdout?.toString() ?? '', }; } export function formatCommandFailure( cmd: string[], result: Pick, ): string { return [ `Command failed: ${cmd.join(' ')}`, result.stderr.trim(), result.stdout.trim(), ] .filter(Boolean) .join('\n'); } export function listGitWorktrees( cwd: string, runtime: Runtime, ): GitWorktreeEntry[] { return parseGitWorktreeList( runProcess(cwd, ['git', 'worktree', 'list', '--porcelain'], runtime), ); } export function findPrimaryWorktreePath( cwd: string, defaultBranch: string, runtime: Runtime, ): string | undefined { const worktrees = listGitWorktrees(cwd, runtime); return worktrees.find( (worktree) => resolve(worktree.path) !== resolve(cwd) && worktree.branch === `refs/heads/${defaultBranch}`, )?.path; } export async function copyLocalBootstrapFilesIfPresent( sourceWorktreePath: string, targetWorktreePath: string, ): Promise { for (const file of WORKTREE_BOOTSTRAP_FILES) { const sourceFilePath = resolve(sourceWorktreePath, file); const targetFilePath = resolve(targetWorktreePath, file); if (!existsSync(sourceFilePath) || existsSync(targetFilePath)) { continue; } await copyFile(sourceFilePath, targetFilePath); } // Recreate symlinks that exist in the source worktree but not in the target. // This covers repo-root symlinks like `.agents` and `tools` that the // delivery orchestrator requires but that git does not carry across worktrees. for (const entry of ['.agents', 'tools']) { const sourcePath = resolve(sourceWorktreePath, entry); const targetPath = resolve(targetWorktreePath, entry); if ( existsSync(targetPath) || lstatSync(sourcePath, { throwIfNoEntry: false })?.isSymbolicLink() !== true ) { continue; } symlinkSync(readlinkSync(sourcePath), targetPath); } } export async function copyLocalEnvIfPresent( sourceWorktreePath: string, targetWorktreePath: string, ): Promise { await copyLocalBootstrapFilesIfPresent( sourceWorktreePath, targetWorktreePath, ); } export async function bootstrapWorktreeIfNeeded( worktreePath: string, packageManager: PackageManager, runtime: Runtime, ): Promise { if ( !existsSync(resolve(worktreePath, 'package.json')) || existsSync(resolve(worktreePath, 'node_modules')) ) { return; } const hasLockfile = LOCK_FILES.some((file) => existsSync(resolve(worktreePath, file)), ); if (!hasLockfile) { return; } runProcess(worktreePath, [packageManager, 'install'], runtime); const packageJson = JSON.parse( await readFile(resolve(worktreePath, 'package.json'), 'utf8'), ) as { scripts?: Record; }; if (packageJson.scripts?.['hooks:install']) { runProcess(worktreePath, [packageManager, 'run', 'hooks:install'], runtime); } const webPath = resolve(worktreePath, 'web'); if ( existsSync(resolve(webPath, 'package.json')) && !existsSync(resolve(webPath, 'node_modules')) ) { runProcess(webPath, [packageManager, 'install'], runtime); } } export function listOpenPullRequests( cwd: string, runtime: Runtime, ): Map { const stdout = runProcess( cwd, [ 'gh', 'pr', 'list', '--state', 'open', '--limit', '100', '--json', 'number,url,headRefName,state', ], runtime, ); const parsed = JSON.parse(stdout) as Array<{ number: number; url: string; headRefName: string; state: string; }>; return new Map( parsed.map((pr) => [ pr.headRefName, { headRefName: pr.headRefName, number: pr.number, url: pr.url, state: pr.state, } satisfies PullRequestSummary, ]), ); } export function listRemoteBranches(cwd: string, runtime: Runtime): string[] { return runProcess( cwd, ['git', 'branch', '-r', '--format=%(refname:short)'], runtime, ) .split('\n') .map((line) => line.trim()) .filter((line) => line.length > 0) .map((line) => line.replace(/^origin\//, '')); } export function listLocalBranches(cwd: string, runtime: Runtime): string[] { return runProcess( cwd, ['git', 'branch', '--format=%(refname:short)'], runtime, ) .split('\n') .map((line) => line.trim()) .filter((line) => line.length > 0); } export function listMergedPullRequests( cwd: string, runtime: Runtime, ): Map { const stdout = runProcess( cwd, [ 'gh', 'pr', 'list', '--state', 'merged', '--limit', '100', '--json', 'number,url,headRefName,state', ], runtime, ); const parsed = JSON.parse(stdout) as Array<{ number: number; url: string; headRefName: string; state: string; }>; return new Map( parsed.map((pr) => [ pr.headRefName, { headRefName: pr.headRefName, number: pr.number, url: pr.url, state: pr.state, } satisfies PullRequestSummary, ]), ); } export function resolveStandalonePullRequest( cwd: string, runtime: Runtime, prNumber?: number, ): { body: string; createdAt: string; headRefName: string; headRefOid: string; number: number; title: string; url: string; } { const target = prNumber ? String(prNumber) : undefined; const stdout = runProcess( cwd, [ 'gh', 'pr', 'view', ...(target ? [target] : []), '--json', 'number,url,title,body,headRefName,headRefOid,createdAt', ], runtime, ); return JSON.parse(stdout) as { body: string; createdAt: string; headRefName: string; headRefOid: string; number: number; title: string; url: string; }; } export function findOpenPullRequest( cwd: string, branch: string, runtime: Runtime, ): PullRequestSummary | undefined { const stdout = runProcess( cwd, [ 'gh', 'pr', 'list', '--state', 'open', '--head', branch, '--json', 'number,url,state', ], runtime, ); const parsed = JSON.parse(stdout) as Array; return parsed[0]; } export function findMergedPullRequest( cwd: string, branch: string, runtime: Runtime, ): PullRequestDetail | undefined { const stdout = runProcess( cwd, [ 'gh', 'pr', 'list', '--state', 'merged', '--head', branch, '--limit', '1', '--json', 'number,url,state,baseRefName,mergedAt', ], runtime, ); const parsed = JSON.parse(stdout) as Array; return parsed[0]; } export function hasMergedPullRequestForBranch( cwd: string, branch: string, runtime: Runtime, ): boolean { return findMergedPullRequest(cwd, branch, runtime) !== undefined; } export function readLatestCommitSubject(cwd: string, runtime: Runtime): string { return runProcess(cwd, ['git', 'log', '-1', '--pretty=%s'], runtime).trim(); } export function readCommitSubject( cwd: string, sha: string, runtime: Runtime, ): string { return runProcess( cwd, ['git', 'log', '-1', '--pretty=%s', sha], runtime, ).trim(); } export function readHeadSha(cwd: string, runtime: Runtime): string { return runProcess(cwd, ['git', 'rev-parse', 'HEAD'], runtime).trim(); } export function readCurrentBranch(cwd: string, runtime: Runtime): string { const branch = runProcess( cwd, ['git', 'branch', '--show-current'], runtime, ).trim(); if (!branch) { throw new Error('Restack requires an attached branch checkout.'); } return branch; } export function ensureCleanWorktree(cwd: string, runtime: Runtime): void { const status = runProcess(cwd, ['git', 'status', '--short'], runtime).trim(); if (status) { throw new Error( 'Restack requires a clean worktree. Commit, stash, or discard local changes first.', ); } } function runGitLines(cwd: string, cmd: string[], runtime: Runtime): string[] { return runProcess(cwd, cmd, runtime) .split('\n') .map((line) => line.trim()) .filter((line) => line.length > 0); } export function ensureBranchPushed( cwd: string, branch: string, runtime: Runtime, ): void { const localSha = runGitLines(cwd, ['git', 'rev-parse', branch], runtime)[0]; const remoteRef = runProcessResult( cwd, ['git', 'ls-remote', '--heads', 'origin', branch], runtime, ); if (remoteRef.exitCode !== 0) { throw new Error( formatCommandFailure( ['git', 'ls-remote', '--heads', 'origin', branch], remoteRef, ), ); } const remoteSha = remoteRef.stdout.trim().split(/\s+/)[0] || undefined; if (!remoteSha) { runProcess(cwd, ['git', 'push', '-u', 'origin', branch], runtime); return; } if (remoteSha !== localSha) { runProcess(cwd, ['git', 'push', 'origin', branch], runtime); } const upstream = runProcessResult( cwd, ['git', 'branch', '--set-upstream-to', `origin/${branch}`, branch], runtime, ); if ( upstream.exitCode !== 0 && !upstream.stderr.includes('is already set up to track') ) { throw new Error( formatCommandFailure( ['git', 'branch', '--set-upstream-to', `origin/${branch}`, branch], upstream, ), ); } } export function addWorktree( cwd: string, worktreePath: string, branch: string, baseBranch: string, runtime: Runtime, ): void { runProcess( cwd, ['git', 'worktree', 'add', worktreePath, '-b', branch, baseBranch], runtime, ); } export function createPullRequest( cwd: string, options: { base: string; body: string; head: string; title: string; }, runtime: Runtime, ): string { return runProcess( cwd, [ 'gh', 'pr', 'create', '--base', options.base, '--head', options.head, '--title', options.title, '--body', options.body, ], runtime, ).trim(); } export function editPullRequest( cwd: string, prNumber: number, options: { base?: string; body?: string; title?: string; }, runtime: Runtime, ): void { const cmd = ['gh', 'pr', 'edit', String(prNumber)] as string[]; if (options.base) { cmd.push('--base', options.base); } if (options.title) { cmd.push('--title', options.title); } if (options.body) { cmd.push('--body', options.body); } runProcess(cwd, cmd, runtime); } export function resolveReviewThread( worktreePath: string, threadId: string, runtime: Runtime, ): string { return runProcess( worktreePath, [ 'gh', 'api', 'graphql', '-F', `threadId=${threadId}`, '-f', 'query=mutation($threadId: ID!) { resolveReviewThread(input: { threadId: $threadId }) { thread { id isResolved } } }', ], runtime, ); } export function resolveGitHubRepo( cwd: string, runtime: Runtime, ): { defaultBranch: string; name: string; owner: string } | undefined { const result = runProcessResult( cwd, ['gh', 'repo', 'view', '--json', 'nameWithOwner,defaultBranchRef'], runtime, ); if (result.exitCode !== 0) { return undefined; } try { const parsed = JSON.parse(result.stdout) as { defaultBranchRef?: { name?: string }; nameWithOwner?: string; }; const nameWithOwner = parsed.nameWithOwner; const defaultBranch = parsed.defaultBranchRef?.name ?? 'main'; if (!nameWithOwner?.includes('/')) { return undefined; } const [owner, name] = nameWithOwner.split('/'); if (!owner || !name) { return undefined; } return { defaultBranch, name, owner }; } catch { return undefined; } } // Body is expected to be single-line plain text. export function replyToReviewComment( cwd: string, owner: string, repo: string, commentDatabaseId: number, body: string, runtime: Runtime, ): void { runProcess( cwd, [ 'gh', 'api', '--method', 'POST', `repos/${owner}/${repo}/pulls/comments/${String(commentDatabaseId)}/replies`, '-f', `body=${body}`, ], runtime, ); } export function fetchOrigin(cwd: string, runtime: Runtime): void { runProcess(cwd, ['git', 'fetch', 'origin'], runtime); } export function isLocalBranchDocOnly( cwd: string, baseBranch: string, runtime: Runtime, ): boolean { try { const stdout = runProcess( cwd, ['git', 'diff', `origin/${baseBranch}...HEAD`, '--name-only'], runtime, ); const files = stdout .split('\n') .map((f) => f.trim()) .filter(Boolean); return ( files.length > 0 && files.every((f) => f.endsWith('.md') || f.endsWith('.json')) ); } catch { return false; } } export function hasLocalBranchCommits( cwd: string, baseBranch: string, runtime: Runtime, runProcessOverride?: (cwd: string, cmd: string[]) => string, ): boolean { try { const runner = runProcessOverride ?? ((c, cmd) => runProcess(c, cmd, runtime)); const stdout = runner(cwd, [ 'git', 'rev-list', '--count', `origin/${baseBranch}..HEAD`, ]); return Number.parseInt(stdout.trim(), 10) > 0; } catch { return false; } } export function getWorkingTreeStatus(cwd: string, runtime: Runtime): string { return runProcess(cwd, ['git', 'status', '--short'], runtime).trimEnd(); } export function hasUncommittedChanges(cwd: string, runtime: Runtime): boolean { return getWorkingTreeStatus(cwd, runtime).trim().length > 0; } export function readMergeBase( cwd: string, branch: string, previousBranch: string, runtime: Runtime, ): string { return runProcess( cwd, ['git', 'merge-base', branch, previousBranch], runtime, ).trim(); } export function rebaseOnto( cwd: string, rebaseTarget: string, oldBase: string, runtime: Runtime, ): void { runProcess(cwd, ['git', 'rebase', '--onto', rebaseTarget, oldBase], runtime); } export function rebaseOntoDefaultBranch( cwd: string, defaultBranch: string, runtime: Runtime, ): void { runProcess(cwd, ['git', 'rebase', `origin/${defaultBranch}`], runtime); } export function listCommitSubjectsBetween( cwd: string, reviewedHeadSha: string, currentHeadSha: string, maxCount: number, runtime: Runtime, ): string[] { return runProcess( cwd, [ 'git', 'log', '--no-merges', '--reverse', `--max-count=${maxCount}`, '--format=%H%x09%s', `${reviewedHeadSha}..${currentHeadSha}`, ], runtime, ) .split('\n') .map((line) => line.trim()) .filter((line) => line.length > 0); }