VoidBot/scripts/install-source-index-post-push-hooks.ts
2026-04-24 13:17:00 +01:00

205 lines
5.6 KiB
TypeScript

import "dotenv/config";
import { readdir, chmod, mkdir, readFile, stat, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { loadConfig } from "@voidbot/config";
const HOOK_MARKER = "# voidbot-source-index-push-hook";
interface ScriptOptions {
repos?: string[];
}
async function main(): Promise<void> {
if (!process.env.DISCORD_OWNER_ID) {
process.env.DISCORD_OWNER_ID = "__source_hook_install__";
}
const config = loadConfig();
if (!config.sourceRepoRoot) {
throw new Error("SOURCE_REPO_ROOT is not configured.");
}
const options = parseArgs(process.argv.slice(2));
const repoNames =
options.repos && options.repos.length > 0
? options.repos
: await resolveRepoNames(config.sourceRepoRoot, config.sourceRepoPatterns);
let installed = 0;
let updated = 0;
let backedUp = 0;
let skipped = 0;
for (const repoName of repoNames) {
const repoPath = join(config.sourceRepoRoot, repoName);
const gitDir = await resolveGitDir(repoPath);
if (!gitDir) {
console.log(`Skipping ${repoName}: no .git directory found.`);
skipped += 1;
continue;
}
const hooksDir = join(gitDir, "hooks");
const hookPath = join(hooksDir, "pre-push");
const backupHookPath = join(hooksDir, "pre-push.voidbot.prev");
await mkdir(hooksDir, { recursive: true });
let existingContent: string | undefined;
try {
existingContent = await readFile(hookPath, "utf8");
} catch {
existingContent = undefined;
}
if (
existingContent &&
!existingContent.includes(HOOK_MARKER) &&
!(await pathExists(backupHookPath))
) {
await writeFile(backupHookPath, existingContent, "utf8");
await chmod(backupHookPath, 0o755);
backedUp += 1;
} else if (
existingContent &&
!existingContent.includes(HOOK_MARKER) &&
(await pathExists(backupHookPath))
) {
console.log(`Skipping ${repoName}: existing pre-push hook already has a Void backup; not clobbering it.`);
skipped += 1;
continue;
}
const hookScript = renderHookScript(process.cwd(), repoName);
await writeFile(hookPath, hookScript, "utf8");
await chmod(hookPath, 0o755);
if (existingContent?.includes(HOOK_MARKER)) {
updated += 1;
console.log(`Updated pre-push hook for ${repoName}.`);
} else {
installed += 1;
console.log(`Installed pre-push hook for ${repoName}.`);
}
}
console.log(
[
`installed=${installed}`,
`updated=${updated}`,
`backedUp=${backedUp}`,
`skipped=${skipped}`,
].join(" "),
);
}
function parseArgs(argv: string[]): ScriptOptions {
const options: ScriptOptions = {};
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index];
if ((argument === "--repo" || argument === "--repos") && argv[index + 1]) {
options.repos = argv[index + 1]
.split(",")
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
index += 1;
}
}
return options;
}
async function resolveRepoNames(root: string, patterns: string[]): Promise<string[]> {
const entries = await readdir(root, { withFileTypes: true });
return entries
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.filter((name) => patterns.length === 0 || patterns.some((pattern) => matchesPattern(name, pattern)))
.sort((left, right) => left.localeCompare(right));
}
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 renderHookScript(voidbotRoot: string, repoName: string): string {
const normalizedRoot = voidbotRoot.replace(/\\/g, "/");
const launcherPath = `${normalizedRoot}/scripts/git-post-push-index.mjs`;
const nodePath = process.execPath.replace(/\\/g, "/");
const quotedNodePath = escapeForDoubleQuotedShell(nodePath);
const quotedLauncherPath = escapeForDoubleQuotedShell(launcherPath);
const quotedRoot = escapeForDoubleQuotedShell(normalizedRoot);
const quotedRepoName = escapeForDoubleQuotedShell(repoName);
return [
"#!/bin/sh",
HOOK_MARKER,
"HOOK_DIR=\"$(CDPATH= cd -- \"$(dirname -- \"$0\")\" && pwd)\"",
"PREV_HOOK=\"$HOOK_DIR/pre-push.voidbot.prev\"",
"",
"if [ -x \"$PREV_HOOK\" ]; then",
" \"$PREV_HOOK\" \"$@\"",
" PREV_STATUS=$?",
" if [ \"$PREV_STATUS\" -ne 0 ]; then",
" exit \"$PREV_STATUS\"",
" fi",
"fi",
"",
`\"${quotedNodePath}\" \"${quotedLauncherPath}\" --voidbot-root \"${quotedRoot}\" --repo-name \"${quotedRepoName}\" >/dev/null 2>&1 || true`,
"",
"exit 0",
"",
].join("\n");
}
function escapeForDoubleQuotedShell(value: string): string {
return value.replace(/(["`$\\])/g, "\\$1");
}
function matchesPattern(value: string, pattern: string): boolean {
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
const regex = new RegExp(`^${escaped}$`, "i");
return regex.test(value);
}
async function pathExists(path: string): Promise<boolean> {
try {
await stat(path);
return true;
} catch {
return false;
}
}
void main().catch((error) => {
console.error(error);
process.exitCode = 1;
});