Skip to content

Commit

Permalink
Merge pull request #310 from nlibjs/refactor
Browse files Browse the repository at this point in the history
Add some docs and a test for disabling hooks
  • Loading branch information
gjbkz authored Oct 14, 2024
2 parents 3a9899c + a68ce40 commit 1d10c02
Show file tree
Hide file tree
Showing 16 changed files with 286 additions and 135 deletions.
8 changes: 7 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
27 changes: 24 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
15 changes: 6 additions & 9 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -39,8 +38,6 @@
"typescript": "5.6.3"
},
"renovate": {
"extends": [
"github>nlibjs/renovate-config"
]
"extends": ["github>nlibjs/renovate-config"]
}
}
10 changes: 6 additions & 4 deletions src/cli.mts
Original file line number Diff line number Diff line change
@@ -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);
}
93 changes: 65 additions & 28 deletions src/cli.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
});
9 changes: 9 additions & 0 deletions src/config.mts
Original file line number Diff line number Diff line change
@@ -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();
20 changes: 20 additions & 0 deletions src/disable.mts
Original file line number Diff line number Diff line change
@@ -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.`);
}
}
};
34 changes: 10 additions & 24 deletions src/enable.mts
Original file line number Diff line number Diff line change
@@ -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`);
};
12 changes: 12 additions & 0 deletions src/getDirectories.mts
Original file line number Diff line number Diff line change
@@ -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 };
});
44 changes: 19 additions & 25 deletions src/isDirectDependency.mts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
}

const listDirectDependencies = function* (): Generator<string> {
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<string> | undefined;
export const getDirectDependencies = (): Set<string> => {
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);
Loading

0 comments on commit 1d10c02

Please sign in to comment.