Skip to content

Instantly share code, notes, and snippets.

@nberlette
Last active August 2, 2024 14:19
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 & {};
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