@verndale/ai-commit 2.5.0 → 2.5.2

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 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
+ | 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`** (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). |
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`** | 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
+ | 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 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. |
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 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). |
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
 
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
19
  const { mergeAiCommitEnvFile } = 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. Env merge targets \`.env.local\` when that file exists, else \`.env\`. \`--force\` replaces \`.env\` / example file / hooks (not a wholesale replace of \`.env.local\`).
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,15 +89,44 @@ 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 });
97
- const envRel = path.relative(cwd, envDest) || ".env";
98
+ const inGit = isInGitRepo(cwd);
99
+ const gitRoot = inGit ? getGitRoot(cwd) : null;
100
+ const packageRoot = findPackageRoot(cwd, gitRoot);
101
+
102
+ const envLocalPath = path.join(packageRoot, ".env.local");
103
+ const envPath = path.join(packageRoot, ".env");
104
+
105
+ if (
106
+ inGit &&
107
+ gitRoot &&
108
+ path.resolve(packageRoot) !== path.resolve(gitRoot)
109
+ ) {
110
+ process.stdout.write(
111
+ `Note: env files are updated under ${packageRoot}; Git hooks use the repository root ${gitRoot}.\n`,
112
+ );
113
+ }
114
+
115
+ /** When `.env.local` exists it is the only env merge target (no `.env` created or updated). */
116
+ const envMergePath = fs.existsSync(envLocalPath) ? envLocalPath : envPath;
117
+ const mergeEnvIntoLocal =
118
+ path.resolve(envMergePath) === path.resolve(envLocalPath);
119
+ /** Never `--force`-replace `.env.local` with the bundled template (would wipe secrets). */
120
+ const envForce = force && !mergeEnvIntoLocal;
121
+ if (force && mergeEnvIntoLocal) {
122
+ process.stderr.write(
123
+ "note: --force does not replace .env.local with the bundled template; ai-commit keys are merged (append / docs) only.\n",
124
+ );
125
+ }
126
+ const envResult = mergeAiCommitEnvFile(envMergePath, bundledExamplePath, {
127
+ force: envForce,
128
+ });
129
+ const envRel = path.relative(cwd, envMergePath) || path.basename(envMergePath);
98
130
  switch (envResult.kind) {
99
131
  case "replaced":
100
132
  process.stdout.write(`Replaced ${envRel} with bundled template (--force).\n`);
@@ -107,16 +139,18 @@ function cmdInit(argv) {
107
139
  break;
108
140
  case "unchanged":
109
141
  process.stdout.write(
110
- `No missing @verndale/ai-commit keys in ${envRel}; left unchanged. Use --force to replace the file with the bundled template.\n`,
142
+ mergeEnvIntoLocal
143
+ ? `No missing @verndale/ai-commit keys in ${envRel}; left unchanged.\n`
144
+ : `No missing @verndale/ai-commit keys in ${envRel}; left unchanged. Use --force to replace the file with the bundled template.\n`,
111
145
  );
112
146
  break;
113
147
  default:
114
148
  break;
115
149
  }
116
150
 
117
- const envExampleDest = path.join(cwd, ".env-example");
118
- const exResult = mergeAiCommitEnvFile(envExampleDest, examplePath, { force });
119
- const exRel = path.relative(cwd, envExampleDest) || ".env-example";
151
+ const envExampleDest = resolveEnvExamplePath(packageRoot);
152
+ const exResult = mergeAiCommitEnvFile(envExampleDest, bundledExamplePath, { force });
153
+ const exRel = path.relative(cwd, envExampleDest) || path.basename(envExampleDest);
120
154
  switch (exResult.kind) {
121
155
  case "replaced":
122
156
  process.stdout.write(`Replaced ${exRel} with bundled template (--force).\n`);
@@ -140,17 +174,24 @@ function cmdInit(argv) {
140
174
  return;
141
175
  }
142
176
 
143
- if (!isInGitRepo(cwd)) {
177
+ if (!inGit) {
144
178
  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",
179
+ "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",
180
+ );
181
+ return;
182
+ }
183
+ if (!gitRoot) {
184
+ process.stderr.write(
185
+ "warning: could not resolve git repository root; skipped Husky and hooks.\n",
146
186
  );
147
187
  return;
148
188
  }
149
189
 
150
- const huskyHelper = path.join(cwd, ".husky", "_", "husky.sh");
190
+ let { dir: huskyDir } = resolveGitHooksDir(gitRoot);
191
+ const huskyHelper = path.join(huskyDir, "_", "husky.sh");
151
192
 
152
193
  if (!fs.existsSync(huskyHelper)) {
153
- const r = runHuskyInit(cwd);
194
+ const r = runHuskyInit(gitRoot);
154
195
  if (!r.ok) {
155
196
  process.stderr.write(
156
197
  r.error
@@ -160,14 +201,15 @@ function cmdInit(argv) {
160
201
  process.exit(1);
161
202
  }
162
203
  process.stdout.write("Ran `npx husky@9 init`.\n");
204
+ huskyDir = resolveGitHooksDir(gitRoot).dir;
163
205
  } else {
164
206
  process.stdout.write(
165
- "Husky already initialized (found .husky/_/husky.sh); skipped `npx husky@9 init`.\n",
207
+ `Husky already initialized (found ${path.join(huskyDir, "_", "husky.sh")}); skipped \`npx husky@9 init\`.\n`,
166
208
  );
167
209
  }
168
210
 
169
211
  if (mergePackageJson) {
170
- const pkgPath = path.join(cwd, "package.json");
212
+ const pkgPath = path.join(packageRoot, "package.json");
171
213
  if (fs.existsSync(pkgPath)) {
172
214
  const { changed } = mergePackageJsonForAiCommit(pkgPath);
173
215
  if (changed) {
@@ -177,16 +219,17 @@ function cmdInit(argv) {
177
219
  }
178
220
  warnIfPrepareMissingHusky(pkgPath);
179
221
  } else {
180
- process.stdout.write("No package.json in this directory; skipped package.json merge (hooks still written).\n");
222
+ process.stdout.write(
223
+ "No package.json found walking up to the git root; skipped package.json merge (hooks still written).\n",
224
+ );
181
225
  }
182
226
  }
183
227
 
184
- const huskyDir = path.join(cwd, ".husky");
185
228
  if (!fs.existsSync(huskyDir)) {
186
229
  fs.mkdirSync(huskyDir, { recursive: true });
187
230
  }
188
231
 
189
- const execPrefix = detectPackageExec(cwd);
232
+ const execPrefix = detectPackageExec(packageRoot);
190
233
  const preparePath = path.join(huskyDir, "prepare-commit-msg");
191
234
  const commitMsgPath = path.join(huskyDir, "commit-msg");
192
235
 
@@ -194,7 +237,7 @@ function cmdInit(argv) {
194
237
  [preparePath, "prepare-commit-msg"],
195
238
  [commitMsgPath, "commit-msg"],
196
239
  ]) {
197
- const body = hookScript(execPrefix, hookKind);
240
+ const body = hookScript(packageRoot, gitRoot, execPrefix, hookKind);
198
241
  if (fs.existsSync(hookPath) && !force) {
199
242
  process.stderr.write(`Skipped ${path.relative(cwd, hookPath)} (already exists). Use --force to overwrite.\n`);
200
243
  } 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.5.0",
3
+ "version": "2.5.2",
4
4
  "description": "AI-assisted conventional commits with bundled commitlint — one install, aligned rules",
5
5
  "license": "MIT",
6
6
  "author": "Verndale",