908 lines · 28.9 KB · TypeScript
RawDownload
import { Cache, Data, Duration, Effect, Exit, FileSystem, Layer, Path } from "effect"; import { GitCommandError } from "../Errors.ts";import { GitService } from "../Services/GitService.ts";import { GitCore, type GitCoreShape } from "../Services/GitCore.ts"; 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 commandLabel(args: readonly string[]): string {  return `git ${args.join(" ")}`;} function parseDefaultBranchFromRemoteHeadRef(value: string): string | null {  const trimmed = value.trim();  const prefix = "refs/remotes/origin/";  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 } : {}),  });} const makeGitCore = Effect.gen(function* () {  const git = yield* GitService;  const fileSystem = yield* FileSystem.FileSystem;  const path = yield* Path.Path;   const executeGit = (    operation: string,    cwd: string,    args: readonly string[],    options: ExecuteGitOptions = {},  ): Effect.Effect<{ code: number; stdout: string; stderr: string }, GitCommandError> =>    git      .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 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",      cwd,      ["fetch", "--quiet", "--no-tags", upstream.remoteName, refspec],      true,    );  };   const fetchUpstreamRefForStatus = (    cwd: string,    upstream: { upstreamRef: string; remoteName: string; upstreamBranch: string },  ): Effect.Effect<void, GitCommandError> => {    const refspec = `+refs/heads/${upstream.upstreamBranch}:refs/remotes/${upstream.upstreamRef}`;    return executeGit(      "GitCore.fetchUpstreamRefForStatus",      cwd,      ["fetch", "--quiet", "--no-tags", upstream.remoteName, refspec],      {        allowNonZeroExit: true,        timeoutMs: Duration.toMillis(STATUS_UPSTREAM_REFRESH_TIMEOUT),      },    ).pipe(Effect.asVoid);  };   const statusUpstreamRefreshCache = yield* Cache.makeWith({    capacity: STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY,    lookup: (cacheKey: StatusUpstreamRefreshCacheKey) =>      Effect.gen(function* () {        yield* fetchUpstreamRefForStatus(cacheKey.cwd, {          upstreamRef: cacheKey.upstreamRef,          remoteName: cacheKey.remoteName,          upstreamBranch: cacheKey.upstreamBranch,        });        return true as const;      }),    // Keep successful refreshes warm; drop failures immediately so next request can retry.    timeToLive: (exit) => (Exit.isSuccess(exit) ? STATUS_UPSTREAM_REFRESH_INTERVAL : Duration.zero),  });   const refreshStatusUpstreamIfStale = (cwd: string): Effect.Effect<void, GitCommandError> =>    Effect.gen(function* () {      const upstream = yield* resolveCurrentUpstream(cwd);      if (!upstream) return;      yield* Cache.get(        statusUpstreamRefreshCache,        new StatusUpstreamRefreshCacheKey({          cwd,          upstreamRef: upstream.upstreamRef,          remoteName: upstream.remoteName,          upstreamBranch: upstream.upstreamBranch,        }),      );    });   const refreshCheckedOutBranchUpstream = (cwd: string): Effect.Effect<void, GitCommandError> =>    Effect.gen(function* () {      const upstream = yield* resolveCurrentUpstream(cwd);      if (!upstream) return;      yield* fetchUpstreamRef(cwd, upstream);    });   const resolveDefaultBranchName = (cwd: string): Effect.Effect<string | null, GitCommandError> =>    executeGit("GitCore.resolveDefaultBranchName", cwd, ["symbolic-ref", "refs/remotes/origin/HEAD"], {      allowNonZeroExit: true,    }).pipe(      Effect.map((result) => {        if (result.code !== 0) {          return null;        }        return parseDefaultBranchFromRemoteHeadRef(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,    }).pipe(Effect.map((result) => result.code === 0));   const remoteBranchExists = (    cwd: string,    branch: string,  ): Effect.Effect<boolean, GitCommandError> =>    executeGit(      "GitCore.remoteBranchExists",      cwd,      ["show-ref", "--verify", "--quiet", `refs/remotes/origin/${branch}`],      {        allowNonZeroExit: true,      },    ).pipe(Effect.map((result) => result.code === 0));   const originRemoteExists = (cwd: string): Effect.Effect<boolean, GitCommandError> =>    executeGit("GitCore.originRemoteExists", cwd, ["remote", "get-url", "origin"], {      allowNonZeroExit: true,    }).pipe(Effect.map((result) => result.code === 0));   const resolveBaseBranchForNoUpstream = (    cwd: string,    branch: string,  ): Effect.Effect<string | null, GitCommandError> =>    Effect.gen(function* () {      const configuredBaseBranch = yield* runGitStdout(        "GitCore.resolveBaseBranchForNoUpstream.config",        cwd,        ["config", "--get", `branch.${branch}.gh-merge-base`],        true,      ).pipe(Effect.map((stdout) => stdout.trim()));       const defaultBranch = yield* resolveDefaultBranchName(cwd);      const candidates = [        configuredBaseBranch.length > 0 ? configuredBaseBranch : null,        defaultBranch,        ...DEFAULT_BASE_BRANCH_CANDIDATES,      ];       for (const candidate of candidates) {        if (!candidate) {          continue;        }         const normalizedCandidate = candidate.startsWith("origin/")          ? candidate.slice("origin/".length)          : candidate;        if (normalizedCandidate.length === 0 || normalizedCandidate === branch) {          continue;        }         if (yield* branchExists(cwd, normalizedCandidate)) {          return normalizedCandidate;        }         if (yield* remoteBranchExists(cwd, normalizedCandidate)) {          return `origin/${normalizedCandidate}`;        }      }       return null;    });   const computeAheadCountAgainstBase = (    cwd: string,    branch: string,  ): Effect.Effect<number, GitCommandError> =>    Effect.gen(function* () {      const baseBranch = yield* resolveBaseBranchForNoUpstream(cwd, branch);      if (!baseBranch) {        return 0;      }       const result = yield* executeGit(        "GitCore.computeAheadCountAgainstBase",        cwd,        ["rev-list", "--count", `${baseBranch}..HEAD`],        { allowNonZeroExit: true },      );      if (result.code !== 0) {        return 0;      }       const parsed = Number.parseInt(result.stdout.trim(), 10);      return Number.isFinite(parsed) ? Math.max(0, parsed) : 0;    });   const readBranchRecency = (cwd: string): Effect.Effect<Map<string, number>, GitCommandError> =>    Effect.gen(function* () {      const branchRecency = yield* executeGit(        "GitCore.readBranchRecency",        cwd,        ["for-each-ref", "--format=%(refname:short)%09%(committerdate:unix)", "refs/heads"],        {          timeoutMs: 15_000,          allowNonZeroExit: true,        },      );       const branchLastCommit = new Map<string, number>();      if (branchRecency.code !== 0) {        return branchLastCommit;      }       for (const line of branchRecency.stdout.split("\n")) {        if (line.length === 0) {          continue;        }        const [name, lastCommitRaw] = line.split("\t");        if (!name) {          continue;        }        const lastCommit = Number.parseInt(lastCommitRaw ?? "0", 10);        branchLastCommit.set(name, Number.isFinite(lastCommit) ? lastCommit : 0);      }       return branchLastCommit;    });   const statusDetails: GitCoreShape["statusDetails"] = (cwd) =>    Effect.gen(function* () {      yield* refreshStatusUpstreamIfStale(cwd).pipe(Effect.catch(() => Effect.void));       const [statusStdout, unstagedNumstatStdout, stagedNumstatStdout] = yield* Effect.all(        [          runGitStdout("GitCore.statusDetails.status", cwd, [            "status",            "--porcelain=2",            "--branch",          ]),          runGitStdout("GitCore.statusDetails.unstagedNumstat", cwd, ["diff", "--numstat"]),          runGitStdout("GitCore.statusDetails.stagedNumstat", cwd, [            "diff",            "--cached",            "--numstat",          ]),        ],        { concurrency: "unbounded" },      );       let branch: string | null = null;      let upstreamRef: string | null = null;      let aheadCount = 0;      let behindCount = 0;      let hasWorkingTreeChanges = false;      const changedFilesWithoutNumstat = new Set<string>();       for (const line of statusStdout.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;          const pathValue = parsePorcelainPath(line);          if (pathValue) changedFilesWithoutNumstat.add(pathValue);        }      }       if (!upstreamRef && branch) {        aheadCount = yield* computeAheadCountAgainstBase(cwd, branch).pipe(          Effect.catch(() => Effect.succeed(0)),        );        behindCount = 0;      }       const stagedEntries = parseNumstatEntries(stagedNumstatStdout);      const unstagedEntries = parseNumstatEntries(unstagedNumstatStdout);      const fileStatMap = new Map<string, { insertions: number; deletions: number }>();      for (const entry of [...stagedEntries, ...unstagedEntries]) {        const existing = fileStatMap.get(entry.path) ?? { insertions: 0, deletions: 0 };        existing.insertions += entry.insertions;        existing.deletions += entry.deletions;        fileStatMap.set(entry.path, existing);      }       let insertions = 0;      let deletions = 0;      const files = Array.from(fileStatMap.entries())        .map(([filePath, stat]) => {          insertions += stat.insertions;          deletions += stat.deletions;          return { path: filePath, insertions: stat.insertions, deletions: stat.deletions };        })        .toSorted((a, b) => a.path.localeCompare(b.path));       for (const filePath of changedFilesWithoutNumstat) {        if (fileStatMap.has(filePath)) continue;        files.push({ path: filePath, insertions: 0, deletions: 0 });      }      files.sort((a, b) => a.path.localeCompare(b.path));       return {        branch,        upstreamRef,        hasWorkingTreeChanges,        workingTree: {          files,          insertions,          deletions,

Lines 1–500 of 908.