1570 lines · 52.7 KB · TypeScript
RawDownload
import {  Cache,  Data,  Duration,  Effect,  Exit,  FileSystem,  Layer,  Option,  Path,  Schema,  Stream,} from "effect";import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { GitCommandError } from "../Errors.ts";import {  GitCore,  type GitCoreShape,  type ExecuteGitInput,  type ExecuteGitResult,} from "../Services/GitCore.ts";import { ServerConfig } from "../../config.ts"; const DEFAULT_TIMEOUT_MS = 30_000;const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000;const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15);const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5);const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048;const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const; class StatusUpstreamRefreshCacheKey extends Data.Class<{  cwd: string;  upstreamRef: string;  remoteName: string;  upstreamBranch: string;}> {} interface ExecuteGitOptions {  timeoutMs?: number | undefined;  allowNonZeroExit?: boolean | undefined;  fallbackErrorMessage?: string | undefined;} 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 parseNumstatEntries(  stdout: string,): Array<{ path: string; insertions: number; deletions: number }> {  const entries: Array<{ path: string; insertions: number; deletions: number }> = [];  for (const line of stdout.split(/\r?\n/g)) {    if (line.trim().length === 0) continue;    const [addedRaw, deletedRaw, ...pathParts] = line.split("\t");    const rawPath =      pathParts.length > 1 ? (pathParts.at(-1) ?? "").trim() : pathParts.join("\t").trim();    if (rawPath.length === 0) continue;    const added = Number.parseInt(addedRaw ?? "0", 10);    const deleted = Number.parseInt(deletedRaw ?? "0", 10);    const renameArrowIndex = rawPath.indexOf(" => ");    const normalizedPath =      renameArrowIndex >= 0 ? rawPath.slice(renameArrowIndex + " => ".length).trim() : rawPath;    entries.push({      path: normalizedPath.length > 0 ? normalizedPath : rawPath,      insertions: Number.isFinite(added) ? added : 0,      deletions: Number.isFinite(deleted) ? deleted : 0,    });  }  return entries;} function parsePorcelainPath(line: string): string | null {  if (line.startsWith("? ") || line.startsWith("! ")) {    const simple = line.slice(2).trim();    return simple.length > 0 ? simple : null;  }   if (!(line.startsWith("1 ") || line.startsWith("2 ") || line.startsWith("u "))) {    return null;  }   const tabIndex = line.indexOf("\t");  if (tabIndex >= 0) {    const fromTab = line.slice(tabIndex + 1);    const [filePath] = fromTab.split("\t");    return filePath?.trim().length ? filePath.trim() : null;  }   const parts = line.trim().split(/\s+/g);  const filePath = parts.at(-1) ?? "";  return filePath.length > 0 ? filePath : null;} function parseBranchLine(line: string): { name: string; current: boolean } | null {  const trimmed = line.trim();  if (trimmed.length === 0) return null;   const name = trimmed.replace(/^[*+]\s+/, "");  // Exclude symbolic refs like: "origin/HEAD -> origin/main".  // Exclude detached HEAD pseudo-refs like: "(HEAD detached at origin/main)".  if (name.includes(" -> ") || name.startsWith("(")) return null;   return {    name,    current: trimmed.startsWith("* "),  };} function parseRemoteNames(stdout: string): ReadonlyArray<string> {  return stdout    .split("\n")    .map((line) => line.trim())    .filter((line) => line.length > 0)    .toSorted((a, b) => b.length - a.length);} function sanitizeRemoteName(value: string): string {  const sanitized = value    .trim()    .replace(/[^A-Za-z0-9._-]+/g, "-")    .replace(/^-+|-+$/g, "");  return sanitized.length > 0 ? sanitized : "fork";} function normalizeRemoteUrl(value: string): string {  return value    .trim()    .replace(/\/+$/g, "")    .replace(/\.git$/i, "")    .toLowerCase();} function parseRemoteFetchUrls(stdout: string): Map<string, string> {  const remotes = new Map<string, string>();  for (const line of stdout.split("\n")) {    const trimmed = line.trim();    if (trimmed.length === 0) continue;    const match = /^(\S+)\s+(\S+)\s+\((fetch|push)\)$/.exec(trimmed);    if (!match) continue;    const [, remoteName = "", remoteUrl = "", direction = ""] = match;    if (direction !== "fetch" || remoteName.length === 0 || remoteUrl.length === 0) {      continue;    }    remotes.set(remoteName, remoteUrl);  }  return remotes;} function parseRemoteRefWithRemoteNames(  branchName: string,  remoteNames: ReadonlyArray<string>,): { remoteRef: string; remoteName: string; localBranch: string } | null {  const trimmedBranchName = branchName.trim();  if (trimmedBranchName.length === 0) return null;   for (const remoteName of remoteNames) {    const remotePrefix = `${remoteName}/`;    if (!trimmedBranchName.startsWith(remotePrefix)) {      continue;    }    const localBranch = trimmedBranchName.slice(remotePrefix.length).trim();    if (localBranch.length === 0) {      return null;    }    return {      remoteRef: trimmedBranchName,      remoteName,      localBranch,    };  }   return null;} function parseTrackingBranchByUpstreamRef(stdout: string, upstreamRef: string): string | null {  for (const line of stdout.split("\n")) {    const trimmedLine = line.trim();    if (trimmedLine.length === 0) {      continue;    }    const [branchNameRaw, upstreamBranchRaw = ""] = trimmedLine.split("\t");    const branchName = branchNameRaw?.trim() ?? "";    const upstreamBranch = upstreamBranchRaw.trim();    if (branchName.length === 0 || upstreamBranch.length === 0) {      continue;    }    if (upstreamBranch === upstreamRef) {      return branchName;    }  }   return null;} function deriveLocalBranchNameFromRemoteRef(branchName: string): string | null {  const separatorIndex = branchName.indexOf("/");  if (separatorIndex <= 0 || separatorIndex === branchName.length - 1) {    return null;  }  const localBranch = branchName.slice(separatorIndex + 1).trim();  return localBranch.length > 0 ? localBranch : null;} function commandLabel(args: readonly string[]): string {  return `git ${args.join(" ")}`;} function parseDefaultBranchFromRemoteHeadRef(value: string, remoteName: string): string | null {  const trimmed = value.trim();  const prefix = `refs/remotes/${remoteName}/`;  if (!trimmed.startsWith(prefix)) {    return null;  }  const branch = trimmed.slice(prefix.length).trim();  return branch.length > 0 ? branch : null;} function createGitCommandError(  operation: string,  cwd: string,  args: readonly string[],  detail: string,  cause?: unknown,): GitCommandError {  return new GitCommandError({    operation,    command: commandLabel(args),    cwd,    detail,    ...(cause !== undefined ? { cause } : {}),  });} function quoteGitCommand(args: ReadonlyArray<string>): string {  return `git ${args.join(" ")}`;} function toGitCommandError(  input: Pick<ExecuteGitInput, "operation" | "cwd" | "args">,  detail: string,) {  return (cause: unknown) =>    Schema.is(GitCommandError)(cause)      ? cause      : new GitCommandError({          operation: input.operation,          command: quoteGitCommand(input.args),          cwd: input.cwd,          detail: `${cause instanceof Error && cause.message.length > 0 ? cause.message : "Unknown error"} - ${detail}`,          ...(cause !== undefined ? { cause } : {}),        });} const collectOutput = Effect.fn(function* <E>(  input: Pick<ExecuteGitInput, "operation" | "cwd" | "args">,  stream: Stream.Stream<Uint8Array, E>,  maxOutputBytes: number,): Effect.fn.Return<string, GitCommandError> {  const decoder = new TextDecoder();  let bytes = 0;  let text = "";   yield* Stream.runForEach(stream, (chunk) =>    Effect.gen(function* () {      bytes += chunk.byteLength;      if (bytes > maxOutputBytes) {        return yield* new GitCommandError({          operation: input.operation,          command: quoteGitCommand(input.args),          cwd: input.cwd,          detail: `${quoteGitCommand(input.args)} output exceeded ${maxOutputBytes} bytes and was truncated.`,        });      }      text += decoder.decode(chunk, { stream: true });    }),  ).pipe(Effect.mapError(toGitCommandError(input, "output stream failed.")));   text += decoder.decode();  return text;}); export const makeGitCore = (options?: { executeOverride?: GitCoreShape["execute"] }) =>  Effect.gen(function* () {    const fileSystem = yield* FileSystem.FileSystem;    const path = yield* Path.Path;    const { worktreesDir } = yield* ServerConfig;     let execute: GitCoreShape["execute"];     if (options?.executeOverride) {      execute = options.executeOverride;    } else {      const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;      execute = Effect.fnUntraced(function* (input) {        const commandInput = {          ...input,          args: [...input.args],        } as const;        const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;        const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;         const commandEffect = Effect.gen(function* () {          const child = yield* commandSpawner            .spawn(              ChildProcess.make("git", commandInput.args, {                cwd: commandInput.cwd,                ...(input.env ? { env: input.env } : {}),              }),            )            .pipe(Effect.mapError(toGitCommandError(commandInput, "failed to spawn.")));           const [stdout, stderr, exitCode] = yield* Effect.all(            [              collectOutput(commandInput, child.stdout, maxOutputBytes),              collectOutput(commandInput, child.stderr, maxOutputBytes),              child.exitCode.pipe(                Effect.map((value) => Number(value)),                Effect.mapError(toGitCommandError(commandInput, "failed to report exit code.")),              ),            ],            { concurrency: "unbounded" },          );           if (!input.allowNonZeroExit && exitCode !== 0) {            const trimmedStderr = stderr.trim();            return yield* new GitCommandError({              operation: commandInput.operation,              command: quoteGitCommand(commandInput.args),              cwd: commandInput.cwd,              detail:                trimmedStderr.length > 0                  ? `${quoteGitCommand(commandInput.args)} failed: ${trimmedStderr}`                  : `${quoteGitCommand(commandInput.args)} failed with code ${exitCode}.`,            });          }           return { code: exitCode, stdout, stderr } satisfies ExecuteGitResult;        });         return yield* commandEffect.pipe(          Effect.scoped,          Effect.timeoutOption(timeoutMs),          Effect.flatMap((result) =>            Option.match(result, {              onNone: () =>                Effect.fail(                  new GitCommandError({                    operation: commandInput.operation,                    command: quoteGitCommand(commandInput.args),                    cwd: commandInput.cwd,                    detail: `${quoteGitCommand(commandInput.args)} timed out.`,                  }),                ),              onSome: Effect.succeed,            }),          ),        );      });    }     const executeGit = (      operation: string,      cwd: string,      args: readonly string[],      options: ExecuteGitOptions = {},    ): Effect.Effect<{ code: number; stdout: string; stderr: string }, GitCommandError> =>      execute({        operation,        cwd,        args,        allowNonZeroExit: true,        ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),      }).pipe(        Effect.flatMap((result) => {          if (options.allowNonZeroExit || result.code === 0) {            return Effect.succeed(result);          }          const stderr = result.stderr.trim();          if (stderr.length > 0) {            return Effect.fail(createGitCommandError(operation, cwd, args, stderr));          }          if (options.fallbackErrorMessage) {            return Effect.fail(              createGitCommandError(operation, cwd, args, options.fallbackErrorMessage),            );          }          return Effect.fail(            createGitCommandError(              operation,              cwd,              args,              `${commandLabel(args)} failed: code=${result.code ?? "null"}`,            ),          );        }),      );     const runGit = (      operation: string,      cwd: string,      args: readonly string[],      allowNonZeroExit = false,    ): Effect.Effect<void, GitCommandError> =>      executeGit(operation, cwd, args, { allowNonZeroExit }).pipe(Effect.asVoid);     const runGitStdout = (      operation: string,      cwd: string,      args: readonly string[],      allowNonZeroExit = false,    ): Effect.Effect<string, GitCommandError> =>      executeGit(operation, cwd, args, { allowNonZeroExit }).pipe(        Effect.map((result) => result.stdout),      );     const branchExists = (cwd: string, branch: string): Effect.Effect<boolean, GitCommandError> =>      executeGit(        "GitCore.branchExists",        cwd,        ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`],        {          allowNonZeroExit: true,          timeoutMs: 5_000,        },      ).pipe(Effect.map((result) => result.code === 0));     const resolveAvailableBranchName = (      cwd: string,      desiredBranch: string,    ): Effect.Effect<string, GitCommandError> =>      Effect.gen(function* () {        const isDesiredTaken = yield* branchExists(cwd, desiredBranch);        if (!isDesiredTaken) {          return desiredBranch;        }         for (let suffix = 1; suffix <= 100; suffix += 1) {          const candidate = `${desiredBranch}-${suffix}`;          const isCandidateTaken = yield* branchExists(cwd, candidate);          if (!isCandidateTaken) {            return candidate;          }        }         return yield* createGitCommandError(          "GitCore.renameBranch",          cwd,          ["branch", "-m", "--", desiredBranch],          `Could not find an available branch name for '${desiredBranch}'.`,        );      });     const resolveCurrentUpstream = (      cwd: string,    ): Effect.Effect<      { upstreamRef: string; remoteName: string; upstreamBranch: string } | null,      GitCommandError    > =>      Effect.gen(function* () {        const upstreamRef = yield* runGitStdout(          "GitCore.resolveCurrentUpstream",          cwd,          ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"],          true,        ).pipe(Effect.map((stdout) => stdout.trim()));         if (upstreamRef.length === 0 || upstreamRef === "@{upstream}") {          return null;        }         const separatorIndex = upstreamRef.indexOf("/");        if (separatorIndex <= 0) {          return null;        }        const remoteName = upstreamRef.slice(0, separatorIndex);        const upstreamBranch = upstreamRef.slice(separatorIndex + 1);        if (remoteName.length === 0 || upstreamBranch.length === 0) {          return null;        }         return {          upstreamRef,          remoteName,          upstreamBranch,        };      });     const fetchUpstreamRef = (      cwd: string,      upstream: { upstreamRef: string; remoteName: string; upstreamBranch: string },    ): Effect.Effect<void, GitCommandError> => {      const refspec = `+refs/heads/${upstream.upstreamBranch}:refs/remotes/${upstream.upstreamRef}`;      return runGit(        "GitCore.fetchUpstreamRef",

Lines 1–500 of 1570.