3419 lines · 117.8 KB · TypeScript
RawDownload
import * as Arr from "effect/Array";import * as Cache from "effect/Cache";import * as Data from "effect/Data";import * as Crypto from "effect/Crypto";import * as DateTime from "effect/DateTime";import * as Duration from "effect/Duration";import * as Effect from "effect/Effect";import * as Encoding from "effect/Encoding";import * as Exit from "effect/Exit";import * as FileSystem from "effect/FileSystem";import * as Option from "effect/Option";import * as Path from "effect/Path";import * as PlatformError from "effect/PlatformError";import * as Ref from "effect/Ref";import * as Result from "effect/Result";import * as Schema from "effect/Schema";import * as Scope from "effect/Scope";import * as Semaphore from "effect/Semaphore";import * as Stream from "effect/Stream";import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import {  GitCommandError,  type ReviewDiffFileContentsInput,  type ReviewDiffPreviewInput,  type ReviewDiffPreviewSource,  type VcsRef,} from "@t3tools/contracts";import { dedupeRemoteBranchesWithLocalMatches, normalizeGitRemoteUrl } from "@t3tools/shared/git";import { compactTraceAttributes } from "@t3tools/shared/observability";import { decodeJsonResult } from "@t3tools/shared/schemaJson";import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../observability/Metrics.ts";import * as GitVcsDriver from "./GitVcsDriver.ts";import {  parseRemoteNames,  parseRemoteNamesInGitOrder,  parseRemoteRefWithRemoteNames,} from "../git/remoteRefs.ts";import { ServerConfig } from "../config.ts"; const DEFAULT_TIMEOUT_MS = 30_000;// `git worktree add` checks out the full tree, so on large repositories it can// take well beyond the default 30s (e.g. a 375k-file repo takes ~40s on an idle// machine). Give it generous headroom while still bounding a genuinely hung git.const WORKTREE_ADD_TIMEOUT_MS = 300_000;const WORKTREE_REMOVE_TIMEOUT_MS = Duration.toMillis(Duration.minutes(5));const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000;const OUTPUT_TRUNCATED_MARKER = "\n\n[truncated]";const PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES = 49_000;const RANGE_COMMIT_SUMMARY_MAX_OUTPUT_BYTES = 19_000;const RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES = 19_000;const RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES = 59_000;const REVIEW_DIFF_PATCH_MAX_OUTPUT_BYTES = 120_000;const REVIEW_UNTRACKED_DIFF_MAX_OUTPUT_BYTES = 80_000;const REVIEW_DIFF_FILE_MAX_OUTPUT_BYTES = 1024 * 1024;// Patches the clients render are parsed against git's default a/ and b/ path// prefixes. A repository or global diff.noprefix or diff.mnemonicPrefix would// otherwise leak into the patch and leave every parsed file unnamed.export const PATCH_RENDER_PREFIX_ARGS = ["--src-prefix=a/", "--dst-prefix=b/"] as const;const WORKSPACE_FILES_MAX_OUTPUT_BYTES = 120_000;const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15);const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5); const STATUS_UPSTREAM_REFRESH_FAILURE_BASE_COOLDOWN = Duration.seconds(30);const STATUS_UPSTREAM_REFRESH_FAILURE_MAX_COOLDOWN = Duration.minutes(15);const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048;const REPOSITORY_PATHS_CACHE_CAPACITY = 2_048;const REPOSITORY_PATHS_CACHE_TTL = Duration.minutes(10);const REPOSITORY_PATHS_REFRESH_COALESCE_TTL = Duration.seconds(5);const NON_REPOSITORY_PATHS_CACHE_TTL = Duration.seconds(1);const LIST_REFS_SNAPSHOT_CACHE_CAPACITY = 64;const LIST_REFS_SNAPSHOT_CACHE_TTL = Duration.minutes(2);const LIST_REFS_REFRESH_COALESCE_TTL = Duration.seconds(5);const LIST_REFS_REFRESH_FAILURE_COOLDOWN = Duration.seconds(30);const STATUS_DEFAULT_BRANCH_CACHE_TTL = Duration.minutes(5);const STATUS_ORIGIN_EXISTS_CACHE_TTL = Duration.minutes(5);const STATUS_UPSTREAM_REFRESH_ENV = Object.freeze({  GCM_INTERACTIVE: "never",  GIT_ASKPASS: "",  GIT_TERMINAL_PROMPT: "0",  SSH_ASKPASS: "",  SSH_ASKPASS_REQUIRE: "never",} satisfies NodeJS.ProcessEnv);const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const;const GIT_LIST_BRANCHES_DEFAULT_LIMIT = 100;const NON_REPOSITORY_STATUS_DETAILS = Object.freeze<GitVcsDriver.GitStatusDetails>({  isRepo: false,  hasOriginRemote: false,  isDefaultBranch: false,  branch: null,  upstreamRef: null,  hasWorkingTreeChanges: false,  workingTree: { files: [], insertions: 0, deletions: 0 },  hasUpstream: false,  aheadCount: 0,  behindCount: 0,  aheadOfDefaultCount: 0,});const NON_REPOSITORY_REMOTE_STATUS_DETAILS = Object.freeze<GitVcsDriver.GitRemoteStatusDetails>({  isRepo: false,  defaultBranch: null,  isDefaultBranch: false,  branch: null,  upstreamRef: null,  hasUpstream: false,  aheadCount: 0,  behindCount: 0,  aheadOfDefaultCount: 0,}); type TraceTailState = {  processedChars: number;  remainder: string;}; class StatusRemoteRefreshCacheKey extends Data.Class<{  gitCommonDir: string;  remoteName: string;}> {} function statusUpstreamRefreshFailureCooldown(consecutiveFailures: number): Duration.Duration {  const exponent = Math.max(0, consecutiveFailures - 1);  const cooldownMs =    Duration.toMillis(STATUS_UPSTREAM_REFRESH_FAILURE_BASE_COOLDOWN) * Math.pow(2, exponent);  return Duration.min(Duration.millis(cooldownMs), STATUS_UPSTREAM_REFRESH_FAILURE_MAX_COOLDOWN);} class GitRefsSnapshotCacheKey extends Data.Class<{  gitCommonDir: string;  epoch: number;}> {} class GitRefsRefreshCacheKey extends Data.Class<{  gitCommonDir: string;  generation: number;}> {} interface GitRepositoryPaths {  readonly gitCommonDir: string;  readonly worktreeRoot: string | null;  readonly currentBranch: string | null;} interface GitRefsSnapshot {  readonly localBranches: ReadonlyArray<VcsRef>;  readonly remoteBranches: ReadonlyArray<VcsRef>;  readonly hasPrimaryRemote: boolean;} interface ExecuteGitOptions {  stdin?: string | undefined;  timeoutMs?: number | null | undefined;  allowNonZeroExit?: boolean | undefined;  fallbackErrorDetail?: string | undefined;  env?: NodeJS.ProcessEnv | undefined;  maxOutputBytes?: number | undefined;  appendTruncationMarker?: boolean | undefined;  progress?: GitVcsDriver.ExecuteGitProgress | 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 filterBranchesForListQuery(  refs: ReadonlyArray<VcsRef>,  query?: string,): ReadonlyArray<VcsRef> {  if (!query) {    return refs;  }   const normalizedQuery = query.toLowerCase();  return refs.filter((refName) => refName.name.toLowerCase().includes(normalizedQuery));} function paginateBranches(input: {  refs: ReadonlyArray<VcsRef>;  cursor?: number | undefined;  limit?: number | undefined;}): {  refs: ReadonlyArray<VcsRef>;  nextCursor: number | null;  totalCount: number;} {  const cursor = input.cursor ?? 0;  const limit = input.limit ?? GIT_LIST_BRANCHES_DEFAULT_LIMIT;  const totalCount = input.refs.length;  const refs = input.refs.slice(cursor, cursor + limit);  const nextCursor = cursor + refs.length < totalCount ? cursor + refs.length : null;   return {    refs,    nextCursor,    totalCount,  };} function parseWorktreeBranchPaths(stdout: string): ReadonlyMap<string, string> {  const worktreePaths = new Map<string, string>();  let currentPath: string | null = null;  let currentBranch: string | null = null;  let currentPrunable = false;   const flush = () => {    if (currentPath !== null && currentBranch !== null && !currentPrunable) {      worktreePaths.set(currentBranch, currentPath);    }    currentPath = null;    currentBranch = null;    currentPrunable = false;  };   for (const field of stdout.split("\0")) {    if (field === "") {      flush();    } else if (field.startsWith("worktree ")) {      currentPath = field.slice("worktree ".length);    } else if (field.startsWith("branch refs/heads/")) {      currentBranch = field.slice("branch refs/heads/".length);    } else if (field === "prunable" || field.startsWith("prunable ")) {      currentPrunable = true;    }  }  flush();   return worktreePaths;} function splitNullSeparatedPaths(input: string, truncated: boolean): string[] {  const parts = input.split("\0");  if (parts.length === 0) return [];   if (truncated && parts[parts.length - 1]?.length) {    parts.pop();  }   return parts.filter((value) => value.length > 0);} export function splitNullSeparatedGitStdoutPaths(  result: Pick<GitVcsDriver.ExecuteGitResult, "stdout" | "stdoutTruncated">,): string[] {  return splitNullSeparatedPaths(result.stdout, result.stdoutTruncated);} function sanitizeRemoteName(value: string): string {  const sanitized = value    .trim()    .replace(/[^A-Za-z0-9._-]+/g, "-")    .replace(/^-+|-+$/g, "");  return sanitized.length > 0 ? sanitized : "fork";} 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 parseUpstreamRefWithRemoteNames(  upstreamRef: string,  remoteNames: ReadonlyArray<string>,): { upstreamRef: string; remoteName: string; branchName: string } | null {  const parsed = parseRemoteRefWithRemoteNames(upstreamRef, remoteNames);  if (!parsed) {    return null;  }   return {    upstreamRef,    remoteName: parsed.remoteName,    branchName: parsed.branchName,  };} function parseUpstreamRefByFirstSeparator(  upstreamRef: string,): { upstreamRef: string; remoteName: string; branchName: string } | null {  const separatorIndex = upstreamRef.indexOf("/");  if (separatorIndex <= 0 || separatorIndex === upstreamRef.length - 1) {    return null;  }   const remoteName = upstreamRef.slice(0, separatorIndex).trim();  const branchName = upstreamRef.slice(separatorIndex + 1).trim();  if (remoteName.length === 0 || branchName.length === 0) {    return null;  }   return {    upstreamRef,    remoteName,    branchName,  };} 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 candidateUpstreamRef = upstreamBranchRaw.trim();    if (branchName.length === 0 || candidateUpstreamRef.length === 0) {      continue;    }    if (candidateUpstreamRef === 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 gitCommandContext(  input: Pick<GitVcsDriver.ExecuteGitInput, "operation" | "cwd" | "args">,) {  return {    operation: input.operation,    command: "git",    cwd: input.cwd,    argumentCount: input.args.length,  } as const;} function parseDefaultBranchFromRemoteHeadRef(value: string, remoteName: string): string | null {  const trimmed = value.trim();  const prefix = `refs/remotes/${remoteName}/`;  if (!trimmed.startsWith(prefix)) {    return null;  }  const refName = trimmed.slice(prefix.length).trim();  return refName.length > 0 ? refName : null;} function isMissingGitCwdError(error: GitCommandError): boolean {  if (!(error.cause instanceof PlatformError.PlatformError)) {    return false;  }   const reason = error.cause.reason;  if (reason._tag === "NotFound") {    return reason.pathOrDescriptor === error.cwd;  }   return (    reason._tag === "BadResource" &&    reason.pathOrDescriptor === error.cwd &&    typeof reason.cause === "object" &&    reason.cause !== null &&    "code" in reason.cause &&    reason.cause.code === "ENOTDIR"  );} function isNonRepositoryGitStderr(stderr: string): boolean {  return stderr.toLowerCase().includes("not a git repository");}function isUnbornHeadStderr(stderr: string): boolean {  const normalized = stderr.toLowerCase();  return (    normalized.includes("bad revision 'head'") ||    (normalized.includes("unknown revision") && normalized.includes("path not in the working tree"))  );} // Matches `git worktree remove` on a path git no longer tracks: "is not a// working tree" when the registration is gone, "cannot remove working tree"// when older gits fail validation on a registered-but-deleted directory.function isMissingWorktreeStderr(stderr: string): boolean {  const normalized = stderr.toLowerCase();  return (    normalized.includes("is not a working tree") ||    normalized.includes("cannot remove working tree")  );} // Fetch stderr can contain remote credentials. Only fixed diagnoses may enter// persisted errors; unrecognized output keeps the generic failure message.function fetchFailureDetail(stderr: string): string | undefined {  const normalized = stderr.toLowerCase();  if (    normalized.includes("authentication failed") ||    normalized.includes("permission denied (publickey") ||    normalized.includes("could not read username") ||    normalized.includes("could not read password") ||    normalized.includes("terminal prompts disabled")  ) {    return "Git could not authenticate with the remote. Check Git credentials or SSH access on the server, then retry.";  }  if (    normalized.includes("could not resolve host") ||    normalized.includes("could not resolve hostname") ||    normalized.includes("failed to connect") ||    normalized.includes("connection timed out") ||    normalized.includes("connection refused") ||    normalized.includes("network is unreachable")  ) {    return "Git could not reach the remote. Check the server's network connection and remote host, then retry.";  }  if (    normalized.includes("repository not found") ||    normalized.includes("does not appear to be a git repository")  ) {    return "Git could not access the remote repository. Check the remote URL and repository permissions on the server.";  }  if (    normalized.includes("cannot lock ref") ||    (normalized.includes("unable to create") && normalized.includes(".lock"))  ) {    return "Git could not update a local reference. Another Git operation or a stale lock may be blocking the fetch; check the repository on the server, then retry.";  }  return undefined;} interface Trace2Monitor {  readonly env: NodeJS.ProcessEnv;  readonly flush: Effect.Effect<void, never>;} const nowUnixNano = DateTime.now.pipe(  Effect.map((now) => BigInt(DateTime.toEpochMillis(now)) * 1_000_000n),); const addCurrentSpanEvent = (name: string, attributes: Record<string, unknown>) =>  Effect.gen(function* () {    const span = yield* Effect.currentSpan;

Lines 1–500 of 3419.