import type { RepositoryIdentity } from "@t3tools/contracts";import * as Cache from "effect/Cache";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 { detectSourceControlProviderFromGitRemoteUrl, normalizeGitRemoteUrl,} from "@t3tools/shared/git"; import * as ProcessRunner 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;} const resolveRepositoryIdentityCacheKey = Effect.fn("resolveRepositoryIdentityCacheKey")(function* ( cwd: string,) { const processRunner = yield* ProcessRunner.ProcessRunner; let cacheKey = cwd; const topLevelResult = yield* processRunner .run({ command: "git", args: ["-C", cwd, "rev-parse", "--show-toplevel"], timeoutBehavior: "timedOutResult", shell: process.platform === "win32", }) .pipe(Effect.option); if (topLevelResult._tag === "None" || topLevelResult.value.code !== 0) { return cacheKey; } const candidate = topLevelResult.value.stdout.trim(); if (candidate.length > 0) { cacheKey = candidate; } return cacheKey;}); const resolveRepositoryIdentityFromCacheKey = Effect.fn("resolveRepositoryIdentityFromCacheKey")( function* ( cacheKey: string, ): Effect.fn.Return<RepositoryIdentity | null, never, ProcessRunner.ProcessRunner> { const processRunner = yield* ProcessRunner.ProcessRunner; const remoteResult = yield* processRunner .run({ command: "git", args: ["-C", cacheKey, "remote", "-v"], timeoutBehavior: "timedOutResult", shell: process.platform === "win32", }) .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 makeRepositoryIdentityResolver = Effect.fn("makeRepositoryIdentityResolver")( function* (options: RepositoryIdentityResolverOptions = {}) { const processRunner = yield* ProcessRunner.ProcessRunner; const repositoryIdentityCache = yield* Cache.makeWith<string, RepositoryIdentity | null>( (cacheKey) => resolveRepositoryIdentityFromCacheKey(cacheKey).pipe( Effect.provideService(ProcessRunner.ProcessRunner, processRunner), ), { 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* resolveRepositoryIdentityCacheKey(cwd).pipe( Effect.provideService(ProcessRunner.ProcessRunner, processRunner), ); return yield* Cache.get(repositoryIdentityCache, cacheKey); }); return { resolve, } satisfies RepositoryIdentityResolverShape; },); export const RepositoryIdentityResolverLive = Layer.effect( RepositoryIdentityResolver, makeRepositoryIdentityResolver(),).pipe(Layer.provide(ProcessRunner.layer));
import type { RepositoryIdentity } from "@t3tools/contracts";
import * as Cache from "effect/Cache";
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 {
detectSourceControlProviderFromGitRemoteUrl,
normalizeGitRemoteUrl,
} from "@t3tools/shared/git";
import * as ProcessRunner 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;
}
const resolveRepositoryIdentityCacheKey = Effect.fn("resolveRepositoryIdentityCacheKey")(function* (
cwd: string,
) {
const processRunner = yield* ProcessRunner.ProcessRunner;
let cacheKey = cwd;
const topLevelResult = yield* processRunner
.run({
command: "git",
args: ["-C", cwd, "rev-parse", "--show-toplevel"],
timeoutBehavior: "timedOutResult",
shell: process.platform === "win32",
})
.pipe(Effect.option);
if (topLevelResult._tag === "None" || topLevelResult.value.code !== 0) {
return cacheKey;
}
const candidate = topLevelResult.value.stdout.trim();
if (candidate.length > 0) {
cacheKey = candidate;
}
return cacheKey;
});
const resolveRepositoryIdentityFromCacheKey = Effect.fn("resolveRepositoryIdentityFromCacheKey")(
function* (
cacheKey: string,
): Effect.fn.Return<RepositoryIdentity | null, never, ProcessRunner.ProcessRunner> {
const processRunner = yield* ProcessRunner.ProcessRunner;
const remoteResult = yield* processRunner
.run({
command: "git",
args: ["-C", cacheKey, "remote", "-v"],
timeoutBehavior: "timedOutResult",
shell: process.platform === "win32",
})
.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 makeRepositoryIdentityResolver = Effect.fn("makeRepositoryIdentityResolver")(
function* (options: RepositoryIdentityResolverOptions = {}) {
const processRunner = yield* ProcessRunner.ProcessRunner;
const repositoryIdentityCache = yield* Cache.makeWith<string, RepositoryIdentity | null>(
(cacheKey) =>
resolveRepositoryIdentityFromCacheKey(cacheKey).pipe(
Effect.provideService(ProcessRunner.ProcessRunner, processRunner),
),
{
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* resolveRepositoryIdentityCacheKey(cwd).pipe(
Effect.provideService(ProcessRunner.ProcessRunner, processRunner),
);
return yield* Cache.get(repositoryIdentityCache, cacheKey);
});
return {
resolve,
} satisfies RepositoryIdentityResolverShape;
},
);
export const RepositoryIdentityResolverLive = Layer.effect(
RepositoryIdentityResolver,
makeRepositoryIdentityResolver(),
).pipe(Layer.provide(ProcessRunner.layer));