1import type { RepositoryIdentity, SourceControlProviderError } from "@t3tools/contracts";
2import {
3 detectSourceControlProviderFromGitRemoteUrl,
4 normalizeGitRemoteUrl,
5} from "@t3tools/shared/git";
6import * as Cache from "effect/Cache";
7import * as Context from "effect/Context";
8import * as Duration from "effect/Duration";
9import * as Effect from "effect/Effect";
10import * as Exit from "effect/Exit";
11import * as Layer from "effect/Layer";
12
13import * as ProcessRunner from "../processRunner.ts";
14
15const 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`.
19const DEFAULT_POSITIVE_CACHE_TTL = Duration.minutes(15);
20// Short, so a folder that gains a repository or a remote shows up quickly.
21const DEFAULT_NEGATIVE_CACHE_TTL = Duration.minutes(1);
22
23export 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
32export 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
42function 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
58function 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
73function repositoryPathOf(canonicalKey: string): string {
74 return canonicalKey.split("/").slice(1).join("/");
75}
76
77function 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
88function 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
118const 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
140const 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
164export 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
216export const layer = Layer.effect(RepositoryIdentityResolver, make()).pipe(
217 Layer.provide(ProcessRunner.layer),
218);