Last active
February 5, 2024 06:42
-
-
Save nberlette/c85798a1e8ce7c305ab7d1c3a5a309db to your computer and use it in GitHub Desktop.
`Hook`: git hooks integration with Deno's task runner
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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