Skip to content

Instantly share code, notes, and snippets.

@nberlette
Last active February 5, 2024 06:42
Show Gist options
  • Save nberlette/c85798a1e8ce7c305ab7d1c3a5a309db to your computer and use it in GitHub Desktop.
Save nberlette/c85798a1e8ce7c305ab7d1c3a5a309db to your computer and use it in GitHub Desktop.
`Hook`: git hooks integration with Deno's task runner
const TASK_NAME_REGEXP = /^[a-z\^\$][a-z0-9\-_$:.\^]*$/i;
type TaskConfig = { [key: string]: string };
type HooksConfig<K extends MaybeHookNames = Hook.Name> = {
readonly [P in K]: string | string[];
};
type HookNames = typeof Hook.names[number];
type strings = string & { __strings__?: never };
type MaybeHookNames = strings | HookNames;
const array = <const T>(value: T): (
| T extends readonly unknown[] ? T
: string extends T ? T[] : [T]
) => Array.isArray(value) ? value : [value] as any;
export interface Hook {
/**
* Run a git hook.
* @param tasks - The scripts / tasks to run.
* @returns The results of the tasks and/or scripts that were run.
*/
<const T extends readonly string[]>(...tasks: [...T]): Promise<Hook.RunResults<T>>;
}
export class Hook extends Function {
/** Current version of the {@link Hook} module. */
static readonly version = "0.0.1";
/** Global instance of {@link Hook} */
static readonly default: Hook = new Hook();
static {
// freeze the version and default instance. this is a one-time operation.
Object.defineProperties(this, {
version: { configurable: false, writable: false },
default: { configurable: false, writable: false },
});
}
/** Remote URL of the {@link Hook} module. */
static readonly #remote = `https://deno.land/x/hook@${Hook.version}/mod.ts`;
static readonly #importMeta: ImportMeta = {
url: Hook.#remote,
resolve: (s: string) => new URL(s, Hook.#remote).toString(),
main: false as boolean,
get filename(): string | undefined {
const resolved = new URL(this.resolve("."));
if (resolved.protocol === "file:") return resolved.toString();
},
get dirname(): string | undefined {
const resolved = new URL(this.resolve("."));
if (resolved.protocol === "file:") {
return resolved.toString().split(
/(?<=(?<!\/)\/|(?<!\\)\\)/
).slice(0, -1).join("");
}
},
} satisfies ImportMeta;
/** Returns the source path (local or remote) for the {@link Hook} module.
* This is used to construct the import statement in the generated git hooks.
* To override the output path (e.g. for testing, or custom implementations),
* you may provide a custom path using the `HOOK_PATH` environment variable.
* The path must point to a file that exports the {@linkcode Hook} API. */
static get PATH() {
const remote = Hook.#remote;
try {
Object.assign(Hook.#importMeta, import.meta);
} catch { /* ignore */ }
if (Deno.env.get("HOOK_PATH")) {
return Deno.env.get("HOOK_PATH") ?? remote;
} else if (Deno.env.get("DEBUG")) {
try {
return new URL(import.meta.url).toString();
} catch { /* ignore */ }
if ("filename" in Hook.#importMeta) {
return Hook.#importMeta.filename ?? remote;
}
}
return remote;
}
/** List of all valid git hook names */
static readonly names = [
'applypatch-msg',
'pre-applypatch',
'post-applypatch',
'pre-commit',
'pre-merge-commit',
'prepare-commit-msg',
'commit-msg',
'post-commit',
'pre-rebase',
'post-checkout',
'post-merge',
'pre-push',
'pre-receive',
'update',
'post-receive',
'post-update',
'reference-transaction',
'push-to-checkout',
'pre-auto-gc',
'post-rewrite',
] as const;
private static get validGitHooks(): Set<string> {
return new Set(Hook.names);
}
public static is<S extends strings | Hook.Name>(name: S): name is S extends Hook.Name ? S : never {
return Hook.validGitHooks.has(name);
}
public static assert<S extends string>(name: S): asserts name is Extract<Hook.Name, S> {
if (!Hook.is(name)) throw new InvalidHookError(name);
}
public static async run<const T extends readonly string[]>(
id: Hook.Name,
...tasks: [...T]
): Promise<Hook.RunResults<T>> {
const hook = new Hook();
return await hook(...tasks);
}
public static shebang(cwd = Deno.cwd()) {
return `#!/usr/bin/env deno run --allow-run --allow-read=${cwd} --allow-write=${cwd} --allow-env --unstable` as const;
}
#__denoExecPath?: string;
#__shellExecPath?: string;
get #denoExecPath(): string {
return this.#__denoExecPath ??= Deno.execPath();
}
get #shellExecPath(): string {
if (!this.#__shellExecPath) {
if (Deno.build.os === "windows") {
this.#__shellExecPath = Deno.env.get("COMSPEC") ?? "cmd.exe";
} else {
this.#__shellExecPath = Deno.env.get("SHELL") ?? "/bin/sh";
}
}
return this.#__shellExecPath;
}
#shellCmdPrefix = Deno.build.os === "windows" ? "/c" : "-c";
/**
* Represents a parsed Deno configuration file.
* @internal
*/
public config: {
tasks?: TaskConfig;
hooks?: HooksConfig;
} = { tasks: {} };
constructor(
private readonly cwd: string = Deno.cwd(),
private configPath = './deno.json',
) {
super("...tasks", "return this.run(...tasks)");
this.loadConfig(configPath);
// we need to bind the hook to itself to ensure it doesn't lose its context
// when called. but we cannot use `.bind` since it nukes the class props.
return new Proxy(this, {
apply: (target, thisArg, args) => {
return target.run(...Array.from(args) as Parameters<Hook['run']>);
},
});
}
public install(cwd = Deno.cwd()): void {
const git_dir = `${cwd}/.git`;
const dir = `${git_dir}/hooks`;
for (const hook of Hook.names) {
const path = `${dir}/${hook}`;
const shebang = Hook.shebang(cwd);
const code = [shebang,`/*! THIS FILE IS AUTOGENERATED. DO NOT EDIT. */`,
`import { Hook } from "${Hook.PATH}";`,
`Hook.run("${hook}");\n`,
].join("\n");
Deno.mkdirSync(git_dir, { recursive: true });
Deno.mkdirSync(dir, { recursive: true });
Deno.writeTextFileSync(path, code, { mode: 0o755 });
}
}
private loadConfig(path?: string | URL): typeof this.config {
let config: string;
try {
if (path) this.configPath = path.toString();
config = Deno.readTextFileSync(this.configPath);
} catch (error) {
if (error.name === "NotFound") {
throw new DenoConfigNotFoundError(this.configPath);
} else {
throw error;
}
}
try {
this.config = JSON.parse(config);
} catch (error) {
throw new DenoConfigParseError(this.configPath, error);
}
if (!this.hasHooks()) throw new DenoConfigNoHooksError(this.configPath);
if (!this.hasTasks()) throw new DenoConfigNoTasksError(this.configPath);
if (!this.validateHooks()) {
throw new DenoConfigInvalidHooksError(this.configPath, this.config.hooks);
}
return this.config;
}
private hasHooks(): this is this extends infer T extends typeof this ? T & {
readonly config: T["config"] & {
readonly hooks: HooksConfig;
};
} : never {
return "hooks" in this.config && this.config.hooks != null && typeof this.config.hooks === "object" && !Array.isArray(this.config.hooks) && Object.keys(this.config.hooks).length > 0;
}
private hasTasks(): this is this extends infer T extends typeof this ? T & {
readonly config: T["config"] & {
readonly tasks: TaskConfig;
};
} : never {
return "tasks" in this.config && this.config.tasks != null && typeof this.config.tasks === "object" && !Array.isArray(this.config.tasks) && Object.keys(this.config.tasks).length > 0;
}
private validateHooks(): boolean {
if (this.hasHooks()) {
const hooks = this.config.hooks;
if (Object.keys(hooks).length) {
for (const h in hooks) {
if (!Hook.validGitHooks.has(h)) return false;
const tasks = hooks[h];
if (!array(tasks).length) return false;
}
return true;
}
}
return false;
}
public async run<H extends Hook.Name>(hook: H): Promise<Hook.RunResults<[]>> {
const hooks = this.config.hooks;
if (!hooks || !hooks[hook]) throw new InvalidHookError(hook);
const tasks = array(hooks[hook]);
const results = await Promise.allSettled(
tasks.map((task) => this.runTaskOrScript(task).catch((error) => {
throw new HookTaskRuntimeError(hook, task, error);
}),
));
return results.map(({ status, ...r }) => ({
status: status === "fulfilled" ? "success" : "failure",
output: "value" in r ? r.value : r.reason as Error,
} as const));
}
protected async runTaskOrScript(task: string): Promise<Deno.CommandOutput> {
let bin: string;
let args: string[];
if (task.startsWith('!')) {
bin = this.#shellExecPath;
args = [this.#shellCmdPrefix, task.slice(1)];
} else {
const { tasks } = this.config ?? {};
if (!tasks) {
throw new DenoConfigNoTasksError(this.configPath);
} else if (!(task in tasks) || tasks[task]) {
throw new InvalidTaskNameError(task);
}
bin = this.#denoExecPath;
args = ['task', task];
}
const stdin = "null", stdout = "piped", stderr = "piped";
const cmd = new Deno.Command(bin, { args, stdin, stdout, stderr }).spawn();
return await cmd.output();
}
protected runTaskOrScriptSync(task: string): Deno.CommandOutput {
let bin: string;
let args: string[];
if (task.startsWith('!')) {
bin = this.#shellExecPath;
args = [this.#shellCmdPrefix, task.slice(1)];
} else {
const { tasks } = this.config ?? {};
if (!tasks) {
throw new DenoConfigNoTasksError(this.configPath);
} else if (!(task in tasks) || tasks[task]) {
throw new InvalidTaskNameError(task);
}
bin = this.#denoExecPath;
args = ['task', task];
}
const stdin = "null", stdout = "piped", stderr = "piped";
return new Deno.Command(bin, { args, stdin, stdout, stderr }).outputSync();
}
}
export declare namespace Hook {
export type names = typeof Hook.names;
export type Name = names[number];
interface UnknownError extends globalThis.Error {}
interface ErrorTypes {
InvalidHookError: InvalidHookError;
InvalidTaskNameError: InvalidTaskNameError;
InvalidHookConfigError: InvalidHookConfigError;
DenoConfigNotFoundError: DenoConfigNotFoundError;
DenoConfigParseError: DenoConfigParseError;
DenoConfigNoHooksError: DenoConfigNoHooksError;
DenoConfigNoTasksError: DenoConfigNoTasksError;
DenoConfigInvalidHooksError: DenoConfigInvalidHooksError;
HookTaskRuntimeError: HookTaskRuntimeError;
HookScriptRuntimeError: HookScriptRuntimeError;
[key: string & {}]: UnknownError;
}
export type Error = ErrorTypes[keyof ErrorTypes];
/** Represents a single task or script to be run by a git hook. */
export interface Command {
/** The hook that delegates this command to be run. */
readonly hook: Name;
/** The name of the command to be run. */
readonly name: string;
/** The command string (with all arguments) that was run. */
readonly text: string;
/** The type of command being run: either a task or a shell script. */
readonly type: "task" | "script";
}
export interface Output extends Pick<Deno.CommandOutput, "code" | "signal" | "success"> {
/** The hook that was responsible for delegating this task or script. */
readonly hook: Name;
/** The index of this task or script in the parent hook's task list. */
readonly index: number;
/** The type of command being run: either a task or a shell script. */
readonly type: "task" | "script";
/** The command string (with all arguments) that was run. */
readonly command: string;
/** An aggregated list of any errors that occurred during execution. */
readonly errors: readonly Error[] | undefined;
/** The combined output text of {@link stdout} and {@link stderr}. */
readonly output: string;
/** The combined output of {@link stdoutBytes} and {@link stderrBytes}. */
readonly outputBytes: Uint8Array;
/** The output text of the command's `stdout` (standard output) stream. */
readonly stdout: string;
/** The output bytes of the command's `stdout` (standard output) stream. */
readonly stdoutBytes: Uint8Array;
/** The output text of the command's `stderr` (standard error) stream. */
readonly stderr: string;
/** The output bytes of the command's `stderr` (standard error) stream. */
readonly stderrBytes: Uint8Array;
}
export type RunResults<T extends readonly string[]> =
| T extends readonly [] ? RunResults<string[]> : [...{
[K in keyof T]: K extends number | `${number}` ? Output : T[K];
}];
type RunResultsTest1 = RunResults<[]>;
type RunResultsTest2 = RunResults<["test"]>;
type RunResultsTest3 = RunResults<["test", "lint"]>;
type RunResultsTest4 = RunResults<["test", "lint", string]>;
}
// #region Errors
/**
* Serialized list of all valid git hook names.
* @internal
*/
const validHookNames = Hook.names.map((name) => ` - ${name}`).join("\n");
export class InvalidHookError extends Error {
constructor(
public readonly hook: string,
public readonly expected?: Hook.Name,
public override readonly cause?: Error,
) {
let message = `Invalid git hook '${hook}' (${typeof hook}). `;
if (expected) {
message += `Expected '${expected}'.`;
} else {
message += `Expected one of the following names:\n${validHookNames}\n`;
}
super(message, { cause });
this.name = "InvalidHookError";
Error.captureStackTrace?.(this);
this.stack?.slice();
}
}
export class InvalidTaskNameError extends Error {
constructor(
public readonly task: string,
public readonly expected?: string,
public override readonly cause?: Error,
) {
let message = `Invalid task name '${task}' (${typeof task}). `;
if (expected) {
message += `Expected '${expected}'.`;
} else {
message += `Expected a string matching the following regular expression:\n${TASK_NAME_REGEXP}\n`;
}
super (message, { cause });
this.name = "InvalidTaskError";
Error.captureStackTrace?.(this);
this.stack?.slice();
}
}
export class InvalidHookConfigError extends Error {
constructor(
public readonly config: unknown,
public override readonly cause?: Error,
) {
super(`Invalid hook config (${typeof config}). Expected an object.`, { cause });
this.name = "InvalidHookConfigError";
Error.captureStackTrace?.(this);
this.stack?.slice();
}
}
export class DenoConfigNotFoundError extends Error {
constructor(
public readonly path: string,
public override readonly cause?: Error,
) {
super(`Deno config file could not be located: ${path}`, { cause });
this.name = "DenoConfigNotFoundError";
Error.captureStackTrace?.(this);
this.stack?.slice();
}
}
export class DenoConfigParseError extends Error {
constructor(
public readonly path: string,
public override readonly cause?: Error,
) {
super(
`Failed to parse Deno config file: ${path}\n\n${cause?.message ?? ""}`, {
cause,
}
);
this.name = "DenoConfigParseError";
Error.captureStackTrace?.(this);
this.stack?.slice();
}
}
export class DenoConfigNoHooksError extends Error {
constructor(
public readonly path: string,
public override readonly cause?: Error,
) {
super(`Deno config file does not contain a 'hooks' property: ${path}`, { cause });
this.name = "DenoConfigNoHooksError";
Error.captureStackTrace?.(this);
this.stack?.slice();
}
}
export class DenoConfigNoTasksError extends Error {
constructor(
public readonly path: string,
public override readonly cause?: Error,
) {
super(`Deno config file does not contain a 'tasks' property: ${path}`, { cause });
this.name = "DenoConfigNoTasksError";
Error.captureStackTrace?.(this);
this.stack?.slice();
}
}
export class DenoConfigInvalidHooksError extends Error {
constructor(
public readonly path: string,
public readonly hooks: unknown,
public override readonly cause?: Error,
) {
super(`Deno config file contains an invalid 'hooks' property (${typeof hooks}). Expected an object with hook names for keys, and one or more task names or shell scripts for values. Valid hook names:\n${validHookNames}\n`, { cause });
this.name = "DenoConfigInvalidHooksError";
Error.captureStackTrace?.(this);
this.stack?.slice();
}
}
export class HookTaskRuntimeError extends Error {
constructor(
public readonly hook: string,
public readonly task: string,
public override readonly cause?: Error,
) {
let message = `Failed to run task '${task}' for hook '${hook}'.\n\n${cause?.message}`;
super(message, { cause });
this.name = "HookTaskRuntimeError";
Error.captureStackTrace?.(this);
this.stack?.slice();
}
}
export class HookScriptRuntimeError extends Error {
constructor(
public readonly hook: string,
public readonly script: string,
public override readonly cause?: Error,
) {
let message = `Failed to run script '${script}' for hook '${hook}'.\n\n${cause?.message}`;
super(message, { cause });
this.name = "HookScriptRuntimeError";
Error.captureStackTrace?.(this);
this.stack?.slice();
}
}
// #endregion Errors
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment