Open History to see the last commit
1import type { RepositoryIdentity, SourceControlProviderError } from "@t3tools/contracts";
2import { repositoryPathFromRemoteUrl } from "@t3tools/source-control-core/server/SourceControlProvider";
3import * as Cache from "effect/Cache";
4import * as Context from "effect/Context";
5import * as Data from "effect/Data";
6import * as Duration from "effect/Duration";
7import * as Effect from "effect/Effect";
8import * as Exit from "effect/Exit";
9import * as FileSystem from "effect/FileSystem";
10import * as Layer from "effect/Layer";
11import * as Path from "effect/Path";
12
13import * as ProcessRunner from "../processRunner.ts";
14import { sourceControlDefinitions } from "../sourceControl/sourceControlDefinitions.ts";
15import { explicitBareRepositoryEnv, unsetInheritedRepositoryEnv } from "../vcs/VcsProcess.ts";
16import * as HostProcess from "@t3tools/shared/HostProcess";
17
18const DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY = 512;
19// Minute-by-minute sweeps need cached hits; clone, publish and PR discovery force refresh.
20const DEFAULT_POSITIVE_CACHE_TTL = Duration.minutes(15);
21const DEFAULT_NEGATIVE_CACHE_TTL = Duration.minutes(1);
22
23export interface RepositoryIdentityResolverOptions {
24 readonly cacheCapacity?: number;
25 readonly refine?: (
26 identity: RepositoryIdentity,
27 ) => Effect.Effect<RepositoryIdentity, SourceControlProviderError>;
28}
29
30export class RepositoryIdentityResolver extends Context.Service<
31 RepositoryIdentityResolver,
32 {
33 readonly resolve: (
34 cwd: string,
35 options?: { readonly refresh?: boolean },
36 ) => Effect.Effect<RepositoryIdentity | null>;
37 }
38>()("t3/project/RepositoryIdentityResolver") {}
39
40// Local paths such as GitButler's gb-local=. would give unrelated repos the same identity.
41function isLocalRemoteUrl(url: string): boolean {
42 if (/^file:/i.test(url)) return true;
43 if (/^[a-z][a-z0-9+.-]*:\/\//i.test(url)) return false;
44 if (/^[a-z]:[\\/]/i.test(url)) return true;
45 // Git treats `host:path` as scp-style only when the colon precedes any slash.
46 const colonIndex = url.indexOf(":");
47 const slashIndex = url.search(/[\\/]/);
48 return colonIndex === -1 || (slashIndex !== -1 && slashIndex < colonIndex);
49}
50
51function parseRemoteFetchUrls(stdout: string): Map<string, string> {
52 const remotes = new Map<string, string>();
53 for (const line of stdout.split(/\r?\n/)) {
54 // Git uses a tab before the URL and one space before the direction; both can occur in URLs.
55 // Partial clones append the promisor filter, e.g. `(fetch) [blob:none]`.
56 const match = /^(\S+)\t(.+) \((fetch|push)\)(?:\s+\[[^\]]+\])?$/.exec(line);
57 if (!match) continue;
58 const [, remoteName = "", remoteUrl = "", direction = ""] = match;
59 if (
60 direction !== "fetch" ||
61 remoteName.length === 0 ||
62 remoteUrl.length === 0 ||
63 isLocalRemoteUrl(remoteUrl)
64 ) {
65 continue;
66 }
67 remotes.set(remoteName, remoteUrl);
68 }
69 return remotes;
70}
71
72// `gh repo set-default` marks the chosen remote with `remote.<name>.gh-resolved base`.
73function parseGhDefaultRemoteName(stdout: string): string | null {
74 const marked = stdout
75 .split("\n")
76 .map((line) => /^remote\.(.+)\.gh-resolved\s+base$/.exec(line.trim())?.[1])
77 .filter((name): name is string => name !== undefined);
78 return marked.length === 1 ? marked[0]! : null;
79}
80
81function pickPrimaryRemote(
82 remotes: ReadonlyMap<string, string>,
83 ghDefaultRemoteName: string | null,
84): { readonly remoteName: string; readonly remoteUrl: string } | null {
85 for (const preferredRemoteName of [ghDefaultRemoteName, "upstream", "origin"]) {
86 if (preferredRemoteName === null) continue;
87 const remoteUrl = remotes.get(preferredRemoteName);
88 if (remoteUrl) {
89 return { remoteName: preferredRemoteName, remoteUrl };
90 }
91 }
92
93 const [remoteName, remoteUrl] =
94 [...remotes.entries()].toSorted(([left], [right]) => left.localeCompare(right))[0] ?? [];
95 return remoteName && remoteUrl ? { remoteName, remoteUrl } : null;
96}
97
98function repositoryPathOf(canonicalKey: string, remoteUrl: string): string {
99 const canonicalPath = canonicalKey.split("/").slice(1).join("/");
100 const displayPath = repositoryPathFromRemoteUrl(remoteUrl);
101 return displayPath?.toLowerCase() === canonicalPath ? displayPath : canonicalPath;
102}
103
104function buildRepositoryOrigin(
105 originUrl: string | undefined,
106 canonicalKey: string,
107): RepositoryIdentity["origin"] {
108 if (!originUrl) return undefined;
109 const originKey = sourceControlDefinitions.repositoryKeyFromRemote(originUrl);
110 if (originKey === canonicalKey) return undefined;
111 const displayName = repositoryPathOf(originKey, originUrl);
112 return { canonicalKey: originKey, remoteUrl: originUrl, ...(displayName ? { displayName } : {}) };
113}
114
115function buildRepositoryIdentity(input: {
116 readonly remoteName: string;
117 readonly remoteUrl: string;
118 readonly originUrl: string | undefined;
119 readonly rootPath: string;
120}): RepositoryIdentity {
121 const canonicalKey = sourceControlDefinitions.repositoryKeyFromRemote(input.remoteUrl);
122 const sourceControlProvider = sourceControlDefinitions.recognizeRemote(input.remoteUrl);
123 const repositoryPath = repositoryPathOf(canonicalKey, input.remoteUrl);
124 const repositoryPathSegments = repositoryPath.split("/").filter((segment) => segment.length > 0);
125 const [owner] = repositoryPathSegments;
126 const repositoryName = repositoryPathSegments.at(-1);
127 const origin = buildRepositoryOrigin(input.originUrl, canonicalKey);
128
129 return {
130 canonicalKey,
131 locator: {
132 source: "git-remote",
133 remoteName: input.remoteName,
134 remoteUrl: input.remoteUrl,
135 },
136 rootPath: input.rootPath,
137 ...(repositoryPath ? { displayName: repositoryPath } : {}),
138 ...(sourceControlProvider ? { provider: sourceControlProvider.kind } : {}),
139 ...(owner ? { owner } : {}),
140 ...(repositoryName ? { name: repositoryName } : {}),
141 ...(origin ? { origin } : {}),
142 };
143}
144
145class RepositoryIdentityCacheKey extends Data.Class<{
146 readonly rootPath: string;
147 readonly isBare: boolean;
148}> {}
149
150const gitEnv = (platform: NodeJS.Platform): NodeJS.ProcessEnv => ({
151 ...unsetInheritedRepositoryEnv(process.env, platform),
152 ...explicitBareRepositoryEnv(process.env, platform),
153});
154
155const resolveRepositoryIdentityCacheKey = Effect.fn("RepositoryIdentityResolver.resolveCacheKey")(
156 function* (cwd: string) {
157 const processRunner = yield* ProcessRunner.ProcessRunner;
158 const fileSystem = yield* FileSystem.FileSystem;
159 const path = yield* Path.Path;
160 const env = gitEnv(yield* HostProcess.Platform);
161
162 // `git -C` cannot enter a missing folder or a file, so every probe below would fail.
163 const isFolder = yield* fileSystem.stat(cwd).pipe(
164 Effect.map((info) => info.type === "Directory"),
165 Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(false)),
166 Effect.orElseSucceed(() => true),
167 );
168 if (!isFolder) return null;
169
170 // git is always a real executable; cmd shell mode would split spaced paths on re-tokenization.
171 const topLevelResult = yield* processRunner
172 .run({
173 command: "git",
174 args: ["-C", cwd, "rev-parse", "--show-toplevel"],
175 env,
176 timeoutBehavior: "timedOutResult",
177 })
178 .pipe(Effect.option);
179 if (topLevelResult._tag === "None" || topLevelResult.value.timedOut) {
180 return null;
181 }
182 if (topLevelResult.value.code === 0) {
183 // Paths may end in spaces, so strip only git's line terminator.
184 const rootPath = topLevelResult.value.stdout.replace(/\r?\n$/, "");
185 return rootPath.length > 0
186 ? new RepositoryIdentityCacheKey({ rootPath, isBare: false })
187 : null;
188 }
189
190 // Explicit Git directories preserve safe.bareRepository=explicit for both layouts.
191 for (const [gitDir, entry] of [
192 [".", "HEAD"],
193 [".git", ".git"],
194 ] as const) {
195 // An explicit --git-dir fails unless the folder has this entry; a symlink counts even
196 // when its target is missing, as git reads a symlinked HEAD's target text.
197 const target = path.join(cwd, entry);
198 const hasEntry = yield* fileSystem.exists(target).pipe(
199 Effect.flatMap((exists) =>
200 exists
201 ? Effect.succeed(true)
202 : fileSystem.readLink(target).pipe(
203 Effect.as(true),
204 Effect.orElseSucceed(() => false),
205 ),
206 ),
207 Effect.orElseSucceed(() => true),
208 );
209 if (!hasEntry) continue;
210 const bareResult = yield* processRunner
211 .run({
212 command: "git",
213 args: [
214 "-C",
215 cwd,
216 `--git-dir=${gitDir}`,
217 "rev-parse",
218 "--is-bare-repository",
219 "--absolute-git-dir",
220 ],
221 env,
222 timeoutBehavior: "timedOutResult",
223 })
224 .pipe(Effect.option);
225 if (bareResult._tag === "None" || bareResult.value.timedOut) return null;
226 if (bareResult.value.code !== 0) continue;
227
228 const [isBare, rootPath = ""] = bareResult.value.stdout.split(/\r?\n/);
229 // A checkout's .git directory contains remotes but is not a project root.
230 return isBare === "true" && rootPath.length > 0
231 ? new RepositoryIdentityCacheKey({ rootPath, isBare: true })
232 : null;
233 }
234 return null;
235 },
236);
237
238const resolveRepositoryIdentityFromCacheKey = Effect.fn(
239 "RepositoryIdentityResolver.resolveFromCacheKey",
240)(function* (
241 cacheKey: RepositoryIdentityCacheKey,
242): Effect.fn.Return<RepositoryIdentity | null, never, ProcessRunner.ProcessRunner> {
243 const processRunner = yield* ProcessRunner.ProcessRunner;
244 const env = gitEnv(yield* HostProcess.Platform);
245 const remoteResult = yield* processRunner
246 .run({
247 command: "git",
248 args: ["-C", cacheKey.rootPath, ...(cacheKey.isBare ? ["--git-dir=."] : []), "remote", "-v"],
249 env,
250 timeoutBehavior: "timedOutResult",
251 })
252 .pipe(Effect.option);
253 if (remoteResult._tag === "None" || remoteResult.value.code !== 0) {
254 return null;
255 }
256
257 const ghResolvedResult = yield* processRunner
258 .run({
259 command: "git",
260 args: [
261 "-C",
262 cacheKey.rootPath,
263 ...(cacheKey.isBare ? ["--git-dir=."] : []),
264 "config",
265 "--get-regexp",
266 "^remote\\..*\\.gh-resolved$",
267 ],
268 env,
269 timeoutBehavior: "timedOutResult",
270 })
271 .pipe(Effect.option);
272 const ghDefaultRemoteName =
273 ghResolvedResult._tag === "Some" && ghResolvedResult.value.code === 0
274 ? parseGhDefaultRemoteName(ghResolvedResult.value.stdout)
275 : null;
276
277 const remotes = parseRemoteFetchUrls(remoteResult.value.stdout);
278 const remote = pickPrimaryRemote(remotes, ghDefaultRemoteName);
279 return remote
280 ? buildRepositoryIdentity({
281 ...remote,
282 originUrl: remotes.get("origin"),
283 rootPath: cacheKey.rootPath,
284 })
285 : null;
286});
287
288export const make = Effect.fn("RepositoryIdentityResolver.make")(function* (
289 options: RepositoryIdentityResolverOptions = {},
290) {
291 const processRunner = yield* ProcessRunner.ProcessRunner;
292 const fileSystem = yield* FileSystem.FileSystem;
293 const path = yield* Path.Path;
294 const cacheCapacity = options.cacheCapacity ?? DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY;
295 const refine = options.refine ?? Effect.succeed;
296 const timeToLive = (exit: Exit.Exit<unknown>) =>
297 Exit.match(exit, {
298 onSuccess: (value) =>
299 value === null ? DEFAULT_NEGATIVE_CACHE_TTL : DEFAULT_POSITIVE_CACHE_TTL,
300 onFailure: () => Duration.zero,
301 });
302
303 const repositoryRootCache = yield* Cache.makeWith<string, RepositoryIdentityCacheKey | null>(
304 (cwd) =>
305 resolveRepositoryIdentityCacheKey(cwd).pipe(
306 Effect.provideService(ProcessRunner.ProcessRunner, processRunner),
307 Effect.provideService(FileSystem.FileSystem, fileSystem),
308 Effect.provideService(Path.Path, path),
309 ),
310 { capacity: cacheCapacity, timeToLive },
311 );
312
313 const repositoryIdentityCache = yield* Cache.makeWith<
314 RepositoryIdentityCacheKey,
315 RepositoryIdentity | null
316 >(
317 (cacheKey) =>
318 resolveRepositoryIdentityFromCacheKey(cacheKey).pipe(
319 Effect.provideService(ProcessRunner.ProcessRunner, processRunner),
320 Effect.filterOrElse(
321 (identity): identity is null => identity === null,
322 (identity) => refine(identity).pipe(Effect.orElseSucceed(() => identity)),
323 ),
324 ),
325 { capacity: cacheCapacity, timeToLive },
326 );
327
328 const resolve: RepositoryIdentityResolver["Service"]["resolve"] = Effect.fnUntraced(
329 function* (cwd, options) {
330 if (options?.refresh) yield* Cache.invalidate(repositoryRootCache, cwd);
331 const cacheKey = yield* Cache.get(repositoryRootCache, cwd);
332 if (cacheKey === null) return null;
333 if (options?.refresh) yield* Cache.invalidate(repositoryIdentityCache, cacheKey);
334 return yield* Cache.get(repositoryIdentityCache, cacheKey);
335 },
336 );
337
338 return RepositoryIdentityResolver.of({ resolve });
339});
340
341export const layer = Layer.effect(RepositoryIdentityResolver, make()).pipe(
342 Layer.provide(ProcessRunner.layer),
343);