| 1 | import type { RepositoryIdentity, SourceControlProviderError } from "@t3tools/contracts"; |
| 2 | import { |
| 3 | detectSourceControlProviderFromGitRemoteUrl, |
| 4 | normalizeGitRemoteUrl, |
| 5 | } from "@t3tools/shared/git"; |
| 6 | import * as Cache from "effect/Cache"; |
| 7 | import * as Context from "effect/Context"; |
| 8 | import * as Duration from "effect/Duration"; |
| 9 | import * as Effect from "effect/Effect"; |
| 10 | import * as Exit from "effect/Exit"; |
| 11 | import * as Layer from "effect/Layer"; |
| 12 | |
| 13 | import * as ProcessRunner from "../processRunner.ts"; |
| 14 | |
| 15 | const DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY = 512; |
| 16 | // Background sweeps resolve every project each minute. A long TTL keeps them |
| 17 | // from spawning git each time. Clone, publish, and PR discovery (after a turn |
| 18 | // and before it saves links) resolve with `refresh: true`. |
| 19 | const DEFAULT_POSITIVE_CACHE_TTL = Duration.minutes(15); |
| 20 | // Short, so a folder that gains a repository or a remote shows up quickly. |
| 21 | const DEFAULT_NEGATIVE_CACHE_TTL = Duration.minutes(1); |
| 22 | |
| 23 | export interface RepositoryIdentityResolverOptions { |
| 24 | readonly cacheCapacity?: number; |
| 25 | readonly positiveCacheTtl?: Duration.Input; |
| 26 | readonly negativeCacheTtl?: Duration.Input; |
| 27 | readonly refine?: ( |
| 28 | identity: RepositoryIdentity, |
| 29 | ) => Effect.Effect<RepositoryIdentity, SourceControlProviderError>; |
| 30 | } |
| 31 | |
| 32 | export class RepositoryIdentityResolver extends Context.Service< |
| 33 | RepositoryIdentityResolver, |
| 34 | { |
| 35 | readonly resolve: ( |
| 36 | cwd: string, |
| 37 | options?: { readonly refresh?: boolean }, |
| 38 | ) => Effect.Effect<RepositoryIdentity | null>; |
| 39 | } |
| 40 | >()("t3/project/RepositoryIdentityResolver") {} |
| 41 | |
| 42 | function parseRemoteFetchUrls(stdout: string): Map<string, string> { |
| 43 | const remotes = new Map<string, string>(); |
| 44 | for (const line of stdout.split("\n")) { |
| 45 | const trimmed = line.trim(); |
| 46 | if (trimmed.length === 0) continue; |
| 47 | const match = /^(\S+)\s+(\S+)\s+\((fetch|push)\)$/.exec(trimmed); |
| 48 | if (!match) continue; |
| 49 | const [, remoteName = "", remoteUrl = "", direction = ""] = match; |
| 50 | if (direction !== "fetch" || remoteName.length === 0 || remoteUrl.length === 0) { |
| 51 | continue; |
| 52 | } |
| 53 | remotes.set(remoteName, remoteUrl); |
| 54 | } |
| 55 | return remotes; |
| 56 | } |
| 57 | |
| 58 | function pickPrimaryRemote( |
| 59 | remotes: ReadonlyMap<string, string>, |
| 60 | ): { readonly remoteName: string; readonly remoteUrl: string } | null { |
| 61 | for (const preferredRemoteName of ["upstream", "origin"] as const) { |
| 62 | const remoteUrl = remotes.get(preferredRemoteName); |
| 63 | if (remoteUrl) { |
| 64 | return { remoteName: preferredRemoteName, remoteUrl }; |
| 65 | } |
| 66 | } |
| 67 | |
| 68 | const [remoteName, remoteUrl] = |
| 69 | [...remotes.entries()].toSorted(([left], [right]) => left.localeCompare(right))[0] ?? []; |
| 70 | return remoteName && remoteUrl ? { remoteName, remoteUrl } : null; |
| 71 | } |
| 72 | |
| 73 | function repositoryPathOf(canonicalKey: string): string { |
| 74 | return canonicalKey.split("/").slice(1).join("/"); |
| 75 | } |
| 76 | |
| 77 | function buildRepositoryOrigin( |
| 78 | originUrl: string | undefined, |
| 79 | canonicalKey: string, |
| 80 | ): RepositoryIdentity["origin"] { |
| 81 | if (!originUrl) return undefined; |
| 82 | const originKey = normalizeGitRemoteUrl(originUrl); |
| 83 | if (originKey === canonicalKey) return undefined; |
| 84 | const displayName = repositoryPathOf(originKey); |
| 85 | return { canonicalKey: originKey, ...(displayName ? { displayName } : {}) }; |
| 86 | } |
| 87 | |
| 88 | function buildRepositoryIdentity(input: { |
| 89 | readonly remoteName: string; |
| 90 | readonly remoteUrl: string; |
| 91 | readonly originUrl: string | undefined; |
| 92 | readonly rootPath: string; |
| 93 | }): RepositoryIdentity { |
| 94 | const canonicalKey = normalizeGitRemoteUrl(input.remoteUrl); |
| 95 | const sourceControlProvider = detectSourceControlProviderFromGitRemoteUrl(input.remoteUrl); |
| 96 | const repositoryPath = repositoryPathOf(canonicalKey); |
| 97 | const repositoryPathSegments = repositoryPath.split("/").filter((segment) => segment.length > 0); |
| 98 | const [owner] = repositoryPathSegments; |
| 99 | const repositoryName = repositoryPathSegments.at(-1); |
| 100 | const origin = buildRepositoryOrigin(input.originUrl, canonicalKey); |
| 101 | |
| 102 | return { |
| 103 | canonicalKey, |
| 104 | locator: { |
| 105 | source: "git-remote", |
| 106 | remoteName: input.remoteName, |
| 107 | remoteUrl: input.remoteUrl, |
| 108 | }, |
| 109 | rootPath: input.rootPath, |
| 110 | ...(repositoryPath ? { displayName: repositoryPath } : {}), |
| 111 | ...(sourceControlProvider ? { provider: sourceControlProvider.kind } : {}), |
| 112 | ...(owner ? { owner } : {}), |
| 113 | ...(repositoryName ? { name: repositoryName } : {}), |
| 114 | ...(origin ? { origin } : {}), |
| 115 | }; |
| 116 | } |
| 117 | |
| 118 | const resolveRepositoryIdentityCacheKey = Effect.fn("RepositoryIdentityResolver.resolveCacheKey")( |
| 119 | function* (cwd: string) { |
| 120 | const processRunner = yield* ProcessRunner.ProcessRunner; |
| 121 | |
| 122 | // git is a real executable on every platform — no cmd.exe shell mode, which |
| 123 | // would split paths containing spaces during cmd's re-tokenization. |
| 124 | const topLevelResult = yield* processRunner |
| 125 | .run({ |
| 126 | command: "git", |
| 127 | args: ["-C", cwd, "rev-parse", "--show-toplevel"], |
| 128 | timeoutBehavior: "timedOutResult", |
| 129 | }) |
| 130 | .pipe(Effect.option); |
| 131 | if (topLevelResult._tag === "None" || topLevelResult.value.code !== 0) { |
| 132 | return null; |
| 133 | } |
| 134 | |
| 135 | const candidate = topLevelResult.value.stdout.trim(); |
| 136 | return candidate.length > 0 ? candidate : null; |
| 137 | }, |
| 138 | ); |
| 139 | |
| 140 | const resolveRepositoryIdentityFromCacheKey = Effect.fn( |
| 141 | "RepositoryIdentityResolver.resolveFromCacheKey", |
| 142 | )(function* ( |
| 143 | cacheKey: string, |
| 144 | ): Effect.fn.Return<RepositoryIdentity | null, never, ProcessRunner.ProcessRunner> { |
| 145 | const processRunner = yield* ProcessRunner.ProcessRunner; |
| 146 | const remoteResult = yield* processRunner |
| 147 | .run({ |
| 148 | command: "git", |
| 149 | args: ["-C", cacheKey, "remote", "-v"], |
| 150 | timeoutBehavior: "timedOutResult", |
| 151 | }) |
| 152 | .pipe(Effect.option); |
| 153 | if (remoteResult._tag === "None" || remoteResult.value.code !== 0) { |
| 154 | return null; |
| 155 | } |
| 156 | |
| 157 | const remotes = parseRemoteFetchUrls(remoteResult.value.stdout); |
| 158 | const remote = pickPrimaryRemote(remotes); |
| 159 | return remote |
| 160 | ? buildRepositoryIdentity({ ...remote, originUrl: remotes.get("origin"), rootPath: cacheKey }) |
| 161 | : null; |
| 162 | }); |
| 163 | |
| 164 | export const make = Effect.fn("RepositoryIdentityResolver.make")(function* ( |
| 165 | options: RepositoryIdentityResolverOptions = {}, |
| 166 | ) { |
| 167 | const processRunner = yield* ProcessRunner.ProcessRunner; |
| 168 | const cacheCapacity = options.cacheCapacity ?? DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY; |
| 169 | const refine = options.refine ?? Effect.succeed; |
| 170 | // Git errors and timeouts resolve to null, so they use the negative TTL like |
| 171 | // "no repository" or "no remote". Only interrupts and defects skip the cache. |
| 172 | const timeToLive = (exit: Exit.Exit<unknown>) => |
| 173 | Exit.match(exit, { |
| 174 | onSuccess: (value) => |
| 175 | value === null |
| 176 | ? (options.negativeCacheTtl ?? DEFAULT_NEGATIVE_CACHE_TTL) |
| 177 | : (options.positiveCacheTtl ?? DEFAULT_POSITIVE_CACHE_TTL), |
| 178 | onFailure: () => Duration.zero, |
| 179 | }); |
| 180 | |
| 181 | const repositoryRootCache = yield* Cache.makeWith<string, string | null>( |
| 182 | (cwd) => |
| 183 | resolveRepositoryIdentityCacheKey(cwd).pipe( |
| 184 | Effect.provideService(ProcessRunner.ProcessRunner, processRunner), |
| 185 | ), |
| 186 | { capacity: cacheCapacity, timeToLive }, |
| 187 | ); |
| 188 | |
| 189 | const repositoryIdentityCache = yield* Cache.makeWith<string, RepositoryIdentity | null>( |
| 190 | (cacheKey) => |
| 191 | resolveRepositoryIdentityFromCacheKey(cacheKey).pipe( |
| 192 | Effect.provideService(ProcessRunner.ProcessRunner, processRunner), |
| 193 | Effect.filterOrElse( |
| 194 | (identity): identity is null => identity === null, |
| 195 | (identity) => refine(identity).pipe(Effect.orElseSucceed(() => identity)), |
| 196 | ), |
| 197 | ), |
| 198 | { capacity: cacheCapacity, timeToLive }, |
| 199 | ); |
| 200 | |
| 201 | // Untraced because almost every call is a cache hit. The lookups that spawn |
| 202 | // git keep their own spans. |
| 203 | const resolve: RepositoryIdentityResolver["Service"]["resolve"] = Effect.fnUntraced( |
| 204 | function* (cwd, options) { |
| 205 | if (options?.refresh) yield* Cache.invalidate(repositoryRootCache, cwd); |
| 206 | const cacheKey = yield* Cache.get(repositoryRootCache, cwd); |
| 207 | if (cacheKey === null) return null; |
| 208 | if (options?.refresh) yield* Cache.invalidate(repositoryIdentityCache, cacheKey); |
| 209 | return yield* Cache.get(repositoryIdentityCache, cacheKey); |
| 210 | }, |
| 211 | ); |
| 212 | |
| 213 | return RepositoryIdentityResolver.of({ resolve }); |
| 214 | }); |
| 215 | |
| 216 | export const layer = Layer.effect(RepositoryIdentityResolver, make()).pipe( |
| 217 | Layer.provide(ProcessRunner.layer), |
| 218 | ); |