495 lines · 14.3 KB · TypeScript
RawDownload
import fs from "node:fs";import os from "node:os";import path from "node:path"; import {  gitStatusInputSchema,  gitStatusResultSchema,  type GitCheckoutInput,  type GitCreateBranchInput,  type GitCreateWorktreeInput,  type GitCreateWorktreeResult,  type GitInitInput,  type GitListBranchesInput,  type GitListBranchesResult,  type GitRemoveWorktreeInput,  type GitStatusInput,  type GitStatusResult,} from "@t3tools/contracts";import { type ProcessRunResult, runProcess } from "./processRunner"; export interface TerminalCommandInput {  command: string;  cwd: string;  timeoutMs?: number;  maxOutputBytes?: number;} export type TerminalCommandResult = ProcessRunResult; export interface GitStatusDetails extends Omit<GitStatusResult, "openPr"> {  upstreamRef: string | null;} export interface GitPreparedCommitContext {  stagedSummary: string;  stagedPatch: string;} export interface GitPushResult {  status: "pushed" | "skipped_up_to_date";  branch: string;  upstreamBranch?: string | undefined;  setUpstream?: boolean | undefined;} export interface GitRangeContext {  commitSummary: string;  diffSummary: string;  diffPatch: string;} interface RunGitOptions {  timeoutMs?: number | undefined;  allowNonZeroExit?: boolean | undefined;} interface ExecuteGitOptions {  timeoutMs?: number | undefined;  allowNonZeroExit?: boolean | undefined;  fallbackErrorMessage?: string | undefined;} const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; /** Spawn git directly with an argv array — no shell, no quoting needed. */function runGit(  args: readonly string[],  cwd: string,  timeoutMs = 30_000,): Promise<ProcessRunResult> {  return runProcess("git", args, {    cwd,    timeoutMs,    allowNonZeroExit: true,    maxBufferBytes: DEFAULT_MAX_OUTPUT_BYTES,    outputMode: "truncate",  });} function trimStdout(value: string): string {  return value.trim();} function parseBranchAb(value: string): { ahead: number; behind: number } {  const match = value.match(/^\+(\d+)\s+-(\d+)$/);  if (!match) return { ahead: 0, behind: 0 };  return {    ahead: Number(match[1] ?? "0"),    behind: Number(match[2] ?? "0"),  };} function commandLabel(args: readonly string[]): string {  return `git ${args.join(" ")}`;} function normalizeGitSpawnError(cwd: string, error: unknown): Error {  if (!(error instanceof Error)) {    return new Error("Failed to run git command.");  }  if (!fs.existsSync(cwd)) {    return new Error(`Working directory does not exist: ${cwd}`);  }  if (error.message.includes("Command not found: git")) {    return new Error("Git is required but not available on PATH.");  }  return new Error(`Failed to run git command: ${error.message}`);} function normalizeGitExecutionError(args: readonly string[], result: TerminalCommandResult): Error {  const stderr = result.stderr.trim();  if (stderr.toLowerCase().includes("not a git repository")) {    return new Error("Current folder is not a git repository.");  }  if (result.timedOut) {    return new Error(`${commandLabel(args)} timed out.`);  }  const detail =    stderr.length > 0 ? stderr : `code=${result.code ?? "null"}, signal=${result.signal ?? "null"}`;  return new Error(`${commandLabel(args)} failed: ${detail}`);} async function runGitOrThrow(  cwd: string,  args: readonly string[],  options: RunGitOptions = {},): Promise<TerminalCommandResult> {  let result: TerminalCommandResult;  try {    result = await runGit(args, cwd, options.timeoutMs);  } catch (error) {    throw normalizeGitSpawnError(cwd, error);  }   if (result.timedOut) {    throw normalizeGitExecutionError(args, result);  }  if (!options.allowNonZeroExit && result.code !== 0) {    throw normalizeGitExecutionError(args, result);  }  return result;} async function executeGit(  cwd: string,  args: readonly string[],  options: ExecuteGitOptions = {},): Promise<TerminalCommandResult> {  const result = await runGitOrThrow(cwd, args, {    timeoutMs: options.timeoutMs,    allowNonZeroExit: true,  });   if (options.allowNonZeroExit || result.code === 0) {    return result;  }   const stderr = result.stderr.trim();  if (stderr.length > 0) {    throw new Error(stderr);  }  if (options.fallbackErrorMessage) {    throw new Error(options.fallbackErrorMessage);  }  throw normalizeGitExecutionError(args, result);} export class GitCoreService {  async status(raw: GitStatusInput): Promise<GitStatusResult> {    const input = gitStatusInputSchema.parse(raw);    const details = await this.statusDetails(input.cwd);    return gitStatusResultSchema.parse({      branch: details.branch,      hasWorkingTreeChanges: details.hasWorkingTreeChanges,      hasUpstream: details.hasUpstream,      aheadCount: details.aheadCount,      behindCount: details.behindCount,      openPr: null,    });  }   async statusDetails(cwd: string): Promise<GitStatusDetails> {    const stdout = await this.gitStdout(cwd, ["status", "--porcelain=2", "--branch"]);     let branch: string | null = null;    let upstreamRef: string | null = null;    let aheadCount = 0;    let behindCount = 0;    let hasWorkingTreeChanges = false;     for (const line of stdout.split(/\r?\n/g)) {      if (line.startsWith("# branch.head ")) {        const value = line.slice("# branch.head ".length).trim();        branch = value.startsWith("(") ? null : value;        continue;      }      if (line.startsWith("# branch.upstream ")) {        const value = line.slice("# branch.upstream ".length).trim();        upstreamRef = value.length > 0 ? value : null;        continue;      }      if (line.startsWith("# branch.ab ")) {        const value = line.slice("# branch.ab ".length).trim();        const parsed = parseBranchAb(value);        aheadCount = parsed.ahead;        behindCount = parsed.behind;        continue;      }      if (line.trim().length > 0 && !line.startsWith("#")) {        hasWorkingTreeChanges = true;      }    }     return {      branch,      upstreamRef,      hasWorkingTreeChanges,      hasUpstream: upstreamRef !== null,      aheadCount,      behindCount,    };  }   async prepareCommitContext(cwd: string): Promise<GitPreparedCommitContext | null> {    await this.git(cwd, ["add", "-A"]);     const stagedSummary = await this.gitStdout(cwd, [      "diff",      "--cached",      "--name-status",    ]);    if (trimStdout(stagedSummary).length === 0) {      return null;    }     const stagedPatch = await this.gitStdout(cwd, [      "diff",      "--cached",      "--patch",      "--minimal",    ]);     return {      stagedSummary,      stagedPatch,    };  }   async commit(cwd: string, subject: string, body: string): Promise<{ commitSha: string }> {    const args = ["commit", "-m", subject];    const trimmedBody = body.trim();    if (trimmedBody.length > 0) {      args.push("-m", trimmedBody);    }    await this.git(cwd, args);    const commitSha = trimStdout(await this.gitStdout(cwd, ["rev-parse", "HEAD"]));    return { commitSha };  }   async pushCurrentBranch(cwd: string, fallbackBranch: string | null): Promise<GitPushResult> {    const details = await this.statusDetails(cwd);    const branch = details.branch ?? fallbackBranch;    if (!branch) {      throw new Error("Cannot push from detached HEAD.");    }     if (details.hasUpstream && details.aheadCount === 0 && details.behindCount === 0) {      return {        status: "skipped_up_to_date",        branch,        ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}),      };    }     if (!details.hasUpstream) {      await this.git(cwd, ["push", "-u", "origin", branch]);      return {        status: "pushed",        branch,        upstreamBranch: `origin/${branch}`,        setUpstream: true,      };    }     await this.git(cwd, ["push"]);    return {      status: "pushed",      branch,      ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}),      setUpstream: false,    };  }   async readRangeContext(cwd: string, baseBranch: string): Promise<GitRangeContext> {    const range = `${baseBranch}..HEAD`;    const [commitSummary, diffSummary, diffPatch] = await Promise.all([      this.gitStdout(cwd, ["log", "--oneline", range]),      this.gitStdout(cwd, ["diff", "--stat", range]),      this.gitStdout(cwd, ["diff", "--patch", "--minimal", range]),    ]);     return {      commitSummary,      diffSummary,      diffPatch,    };  }   async readConfigValue(cwd: string, key: string): Promise<string | null> {    const stdout = await this.gitStdout(cwd, ["config", "--get", key], true);    const value = trimStdout(stdout);    return value.length > 0 ? value : null;  }   async listBranches(input: GitListBranchesInput): Promise<GitListBranchesResult> {    const result = await executeGit(input.cwd, ["branch", "--no-color"], {      timeoutMs: 10_000,      allowNonZeroExit: true,    });     if (result.code !== 0) {      const stderr = result.stderr.trim();      if (stderr.toLowerCase().includes("not a git repository")) {        return { branches: [], isRepo: false };      }      throw new Error(stderr || "git branch failed");    }     const [defaultRef, worktreeList] = await Promise.all([      executeGit(input.cwd, ["symbolic-ref", "refs/remotes/origin/HEAD"], {        timeoutMs: 5_000,        allowNonZeroExit: true,      }),      executeGit(input.cwd, ["worktree", "list", "--porcelain"], {        timeoutMs: 5_000,        allowNonZeroExit: true,      }),    ]);    const defaultBranch =      defaultRef.code === 0        ? defaultRef.stdout.trim().replace(/^refs\/remotes\/origin\//, "")        : null;     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 };  }   async createWorktree(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);     await executeGit(      input.cwd,      ["worktree", "add", "-b", input.newBranch, worktreePath, input.branch],      { fallbackErrorMessage: "git worktree add failed" },    );     return {      worktree: {        path: worktreePath,        branch: input.newBranch,      },    };  }   async removeWorktree(input: GitRemoveWorktreeInput): Promise<void> {    await executeGit(input.cwd, ["worktree", "remove", input.path], {      timeoutMs: 15_000,      fallbackErrorMessage: "git worktree remove failed",    });  }   async createBranch(input: GitCreateBranchInput): Promise<void> {    await executeGit(input.cwd, ["branch", input.branch], {      timeoutMs: 10_000,      fallbackErrorMessage: "git branch create failed",    });  }   async checkoutBranch(input: GitCheckoutInput): Promise<void> {    await executeGit(input.cwd, ["checkout", input.branch], {      timeoutMs: 10_000,      fallbackErrorMessage: "git checkout failed",    });  }   async initRepo(input: GitInitInput): Promise<void> {    await executeGit(input.cwd, ["init"], {      timeoutMs: 10_000,      fallbackErrorMessage: "git init failed",    });  }   async git(cwd: string, args: readonly string[], allowNonZeroExit = false): Promise<void> {    await executeGit(cwd, args, { allowNonZeroExit });  }   async gitStdout(cwd: string, args: readonly string[], allowNonZeroExit = false): Promise<string> {    const result = await executeGit(cwd, args, { allowNonZeroExit });    return result.stdout;  }} const defaultGitCoreService = new GitCoreService(); 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];   const result = await runProcess(shellPath, args, {    cwd: input.cwd,    timeoutMs: input.timeoutMs ?? 30_000,    allowNonZeroExit: true,    maxBufferBytes: maxOutputBytes,    outputMode: "truncate",  });   if (!result.stdoutTruncated && !result.stderrTruncated) {    return result;  }   return {    ...result,    stderr: `${result.stderr}\n[output truncated at ${maxOutputBytes} bytes]`,  };} export async function listGitBranches(input: GitListBranchesInput): Promise<GitListBranchesResult> {  return defaultGitCoreService.listBranches(input);} export async function createGitWorktree(  input: GitCreateWorktreeInput,): Promise<GitCreateWorktreeResult> {  return defaultGitCoreService.createWorktree(input);} export async function removeGitWorktree(input: GitRemoveWorktreeInput): Promise<void> {  await defaultGitCoreService.removeWorktree(input);} export async function createGitBranch(input: GitCreateBranchInput): Promise<void> {  await defaultGitCoreService.createBranch(input);} export async function checkoutGitBranch(input: GitCheckoutInput): Promise<void> {  await defaultGitCoreService.checkoutBranch(input);} export async function initGitRepo(input: GitInitInput): Promise<void> {  await defaultGitCoreService.initRepo(input);}