@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 +100 -12
- package/bin/cli.js +57 -19
- package/lib/core/git.js +50 -0
- package/lib/init-env.js +11 -4
- package/lib/init-paths.js +65 -0
- package/lib/init-workspace.js +11 -2
- package/package.json +1 -1
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
|
|
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
|
-
|
|
|
45
|
-
|
|
|
46
|
-
|
|
|
47
|
-
|
|
|
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 /
|
|
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
|
|
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
|
-
|
|
|
72
|
-
|
|
|
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 (
|
|
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
|
|
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
|
|
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
|
|
92
|
+
const bundledExamplePath = path.join(__dirname, "..", ".env-example");
|
|
90
93
|
|
|
91
|
-
if (!fs.existsSync(
|
|
94
|
+
if (!fs.existsSync(bundledExamplePath)) {
|
|
92
95
|
throw new Error("Missing bundled .env-example (corrupt install?).");
|
|
93
96
|
}
|
|
94
97
|
|
|
95
|
-
const
|
|
96
|
-
const
|
|
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 =
|
|
118
|
-
const exResult = mergeAiCommitEnvFile(envExampleDest,
|
|
119
|
-
const exRel = path.relative(cwd, envExampleDest) ||
|
|
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 (!
|
|
172
|
+
if (!inGit) {
|
|
144
173
|
process.stdout.write(
|
|
145
|
-
"Not a git repository (or git unavailable); skipped Husky and package.json.
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
+
};
|
package/lib/init-workspace.js
CHANGED
|
@@ -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
|
|