import type { RepositoryIdentity } from "@t3tools/contracts"; import { detectSourceControlProviderFromGitRemoteUrl, normalizeGitRemoteUrl, } from "@t3tools/shared/git"; import * as Cache from "effect/Cache"; import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as ProcessRunner from "../processRunner.ts"; const DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY = 512; const DEFAULT_POSITIVE_CACHE_TTL = Duration.minutes(1); const DEFAULT_NEGATIVE_CACHE_TTL = Duration.minutes(1); export interface RepositoryIdentityResolverOptions { readonly cacheCapacity?: number; readonly positiveCacheTtl?: Duration.Input; readonly negativeCacheTtl?: Duration.Input; } export class RepositoryIdentityResolver extends Context.Service< RepositoryIdentityResolver, { readonly resolve: (cwd: string) => Effect.Effect; } >()("t3/project/RepositoryIdentityResolver") {} function parseRemoteFetchUrls(stdout: string): Map { const remotes = new Map(); 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, ): { 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 resolveRepositoryIdentityCacheKey = Effect.fn("RepositoryIdentityResolver.resolveCacheKey")( function* (cwd: string) { const processRunner = yield* ProcessRunner.ProcessRunner; // git is a real executable on every platform — no cmd.exe shell mode, which // would split paths containing spaces during cmd's re-tokenization. const topLevelResult = yield* processRunner .run({ command: "git", args: ["-C", cwd, "rev-parse", "--show-toplevel"], timeoutBehavior: "timedOutResult", }) .pipe(Effect.option); if (topLevelResult._tag === "None" || topLevelResult.value.code !== 0) { return null; } const candidate = topLevelResult.value.stdout.trim(); return candidate.length > 0 ? candidate : null; }, ); const resolveRepositoryIdentityFromCacheKey = Effect.fn( "RepositoryIdentityResolver.resolveFromCacheKey", )(function* ( cacheKey: string, ): Effect.fn.Return { const processRunner = yield* ProcessRunner.ProcessRunner; const remoteResult = yield* processRunner .run({ command: "git", args: ["-C", cacheKey, "remote", "-v"], timeoutBehavior: "timedOutResult", }) .pipe(Effect.option); if (remoteResult._tag === "None" || remoteResult.value.code !== 0) { return null; } const remote = pickPrimaryRemote(parseRemoteFetchUrls(remoteResult.value.stdout)); return remote ? buildRepositoryIdentity({ ...remote, rootPath: cacheKey }) : null; }); export const make = Effect.fn("RepositoryIdentityResolver.make")(function* ( options: RepositoryIdentityResolverOptions = {}, ) { const processRunner = yield* ProcessRunner.ProcessRunner; const cacheCapacity = options.cacheCapacity ?? DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY; const repositoryRootCache = yield* Cache.makeWith( (cwd) => resolveRepositoryIdentityCacheKey(cwd).pipe( Effect.provideService(ProcessRunner.ProcessRunner, processRunner), ), { capacity: cacheCapacity, timeToLive: Exit.match({ onSuccess: (value) => value === null ? Duration.zero : (options.positiveCacheTtl ?? DEFAULT_POSITIVE_CACHE_TTL), onFailure: () => Duration.zero, }), }, ); const repositoryIdentityCache = yield* Cache.makeWith( (cacheKey) => resolveRepositoryIdentityFromCacheKey(cacheKey).pipe( Effect.provideService(ProcessRunner.ProcessRunner, processRunner), ), { capacity: cacheCapacity, timeToLive: Exit.match({ onSuccess: (value) => value === null ? (options.negativeCacheTtl ?? DEFAULT_NEGATIVE_CACHE_TTL) : (options.positiveCacheTtl ?? DEFAULT_POSITIVE_CACHE_TTL), onFailure: () => Duration.zero, }), }, ); const resolve: RepositoryIdentityResolver["Service"]["resolve"] = Effect.fn( "RepositoryIdentityResolver.resolve", )(function* (cwd) { const cacheKey = yield* Cache.get(repositoryRootCache, cwd); if (cacheKey === null) return null; return yield* Cache.get(repositoryIdentityCache, cacheKey); }); return RepositoryIdentityResolver.of({ resolve }); }); export const layer = Layer.effect(RepositoryIdentityResolver, make()).pipe( Layer.provide(ProcessRunner.layer), );