VoidBot/scripts/source-repo-discovery.ts
2026-07-11 16:23:06 +01:00

191 lines
5.4 KiB
TypeScript

import { execFile as execFileCallback } from "node:child_process";
import { promisify } from "node:util";
import { readdir, readFile, stat } from "node:fs/promises";
import { join, resolve } from "node:path";
const execFile = promisify(execFileCallback);
const GAMECULT_ORIGIN_PATTERN = /github\.com[:/]GameCult\//i;
export interface SourceRepoMatch {
/** Canonical GameCult upstream slug: the source archive identity. */
repoName: string;
/** Local checkout label; diagnostic only, never an archive key. */
localRepoName: string;
repoPath: string;
gitDir: string;
}
export async function discoverSourceRepos(
root: string,
patterns: string[],
): Promise<SourceRepoMatch[]> {
const entries = await readdir(root, { withFileTypes: true });
const repoMatches: SourceRepoMatch[] = [];
for (const entry of entries) {
if (!entry.isDirectory()) {
continue;
}
if (patterns.length > 0 && !patterns.some((pattern) => matchesPattern(entry.name, pattern))) {
continue;
}
const repoPath = join(root, entry.name);
const gitDir = await resolveGitDir(repoPath);
if (!gitDir) {
continue;
}
repoMatches.push({
repoName: entry.name,
localRepoName: entry.name,
repoPath,
gitDir,
});
}
const approved = await Promise.all(
repoMatches.map(async (repo) => ({
repo,
originUrl: await readOriginUrl(repo.repoPath),
})),
);
return selectCanonicalGameCultRepos(approved);
}
async function readOriginUrl(repoPath: string): Promise<string | undefined> {
try {
const { stdout } = await execFile("git", ["-C", repoPath, "remote", "get-url", "origin"], {
windowsHide: true,
});
const originUrl = stdout.trim();
return originUrl.length > 0 ? originUrl : undefined;
} catch {
return undefined;
}
}
function selectCanonicalGameCultRepos(
candidates: Array<{ repo: SourceRepoMatch; originUrl?: string }>,
): SourceRepoMatch[] {
const byOrigin = new Map<string, Array<{ repo: SourceRepoMatch; originUrl: string }>>();
for (const candidate of candidates) {
if (!candidate.originUrl || !GAMECULT_ORIGIN_PATTERN.test(candidate.originUrl)) {
continue;
}
const originKey = normalizeOrigin(candidate.originUrl);
const entries = byOrigin.get(originKey) ?? [];
entries.push({ repo: candidate.repo, originUrl: candidate.originUrl });
byOrigin.set(originKey, entries);
}
return [...byOrigin.values()]
.map((entries) => {
const canonical = chooseCanonicalRepo(entries);
return {
...canonical,
repoName: repoNameFromOrigin(entries[0].originUrl),
};
})
.sort((left, right) => left.repoName.localeCompare(right.repoName));
}
function chooseCanonicalRepo(entries: Array<{ repo: SourceRepoMatch; originUrl: string }>): SourceRepoMatch {
const remoteName = repoNameFromOrigin(entries[0].originUrl);
return entries
.slice()
.sort((left, right) => canonicalRank(left.repo, remoteName) - canonicalRank(right.repo, remoteName)
|| left.repo.repoName.localeCompare(right.repo.repoName))[0].repo;
}
function canonicalRank(repo: SourceRepoMatch, remoteName: string): number {
if (repo.localRepoName.localeCompare(remoteName, undefined, { sensitivity: "accent" }) === 0) {
return 0;
}
return repo.gitDir.replace(/\\/g, "/").toLowerCase().endsWith("/.git") ? 1 : 2;
}
function normalizeOrigin(originUrl: string): string {
return originUrl.trim().replace(/\\/g, "/").replace(/\.git$/i, "").toLowerCase();
}
function repoNameFromOrigin(originUrl: string): string {
const normalized = originUrl.trim().replace(/\\/g, "/").replace(/\.git$/i, "");
return normalized.slice(normalized.lastIndexOf("/") + 1);
}
export function selectSourceRepos(
availableRepos: SourceRepoMatch[],
requestedRepoNames?: string[],
): SourceRepoMatch[] {
if (!requestedRepoNames || requestedRepoNames.length === 0) {
return availableRepos;
}
const lookup = new Map(
availableRepos.flatMap((repo) => [
[repo.repoName.toLowerCase(), repo] as const,
[repo.localRepoName.toLowerCase(), repo] as const,
]),
);
const selectedRepos: SourceRepoMatch[] = [];
const missingRepoNames: string[] = [];
for (const requestedRepoName of requestedRepoNames) {
const repo = lookup.get(requestedRepoName.toLowerCase());
if (!repo) {
missingRepoNames.push(requestedRepoName);
continue;
}
if (!selectedRepos.some((entry) => entry.repoName === repo.repoName)) {
selectedRepos.push(repo);
}
}
if (missingRepoNames.length > 0) {
throw new Error(
`Requested source repos were not found under SOURCE_REPO_ROOT: ${missingRepoNames.join(", ")}`,
);
}
return selectedRepos;
}
export async function resolveGitDir(repoPath: string): Promise<string | undefined> {
const dotGitPath = join(repoPath, ".git");
try {
const dotGitStats = await stat(dotGitPath);
if (dotGitStats.isDirectory()) {
return dotGitPath;
}
if (dotGitStats.isFile()) {
const pointer = await readFile(dotGitPath, "utf8");
const match = pointer.match(/^gitdir:\s*(.+)$/im);
if (match?.[1]) {
return resolve(repoPath, match[1].trim());
}
}
} catch {
return undefined;
}
return undefined;
}
function matchesPattern(value: string, pattern: string): boolean {
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
const regex = new RegExp(`^${escaped}$`, "i");
return regex.test(value);
}