Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 21 additions & 10 deletions src/hooks.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,33 @@
import { spawnSync } from "node:child_process";
import { chmodSync, lstatSync } from "node:fs";
import { closeSync, constants, fchmodSync, fstatSync, openSync } from "node:fs";
import { join } from "node:path";
import { gitEnvironmentForRepository } from "./lib/gitProcess.ts";
import { workspaceLstat } from "./lib/workspaceFs.ts";

export function installHooks(repoRoot: string): string {
const hook = join(repoRoot, ".githooks", "pre-commit");
const stat = lstatSync(hook);
const stat = workspaceLstat(repoRoot, ".githooks/pre-commit");
if (!stat) throw new Error(".githooks/pre-commit is missing");
if (!stat.isFile() || stat.isSymbolicLink()) {
throw new Error(".githooks/pre-commit must be a regular file");
}
const result = spawnSync("git", ["config", "core.hooksPath", ".githooks"], {
cwd: repoRoot,
encoding: "utf8",
env: gitEnvironmentForRepository(),
});
if (result.status !== 0) {
throw new Error(result.stderr.trim() || "could not configure core.hooksPath");
const descriptor = openSync(hook, constants.O_RDONLY | constants.O_NOFOLLOW);
try {
const opened = fstatSync(descriptor);
if (!opened.isFile() || opened.dev !== stat.dev || opened.ino !== stat.ino) {
throw new Error(".githooks/pre-commit changed while installing hooks");
}
const result = spawnSync("git", ["config", "core.hooksPath", ".githooks"], {
cwd: repoRoot,
encoding: "utf8",
env: gitEnvironmentForRepository(),
});
if (result.status !== 0) {
throw new Error(result.stderr.trim() || "could not configure core.hooksPath");
}
fchmodSync(descriptor, opened.mode | 0o111);
} finally {
closeSync(descriptor);
}
chmodSync(hook, stat.mode | 0o111);
return `Git hooks use ${repoRoot}/.githooks`;
}
68 changes: 67 additions & 1 deletion test/hooks.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
import assert from "node:assert/strict";
import { execFileSync, spawnSync } from "node:child_process";
import { chmodSync, mkdirSync, mkdtempSync, realpathSync, writeFileSync } from "node:fs";
import {
chmodSync,
mkdirSync,
mkdtempSync,
realpathSync,
rmSync,
statSync,
symlinkSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
Expand All @@ -10,6 +19,63 @@ import { gitEnvironmentForRepository } from "../src/lib/gitProcess.ts";
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const cli = join(root, "src", "cli.ts");

for (const missing of ["directory", "file"]) {
test(`hooks install reports a missing hook ${missing} without changing config`, () => {
const fixture = mkdtempSync(join(tmpdir(), "workspace-hooks-"));
try {
if (missing === "file") mkdirSync(join(fixture, ".githooks"));
git(fixture, "init", "-q");
git(fixture, "config", "core.hooksPath", "existing-hooks");
const configBefore = git(fixture, "config", "--local", "--list");
const install = spawnSync(process.execPath, [cli, "hooks", "install"], {
cwd: fixture,
encoding: "utf8",
});
assert.equal(install.status, 1, install.stderr);
assert.match(install.stderr, /\.githooks\/pre-commit is missing/);
assert.equal(git(fixture, "config", "--local", "--list"), configBefore);
} finally {
rmSync(fixture, { recursive: true, force: true });
}
});
}

for (const link of ["parent", "hook"]) {
for (const existingHooksPath of [undefined, "existing-hooks"]) {
test(`hooks install rejects a symlinked ${link} with hooksPath ${existingHooksPath ?? "unset"}`, () => {
const temporaryRoot = mkdtempSync(join(tmpdir(), "workspace-hooks-"));
try {
const fixture = join(temporaryRoot, "workspace");
const external = join(temporaryRoot, "external");
mkdirSync(fixture);
mkdirSync(external);
const externalHook = join(external, "pre-commit");
writeFileSync(externalHook, "#!/bin/sh\nexit 0\n");
chmodSync(externalHook, 0o600);
if (link === "parent") {
symlinkSync(external, join(fixture, ".githooks"));
} else {
mkdirSync(join(fixture, ".githooks"));
symlinkSync(externalHook, join(fixture, ".githooks", "pre-commit"));
}
git(fixture, "init", "-q");
if (existingHooksPath) git(fixture, "config", "core.hooksPath", existingHooksPath);
const configBefore = git(fixture, "config", "--local", "--list");
const install = spawnSync(process.execPath, [cli, "hooks", "install"], {
cwd: fixture,
encoding: "utf8",
});
assert.equal(statSync(externalHook).mode & 0o777, 0o600);
assert.equal(git(fixture, "config", "--local", "--list"), configBefore);
assert.equal(install.status, 1, install.stderr);
assert.match(install.stderr, link === "parent" ? /symbolic-link parent/ : /regular file/);
} finally {
rmSync(temporaryRoot, { recursive: true, force: true });
}
});
}
}

function git(cwd: string, ...args: string[]): string {
return execFileSync("git", args, {
cwd,
Expand Down