Last active
August 2, 2024 14:19
-
-
Save nberlette/c85798a1e8ce7c305ab7d1c3a5a309db to your computer and use it in GitHub Desktop.
`Hook`: git hooks integration with Deno's task runner
This file contains hidden or 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 & {}; | |
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 commands The script(s) and/or task(s) to run. | |
* @returns The results of the tasks and/or scripts that were run. | |
*/ | |
<const T extends readonly string[]>(...commands: [...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(); | |
/** 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() { | |
const resolved = new URL(this.resolve(".")); | |
if (resolved.protocol === "file:") return resolved.toString(); | |
}, | |
get dirname() { | |
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(): string { | |
const { remote } = Hook; | |
if (Deno.env.get("HOOK_PATH")) return Deno.env.get("HOOK_PATH") ?? remote; | |
const importMeta = { ...Hook.importMeta }; | |
try { | |
Object.assign(importMeta, import.meta); | |
} catch { /* ignore */ } | |
return importMeta.filename ?? 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; | |
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 }, | |
names: { configurable: false, writable: false }, | |
}); | |
Object.freeze(this.names); | |
} | |
static get validGitHooks(): Set<string> { | |
return new Set(Hook.names); | |
} | |
static is<S extends strings | Hook.Name>(name: S): name is S extends Hook.Name ? S : never { | |
return Hook.validGitHooks.has(name); | |
} | |
static assert<S extends string>(name: S): asserts name is Extract<Hook.Name, S> { | |
if (!Hook.is(name)) throw new InvalidHookError(name); | |
} | |
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); | |
} | |
static shebang(cwd = Deno.cwd()) { | |
return `#!/usr/bin/env -S deno run --allow-run --allow-read=${cwd} --allow-write=${cwd} --allow-env --unstable-fs` as const; | |
} | |
static #getExecutableSource(id: string, root: string | URL, path: string | URL): string { | |
const pathname = `${path}/${id}`; | |
const shebang = Hook.shebang(root); | |
const copyright = Hook.copyright(id); | |
const code = [ | |
shebang, | |
copyright, | |
`import { Hook } from "${Hook.path}";`, | |
`if (!import.meta.main) {`, | |
` reportError("Cannot run '${id}' hook in a non-executable context. " + `, | |
` "Please run the hook directly rather than attempting to import it.");`, | |
`}`, | |
`const { stdin, args } = Deno;`, | |
`const h = Hook.from("${id}"), p = h.spawn();`, | |
`p.ref();`, | |
`if (args.length) h.setArguments(...args);`, | |
`await stdin.readable.pipeTo(p.stdin.writable);`, | |
`const r = await p.output();`, | |
`p.unref();`, | |
`Deno.exit(r.code);`, | |
].join("\n"); | |
} | |
#__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 | |
*/ | |
readonly config: { | |
tasks?: TaskConfig; | |
hooks?: HooksConfig; | |
} = { hooks: {}, tasks: {} }; | |
constructor( | |
readonly cwd: string = Deno.cwd(), | |
readonly 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: (t, _, a) => Reflect.apply(t.run, this, Array.from(a)), | |
}); | |
} | |
async install( | |
names = Hook.names[], | |
root = Deno.cwd(), | |
path = `${root}/.hooks`, | |
): Promise<this> { | |
for (const id of names) { | |
const code = Hook.#getExecutableCode(id, root, path); | |
await Deno.mkdir(path, { recursive: true }).catch(() => {}); | |
await Deno.writeTextFile(`${path}/${id}`, code, { mode: 0o755 }); | |
} | |
return this; | |
} | |
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; | |
} | |
hasHooks(): this is this Hook.HasHooks<this> { | |
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 | |
); | |
} | |
hasTasks(): this is Hook.HasTasks<this> { | |
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 | |
); | |
} | |
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; | |
} | |
async run<H extends Hook.Name>(hook: H): Promise<Hook.RunResults<[]>> { | |
if (!this.config.hooks?.[hook]) throw new InvalidHookError(hook); | |
const tasks = [this.config.hooks?.[hook]].flat(); | |
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)); | |
} | |
getRunner( | |
command: `!${strings}` | strings, | |
options?: Deno.CommandOptions, | |
): Deno.Command { | |
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]; | |
} | |
options = { | |
args, | |
env, | |
clearEnv, | |
stdin: "null", | |
stdout: "piped", | |
stderr: "piped", | |
...options, | |
}; | |
}; | |
return new Deno.Command(bin, options); | |
} | |
spawn(task: string, env?: Record<string, string>): Deno.ChildProcess { | |
return this.getRunner(task, { env, stdin: "piped" }); | |
} | |
async runTaskOrScript(task: string, env?: Record<string, string>): Promise<Deno.CommandOutput> { | |
return await this.getRunner(task, { env }).output(); | |
} | |
runTaskOrScriptSync(task: string, env?: Record<string, string>): Deno.CommandOutput { | |
return this.getRunner(task, { env }).outputSync(); | |
} | |
} | |
export declare namespace Hook { | |
export {}; | |
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: strings]: 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 [] ? Output[] | |
: string[] extends T ? Output[] | |
: [...{ [K in keyof T]: K extends number | `${number}` ? Output : T[K] }]; | |
export type HasHooks<T extends Hook> = T & { | |
readonly config: T["config"] & { | |
readonly hooks: Pick<T["config"], "hooks">["hooks"] & HooksConfig; | |
}; | |
}; | |
export type HasTasks<T extends Hook> = T & { | |
readonly config: T["config"] & { | |
readonly tasks: Pick<T["config"], "tasks">["tasks"] & TaskConfig; | |
}; | |
}; | |
} | |
// #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