@verndale/ai-commit 2.4.4 → 2.5.1

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
@@ -25,7 +25,7 @@ pnpm add -D @verndale/ai-commit
25
25
 
26
26
  ## Setup
27
27
 
28
- Do these **in order** from your **git repository root** (the directory that contains `package.json`).
28
+ Do these **in order** from the **app directory** where **`@verndale/ai-commit`** is installed — the folder that contains **`package.json`** (in a monorepo, that is often **not** the git repository root). Init updates **`.env`** / the example file there and installs hooks at the **git root** (with a `cd` in hook scripts when those paths differ).
29
29
 
30
30
  ### 1. Install the package
31
31
 
@@ -41,10 +41,13 @@ pnpm exec ai-commit init
41
41
 
42
42
  | Action | Detail |
43
43
  | --- | --- |
44
- | Env files | Merges **`.env`** and **`.env-example`**; creates **`.env-example`** from the bundled template if missing. Template reference: [`.env-example`](.env-example). |
45
- | Husky | Runs **`npx husky@9 init`** if Husky is not present. |
46
- | `package.json` | Adds missing **`commit`**, **`prepare`**, **`husky`** entries when the file exists. |
47
- | Hooks | Writes **`.husky`** hook files. |
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`**. |
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
+ | Husky | Runs **`npx husky@9 init`** at the **git root** if **`husky.sh`** is missing under the resolved hooks directory. |
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
+ | `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`**. |
48
51
 
49
52
  If **`package.json`** changed, run **`pnpm install`** (or `npm install`) again.
50
53
 
@@ -59,17 +62,19 @@ Set **`OPENAI_API_KEY`** in **`.env`** and/or **`.env.local`**. Duplicate keys:
59
62
  | Flag | Use when |
60
63
  | --- | --- |
61
64
  | *(none)* | Full setup: env files + Husky + hooks + `package.json` updates (when applicable). |
62
- | `--env-only` | You only want env / **`.env-example`** updates—no Git hooks. |
65
+ | `--env-only` | You only want env / example-file updates—no Git hooks. |
63
66
  | `--husky` | Hooks + Husky only; skips **`package.json`** changes. Combine with **`--workspace`** if you need **`package.json`** merged again. |
64
- | `--force` | Replace **`.env`** and **`.env-example`** with the bundled template **(destructive)** and/or overwrite existing Husky hook files. |
67
+ | `--force` | Replace **`.env`** and the resolved example file (see table above) with the bundled template **(destructive)** and/or overwrite existing Husky hook files. |
65
68
 
66
69
  **Edge cases**
67
70
 
68
71
  | Situation | Behavior |
69
72
  | --- | --- |
70
- | Not in a git repo | Init updates env files only and reports that Git/Husky were skipped. |
71
- | Template filename | The published file is **`.env-example`** (hyphen), not **`.env.example`**. |
72
- | Without **`--force`** | Missing **`.env-example`** is created; otherwise missing ai-commit keys are **appended** to **`.env`** (and the example file) without wiping the file. |
73
+ | Not in a git repo | Init updates env files only (under cwd) and reports that Git/Husky were skipped. |
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. |
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. |
73
78
 
74
79
  ---
75
80
 
@@ -124,7 +129,7 @@ pnpm exec ai-commit init --force
124
129
  | Command | Purpose |
125
130
  | --- | --- |
126
131
  | **`ai-commit run`** | Build a message from the staged diff and run **`git commit`**. |
127
- | **`ai-commit init`** | Env merge (including **`.env-example`**), Husky if needed, **`package.json`** when present, hooks. See [flags](#init-flags-and-shortcuts). |
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). |
128
133
  | **`ai-commit prepare-commit-msg <file> [source]`** | Hook: fill an empty message; skips `merge` / `squash`. |
129
134
  | **`ai-commit lint --edit <file>`** | Hook: commitlint with this package’s default config. |
130
135
 
@@ -164,7 +169,7 @@ pnpm exec ai-commit prepare-commit-msg "$1" "$2"
164
169
  pnpm exec ai-commit lint --edit "$1"
165
170
  ```
166
171
 
167
- Hooks from **`init`** use **`pnpm exec ai-commit`** when **`pnpm-lock.yaml`** exists; otherwise **`npx --no ai-commit`**. Edit the files if you use another runner.
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.
168
173
 
169
174
  **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`**.
170
175
 
@@ -193,6 +198,87 @@ const rules = require("@verndale/ai-commit/rules");
193
198
 
194
199
  ---
195
200
 
201
+ ## GitHub Actions (CI snippet)
202
+
203
+ Use **commitlint in your own workflow file** — nothing calls back to the `ai-commit` repository’s pipelines. After `pnpm add -D @verndale/ai-commit`, add a root **`commitlint.config.cjs`** (or `.js`) that **`extends: ["@verndale/ai-commit"]`** as in [commitlint without a second install](#commitlint-without-a-second-install). **`@commitlint/cli`** is already a dependency of this package, so `pnpm exec commitlint` works once dependencies are installed.
204
+
205
+ Save as **`.github/workflows/commitlint.yml`** (or merge the job into an existing workflow). Adjust **`branches`** / **`branches-ignore`** if your default branch is not **`main`**.
206
+
207
+ ```yaml
208
+ name: Commit message lint
209
+
210
+ on:
211
+ pull_request:
212
+ branches: [main]
213
+ types: [opened, synchronize, reopened, edited]
214
+ push:
215
+ branches-ignore:
216
+ - main
217
+
218
+ jobs:
219
+ commitlint:
220
+ runs-on: ubuntu-latest
221
+ steps:
222
+ - name: Checkout
223
+ uses: actions/checkout@v4
224
+ with:
225
+ fetch-depth: 0
226
+
227
+ - name: Setup Node
228
+ uses: actions/setup-node@v4
229
+ with:
230
+ node-version: "24.14.0"
231
+
232
+ - name: Enable pnpm via Corepack
233
+ run: corepack enable && corepack prepare pnpm@10.11.0 --activate
234
+
235
+ - name: Get pnpm store path
236
+ id: pnpm-cache
237
+ run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
238
+
239
+ - name: Cache pnpm store
240
+ uses: actions/cache@v4
241
+ with:
242
+ path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
243
+ key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
244
+ restore-keys: |
245
+ ${{ runner.os }}-pnpm-store-
246
+
247
+ - name: Install dependencies
248
+ run: pnpm install --frozen-lockfile
249
+
250
+ - name: Lint PR title (squash merge becomes the commit on main)
251
+ if: github.event_name == 'pull_request'
252
+ env:
253
+ PR_TITLE: ${{ github.event.pull_request.title }}
254
+ run: |
255
+ printf '%s\n' "$PR_TITLE" | pnpm exec commitlint --verbose
256
+
257
+ - name: Lint commit messages (PR range)
258
+ if: github.event_name == 'pull_request'
259
+ run: |
260
+ pnpm exec commitlint \
261
+ --from "${{ github.event.pull_request.base.sha }}" \
262
+ --to "${{ github.event.pull_request.head.sha }}" \
263
+ --verbose
264
+
265
+ - name: Lint last commit (push)
266
+ if: github.event_name == 'push'
267
+ run: |
268
+ pnpm exec commitlint --from=HEAD~1 --to=HEAD --verbose
269
+ ```
270
+
271
+ **Notes**
272
+
273
+ | Topic | Detail |
274
+ | --- | --- |
275
+ | **Node** | Use a version that satisfies this package’s **`engines.node`** (see [Requirements](#requirements)). |
276
+ | **npm or Yarn** | Replace the Corepack + pnpm steps with your install (`npm ci`, `yarn install --immutable`, etc.) and run **`npx --no commitlint`** or **`yarn exec commitlint`** instead of **`pnpm exec commitlint`**. |
277
+ | **Config path** | If commitlint does not find your config (non-root monorepo, unusual filename), add **`--config path/to/commitlint.config.cjs`** to each **`commitlint`** invocation. |
278
+ | **Alignment with hooks** | The same rules apply as for **`.husky/commit-msg`** when it runs **`ai-commit lint --edit`** — both use the **`@verndale/ai-commit`** preset. |
279
+
280
+ ---
281
+
196
282
  ## Development (this repository)
197
283
 
198
284
  ```bash
@@ -204,6 +290,8 @@ Copy **`.env-example`** to `.env` / `.env.local` and set **`OPENAI_API_KEY`**. A
204
290
 
205
291
  ### Repository automation
206
292
 
293
+ To run the same style of checks in **another** repository, copy the workflow in [GitHub Actions (CI snippet)](#github-actions-ci-snippet) (self-contained YAML; no call into this repo’s Actions).
294
+
207
295
  | Workflow | Trigger | Purpose |
208
296
  | --- | --- | --- |
209
297
  | [`.github/workflows/commitlint.yml`](./.github/workflows/commitlint.yml) | PRs to `main`, pushes to non-`main` | Commitlint on PR range or last push |
package/bin/cli.js CHANGED
@@ -11,10 +11,13 @@ const { generateAndValidate } = require("../lib/core/generate.js");
11
11
  const {
12
12
  assertInGitRepo,
13
13
  isInGitRepo,
14
+ getGitRoot,
15
+ resolveGitHooksDir,
14
16
  hasStagedChanges,
15
17
  commitFromFile,
16
18
  } = require("../lib/core/git.js");
17
- const { mergeAiCommitEnvFile } = require("../lib/init-env.js");
19
+ const { mergeAiCommitEnvFile, parseDotenvAssignedKeys } = require("../lib/init-env.js");
20
+ const { resolveEnvExamplePath, findPackageRoot } = require("../lib/init-paths.js");
18
21
  const {
19
22
  detectPackageExec,
20
23
  hookScript,
@@ -42,7 +45,7 @@ Usage:
42
45
 
43
46
  Commands:
44
47
  run Generate a message from the staged diff and run git commit.
45
- 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\` / \`.env-example\` / hooks.
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\`).
46
49
  prepare-commit-msg Git hook: fill an empty commit message file (merge/squash skipped).
47
50
  lint Run commitlint with the package default config (for commit-msg hook).
48
51
 
@@ -86,14 +89,40 @@ function cmdInit(argv) {
86
89
  const cwd = process.cwd();
87
90
  /** Full package.json merge: default on, or `--workspace`; off for `--husky` alone (legacy). */
88
91
  const mergePackageJson = !husky || workspace;
89
- const examplePath = path.join(__dirname, "..", ".env-example");
92
+ const bundledExamplePath = path.join(__dirname, "..", ".env-example");
90
93
 
91
- if (!fs.existsSync(examplePath)) {
94
+ if (!fs.existsSync(bundledExamplePath)) {
92
95
  throw new Error("Missing bundled .env-example (corrupt install?).");
93
96
  }
94
97
 
95
- const envDest = path.join(cwd, ".env");
96
- const envResult = mergeAiCommitEnvFile(envDest, examplePath, { force });
98
+ const inGit = isInGitRepo(cwd);
99
+ const gitRoot = inGit ? getGitRoot(cwd) : null;
100
+ const packageRoot = findPackageRoot(cwd, gitRoot);
101
+
102
+ const extraAssignedKeys = new Set();
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
+ }
110
+
111
+ if (
112
+ inGit &&
113
+ gitRoot &&
114
+ path.resolve(packageRoot) !== path.resolve(gitRoot)
115
+ ) {
116
+ process.stdout.write(
117
+ `Note: env files are updated under ${packageRoot}; Git hooks use the repository root ${gitRoot}.\n`,
118
+ );
119
+ }
120
+
121
+ const envDest = path.join(packageRoot, ".env");
122
+ const envResult = mergeAiCommitEnvFile(envDest, bundledExamplePath, {
123
+ force,
124
+ extraAssignedKeys,
125
+ });
97
126
  const envRel = path.relative(cwd, envDest) || ".env";
98
127
  switch (envResult.kind) {
99
128
  case "replaced":
@@ -114,9 +143,9 @@ function cmdInit(argv) {
114
143
  break;
115
144
  }
116
145
 
117
- const envExampleDest = path.join(cwd, ".env-example");
118
- const exResult = mergeAiCommitEnvFile(envExampleDest, examplePath, { force });
119
- const exRel = path.relative(cwd, envExampleDest) || ".env-example";
146
+ const envExampleDest = resolveEnvExamplePath(packageRoot);
147
+ const exResult = mergeAiCommitEnvFile(envExampleDest, bundledExamplePath, { force });
148
+ const exRel = path.relative(cwd, envExampleDest) || path.basename(envExampleDest);
120
149
  switch (exResult.kind) {
121
150
  case "replaced":
122
151
  process.stdout.write(`Replaced ${exRel} with bundled template (--force).\n`);
@@ -140,17 +169,24 @@ function cmdInit(argv) {
140
169
  return;
141
170
  }
142
171
 
143
- if (!isInGitRepo(cwd)) {
172
+ if (!inGit) {
144
173
  process.stdout.write(
145
- "Not a git repository (or git unavailable); skipped Husky and package.json. Re-run from a repo root for hooks and scripts.\n",
174
+ "Not a git repository (or git unavailable); skipped Husky and package.json hooks. Run init from your app directory inside a git repo (with package.json there) for full setup.\n",
175
+ );
176
+ return;
177
+ }
178
+ if (!gitRoot) {
179
+ process.stderr.write(
180
+ "warning: could not resolve git repository root; skipped Husky and hooks.\n",
146
181
  );
147
182
  return;
148
183
  }
149
184
 
150
- const huskyHelper = path.join(cwd, ".husky", "_", "husky.sh");
185
+ let { dir: huskyDir } = resolveGitHooksDir(gitRoot);
186
+ const huskyHelper = path.join(huskyDir, "_", "husky.sh");
151
187
 
152
188
  if (!fs.existsSync(huskyHelper)) {
153
- const r = runHuskyInit(cwd);
189
+ const r = runHuskyInit(gitRoot);
154
190
  if (!r.ok) {
155
191
  process.stderr.write(
156
192
  r.error
@@ -160,14 +196,15 @@ function cmdInit(argv) {
160
196
  process.exit(1);
161
197
  }
162
198
  process.stdout.write("Ran `npx husky@9 init`.\n");
199
+ huskyDir = resolveGitHooksDir(gitRoot).dir;
163
200
  } else {
164
201
  process.stdout.write(
165
- "Husky already initialized (found .husky/_/husky.sh); skipped `npx husky@9 init`.\n",
202
+ `Husky already initialized (found ${path.join(huskyDir, "_", "husky.sh")}); skipped \`npx husky@9 init\`.\n`,
166
203
  );
167
204
  }
168
205
 
169
206
  if (mergePackageJson) {
170
- const pkgPath = path.join(cwd, "package.json");
207
+ const pkgPath = path.join(packageRoot, "package.json");
171
208
  if (fs.existsSync(pkgPath)) {
172
209
  const { changed } = mergePackageJsonForAiCommit(pkgPath);
173
210
  if (changed) {
@@ -177,16 +214,17 @@ function cmdInit(argv) {
177
214
  }
178
215
  warnIfPrepareMissingHusky(pkgPath);
179
216
  } else {
180
- process.stdout.write("No package.json in this directory; skipped package.json merge (hooks still written).\n");
217
+ process.stdout.write(
218
+ "No package.json found walking up to the git root; skipped package.json merge (hooks still written).\n",
219
+ );
181
220
  }
182
221
  }
183
222
 
184
- const huskyDir = path.join(cwd, ".husky");
185
223
  if (!fs.existsSync(huskyDir)) {
186
224
  fs.mkdirSync(huskyDir, { recursive: true });
187
225
  }
188
226
 
189
- const execPrefix = detectPackageExec(cwd);
227
+ const execPrefix = detectPackageExec(packageRoot);
190
228
  const preparePath = path.join(huskyDir, "prepare-commit-msg");
191
229
  const commitMsgPath = path.join(huskyDir, "commit-msg");
192
230
 
@@ -194,7 +232,7 @@ function cmdInit(argv) {
194
232
  [preparePath, "prepare-commit-msg"],
195
233
  [commitMsgPath, "commit-msg"],
196
234
  ]) {
197
- const body = hookScript(execPrefix, hookKind);
235
+ const body = hookScript(packageRoot, gitRoot, execPrefix, hookKind);
198
236
  if (fs.existsSync(hookPath) && !force) {
199
237
  process.stderr.write(`Skipped ${path.relative(cwd, hookPath)} (already exists). Use --force to overwrite.\n`);
200
238
  } else {
package/lib/core/git.js CHANGED
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
2
 
3
+ const path = require("path");
3
4
  const { execFileSync } = require("child_process");
4
5
 
5
6
  /** Large staged diffs must not throw ENOBUFS (align with generous buffer in prior tooling). */
@@ -31,6 +32,53 @@ function isInGitRepo(cwd = process.cwd()) {
31
32
  }
32
33
  }
33
34
 
35
+ /**
36
+ * @param {string} cwd
37
+ * @returns {string | null} Absolute git root, or null if not in a repo / git unavailable
38
+ */
39
+ function getGitRoot(cwd = process.cwd()) {
40
+ try {
41
+ return execGit(["rev-parse", "--show-toplevel"], { cwd }).trim();
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Resolve the directory where Git runs hooks (`core.hooksPath` relative to git root, or `.husky`).
49
+ * Falls back to `<gitRoot>/.husky` with a warning if `core.hooksPath` is missing, empty, or outside the repo.
50
+ * @param {string} gitRoot
51
+ * @returns {{ dir: string, warned: boolean }}
52
+ */
53
+ function resolveGitHooksDir(gitRoot) {
54
+ const defaultDir = path.join(gitRoot, ".husky");
55
+ let raw = "";
56
+ try {
57
+ raw = execGit(["config", "--get", "core.hooksPath"], { cwd: gitRoot }).trim();
58
+ } catch {
59
+ return { dir: defaultDir, warned: false };
60
+ }
61
+ if (!raw) {
62
+ return { dir: defaultDir, warned: false };
63
+ }
64
+ const unquoted = raw.replace(/^"(.*)"$/, "$1").replace(/^'(.*)'$/, "$1");
65
+ const hooksPath = unquoted;
66
+ const resolved = path.isAbsolute(hooksPath)
67
+ ? path.normalize(hooksPath)
68
+ : path.resolve(gitRoot, hooksPath);
69
+ const rootResolved = path.resolve(gitRoot);
70
+ const hooksResolved = path.resolve(resolved);
71
+ const rel = path.relative(rootResolved, hooksResolved);
72
+ const outside = rel.startsWith("..") || path.isAbsolute(rel);
73
+ if (outside) {
74
+ process.stderr.write(
75
+ "warning: core.hooksPath points outside the repository or is invalid; using .husky at the git root.\n",
76
+ );
77
+ return { dir: defaultDir, warned: true };
78
+ }
79
+ return { dir: resolved, warned: false };
80
+ }
81
+
34
82
  function assertInGitRepo(cwd = process.cwd()) {
35
83
  if (!isInGitRepo(cwd)) {
36
84
  const err = new Error("Not a git repository (or git not available).");
@@ -97,6 +145,8 @@ module.exports = {
97
145
  DIFF_EXCLUDE_PATHSPECS,
98
146
  execGit,
99
147
  isInGitRepo,
148
+ getGitRoot,
149
+ resolveGitHooksDir,
100
150
  assertInGitRepo,
101
151
  getStagedDiff,
102
152
  getChangedFiles,
package/lib/init-env.js CHANGED
@@ -139,11 +139,18 @@ function injectAiCommitDocsForExistingKeys(content) {
139
139
  /**
140
140
  * Build text to append so OPENAI_API_KEY / COMMIT_AI_MODEL placeholders exist.
141
141
  * Returns null if nothing to add.
142
+ * Keys listed in `extraAssignedKeys` (e.g. from `.env.local`) count as already satisfied.
142
143
  * @param {string} existing
144
+ * @param {Set<string> | undefined} [extraAssignedKeys]
143
145
  * @returns {string | null}
144
146
  */
145
- function buildAiCommitEnvAppend(existing) {
147
+ function buildAiCommitEnvAppend(existing, extraAssignedKeys) {
146
148
  const keys = parseDotenvAssignedKeys(existing);
149
+ if (extraAssignedKeys && extraAssignedKeys.size > 0) {
150
+ for (const k of extraAssignedKeys) {
151
+ keys.add(k);
152
+ }
153
+ }
147
154
  const hasCommitPlaceholder =
148
155
  keys.has("COMMIT_AI_MODEL") ||
149
156
  /^\s*#\s*COMMIT_AI_MODEL\s*=/m.test(existing) ||
@@ -186,11 +193,11 @@ function buildAiCommitEnvAppend(existing) {
186
193
  * Merge bundled ai-commit env keys into a file. Never removes existing lines.
187
194
  * @param {string} destPath
188
195
  * @param {string} bundledPath
189
- * @param {{ force?: boolean }} [options]
196
+ * @param {{ force?: boolean, extraAssignedKeys?: Set<string> }} [options]
190
197
  * @returns {{ kind: 'replaced' | 'wrote' | 'merged' | 'unchanged' }}
191
198
  */
192
199
  function mergeAiCommitEnvFile(destPath, bundledPath, options = {}) {
193
- const { force = false } = options;
200
+ const { force = false, extraAssignedKeys } = options;
194
201
  const bundled = fs.readFileSync(bundledPath, "utf8");
195
202
 
196
203
  if (force) {
@@ -209,7 +216,7 @@ function mergeAiCommitEnvFile(destPath, bundledPath, options = {}) {
209
216
  }
210
217
 
211
218
  let text = injectAiCommitDocsForExistingKeys(existing);
212
- const append = buildAiCommitEnvAppend(text);
219
+ const append = buildAiCommitEnvAppend(text, extraAssignedKeys);
213
220
  if (append !== null) {
214
221
  const sep = text.endsWith("\n") ? "" : "\n";
215
222
  text = `${text}${sep}${append}`;
@@ -0,0 +1,65 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+
6
+ /**
7
+ * Pick the consumer example env file: prefer `.env.example`, else `.env-example`, else default `.env.example`.
8
+ * If both dot forms exist, merges target `.env.example` and emits a one-line stderr warning.
9
+ * @param {string} dir Absolute or resolved directory (e.g. package root)
10
+ * @returns {string} Destination path for the example/template merge
11
+ */
12
+ function resolveEnvExamplePath(dir) {
13
+ const dotExample = path.join(dir, ".env.example");
14
+ const dotHyphen = path.join(dir, ".env-example");
15
+ const hasExample = fs.existsSync(dotExample);
16
+ const hasHyphen = fs.existsSync(dotHyphen);
17
+ if (hasExample && hasHyphen) {
18
+ process.stderr.write(
19
+ "warning: both .env.example and .env-example exist; using .env.example. Remove or consolidate the other file if redundant.\n",
20
+ );
21
+ }
22
+ if (hasExample) {
23
+ return dotExample;
24
+ }
25
+ if (hasHyphen) {
26
+ return dotHyphen;
27
+ }
28
+ return dotExample;
29
+ }
30
+
31
+ /**
32
+ * Walk from `cwd` up toward `gitRoot` (inclusive); first directory with `package.json` wins.
33
+ * If `gitRoot` is null, returns `cwd` (no upward walk).
34
+ * If none found before/at git root, returns `cwd`.
35
+ * @param {string} cwd
36
+ * @param {string | null} gitRoot
37
+ * @returns {string}
38
+ */
39
+ function findPackageRoot(cwd, gitRoot) {
40
+ const cwdResolved = path.resolve(cwd);
41
+ if (!gitRoot) {
42
+ return cwdResolved;
43
+ }
44
+ const rootResolved = path.resolve(gitRoot);
45
+ let dir = cwdResolved;
46
+ for (;;) {
47
+ if (fs.existsSync(path.join(dir, "package.json"))) {
48
+ return dir;
49
+ }
50
+ if (dir === rootResolved) {
51
+ break;
52
+ }
53
+ const parent = path.dirname(dir);
54
+ if (parent === dir) {
55
+ break;
56
+ }
57
+ dir = parent;
58
+ }
59
+ return cwdResolved;
60
+ }
61
+
62
+ module.exports = {
63
+ resolveEnvExamplePath,
64
+ findPackageRoot,
65
+ };
@@ -18,18 +18,27 @@ function detectPackageExec(cwd) {
18
18
  }
19
19
 
20
20
  /**
21
+ * @param {string} packageRoot Absolute package directory (where lockfile / ai-commit dep live)
22
+ * @param {string} gitRoot Absolute git repository root
21
23
  * @param {string} execPrefix from detectPackageExec
22
24
  * @param {"prepare-commit-msg" | "commit-msg"} hook
23
25
  */
24
- function hookScript(execPrefix, hook) {
26
+ function hookScript(packageRoot, gitRoot, execPrefix, hook) {
25
27
  const cmd =
26
28
  hook === "prepare-commit-msg"
27
29
  ? `${execPrefix} ai-commit prepare-commit-msg "$1" "$2"`
28
30
  : `${execPrefix} ai-commit lint --edit "$1"`;
31
+ const pkgNorm = path.resolve(packageRoot);
32
+ const gitNorm = path.resolve(gitRoot);
33
+ let cdBlock = "";
34
+ if (pkgNorm !== gitNorm) {
35
+ const rel = path.relative(gitNorm, pkgNorm).split(path.sep).join("/");
36
+ cdBlock = `root="$(git rev-parse --show-toplevel)"\ncd "$root/${rel}"\n`;
37
+ }
29
38
  return `#!/usr/bin/env sh
30
39
  . "$(dirname -- "$0")/_/husky.sh"
31
40
 
32
- ${cmd}
41
+ ${cdBlock}${cmd}
33
42
  `;
34
43
  }
35
44
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@verndale/ai-commit",
3
- "version": "2.4.4",
3
+ "version": "2.5.1",
4
4
  "description": "AI-assisted conventional commits with bundled commitlint — one install, aligned rules",
5
5
  "license": "MIT",
6
6
  "author": "Verndale",