com.wallstop-studios.unity-helpers
Version:
Treasure chest of Unity developer tools
163 lines (144 loc) • 5.63 kB
JavaScript
// MIT License — Copyright (c) wallstop studios
//
// postinstall hook: ensure git hooks are installed for local contributors so
// `npm install` is sufficient to get pre-commit / pre-push safety nets.
//
// Idempotent: if core.hooksPath resolves to `.githooks` (including common
// equivalent forms like trailing slash or Windows separators), does nothing.
//
// Safe in CI / non-repo checkouts. Skip triggers (each logs a clear reason):
// - CI=true or CI=1 (CI images install hooks explicitly if needed)
// - SKIP_POSTINSTALL_HOOKS=1 (opt-out for local workflows)
// - NPM_CONFIG_IGNORE_SCRIPTS=true|1 (npm's --ignore-scripts, the standard
// way to opt out of postinstall logic)
// - HUSKY=0 (industry-standard opt-out popularized by Husky)
// - Not inside a git work tree
// - Never fails npm install: any error is logged and we exit 0.
//
// Cross-platform: the chmod is best-effort; on Windows, git-bash/cmd do not
// require chmod for hook execution (git invokes hooks via the shebang).
;
const { execSync, spawnSync } = require("child_process");
const fs = require("fs");
const path = require("path");
function skipReason() {
if (process.env.CI === "true" || process.env.CI === "1") {
return "CI=true — skipping postinstall hook install";
}
if (process.env.SKIP_POSTINSTALL_HOOKS === "1") {
return "SKIP_POSTINSTALL_HOOKS=1 — skipping";
}
// Honor `npm install --ignore-scripts`. npm exports the config as the
// lower-case form `npm_config_ignore_scripts` when it spawns lifecycle
// scripts (so `npm install --ignore-scripts` skips postinstall entirely
// via that path). A contributor re-running the node script directly after
// `export NPM_CONFIG_IGNORE_SCRIPTS=1` only has the upper-case form
// visible. Check both so either manual spelling is honored.
// Accept both common boolean-ish spellings.
const ignoreScripts =
process.env.NPM_CONFIG_IGNORE_SCRIPTS || process.env.npm_config_ignore_scripts;
if (ignoreScripts === "true" || ignoreScripts === "1") {
return `npm_config_ignore_scripts=${ignoreScripts} — skipping`;
}
// Industry-standard opt-out popularized by Husky. Respecting it lets
// contributors who never want hook install (Docker images, ephemeral
// CI checkouts) keep using the familiar flag.
if (process.env.HUSKY === "0") {
return "HUSKY=0 — skipping";
}
return null;
}
function isGitRepo(cwd) {
const result = spawnSync("git", ["rev-parse", "--is-inside-work-tree"], {
cwd,
stdio: ["ignore", "pipe", "ignore"]
});
return result.status === 0 && String(result.stdout).trim() === "true";
}
function currentHooksPath(cwd) {
const result = spawnSync("git", ["config", "--get", "core.hooksPath"], {
cwd,
stdio: ["ignore", "pipe", "ignore"]
});
if (result.status !== 0) {
return "";
}
return String(result.stdout).trim();
}
function normalizeHooksPath(value) {
if (!value) {
return "";
}
let normalized = String(value).replace(/\r/g, "").trim();
normalized = normalized.replace(/\\/g, "/");
while (normalized.startsWith("./")) {
normalized = normalized.slice(2);
}
while (normalized.endsWith("/")) {
normalized = normalized.slice(0, -1);
}
return normalized;
}
function main() {
const reason = skipReason();
if (reason) {
console.log(`[postinstall-hooks] ${reason}`);
return;
}
const repoRoot = path.resolve(__dirname, "..");
const hooksDir = path.join(repoRoot, ".githooks");
if (!fs.existsSync(hooksDir)) {
console.log("[postinstall-hooks] .githooks directory not present — skipping");
return;
}
if (!isGitRepo(repoRoot)) {
console.log("[postinstall-hooks] not a git work tree — skipping");
return;
}
const existing = currentHooksPath(repoRoot);
const normalizedExisting = normalizeHooksPath(existing);
const expectedHooksPath = normalizeHooksPath(".githooks");
if (normalizedExisting === expectedHooksPath) {
// Already configured — idempotent no-op.
return;
}
if (normalizedExisting !== "") {
// A custom hooksPath is already set (power-user config) — do NOT overwrite.
// Surface a hint so the contributor can opt in explicitly if they want this
// repo's hooks.
console.log(
`[postinstall-hooks] core.hooksPath is "${existing}" — leaving unchanged. ` +
"Run 'npm run hooks:install' if you want this repo's hooks."
);
return;
}
try {
execSync("git config core.hooksPath .githooks", { cwd: repoRoot, stdio: "inherit" });
// Best-effort chmod on Unix; ignored on Windows.
if (process.platform !== "win32") {
const hookFiles = ["pre-commit", "pre-merge-commit", "pre-push"];
for (const hook of hookFiles) {
const full = path.join(hooksDir, hook);
if (fs.existsSync(full)) {
try {
fs.chmodSync(full, 0o755);
} catch (_err) {
// Non-fatal: user may already have correct permissions.
}
}
}
}
console.log("[postinstall-hooks] git hooks installed (core.hooksPath=.githooks)");
} catch (err) {
// Never fail npm install over hook setup — log and move on.
console.log(
`[postinstall-hooks] warning: could not configure hooks (${err.message}). Run 'npm run hooks:install' manually.`
);
}
}
try {
main();
} catch (err) {
console.log(`[postinstall-hooks] warning: ${err.message}`);
}