Today I realised that pulling in Husky just to run lint and format felt like overkill on a small side‑project. I wanted the same guardrails without the extra install time or nested node_modules. Here's the path I took.

1. Create a lightweight pre-commit hook

# githooks/pre-commit
#!/bin/sh
# Run the precommit command defined in your package.json
pnpm run precommit
RESULT=$?           # capture exit status
[ $RESULT -ne 0 ] && exit 1  # abort commit on failure
exit 0
chmod +x githooks/pre-commit   # make it executable

2. Wire it up in package.json

{
  "scripts": {
    ...
    "precommit": "pnpm run lint && pnpm run format",
    "postinstall": "git config core.hooksPath githooks"
  }
}

postinstall tells Git to use the custom githooks/ folder instead of .git/hooks/, so the hook comes along when the repo is cloned.

Why PNPM?

Any package manager works, but I’m already on pnpm and its workspace‑level caching keeps CI snappy.

3. CI gotcha 💥 ... and the one‑liner fix

My first pipeline exploded because CI images sometimes omit Git entirely (e.g., alpine‑based containers). git config naturally fails, which bubbles up as a non‑zero exit code and marks the whole build red.

The quick fix: guard the call with command -v git and swallow the error.

"postinstall": "command -v git >/dev/null 2>&1 && git config core.hooksPath githooks || true"

Now postinstall succeeds whether or not Git is present, and local devs still get the hook automatically.

Results

  • Zero extra dependencies – no Husky, no lint‑staged.
  • Instant feedback – commits abort locally if either linter or formatter fails.
  • CI friendly – no Git? no problem.

If your project only needs a single hook, this 6‑line script plus a one‑liner in package.json may be all you need.


Helpful links