@verndale/ai-commit 2.5.1 → 2.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -42,12 +42,12 @@ pnpm exec ai-commit init
42
42
  | Action | Detail |
43
43
  | --- | --- |
44
44
  | Roots | **Package root** — walks up from the current directory toward the git root and uses the first directory that has **`package.json`** (if none, uses cwd). **Git root** — `git rev-parse --show-toplevel`. Env files and **`package.json`** use the package root; hooks use the git root. |
45
- | Env files | Merges **`.env`** and the **example env file** (see below). Keys already set in **`.env.local`** are treated as satisfied for the **`.env`** merge only (same as runtime load order). Init does not write **`.env.local`**. |
45
+ | Env files | Merges ai-commit keys into **`.env.local`** when that file exists; otherwise into **`.env`** (creates **`.env`** from the bundled template if missing). **`.env`** is not used when **`.env.local`** is present. Also merges the **example env file** (see below). **`--force`** does not wholesale-replace **`.env.local`** (append / docs only). |
46
46
  | Example file | Uses **`.env.example`** if it exists; else **`.env-example`** if it exists; else creates **`.env.example`**. If both dot forms exist, init uses **`.env.example`** and prints a warning. The **bundled** template in the package remains [`.env-example`](.env-example) (hyphen). |
47
47
  | Husky | Runs **`npx husky@9 init`** at the **git root** if **`husky.sh`** is missing under the resolved hooks directory. |
48
48
  | Hooks directory | **`core.hooksPath`** relative to the git root when set; otherwise **`<git-root>/.husky`**. Falls back to **`.husky`** at the git root with a warning if the config path is invalid or outside the repo. |
49
49
  | `package.json` | Adds missing **`commit`**, **`prepare`**, **`husky`** entries when **`package.json`** exists at the package root. |
50
- | 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`**. |
50
+ | 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. |
51
51
 
52
52
  If **`package.json`** changed, run **`pnpm install`** (or `npm install`) again.
53
53
 
@@ -64,7 +64,7 @@ Set **`OPENAI_API_KEY`** in **`.env`** and/or **`.env.local`**. Duplicate keys:
64
64
  | *(none)* | Full setup: env files + Husky + hooks + `package.json` updates (when applicable). |
65
65
  | `--env-only` | You only want env / example-file updates—no Git hooks. |
66
66
  | `--husky` | Hooks + Husky only; skips **`package.json`** changes. Combine with **`--workspace`** if you need **`package.json`** merged again. |
67
- | `--force` | Replace **`.env`** and the resolved example file (see table above) with the bundled template **(destructive)** and/or overwrite existing Husky hook files. |
67
+ | `--force` | Replace **`.env`** (when that is the merge target) and the resolved example file with the bundled template **(destructive)** and/or overwrite existing Husky hook files. Does **not** wholesale-replace **`.env.local`** (merge/append only). |
68
68
 
69
69
  **Edge cases**
70
70
 
@@ -72,9 +72,9 @@ Set **`OPENAI_API_KEY`** in **`.env`** and/or **`.env.local`**. Duplicate keys:
72
72
  | --- | --- |
73
73
  | Not in a git repo | Init updates env files only (under cwd) and reports that Git/Husky were skipped. |
74
74
  | Monorepo (package not at git root) | Run **`ai-commit init`** from the app folder that has **`package.json`** and the dependency. Hooks live at the repo root; hook scripts change into the package directory before running **`ai-commit`**. |
75
- | **`.env.local`** | **`OPENAI_API_KEY`** / **`COMMIT_AI_MODEL`** there count as already present when merging **`.env`**, so init will not add duplicate placeholders for those keys. |
75
+ | **`.env.local`** | If this file exists, init merges ai-commit keys **only** here and does not create or update **`.env`** (see [Env files](#2-run-init) row). |
76
76
  | Bundled vs consumer example name | The npm package ships **`.env-example`** (hyphen) as the template source; the file init merges into on disk follows **`.env.example`** first, then **`.env-example`**, then default **`.env.example`**. |
77
- | Without **`--force`** | Missing example file is created (**`.env.example`** when neither exists); otherwise missing ai-commit keys are **appended** to **`.env`** and the example file without wiping them. |
77
+ | Without **`--force`** | Missing example file is created (**`.env.example`** when neither dot form exists); otherwise missing ai-commit keys are **appended** to the env merge target (**`.env.local`** or **`.env`**) and the example file without wiping them. |
78
78
 
79
79
  ---
80
80
 
@@ -129,7 +129,7 @@ pnpm exec ai-commit init --force
129
129
  | Command | Purpose |
130
130
  | --- | --- |
131
131
  | **`ai-commit run`** | Build a message from the staged diff and run **`git commit`**. |
132
- | **`ai-commit init`** | Env merge (**`.env`** + resolved example file; **`.env.local`** satisfies keys for the **`.env`** merge only), Husky at git root if needed, **`package.json`** at package root, hooks in **`core.hooksPath`** or **`.husky`**. See [flags](#init-flags-and-shortcuts). |
132
+ | **`ai-commit init`** | Env merge into **`.env.local`** or **`.env`** (see [Env files](#2-run-init)) plus resolved example file; Husky at git root if needed, **`package.json`** at package root, hooks in **`core.hooksPath`** or **`.husky`**. See [flags](#init-flags-and-shortcuts). |
133
133
  | **`ai-commit prepare-commit-msg <file> [source]`** | Hook: fill an empty message; skips `merge` / `squash`. |
134
134
  | **`ai-commit lint --edit <file>`** | Hook: commitlint with this package’s default config. |
135
135
 
@@ -171,6 +171,8 @@ pnpm exec ai-commit lint --edit "$1"
171
171
 
172
172
  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.
173
173
 
174
+ **`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.
175
+
174
176
  **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`**.
175
177
 
176
178
  ---
package/bin/cli.js CHANGED
@@ -16,12 +16,13 @@ const {
16
16
  hasStagedChanges,
17
17
  commitFromFile,
18
18
  } = require("../lib/core/git.js");
19
- const { mergeAiCommitEnvFile, parseDotenvAssignedKeys } = require("../lib/init-env.js");
19
+ const { mergeAiCommitEnvFile } = require("../lib/init-env.js");
20
20
  const { resolveEnvExamplePath, findPackageRoot } = require("../lib/init-paths.js");
21
21
  const {
22
22
  detectPackageExec,
23
23
  hookScript,
24
24
  runHuskyInit,
25
+ removeHuskyDefaultPreCommitIfPresent,
25
26
  mergePackageJsonForAiCommit,
26
27
  warnIfPrepareMissingHusky,
27
28
  } = require("../lib/init-workspace.js");
@@ -45,7 +46,7 @@ Usage:
45
46
 
46
47
  Commands:
47
48
  run Generate a message from the staged diff and run git commit.
48
- init Merge env, then Husky + package.json + hooks (from a git repo). \`--env-only\` stops after env files. \`--husky\` skips package.json. \`--force\` replaces \`.env\` / example env file / hooks (example path: existing \`.env.example\` or \`.env-example\`, default \`.env.example\`).
49
+ init Merge env, then Husky + package.json + hooks (from a git repo). \`--env-only\` stops after env files. \`--husky\` skips package.json. Env merge targets \`.env.local\` when that file exists, else \`.env\`. \`--force\` replaces \`.env\` / example file / hooks (not a wholesale replace of \`.env.local\`).
49
50
  prepare-commit-msg Git hook: fill an empty commit message file (merge/squash skipped).
50
51
  lint Run commitlint with the package default config (for commit-msg hook).
51
52
 
@@ -99,14 +100,8 @@ function cmdInit(argv) {
99
100
  const gitRoot = inGit ? getGitRoot(cwd) : null;
100
101
  const packageRoot = findPackageRoot(cwd, gitRoot);
101
102
 
102
- const extraAssignedKeys = new Set();
103
103
  const envLocalPath = path.join(packageRoot, ".env.local");
104
- if (fs.existsSync(envLocalPath)) {
105
- const localContent = fs.readFileSync(envLocalPath, "utf8");
106
- for (const k of parseDotenvAssignedKeys(localContent)) {
107
- extraAssignedKeys.add(k);
108
- }
109
- }
104
+ const envPath = path.join(packageRoot, ".env");
110
105
 
111
106
  if (
112
107
  inGit &&
@@ -118,12 +113,21 @@ function cmdInit(argv) {
118
113
  );
119
114
  }
120
115
 
121
- const envDest = path.join(packageRoot, ".env");
122
- const envResult = mergeAiCommitEnvFile(envDest, bundledExamplePath, {
123
- force,
124
- extraAssignedKeys,
116
+ /** When `.env.local` exists it is the only env merge target (no `.env` created or updated). */
117
+ const envMergePath = fs.existsSync(envLocalPath) ? envLocalPath : envPath;
118
+ const mergeEnvIntoLocal =
119
+ path.resolve(envMergePath) === path.resolve(envLocalPath);
120
+ /** Never `--force`-replace `.env.local` with the bundled template (would wipe secrets). */
121
+ const envForce = force && !mergeEnvIntoLocal;
122
+ if (force && mergeEnvIntoLocal) {
123
+ process.stderr.write(
124
+ "note: --force does not replace .env.local with the bundled template; ai-commit keys are merged (append / docs) only.\n",
125
+ );
126
+ }
127
+ const envResult = mergeAiCommitEnvFile(envMergePath, bundledExamplePath, {
128
+ force: envForce,
125
129
  });
126
- const envRel = path.relative(cwd, envDest) || ".env";
130
+ const envRel = path.relative(cwd, envMergePath) || path.basename(envMergePath);
127
131
  switch (envResult.kind) {
128
132
  case "replaced":
129
133
  process.stdout.write(`Replaced ${envRel} with bundled template (--force).\n`);
@@ -136,7 +140,9 @@ function cmdInit(argv) {
136
140
  break;
137
141
  case "unchanged":
138
142
  process.stdout.write(
139
- `No missing @verndale/ai-commit keys in ${envRel}; left unchanged. Use --force to replace the file with the bundled template.\n`,
143
+ mergeEnvIntoLocal
144
+ ? `No missing @verndale/ai-commit keys in ${envRel}; left unchanged.\n`
145
+ : `No missing @verndale/ai-commit keys in ${envRel}; left unchanged. Use --force to replace the file with the bundled template.\n`,
140
146
  );
141
147
  break;
142
148
  default:
@@ -224,6 +230,13 @@ function cmdInit(argv) {
224
230
  fs.mkdirSync(huskyDir, { recursive: true });
225
231
  }
226
232
 
233
+ for (const abs of removeHuskyDefaultPreCommitIfPresent(gitRoot, huskyDir)) {
234
+ const rel = path.relative(cwd, abs) || path.basename(abs);
235
+ process.stdout.write(
236
+ `Removed Husky default pre-commit (${rel}); add your own .husky/pre-commit or use CI if you want tests on every commit.\n`,
237
+ );
238
+ }
239
+
227
240
  const execPrefix = detectPackageExec(packageRoot);
228
241
  const preparePath = path.join(huskyDir, "prepare-commit-msg");
229
242
  const commitMsgPath = path.join(huskyDir, "commit-msg");
@@ -61,6 +61,92 @@ function runHuskyInit(cwd) {
61
61
  return { ok: status === 0, status };
62
62
  }
63
63
 
64
+ /**
65
+ * Husky `init` writes `.husky/pre-commit` with only `(npm|pnpm|yarn) test` (see husky bin.js).
66
+ * That often breaks commits when tests fail or are slow. Match that template and optional
67
+ * minimal shebang + husky.sh wrapper so we do not delete custom hooks.
68
+ * @param {string} raw
69
+ * @returns {boolean}
70
+ */
71
+ function isHuskyDefaultPreCommitContent(raw) {
72
+ const text = raw.replace(/\r\n/g, "\n").trim();
73
+ const nonEmpty = text
74
+ .split("\n")
75
+ .map((l) => l.trim())
76
+ .filter((l) => {
77
+ if (l.length === 0) {
78
+ return false;
79
+ }
80
+ if (/^#!\//.test(l)) {
81
+ return true;
82
+ }
83
+ return !/^\s*#/.test(l);
84
+ });
85
+ if (nonEmpty.length === 0) {
86
+ return false;
87
+ }
88
+ if (nonEmpty.length === 1) {
89
+ return /^(npm|pnpm|yarn)\s+test$/i.test(nonEmpty[0]);
90
+ }
91
+ const last = nonEmpty[nonEmpty.length - 1];
92
+ if (!/^(npm|pnpm|yarn)\s+test$/i.test(last)) {
93
+ return false;
94
+ }
95
+ const head = nonEmpty.slice(0, -1);
96
+ const shebang = /^#!\/usr\/bin\/env\s+sh$/.test(head[0]);
97
+ const huskySource = head.some((l) => {
98
+ if (/\bhusky\.sh\b/.test(l)) {
99
+ return true;
100
+ }
101
+ if (l.includes("$(dirname") && (l.includes("_/husky.sh") || l.includes('_/h"'))) {
102
+ return true;
103
+ }
104
+ return false;
105
+ });
106
+ return shebang && huskySource && head.length <= 3;
107
+ }
108
+
109
+ /**
110
+ * Remove Husky’s stock `pre-commit` (e.g. `pnpm test`) from common paths. Custom hooks are kept.
111
+ * @param {string} gitRoot
112
+ * @param {string} huskyDir Resolved hooks directory (from `core.hooksPath` or `.husky`)
113
+ * @returns {string[]} Absolute paths of removed files
114
+ */
115
+ function removeHuskyDefaultPreCommitIfPresent(gitRoot, huskyDir) {
116
+ const candidates = [
117
+ path.join(huskyDir, "pre-commit"),
118
+ path.join(gitRoot, ".husky", "pre-commit"),
119
+ ];
120
+ const seen = new Set();
121
+ const removed = [];
122
+ for (const filePath of candidates) {
123
+ const abs = path.resolve(filePath);
124
+ if (seen.has(abs)) {
125
+ continue;
126
+ }
127
+ seen.add(abs);
128
+ if (!fs.existsSync(abs)) {
129
+ continue;
130
+ }
131
+ let raw;
132
+ try {
133
+ raw = fs.readFileSync(abs, "utf8");
134
+ } catch {
135
+ continue;
136
+ }
137
+ if (!isHuskyDefaultPreCommitContent(raw)) {
138
+ continue;
139
+ }
140
+ try {
141
+ fs.unlinkSync(abs);
142
+ removed.push(abs);
143
+ } catch {
144
+ // ignore
145
+ }
146
+ }
147
+ return removed;
148
+ }
149
+
64
150
  /**
65
151
  * Ensure `commit` script, `prepare` for husky, and `devDependencies.husky`. Does not remove existing scripts.
66
152
  * @param {string} packageJsonPath
@@ -117,6 +203,7 @@ module.exports = {
117
203
  detectPackageExec,
118
204
  hookScript,
119
205
  runHuskyInit,
206
+ removeHuskyDefaultPreCommitIfPresent,
120
207
  mergePackageJsonForAiCommit,
121
208
  warnIfPrepareMissingHusky,
122
209
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@verndale/ai-commit",
3
- "version": "2.5.1",
3
+ "version": "2.5.3",
4
4
  "description": "AI-assisted conventional commits with bundled commitlint — one install, aligned rules",
5
5
  "license": "MIT",
6
6
  "author": "Verndale",