298 lines · 8.8 KB · TypeScript
RawDownload
import { spawn } from "node:child_process";import fs from "node:fs";import os from "node:os";import path from "node:path"; import type {  GitCheckoutInput,  GitCreateBranchInput,  GitCreateWorktreeInput,  GitCreateWorktreeResult,  GitInitInput,  GitListBranchesInput,  GitListBranchesResult,  GitRemoveWorktreeInput,} from "@t3tools/contracts"; export interface TerminalCommandInput {  command: string;  cwd: string;  timeoutMs?: number;  maxOutputBytes?: number;} export interface TerminalCommandResult {  stdout: string;  stderr: string;  code: number | null;  signal: NodeJS.Signals | null;  timedOut: boolean;} const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; function appendChunkWithinLimit(  target: string,  currentBytes: number,  chunk: Buffer,  maxBytes: number,): {  next: string;  nextBytes: number;  truncated: boolean;} {  const remaining = maxBytes - currentBytes;  if (remaining <= 0) {    return { next: target, nextBytes: currentBytes, truncated: true };  }  if (chunk.length <= remaining) {    return {      next: `${target}${chunk.toString()}`,      nextBytes: currentBytes + chunk.length,      truncated: false,    };  }  return {    next: `${target}${chunk.subarray(0, remaining).toString()}`,    nextBytes: currentBytes + remaining,    truncated: true,  };} /** Spawn git directly with an argv array — no shell, no quoting needed. */function runGit(args: string[], cwd: string, timeoutMs = 30_000): Promise<TerminalCommandResult> {  return new Promise((resolve, reject) => {    const child = spawn("git", args, {      cwd,      env: process.env,      stdio: ["ignore", "pipe", "pipe"],    });     const maxOutputBytes = DEFAULT_MAX_OUTPUT_BYTES;    let stdout = "";    let stderr = "";    let stdoutBytes = 0;    let stderrBytes = 0;    let timedOut = false;    let outputTruncated = false;     const timeout = setTimeout(() => {      timedOut = true;      child.kill("SIGTERM");      setTimeout(() => {        if (!child.killed) child.kill("SIGKILL");      }, 1_000).unref();    }, timeoutMs);     child.stdout?.on("data", (chunk: Buffer) => {      const appended = appendChunkWithinLimit(stdout, stdoutBytes, chunk, maxOutputBytes);      stdout = appended.next;      stdoutBytes = appended.nextBytes;      outputTruncated = outputTruncated || appended.truncated;    });    child.stderr?.on("data", (chunk: Buffer) => {      const appended = appendChunkWithinLimit(stderr, stderrBytes, chunk, maxOutputBytes);      stderr = appended.next;      stderrBytes = appended.nextBytes;      outputTruncated = outputTruncated || appended.truncated;    });    child.on("error", (error) => {      clearTimeout(timeout);      reject(error);    });    child.on("close", (code, signal) => {      clearTimeout(timeout);      if (outputTruncated) {        stderr = `${stderr}\n[output truncated at ${maxOutputBytes} bytes]`;      }      resolve({ stdout, stderr, code: code ?? null, signal: signal ?? null, timedOut });    });  });} export async function runTerminalCommand(  input: TerminalCommandInput,): Promise<TerminalCommandResult> {  const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;  const shellPath =    process.platform === "win32"      ? (process.env.ComSpec ?? "cmd.exe")      : (process.env.SHELL ?? "/bin/sh");   const args =    process.platform === "win32" ? ["/d", "/s", "/c", input.command] : ["-lc", input.command];   return new Promise((resolve, reject) => {    const child = spawn(shellPath, args, {      cwd: input.cwd,      env: process.env,      stdio: ["ignore", "pipe", "pipe"],    });     let stdout = "";    let stderr = "";    let stdoutBytes = 0;    let stderrBytes = 0;    let timedOut = false;    let outputTruncated = false;     const timeout = setTimeout(() => {      timedOut = true;      child.kill("SIGTERM");      setTimeout(() => {        if (!child.killed) {          child.kill("SIGKILL");        }      }, 1_000).unref();    }, input.timeoutMs ?? 30_000);     child.stdout?.on("data", (chunk: Buffer) => {      const appended = appendChunkWithinLimit(stdout, stdoutBytes, chunk, maxOutputBytes);      stdout = appended.next;      stdoutBytes = appended.nextBytes;      outputTruncated = outputTruncated || appended.truncated;    });     child.stderr?.on("data", (chunk: Buffer) => {      const appended = appendChunkWithinLimit(stderr, stderrBytes, chunk, maxOutputBytes);      stderr = appended.next;      stderrBytes = appended.nextBytes;      outputTruncated = outputTruncated || appended.truncated;    });     child.on("error", (error) => {      clearTimeout(timeout);      reject(error);    });     child.on("close", (code, signal) => {      clearTimeout(timeout);      if (outputTruncated) {        stderr = `${stderr}\n[output truncated at ${maxOutputBytes} bytes]`;      }      resolve({        stdout,        stderr,        code: code ?? null,        signal: signal ?? null,        timedOut,      });    });  });} export async function listGitBranches(input: GitListBranchesInput): Promise<GitListBranchesResult> {  const result = await runGit(["branch", "--no-color"], input.cwd, 10_000);   if (result.code !== 0) {    const stderr = result.stderr.trim();    if (stderr.includes("not a git repository")) {      return { branches: [], isRepo: false };    }    throw new Error(stderr || "git branch failed");  }   // Resolve the real default branch from the remote  const [defaultRef, worktreeList] = await Promise.all([    runGit(["symbolic-ref", "refs/remotes/origin/HEAD"], input.cwd, 5_000),    runGit(["worktree", "list", "--porcelain"], input.cwd, 5_000),  ]);  const defaultBranch =    defaultRef.code === 0 ? defaultRef.stdout.trim().replace(/^refs\/remotes\/origin\//, "") : null;   // Build branch-name → worktree-path map from porcelain output.  // Only include worktrees whose directories still exist on disk (skip prunable/stale ones).  const worktreeMap = new Map<string, string>();  if (worktreeList.code === 0) {    let currentPath: string | null = null;    for (const line of worktreeList.stdout.split("\n")) {      if (line.startsWith("worktree ")) {        const candidatePath = line.slice("worktree ".length);        currentPath = fs.existsSync(candidatePath) ? candidatePath : null;      } else if (line.startsWith("branch refs/heads/") && currentPath) {        worktreeMap.set(line.slice("branch refs/heads/".length), currentPath);      } else if (line === "") {        currentPath = null;      }    }  }   const branches = result.stdout    .split("\n")    .map((line) => line.trim())    .filter((line) => line.length > 0)    .map((line) => {      const name = line.replace(/^[*+]\s+/, "");      return {        name,        current: line.startsWith("* "),        isDefault: name === defaultBranch,        worktreePath: worktreeMap.get(name) ?? null,      };    })    .toSorted((a, b) => {      if (a.current !== b.current) return a.current ? -1 : 1;      if (a.isDefault !== b.isDefault) return a.isDefault ? -1 : 1;      return a.name.localeCompare(b.name);    });   return { branches, isRepo: true };} export async function createGitWorktree(  input: GitCreateWorktreeInput,): Promise<GitCreateWorktreeResult> {  const sanitizedBranch = input.newBranch.replace(/\//g, "-");  const repoName = path.basename(input.cwd);  const worktreePath =    input.path ?? path.join(os.homedir(), ".t3", "worktrees", repoName, sanitizedBranch);   // Create a new branch from the base branch in a new worktree  const result = await runGit(    ["worktree", "add", "-b", input.newBranch, worktreePath, input.branch],    input.cwd,  );   if (result.code !== 0) {    throw new Error(result.stderr.trim() || "git worktree add failed");  }   return {    worktree: {      path: worktreePath,      branch: input.newBranch,    },  };} export async function removeGitWorktree(input: GitRemoveWorktreeInput): Promise<void> {  const result = await runGit(["worktree", "remove", input.path], input.cwd, 15_000);   if (result.code !== 0) {    throw new Error(result.stderr.trim() || "git worktree remove failed");  }} export async function createGitBranch(input: GitCreateBranchInput): Promise<void> {  const result = await runGit(["branch", input.branch], input.cwd, 10_000);   if (result.code !== 0) {    throw new Error(result.stderr.trim() || "git branch create failed");  }} export async function checkoutGitBranch(input: GitCheckoutInput): Promise<void> {  const result = await runGit(["checkout", input.branch], input.cwd, 10_000);   if (result.code !== 0) {    throw new Error(result.stderr.trim() || "git checkout failed");  }} export async function initGitRepo(input: GitInitInput): Promise<void> {  const result = await runGit(["init"], input.cwd, 10_000);   if (result.code !== 0) {    throw new Error(result.stderr.trim() || "git init failed");  }}