diff --git a/README.md b/README.md index f4278ac..446e67e 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ pnpm exec ai-commit init | Husky | Runs **`npx husky@9 init`** at the **git root** if **`husky.sh`** is missing under the resolved hooks directory. | | Hooks directory | **`core.hooksPath`** relative to the git root when set; otherwise **`/.husky`**. Falls back to **`.husky`** at the git root with a warning if the config path is invalid or outside the repo. | | `package.json` | Adds missing **`commit`**, **`prepare`**, **`husky`** entries when **`package.json`** exists at the package root. | -| Hooks | Writes **`prepare-commit-msg`** and **`commit-msg`** in the hooks directory. If package root ≠ git root, each hook **`cd`s** into the package directory before **`pnpm exec ai-commit`** / **`npx`**. | +| Hooks | Writes **`prepare-commit-msg`** and **`commit-msg`** in the hooks directory. If package root ≠ git root, each hook **`cd`s** into the package directory before **`pnpm exec ai-commit`** / **`npx`**. Removes a **stock** **`.husky/pre-commit`** that is only **`npm`**/**`pnpm`**/**`yarn`** **`test`** (Husky’s **`init`** default) so that hook does not block commits; custom **pre-commit** files are kept. | If **`package.json`** changed, run **`pnpm install`** (or `npm install`) again. @@ -171,6 +171,8 @@ pnpm exec ai-commit lint --edit "$1" Hooks from **`init`** use **`pnpm exec ai-commit`** when **`pnpm-lock.yaml`** exists in the **package root**; otherwise **`npx --no ai-commit`**. In a monorepo, generated hooks **`cd`** from the git root into that package directory first. Edit the files if you use another runner. +**`pre-commit`:** Husky’s **`init`** often adds **`.husky/pre-commit`** with only **`pnpm test`** (or **`npm test`** / **`yarn test`**). That can block **`git commit`** when tests fail. On each **`ai-commit init`**, **`init`** removes **only** that stock one-liner (or the same command behind a minimal **`husky.sh`** wrapper). If you add other lines (e.g. **lint-staged**), the file is left unchanged. Add your own **pre-commit** or rely on **CI** if you still want tests on every commit. + **Already using Husky?** If **`.husky/_/husky.sh`** exists, **`init`** does not run **`npx husky@9 init`**. **`package.json`** is only amended for missing **`commit`**, **`prepare`**, or **`devDependencies.husky`**. Existing **`.husky/prepare-commit-msg`** and **`.husky/commit-msg`** are not overwritten unless you use **`ai-commit init --force`**. --- diff --git a/bin/cli.js b/bin/cli.js index 07fc766..3cb0977 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -22,6 +22,7 @@ const { detectPackageExec, hookScript, runHuskyInit, + removeHuskyDefaultPreCommitIfPresent, mergePackageJsonForAiCommit, warnIfPrepareMissingHusky, } = require("../lib/init-workspace.js"); @@ -229,6 +230,13 @@ function cmdInit(argv) { fs.mkdirSync(huskyDir, { recursive: true }); } + for (const abs of removeHuskyDefaultPreCommitIfPresent(gitRoot, huskyDir)) { + const rel = path.relative(cwd, abs) || path.basename(abs); + process.stdout.write( + `Removed Husky default pre-commit (${rel}); add your own .husky/pre-commit or use CI if you want tests on every commit.\n`, + ); + } + const execPrefix = detectPackageExec(packageRoot); const preparePath = path.join(huskyDir, "prepare-commit-msg"); const commitMsgPath = path.join(huskyDir, "commit-msg"); diff --git a/lib/init-workspace.js b/lib/init-workspace.js index 600c2b3..8c5daef 100644 --- a/lib/init-workspace.js +++ b/lib/init-workspace.js @@ -61,6 +61,92 @@ function runHuskyInit(cwd) { return { ok: status === 0, status }; } +/** + * Husky `init` writes `.husky/pre-commit` with only `(npm|pnpm|yarn) test` (see husky bin.js). + * That often breaks commits when tests fail or are slow. Match that template and optional + * minimal shebang + husky.sh wrapper so we do not delete custom hooks. + * @param {string} raw + * @returns {boolean} + */ +function isHuskyDefaultPreCommitContent(raw) { + const text = raw.replace(/\r\n/g, "\n").trim(); + const nonEmpty = text + .split("\n") + .map((l) => l.trim()) + .filter((l) => { + if (l.length === 0) { + return false; + } + if (/^#!\//.test(l)) { + return true; + } + return !/^\s*#/.test(l); + }); + if (nonEmpty.length === 0) { + return false; + } + if (nonEmpty.length === 1) { + return /^(npm|pnpm|yarn)\s+test$/i.test(nonEmpty[0]); + } + const last = nonEmpty[nonEmpty.length - 1]; + if (!/^(npm|pnpm|yarn)\s+test$/i.test(last)) { + return false; + } + const head = nonEmpty.slice(0, -1); + const shebang = /^#!\/usr\/bin\/env\s+sh$/.test(head[0]); + const huskySource = head.some((l) => { + if (/\bhusky\.sh\b/.test(l)) { + return true; + } + if (l.includes("$(dirname") && (l.includes("_/husky.sh") || l.includes('_/h"'))) { + return true; + } + return false; + }); + return shebang && huskySource && head.length <= 3; +} + +/** + * Remove Husky’s stock `pre-commit` (e.g. `pnpm test`) from common paths. Custom hooks are kept. + * @param {string} gitRoot + * @param {string} huskyDir Resolved hooks directory (from `core.hooksPath` or `.husky`) + * @returns {string[]} Absolute paths of removed files + */ +function removeHuskyDefaultPreCommitIfPresent(gitRoot, huskyDir) { + const candidates = [ + path.join(huskyDir, "pre-commit"), + path.join(gitRoot, ".husky", "pre-commit"), + ]; + const seen = new Set(); + const removed = []; + for (const filePath of candidates) { + const abs = path.resolve(filePath); + if (seen.has(abs)) { + continue; + } + seen.add(abs); + if (!fs.existsSync(abs)) { + continue; + } + let raw; + try { + raw = fs.readFileSync(abs, "utf8"); + } catch { + continue; + } + if (!isHuskyDefaultPreCommitContent(raw)) { + continue; + } + try { + fs.unlinkSync(abs); + removed.push(abs); + } catch { + // ignore + } + } + return removed; +} + /** * Ensure `commit` script, `prepare` for husky, and `devDependencies.husky`. Does not remove existing scripts. * @param {string} packageJsonPath @@ -117,6 +203,7 @@ module.exports = { detectPackageExec, hookScript, runHuskyInit, + removeHuskyDefaultPreCommitIfPresent, mergePackageJsonForAiCommit, warnIfPrepareMissingHusky, };