diff --git a/scripts/source-repo-discovery.ts b/scripts/source-repo-discovery.ts index c1a2180..308c84e 100644 --- a/scripts/source-repo-discovery.ts +++ b/scripts/source-repo-discovery.ts @@ -1,6 +1,11 @@ +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 { repoName: string; repoPath: string; @@ -37,8 +42,72 @@ export async function discoverSourceRepos( }); } - repoMatches.sort((left, right) => left.repoName.localeCompare(right.repoName)); - return repoMatches; + const approved = await Promise.all( + repoMatches.map(async (repo) => ({ + repo, + originUrl: await readOriginUrl(repo.repoPath), + })), + ); + + return selectCanonicalGameCultRepos(approved); +} + +async function readOriginUrl(repoPath: string): Promise { + 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>(); + + 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) => chooseCanonicalRepo(entries)) + .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.repoName.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 = normalizeOrigin(originUrl); + return normalized.slice(normalized.lastIndexOf("/") + 1); } export function selectSourceRepos(