import { execFileSync } from "node:child_process"; import { readFileSync, realpathSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; /** Configuration read from the global Pi Must Win config file. */ export type RepoDisableConfig = { /** URL keys such as `github.com/openclaw/openclaw`, or absolute repository paths. */ disabledRepos: string[]; }; /** Repository identity resolved from a working directory, shared by all its worktrees. */ export type RepoIdentity = { /** Normalized remote URL key such as `github.com/openclaw/openclaw`. */ urlKey: string | undefined; /** Absolute path of the main clone, without a trailing `/.git`. */ repoPath: string | undefined; }; /** Test and integration overrides for {@link isRepoDisabledForSession}. */ export type RepoDisableOptions = { /** Config file path, defaults to {@link DEFAULT_CONFIG_PATH}. */ configPath?: string; /** Working directory used to resolve the repository, defaults to `process.cwd()`. */ cwd?: string; /** Pre-resolved repository identity; skips Git subprocesses when set. */ identity?: RepoIdentity; }; /** Resolve the default config path from `XDG_CONFIG_HOME` with a `~/.config` fallback. */ export function defaultConfigPath(env: NodeJS.ProcessEnv = process.env): string { const configured = env["XDG_CONFIG_HOME"]?.trim(); if (configured === undefined || configured === "") { return join(homedir(), ".config", "pi-must-win", "config.json"); } return join(configured, "pi-must-win", "config.json"); } export const DEFAULT_CONFIG_PATH = defaultConfigPath(); /** Parse config file text, falling back to an empty list on malformed input. */ export function parseConfig(text: string): RepoDisableConfig { let value: unknown; try { value = JSON.parse(text); } catch { return { disabledRepos: [] }; } if (typeof value !== "object" || value === null) return { disabledRepos: [] }; if (!("disabledRepos" in value)) return { disabledRepos: [] }; const entries: unknown = value.disabledRepos; if (!Array.isArray(entries)) return { disabledRepos: [] }; return { disabledRepos: entries.filter((entry): entry is string => typeof entry === "string") }; } /** Load the config file, falling back to an empty list when it cannot be read. */ export function loadConfig(path: string): RepoDisableConfig { let text: string; try { text = readFileSync(path, "utf8"); } catch { return { disabledRepos: [] }; } return parseConfig(text); } /** Normalize a Git remote URL or URL-like config entry to a `host/path` key. */ export function normalizeRepoUrl(value: string): string { let text = value.trim().toLowerCase(); const hadScheme = /^(https?|ssh|git):\/\//.test(text); text = text.replace(/^(https?|ssh|git):\/\//, ""); text = text.replace(/^[^@/]+@/, ""); // Scheme URLs keep an explicit port (`host:2222/path`); scp-like syntax uses `host:path`. text = hadScheme ? text.replace(/^([^:/]+):\d+(?=\/|$)/, "$1") : text.replace(":", "/"); text = text.replace(/\/+$/, ""); text = text.replace(/\.git$/, ""); return text; } function isPathEntry(entry: string): boolean { const text = entry.trimStart(); return text.startsWith("/") || text.startsWith("~"); } function normalizeRepoPath(value: string): string { let text = value.trim(); if (text.startsWith("~")) text = join(homedir(), text.slice(1)); text = text.replace(/\/+$/, ""); text = text.replace(/\/\.git$/, ""); return text; } /** Resolve symlinks when possible so logical config paths match Git's physical paths. */ function resolveRealPath(path: string): string { try { return realpathSync(path); } catch { return path; } } function matchesEntry(identity: RepoIdentity, entry: string): boolean { if (isPathEntry(entry)) { return ( identity.repoPath !== undefined && resolveRealPath(normalizeRepoPath(entry)) === resolveRealPath(identity.repoPath) ); } if (identity.urlKey === undefined) return false; const key = normalizeRepoUrl(entry); return key !== "" && (identity.urlKey === key || identity.urlKey.startsWith(`${key}/`)); } /** Check whether the repository matches a disabled entry; URL entries match path prefixes. */ export function isRepoDisabled(identity: RepoIdentity, config: RepoDisableConfig): boolean { return config.disabledRepos.some((entry) => matchesEntry(identity, entry)); } function git(args: string[], cwd: string): string | undefined { try { return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000, }); } catch { return undefined; } } /** Resolve the repository identity for a working directory, following worktrees to the main clone. */ export function resolveRepoIdentity(cwd: string): RepoIdentity { const remote = git(["remote", "get-url", "origin"], cwd); const commonDir = git(["rev-parse", "--path-format=absolute", "--git-common-dir"], cwd); // An empty URL key never matches a non-empty entry, so no special-casing is needed. return { urlKey: remote === undefined ? undefined : normalizeRepoUrl(remote), repoPath: commonDir === undefined ? undefined : normalizeRepoPath(commonDir), }; } /** Pre-normalized disabled entries for the attribution environment. */ export type DisabledEntries = { /** Lowercase `host/path` URL keys, prefix-matched on path segments. */ urls: string[]; /** Realpath-resolved absolute clone paths, exact-matched. */ paths: string[]; }; /** Normalize the disabled entries once per session; no identity resolution happens here. */ export function disabledEntriesForEnv(configPath: string = DEFAULT_CONFIG_PATH): DisabledEntries { const config = loadConfig(configPath); const urls: string[] = []; const paths: string[] = []; for (const entry of config.disabledRepos) { if (isPathEntry(entry)) { paths.push(resolveRealPath(normalizeRepoPath(entry))); } else { const key = normalizeRepoUrl(entry); if (key !== "") urls.push(key); } } return { urls, paths }; } /** Check the session repository against the global disable config, failing open. */ export function isRepoDisabledForSession(options: RepoDisableOptions = {}): boolean { const config = loadConfig(options.configPath ?? DEFAULT_CONFIG_PATH); // Skip Git subprocesses when nothing can be disabled. const identity = config.disabledRepos.length === 0 ? { urlKey: undefined, repoPath: undefined } : (options.identity ?? resolveRepoIdentity(options.cwd ?? process.cwd())); return isRepoDisabled(identity, config); }