147 lines
4.8 KiB
JavaScript
147 lines
4.8 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { access, lstat, mkdir, readFile, readdir, rename, rm } from 'node:fs/promises';
|
|
import { execFile, spawn } from 'node:child_process';
|
|
import { promisify } from 'node:util';
|
|
import { fileURLToPath } from 'node:url';
|
|
import path from 'node:path';
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
function run(command, args, options) {
|
|
return new Promise((resolve, reject) => {
|
|
const child = spawn(command, args, options);
|
|
child.once('error', reject);
|
|
child.once('exit', (code, signal) => {
|
|
if (code === 0) resolve();
|
|
else reject(new Error(signal ? `husky terminated by ${signal}` : `husky exited ${code}`));
|
|
});
|
|
});
|
|
}
|
|
|
|
async function pathExists(target) {
|
|
try {
|
|
await access(target);
|
|
return true;
|
|
} catch (error) {
|
|
if (error.code === 'ENOENT') return false;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function directorySnapshot(root) {
|
|
const snapshot = [];
|
|
async function walk(current) {
|
|
const children = await readdir(current, { withFileTypes: true });
|
|
for (const child of children.sort((left, right) => left.name.localeCompare(right.name))) {
|
|
const target = path.join(current, child.name);
|
|
const relative = path.relative(root, target);
|
|
const stats = await lstat(target);
|
|
if (child.isDirectory()) {
|
|
snapshot.push([relative, 'directory', stats.mode & 0o777]);
|
|
await walk(target);
|
|
} else {
|
|
snapshot.push([
|
|
relative,
|
|
'file',
|
|
stats.mode & 0o777,
|
|
(await readFile(target)).toString('base64'),
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
await walk(root);
|
|
return JSON.stringify(snapshot);
|
|
}
|
|
|
|
async function directoriesMatch(left, right) {
|
|
return (await directorySnapshot(left)) === (await directorySnapshot(right));
|
|
}
|
|
|
|
export async function installHooks({
|
|
root = process.cwd(),
|
|
disabled = process.env.HUSKY === '0',
|
|
quarantineRoot = path.join(root, '.mosaic-test-work', 'husky-quarantine'),
|
|
runHusky = async (_stagingHooks, stagingRepo) => {
|
|
await execFileAsync('git', ['init', '--quiet', stagingRepo]);
|
|
await run(path.join(root, 'node_modules', '.bin', 'husky'), ['.husky'], {
|
|
cwd: stagingRepo,
|
|
stdio: 'inherit',
|
|
});
|
|
},
|
|
activateHooks = async () => {
|
|
await run('git', ['config', 'core.hooksPath', '.husky/_'], { cwd: root, stdio: 'inherit' });
|
|
},
|
|
} = {}) {
|
|
if (disabled) return;
|
|
|
|
const huskyDir = path.join(root, '.husky');
|
|
const active = path.join(huskyDir, '_');
|
|
const nonce = `${Date.now()}-${process.pid}`;
|
|
const stagingRepo = path.join(root, '.mosaic-test-work', `husky-stage-${nonce}`);
|
|
const stagingHooks = path.join(stagingRepo, '.husky');
|
|
const quarantined = path.join(quarantineRoot, `${path.basename(root)}-${nonce}`);
|
|
await mkdir(huskyDir, { recursive: true });
|
|
await mkdir(quarantineRoot, { recursive: true });
|
|
|
|
const previousComplete = (await pathExists(active)) && (await pathExists(path.join(active, 'h')));
|
|
let previousQuarantined = false;
|
|
try {
|
|
if ((await pathExists(active)) && !previousComplete) {
|
|
await rename(active, quarantined);
|
|
previousQuarantined = true;
|
|
}
|
|
await mkdir(stagingRepo, { recursive: true });
|
|
await runHusky(stagingHooks, stagingRepo);
|
|
const staged = path.join(stagingHooks, '_');
|
|
if (!(await pathExists(path.join(staged, 'h')))) {
|
|
throw new Error('husky did not produce its required h shim');
|
|
}
|
|
if (previousComplete) {
|
|
if (!(await directoriesMatch(active, staged))) {
|
|
throw new Error('existing complete hook set differs from the installed Husky version');
|
|
}
|
|
await rm(stagingRepo, { recursive: true, force: true });
|
|
} else {
|
|
await rename(staged, active);
|
|
await rm(stagingRepo, { recursive: true, force: true });
|
|
}
|
|
await activateHooks();
|
|
if (previousQuarantined) {
|
|
try {
|
|
await rm(quarantined, { recursive: true, force: true });
|
|
} catch {
|
|
console.warn(
|
|
`Previous hook state was deactivated but remains quarantined at ${quarantined}`,
|
|
);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
const cleanupFailures = [];
|
|
try {
|
|
if (await pathExists(stagingRepo)) {
|
|
await rename(stagingRepo, `${quarantined}-staging`);
|
|
}
|
|
} catch (cleanupError) {
|
|
cleanupFailures.push(`staging hooks: ${cleanupError.message}`);
|
|
}
|
|
const cleanup =
|
|
cleanupFailures.length === 0
|
|
? 'No partial hook set was activated.'
|
|
: `Automatic cleanup was incomplete (${cleanupFailures.join('; ')}).`;
|
|
throw new Error(
|
|
`Hook installation failed: ${error.message}. ${cleanup} Fix: rm -rf .husky/_ && git config core.hooksPath .husky/_ && pnpm install --frozen-lockfile`,
|
|
{ cause: error },
|
|
);
|
|
}
|
|
}
|
|
|
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
try {
|
|
await installHooks();
|
|
} catch (error) {
|
|
console.error(error.message);
|
|
process.exitCode = 1;
|
|
}
|
|
}
|