diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e435e954..6d6f273d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,4 +1,10 @@ -on: [push, pull_request] +on: + push: + branches: + - main + pull_request: + branches: + - main concurrency: group: ci-${{ github.ref }} cancel-in-progress: true diff --git a/README.md b/README.md index cbe1196a..be3e2715 100644 --- a/README.md +++ b/README.md @@ -7,18 +7,39 @@ A command to enable/disable git hooks scripts in `repository/.githooks`. ## Usage -Install `@nlib/githooks` with the `--save-dev` flag. +Install `@nlib/githooks` with `--save-dev` flag. ``` npm install --save-dev @nlib/githooks ``` -That's all. If `@nlib/githooks` is installed as the direct devDependency (listed in the package.json), it configures git hooks automatically. +That's all. If `@nlib/githooks` is installed as the direct devDependency +(listed in the package.json), it configures git hooks automatically. -Then, your scripts in `repository/.githooks` are now recognized by git. +Then, your scripts in `.githooks` are now recognized by git. *Note: Don't forget to run `chmod +x .githooks/your-script`.* +## How it works + +This package sets the `core.hooksPath` configuration to `.githooks`: + +```sh +git config --local core.hooksPath .githooks +``` + +> Q. Why do I need this package? +> Can't I just add `git config --local core.hooksPath .githooks` to the +> `postinstall` script in `package.json`? + +A. You could do that. However, if you're the author of a package, the +`postinstall` script would also run for anyone who installs your package. +This means your `git hooks` configuration would be applied to their repository +as well, which may not be what you want. +By using this package as a devDependency, you ensure that the configuration is +applied only in your own project and not propagated to others who install your +package. + ## Uninstalling `<0.1.x` reverts the installation on uninstalling of this package. But [uninstall lifecycle scripts were removed](https://docs.npmjs.com/cli/v7/using-npm/scripts#a-note-on-a-lack-of-npm-uninstall-scripts) in npm@7, `>0.1.x` do nothing on uninstalling of this package. diff --git a/package.json b/package.json index 174ca0d4..b50f8209 100644 --- a/package.json +++ b/package.json @@ -16,12 +16,11 @@ "node": ">=16" }, "type": "module", - "main": "./esm/cli.mjs", - "bin": "./esm/cli.mjs", - "files": [ - "esm", - "!**/*.test.*" - ], + "main": "esm/cli.mjs", + "bin": { + "githooks-cli": "esm/cli.mjs" + }, + "files": ["esm", "!**/*.test.*"], "scripts": { "postinstall": "node esm/cli.mjs enable", "build": "tsc", @@ -39,8 +38,6 @@ "typescript": "5.6.3" }, "renovate": { - "extends": [ - "github>nlibjs/renovate-config" - ] + "extends": ["github>nlibjs/renovate-config"] } } diff --git a/src/cli.mts b/src/cli.mts index cda83127..4878d2fc 100644 --- a/src/cli.mts +++ b/src/cli.mts @@ -1,14 +1,16 @@ +#!/usr/bin/env node import * as process from "node:process"; +import { usage } from "./config.mjs"; +import { disable } from "./disable.mjs"; import { enable } from "./enable.mjs"; -import { spawnSync } from "./spawnSync.mjs"; switch (process.argv[2]) { case "enable": - await enable({ hooksDirectory: ".githooks" }); + await enable(); break; case "disable": - spawnSync("git config --local --unset core.hooksPath"); + await disable(); break; default: - throw new Error(`UnexpectedAction: ${process.argv.slice(2).join(" ")}`); + console.info(usage); } diff --git a/src/cli.test.mts b/src/cli.test.mts index 7d1e12b2..06c0bf00 100644 --- a/src/cli.test.mts +++ b/src/cli.test.mts @@ -3,36 +3,73 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { test } from "node:test"; -import { spawnSync } from "./spawnSync.mjs"; +import { fileURLToPath } from "node:url"; +import { dirnameForHooks } from "./config.mjs"; +import { run } from "./run.mjs"; +import { statOrNull } from "./statOrNull.mjs"; -const projectRoot = new URL("../", import.meta.url); - -test("enable/disable", async () => { - const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "githooks-")); - spawnSync("git init", { cwd }); - const { stdout: packOutput } = spawnSync("npm pack", { cwd: projectRoot }); - await fs.writeFile( - path.join(cwd, "package.json"), - JSON.stringify({ name: "@nlib/githooks-test", private: true }, null, 4), - ); - const gitHooksDirectory = path.join(cwd, ".githooks"); - await assert.rejects( - async () => { - await fs.stat(gitHooksDirectory); - }, - { code: "ENOENT" }, - ); - const originalPackedFile = new URL(packOutput, projectRoot); - const packedFile = path.join(cwd, packOutput); - /** fs.rename causes EXDEV error if os.tmpdir returned a path on another device (Windows). */ - await fs.copyFile(originalPackedFile, packedFile); - await fs.unlink(originalPackedFile); - spawnSync(`npm install --save-dev ${packedFile}`, { cwd }); - const afterStats = await fs.stat(gitHooksDirectory); - assert.equal(afterStats.isDirectory(), true); +test("enable → disable", async (t) => { + const testDir = await fs.mkdtemp(path.join(os.tmpdir(), "githooks-")); + t.diagnostic(`testDir: ${testDir}`); + { + await run("git init", testDir); + t.diagnostic("done: git init"); + const command = "git config --local --get core.hooksPath"; + const result = await run(command, testDir, true); + t.diagnostic(`core.hooksPath: ${result.stdout}`); + assert.equal(result.stdout, ""); + } + { + const dest = path.join(testDir, "package.json"); + const data = { name: "@nlib/githooks-test", private: true }; + await fs.writeFile(dest, JSON.stringify(data, null, 4)); + t.diagnostic("created: package.json"); + } + let tgzFileUrl: URL | undefined; + t.after(async () => { + if (tgzFileUrl) { + await fs.unlink(tgzFileUrl); + } + }); + { + const cwd = new URL("../", import.meta.url); + const tgzFileName = (await run("npm pack", cwd)).stdout; + t.diagnostic(`done: npm pack { tgzFileName: ${tgzFileName} }`); + tgzFileUrl = new URL(tgzFileName, cwd); + } + { + const command = [ + "npm install --save-dev", + fileURLToPath(tgzFileUrl), + "--foreground-scripts", + ].join(" "); + await run(command, testDir); + t.diagnostic("done: npm install"); + } { const command = "git config --local --get core.hooksPath"; - const { stdout } = spawnSync(command, { cwd }); - assert.equal(stdout, ".githooks"); + const result = await run(command, testDir); + t.diagnostic(`core.hooksPath: ${result.stdout}`); + assert.equal(result.stdout, dirnameForHooks); + } + { + t.diagnostic(`files: ${(await fs.readdir(testDir)).join(", ")}`); + const stats = await statOrNull(path.join(testDir, dirnameForHooks)); + assert.equal(stats?.isDirectory(), true); + } + { + const command = "npx githooks-cli disable"; + await run(command, testDir); + t.diagnostic("done: disable hooks"); + } + { + const command = "git config --local --get core.hooksPath"; + const result = await run(command, testDir, true); + t.diagnostic(`core.hooksPath: ${result.stdout}`); + assert.equal(result.stdout, ""); + } + { + const command = "npx githooks-cli enable"; + await assert.rejects(async () => await run(command, testDir)); } }); diff --git a/src/config.mts b/src/config.mts new file mode 100644 index 00000000..ea762724 --- /dev/null +++ b/src/config.mts @@ -0,0 +1,9 @@ +export const packageName = "@nlib/githooks"; +export const dirnameForHooks = ".githooks"; +export const usage = ` +Usage: + npx ${packageName} enable + Enables the hooks + npx ${packageName} disable + Disables the hooks +`.trim(); diff --git a/src/disable.mts b/src/disable.mts new file mode 100644 index 00000000..54c2b30e --- /dev/null +++ b/src/disable.mts @@ -0,0 +1,20 @@ +import * as fs from "node:fs"; +import { dirnameForHooks, packageName } from "./config.mjs"; +import { getDirectories } from "./getDirectories.mjs"; +import { run } from "./run.mjs"; +import { statOrNull } from "./statOrNull.mjs"; + +export const disable = async () => { + const dirs = getDirectories(); + await run("git config --local --unset core.hooksPath", dirs.projectRoot); + await run(`npm uninstall ${packageName}`, dirs.projectRoot); + const stats = await statOrNull(dirs.hooks); + if (stats) { + if (stats.isDirectory() && fs.readdirSync(dirs.hooks).length === 0) { + fs.rmdirSync(dirs.hooks); + } else { + console.info(`${dirnameForHooks} is not empty.`); + console.info(`Please remove the ${dirnameForHooks} directory manually.`); + } + } +}; diff --git a/src/enable.mts b/src/enable.mts index b3e6bb05..0c12edbd 100644 --- a/src/enable.mts +++ b/src/enable.mts @@ -1,29 +1,15 @@ -import * as console from "node:console"; import * as fs from "node:fs/promises"; -import * as path from "node:path"; +import { dirnameForHooks, packageName } from "./config.mjs"; +import { getDirectories } from "./getDirectories.mjs"; import { isDirectDependency } from "./isDirectDependency.mjs"; -import { spawnSync } from "./spawnSync.mjs"; +import { run } from "./run.mjs"; -const getPackageName = async () => { - const jsonUrl = new URL("../package.json", import.meta.url); - const json = await fs.readFile(jsonUrl, "utf8"); - const { name } = JSON.parse(json); - if (typeof name !== "string") { - throw new TypeError("Cannot read package name from package.json"); +export const enable = async () => { + if (isDirectDependency(packageName)) { + const dirs = getDirectories(); + await fs.mkdir(dirs.hooks, { recursive: true }); + console.info(`${packageName}: created ${dirs.hooks}`); + const command = `git config --local core.hooksPath ${dirnameForHooks}`; + await run(command, dirs.projectRoot); } - return name; -}; - -export const enable = async ({ - hooksDirectory, -}: { hooksDirectory: string }) => { - const packageName = await getPackageName(); - if (!isDirectDependency(packageName)) { - return; - } - const { stdout: projectRoot } = spawnSync("git rev-parse --show-toplevel"); - console.info(`${packageName}.enable: mkdir -p ${projectRoot}`); - await fs.mkdir(path.join(projectRoot, hooksDirectory), { recursive: true }); - spawnSync(`git config --local core.hooksPath ${hooksDirectory}`); - console.info(`${packageName}.enable: done`); }; diff --git a/src/getDirectories.mts b/src/getDirectories.mts new file mode 100644 index 00000000..b70c6b2c --- /dev/null +++ b/src/getDirectories.mts @@ -0,0 +1,12 @@ +import { execSync } from "node:child_process"; +import * as path from "node:path"; +import { dirnameForHooks } from "./config.mjs"; +import { memoize } from "./memoize.mjs"; + +export const getDirectories = memoize(() => { + const command = "git rev-parse --show-toplevel"; + const cwd = new URL("..", import.meta.url); + const projectRoot = `${execSync(command, { cwd })}`.trim(); + const hooks = path.join(projectRoot, dirnameForHooks); + return { projectRoot, hooks }; +}); diff --git a/src/isDirectDependency.mts b/src/isDirectDependency.mts index 081a17fb..002df8fa 100644 --- a/src/isDirectDependency.mts +++ b/src/isDirectDependency.mts @@ -1,30 +1,24 @@ -import { spawnSync } from "./spawnSync.mjs"; +import { execSync } from "node:child_process"; +import { getDirectories } from "./getDirectories.mjs"; +import { isObjectLike } from "./isObjectLike.mjs"; +import { memoize } from "./memoize.mjs"; -interface NpmLsOutput { - name?: string; - dependencies?: Record; -} - -const listDirectDependencies = function* (): Generator { - const { stdout } = spawnSync("npm ls --depth=0 --json"); - const { name = "", dependencies = {} } = JSON.parse( - `${stdout}`.trim(), - ) as NpmLsOutput; - if (name) { - yield name; - } - for (const key of Object.keys(dependencies)) { - yield key; - } -}; - -let cached: Set | undefined; -export const getDirectDependencies = (): Set => { - if (!cached) { - cached = new Set(listDirectDependencies()); +const getDirectDependencies = memoize(() => { + const dirs = getDirectories(); + const parseResult = JSON.parse( + `${execSync("npm ls --depth=0 --json", { cwd: dirs.projectRoot })}`, + ); + if (isObjectLike(parseResult) && isObjectLike(parseResult.dependencies)) { + return new Set(Object.keys(parseResult.dependencies)); } - return cached; -}; + throw new Error("Failed to get dependencies."); +}); +/** + * Check if a package is a direct dependency of the current project. + * A direct dependency is a package that is listed in the package.json file. + * This function gets the list of direct dependencies by executing + * `npm ls --depth=0 --json`. + */ export const isDirectDependency = (packageName: string): boolean => getDirectDependencies().has(packageName); diff --git a/src/isDirectDependency.test.mts b/src/isDirectDependency.test.mts index 1072efea..43001b04 100644 --- a/src/isDirectDependency.test.mts +++ b/src/isDirectDependency.test.mts @@ -1,12 +1,13 @@ import * as assert from "node:assert/strict"; import * as fs from "node:fs"; import { test } from "node:test"; -import { - getDirectDependencies, - isDirectDependency, -} from "./isDirectDependency.mjs"; +import { isDirectDependency } from "./isDirectDependency.mjs"; +import { isObjectLike } from "./isObjectLike.mjs"; -const listPackages = function* ( +/** + * walk through the modulesDirectory and yield the package names. + */ +const listPackagesInDirectory = function* ( modulesDirectory: URL, scope?: string, ): Generator { @@ -17,9 +18,9 @@ const listPackages = function* ( if (stats.isDirectory()) { if (fileName.startsWith("@")) { if (scope) { - throw new Error(`Unexpected scoped scope: ${scope}/${fileName}`); + throw new Error(`Unexpected scope: ${scope}/${fileName}`); } - yield* listPackages(directory, fileName); + yield* listPackagesInDirectory(directory, fileName); } else { const modulePackageJsonPath = new URL("package.json", `${directory}/`); try { @@ -34,11 +35,35 @@ const listPackages = function* ( } }; -const primaries = getDirectDependencies(); -const nodeModules = new URL("../node_modules/", import.meta.url); -for (const input of listPackages(nodeModules)) { - const expected = primaries.has(input); - test(`isDirectDependency('${input}') → ${expected}`, () => { - assert.equal(isDirectDependency(input), expected); +test("isDirectDependency ", async (t) => { + const directDependencies = new Set(); + + await t.test("list direct dependencies from package.json", async (tt) => { + const packageJsonUrl = new URL("../package.json", import.meta.url); + const json = fs.readFileSync(packageJsonUrl, "utf8"); + const parseResult = JSON.parse(json); + if (isObjectLike(parseResult)) { + const { dependencies, devDependencies } = parseResult; + if (isObjectLike(dependencies)) { + for (const key of Object.keys(dependencies)) { + directDependencies.add(key); + } + } + if (isObjectLike(devDependencies)) { + for (const key of Object.keys(devDependencies)) { + directDependencies.add(key); + } + } + } + const list = Array.from(directDependencies); + tt.diagnostic(`directDependencies: ${list.join(", ")}`); }); -} + + const nodeModules = new URL("../node_modules/", import.meta.url); + for (const input of listPackagesInDirectory(nodeModules)) { + const expected = directDependencies.has(input); + await t.test(`isDirectDependency('${input}') → ${expected}`, () => { + assert.equal(isDirectDependency(input), expected); + }); + } +}); diff --git a/src/isObjectLike.mts b/src/isObjectLike.mts new file mode 100644 index 00000000..1b7a8b44 --- /dev/null +++ b/src/isObjectLike.mts @@ -0,0 +1,2 @@ +export const isObjectLike = (v: unknown): v is Record => + Boolean(v) && (typeof v === "object" || typeof v === "function"); diff --git a/src/memoize.mts b/src/memoize.mts new file mode 100644 index 00000000..407bab42 --- /dev/null +++ b/src/memoize.mts @@ -0,0 +1,10 @@ +// biome-ignore lint/complexity/noUselessTypeConstraint: ts(7060) +export const memoize = (getter: () => T): (() => T) => { + let cache: { value: T } | undefined; + return () => { + if (!cache) { + cache = { value: getter() }; + } + return cache.value; + }; +}; diff --git a/src/run.mts b/src/run.mts new file mode 100644 index 00000000..168b7994 --- /dev/null +++ b/src/run.mts @@ -0,0 +1,43 @@ +import { type SpawnOptions, spawn } from "node:child_process"; +import * as console from "node:console"; +import { packageName } from "./config.mjs"; + +interface RunResult { + status: number | null; + stdout: string; + stderr: string; +} + +const printer = + (writable: { write: (chunk: Buffer) => void }, buffer: Array) => + (chunk: Buffer) => { + writable.write(chunk); + buffer.push(chunk); + }; + +/** + * execute a command and return the output as a trimmed string. + */ +export const run = async ( + command: string, + cwd: Exclude, + ignoreStatus = false, +): Promise => + await new Promise((resolve, reject) => { + console.info(`${packageName}: ${command}`); + const child = spawn(command, { shell: true, cwd }); + const stdoutBuffer: Array = []; + const stderrBuffer: Array = []; + child.stdout?.on("data", printer(process.stdout, stdoutBuffer)); + child.stderr?.on("data", printer(process.stderr, stderrBuffer)); + child.once("error", reject); + child.once("exit", (status) => { + const stdout = Buffer.concat(stdoutBuffer).toString().trim(); + const stderr = Buffer.concat(stderrBuffer).toString().trim(); + if (status === 0 || ignoreStatus) { + resolve({ status, stdout, stderr }); + } else { + reject(new Error(`exit status is ${status}`)); + } + }); + }); diff --git a/src/spawnSync.mts b/src/spawnSync.mts deleted file mode 100644 index 9f702674..00000000 --- a/src/spawnSync.mts +++ /dev/null @@ -1,27 +0,0 @@ -import * as childProcess from "node:child_process"; -import * as console from "node:console"; - -interface SpawnResult { - stdout: string; - stderr: string; -} - -export const spawnSync = ( - command: string, - options: childProcess.SpawnSyncOptions = {}, -): SpawnResult => { - console.info(`spawn: ${command}`); - const { error, output } = childProcess.spawnSync(command, { - shell: true, - ...options, - }); - if (error) { - throw error; - } - const stdout = `${output[1]}`.trim(); - const stderr = `${output[2]}`.trim(); - if (stderr) { - console.error(stderr); - } - return { stdout, stderr }; -}; diff --git a/src/statOrNull.mts b/src/statOrNull.mts new file mode 100644 index 00000000..664b96ac --- /dev/null +++ b/src/statOrNull.mts @@ -0,0 +1,14 @@ +import type { PathLike, Stats } from "node:fs"; +import * as fs from "node:fs/promises"; +import { isObjectLike } from "./isObjectLike.mjs"; + +export const statOrNull = async (pathLike: PathLike): Promise => { + try { + return await fs.stat(pathLike); + } catch (error: unknown) { + if (isObjectLike(error) && error.code === "ENOENT") { + return null; + } + throw error; + } +};