tools/delivery

platform.ts

tools/delivery/platform.ts
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<CommandResult, 'stderr' | 'stdout'>,
): 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<void> {
  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<void> {
  await copyLocalBootstrapFilesIfPresent(
    sourceWorktreePath,
    targetWorktreePath,
  );
}

export async function bootstrapWorktreeIfNeeded(
  worktreePath: string,
  packageManager: PackageManager,
  runtime: Runtime,
): Promise<void> {
  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<string, string>;
  };

  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<string, PullRequestSummary> {
  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<string, PullRequestSummary> {
  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<PullRequestSummary>;
  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<PullRequestDetail>;
  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);
}