154 lines · 5.1 KB · TypeScript
RawDownload
import type { RepositoryIdentity } from "@t3tools/contracts";import { Cache, Duration, Effect, Exit, Layer } from "effect";import {  detectSourceControlProviderFromGitRemoteUrl,  normalizeGitRemoteUrl,} from "@t3tools/shared/git"; import { runProcess } from "../../processRunner.ts";import {  RepositoryIdentityResolver,  type RepositoryIdentityResolverShape,} from "../Services/RepositoryIdentityResolver.ts"; 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 pickPrimaryRemote(  remotes: ReadonlyMap<string, string>,): { readonly remoteName: string; readonly remoteUrl: string } | null {  for (const preferredRemoteName of ["upstream", "origin"] as const) {    const remoteUrl = remotes.get(preferredRemoteName);    if (remoteUrl) {      return { remoteName: preferredRemoteName, remoteUrl };    }  }   const [remoteName, remoteUrl] =    [...remotes.entries()].toSorted(([left], [right]) => left.localeCompare(right))[0] ?? [];  return remoteName && remoteUrl ? { remoteName, remoteUrl } : null;} function buildRepositoryIdentity(input: {  readonly remoteName: string;  readonly remoteUrl: string;  readonly rootPath: string;}): RepositoryIdentity {  const canonicalKey = normalizeGitRemoteUrl(input.remoteUrl);  const sourceControlProvider = detectSourceControlProviderFromGitRemoteUrl(input.remoteUrl);  const repositoryPath = canonicalKey.split("/").slice(1).join("/");  const repositoryPathSegments = repositoryPath.split("/").filter((segment) => segment.length > 0);  const [owner] = repositoryPathSegments;  const repositoryName = repositoryPathSegments.at(-1);   return {    canonicalKey,    locator: {      source: "git-remote",      remoteName: input.remoteName,      remoteUrl: input.remoteUrl,    },    rootPath: input.rootPath,    ...(repositoryPath ? { displayName: repositoryPath } : {}),    ...(sourceControlProvider ? { provider: sourceControlProvider.kind } : {}),    ...(owner ? { owner } : {}),    ...(repositoryName ? { name: repositoryName } : {}),  };} const DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY = 512;const DEFAULT_POSITIVE_CACHE_TTL = Duration.minutes(1);const DEFAULT_NEGATIVE_CACHE_TTL = Duration.minutes(1); interface RepositoryIdentityResolverOptions {  readonly cacheCapacity?: number;  readonly positiveCacheTtl?: Duration.Input;  readonly negativeCacheTtl?: Duration.Input;} async function resolveRepositoryIdentityCacheKey(cwd: string): Promise<string> {  let cacheKey = cwd;   try {    const topLevelResult = await runProcess("git", ["-C", cwd, "rev-parse", "--show-toplevel"], {      allowNonZeroExit: true,    });    if (topLevelResult.code !== 0) {      return cacheKey;    }     const candidate = topLevelResult.stdout.trim();    if (candidate.length > 0) {      cacheKey = candidate;    }  } catch {    return cacheKey;  }   return cacheKey;} async function resolveRepositoryIdentityFromCacheKey(  cacheKey: string,): Promise<RepositoryIdentity | null> {  try {    const remoteResult = await runProcess("git", ["-C", cacheKey, "remote", "-v"], {      allowNonZeroExit: true,    });    if (remoteResult.code !== 0) {      return null;    }     const remote = pickPrimaryRemote(parseRemoteFetchUrls(remoteResult.stdout));    return remote ? buildRepositoryIdentity({ ...remote, rootPath: cacheKey }) : null;  } catch {    return null;  }} export const makeRepositoryIdentityResolver = Effect.fn("makeRepositoryIdentityResolver")(  function* (options: RepositoryIdentityResolverOptions = {}) {    const repositoryIdentityCache = yield* Cache.makeWith<string, RepositoryIdentity | null>(      (cacheKey) => Effect.promise(() => resolveRepositoryIdentityFromCacheKey(cacheKey)),      {        capacity: options.cacheCapacity ?? DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY,        timeToLive: Exit.match({          onSuccess: (value) =>            value === null              ? (options.negativeCacheTtl ?? DEFAULT_NEGATIVE_CACHE_TTL)              : (options.positiveCacheTtl ?? DEFAULT_POSITIVE_CACHE_TTL),          onFailure: () => Duration.zero,        }),      },    );     const resolve: RepositoryIdentityResolverShape["resolve"] = Effect.fn(      "RepositoryIdentityResolver.resolve",    )(function* (cwd) {      const cacheKey = yield* Effect.promise(() => resolveRepositoryIdentityCacheKey(cwd));      return yield* Cache.get(repositoryIdentityCache, cacheKey);    });     return {      resolve,    } satisfies RepositoryIdentityResolverShape;  },); export const RepositoryIdentityResolverLive = Layer.effect(  RepositoryIdentityResolver,  makeRepositoryIdentityResolver(),);