Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 17 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ pnpm add -D @verndale/ai-commit

## Setup

Do these **in order** from your **git repository root** (the directory that contains `package.json`).
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).

### 1. Install the package

Expand All @@ -41,10 +41,13 @@ pnpm exec ai-commit init

| Action | Detail |
| --- | --- |
| Env files | Merges **`.env`** and **`.env-example`**; creates **`.env-example`** from the bundled template if missing. Template reference: [`.env-example`](.env-example). |
| Husky | Runs **`npx husky@9 init`** if Husky is not present. |
| `package.json` | Adds missing **`commit`**, **`prepare`**, **`husky`** entries when the file exists. |
| Hooks | Writes **`.husky`** hook files. |
| 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. |
| 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`**. |
| 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). |
| Husky | Runs **`npx husky@9 init`** at the **git root** if **`husky.sh`** is missing under the resolved hooks directory. |
| Hooks directory | **`core.hooksPath`** relative to the git root when set; otherwise **`<git-root>/.husky`**. Falls back to **`.husky`** at the git root with a warning if the config path is invalid or outside the repo. |
| `package.json` | Adds missing **`commit`**, **`prepare`**, **`husky`** entries when **`package.json`** exists at the package root. |
| Hooks | Writes **`prepare-commit-msg`** and **`commit-msg`** in the hooks directory. If package root ≠ git root, each hook **`cd`s** into the package directory before **`pnpm exec ai-commit`** / **`npx`**. |

If **`package.json`** changed, run **`pnpm install`** (or `npm install`) again.

Expand All @@ -59,17 +62,19 @@ Set **`OPENAI_API_KEY`** in **`.env`** and/or **`.env.local`**. Duplicate keys:
| Flag | Use when |
| --- | --- |
| *(none)* | Full setup: env files + Husky + hooks + `package.json` updates (when applicable). |
| `--env-only` | You only want env / **`.env-example`** updates—no Git hooks. |
| `--env-only` | You only want env / example-file updates—no Git hooks. |
| `--husky` | Hooks + Husky only; skips **`package.json`** changes. Combine with **`--workspace`** if you need **`package.json`** merged again. |
| `--force` | Replace **`.env`** and **`.env-example`** with the bundled template **(destructive)** and/or overwrite existing Husky hook files. |
| `--force` | Replace **`.env`** and the resolved example file (see table above) with the bundled template **(destructive)** and/or overwrite existing Husky hook files. |

**Edge cases**

| Situation | Behavior |
| --- | --- |
| Not in a git repo | Init updates env files only and reports that Git/Husky were skipped. |
| Template filename | The published file is **`.env-example`** (hyphen), not **`.env.example`**. |
| Without **`--force`** | Missing **`.env-example`** is created; otherwise missing ai-commit keys are **appended** to **`.env`** (and the example file) without wiping the file. |
| Not in a git repo | Init updates env files only (under cwd) and reports that Git/Husky were skipped. |
| 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`**. |
| **`.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. |
| 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`**. |
| 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. |

---

Expand Down Expand Up @@ -124,7 +129,7 @@ pnpm exec ai-commit init --force
| Command | Purpose |
| --- | --- |
| **`ai-commit run`** | Build a message from the staged diff and run **`git commit`**. |
| **`ai-commit init`** | Env merge (including **`.env-example`**), Husky if needed, **`package.json`** when present, hooks. See [flags](#init-flags-and-shortcuts). |
| **`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). |
| **`ai-commit prepare-commit-msg <file> [source]`** | Hook: fill an empty message; skips `merge` / `squash`. |
| **`ai-commit lint --edit <file>`** | Hook: commitlint with this package’s default config. |

Expand Down Expand Up @@ -164,7 +169,7 @@ pnpm exec ai-commit prepare-commit-msg "$1" "$2"
pnpm exec ai-commit lint --edit "$1"
```

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.
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.

**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`**.

Expand Down
76 changes: 57 additions & 19 deletions bin/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,13 @@ const { generateAndValidate } = require("../lib/core/generate.js");
const {
assertInGitRepo,
isInGitRepo,
getGitRoot,
resolveGitHooksDir,
hasStagedChanges,
commitFromFile,
} = require("../lib/core/git.js");
const { mergeAiCommitEnvFile } = require("../lib/init-env.js");
const { mergeAiCommitEnvFile, parseDotenvAssignedKeys } = require("../lib/init-env.js");
const { resolveEnvExamplePath, findPackageRoot } = require("../lib/init-paths.js");
const {
detectPackageExec,
hookScript,
Expand Down Expand Up @@ -42,7 +45,7 @@ Usage:

Commands:
run Generate a message from the staged diff and run git commit.
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.
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\`).
prepare-commit-msg Git hook: fill an empty commit message file (merge/squash skipped).
lint Run commitlint with the package default config (for commit-msg hook).

Expand Down Expand Up @@ -86,14 +89,40 @@ function cmdInit(argv) {
const cwd = process.cwd();
/** Full package.json merge: default on, or `--workspace`; off for `--husky` alone (legacy). */
const mergePackageJson = !husky || workspace;
const examplePath = path.join(__dirname, "..", ".env-example");
const bundledExamplePath = path.join(__dirname, "..", ".env-example");

if (!fs.existsSync(examplePath)) {
if (!fs.existsSync(bundledExamplePath)) {
throw new Error("Missing bundled .env-example (corrupt install?).");
}

const envDest = path.join(cwd, ".env");
const envResult = mergeAiCommitEnvFile(envDest, examplePath, { force });
const inGit = isInGitRepo(cwd);
const gitRoot = inGit ? getGitRoot(cwd) : null;
const packageRoot = findPackageRoot(cwd, gitRoot);

const extraAssignedKeys = new Set();
const envLocalPath = path.join(packageRoot, ".env.local");
if (fs.existsSync(envLocalPath)) {
const localContent = fs.readFileSync(envLocalPath, "utf8");
for (const k of parseDotenvAssignedKeys(localContent)) {
extraAssignedKeys.add(k);
}
}

if (
inGit &&
gitRoot &&
path.resolve(packageRoot) !== path.resolve(gitRoot)
) {
process.stdout.write(
`Note: env files are updated under ${packageRoot}; Git hooks use the repository root ${gitRoot}.\n`,
);
}

const envDest = path.join(packageRoot, ".env");
const envResult = mergeAiCommitEnvFile(envDest, bundledExamplePath, {
force,
extraAssignedKeys,
});
const envRel = path.relative(cwd, envDest) || ".env";
switch (envResult.kind) {
case "replaced":
Expand All @@ -114,9 +143,9 @@ function cmdInit(argv) {
break;
}

const envExampleDest = path.join(cwd, ".env-example");
const exResult = mergeAiCommitEnvFile(envExampleDest, examplePath, { force });
const exRel = path.relative(cwd, envExampleDest) || ".env-example";
const envExampleDest = resolveEnvExamplePath(packageRoot);
const exResult = mergeAiCommitEnvFile(envExampleDest, bundledExamplePath, { force });
const exRel = path.relative(cwd, envExampleDest) || path.basename(envExampleDest);
switch (exResult.kind) {
case "replaced":
process.stdout.write(`Replaced ${exRel} with bundled template (--force).\n`);
Expand All @@ -140,17 +169,24 @@ function cmdInit(argv) {
return;
}

if (!isInGitRepo(cwd)) {
if (!inGit) {
process.stdout.write(
"Not a git repository (or git unavailable); skipped Husky and package.json. Re-run from a repo root for hooks and scripts.\n",
"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",
);
return;
}
if (!gitRoot) {
process.stderr.write(
"warning: could not resolve git repository root; skipped Husky and hooks.\n",
);
return;
}

const huskyHelper = path.join(cwd, ".husky", "_", "husky.sh");
let { dir: huskyDir } = resolveGitHooksDir(gitRoot);
const huskyHelper = path.join(huskyDir, "_", "husky.sh");

if (!fs.existsSync(huskyHelper)) {
const r = runHuskyInit(cwd);
const r = runHuskyInit(gitRoot);
if (!r.ok) {
process.stderr.write(
r.error
Expand All @@ -160,14 +196,15 @@ function cmdInit(argv) {
process.exit(1);
}
process.stdout.write("Ran `npx husky@9 init`.\n");
huskyDir = resolveGitHooksDir(gitRoot).dir;
} else {
process.stdout.write(
"Husky already initialized (found .husky/_/husky.sh); skipped `npx husky@9 init`.\n",
`Husky already initialized (found ${path.join(huskyDir, "_", "husky.sh")}); skipped \`npx husky@9 init\`.\n`,
);
}

if (mergePackageJson) {
const pkgPath = path.join(cwd, "package.json");
const pkgPath = path.join(packageRoot, "package.json");
if (fs.existsSync(pkgPath)) {
const { changed } = mergePackageJsonForAiCommit(pkgPath);
if (changed) {
Expand All @@ -177,24 +214,25 @@ function cmdInit(argv) {
}
warnIfPrepareMissingHusky(pkgPath);
} else {
process.stdout.write("No package.json in this directory; skipped package.json merge (hooks still written).\n");
process.stdout.write(
"No package.json found walking up to the git root; skipped package.json merge (hooks still written).\n",
);
}
}

const huskyDir = path.join(cwd, ".husky");
if (!fs.existsSync(huskyDir)) {
fs.mkdirSync(huskyDir, { recursive: true });
}

const execPrefix = detectPackageExec(cwd);
const execPrefix = detectPackageExec(packageRoot);
const preparePath = path.join(huskyDir, "prepare-commit-msg");
const commitMsgPath = path.join(huskyDir, "commit-msg");

for (const [hookPath, hookKind] of [
[preparePath, "prepare-commit-msg"],
[commitMsgPath, "commit-msg"],
]) {
const body = hookScript(execPrefix, hookKind);
const body = hookScript(packageRoot, gitRoot, execPrefix, hookKind);
if (fs.existsSync(hookPath) && !force) {
process.stderr.write(`Skipped ${path.relative(cwd, hookPath)} (already exists). Use --force to overwrite.\n`);
} else {
Expand Down
50 changes: 50 additions & 0 deletions lib/core/git.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"use strict";

const path = require("path");
const { execFileSync } = require("child_process");

/** Large staged diffs must not throw ENOBUFS (align with generous buffer in prior tooling). */
Expand Down Expand Up @@ -31,6 +32,53 @@ function isInGitRepo(cwd = process.cwd()) {
}
}

/**
* @param {string} cwd
* @returns {string | null} Absolute git root, or null if not in a repo / git unavailable
*/
function getGitRoot(cwd = process.cwd()) {
try {
return execGit(["rev-parse", "--show-toplevel"], { cwd }).trim();
} catch {
return null;
}
}

/**
* Resolve the directory where Git runs hooks (`core.hooksPath` relative to git root, or `.husky`).
* Falls back to `<gitRoot>/.husky` with a warning if `core.hooksPath` is missing, empty, or outside the repo.
* @param {string} gitRoot
* @returns {{ dir: string, warned: boolean }}
*/
function resolveGitHooksDir(gitRoot) {
const defaultDir = path.join(gitRoot, ".husky");
let raw = "";
try {
raw = execGit(["config", "--get", "core.hooksPath"], { cwd: gitRoot }).trim();
} catch {
return { dir: defaultDir, warned: false };
}
if (!raw) {
return { dir: defaultDir, warned: false };
}
const unquoted = raw.replace(/^"(.*)"$/, "$1").replace(/^'(.*)'$/, "$1");
const hooksPath = unquoted;
const resolved = path.isAbsolute(hooksPath)
? path.normalize(hooksPath)
: path.resolve(gitRoot, hooksPath);
const rootResolved = path.resolve(gitRoot);
const hooksResolved = path.resolve(resolved);
const rel = path.relative(rootResolved, hooksResolved);
const outside = rel.startsWith("..") || path.isAbsolute(rel);
if (outside) {
process.stderr.write(
"warning: core.hooksPath points outside the repository or is invalid; using .husky at the git root.\n",
);
return { dir: defaultDir, warned: true };
}
return { dir: resolved, warned: false };
}

function assertInGitRepo(cwd = process.cwd()) {
if (!isInGitRepo(cwd)) {
const err = new Error("Not a git repository (or git not available).");
Expand Down Expand Up @@ -97,6 +145,8 @@ module.exports = {
DIFF_EXCLUDE_PATHSPECS,
execGit,
isInGitRepo,
getGitRoot,
resolveGitHooksDir,
assertInGitRepo,
getStagedDiff,
getChangedFiles,
Expand Down
15 changes: 11 additions & 4 deletions lib/init-env.js
Original file line number Diff line number Diff line change
Expand Up @@ -139,11 +139,18 @@ function injectAiCommitDocsForExistingKeys(content) {
/**
* Build text to append so OPENAI_API_KEY / COMMIT_AI_MODEL placeholders exist.
* Returns null if nothing to add.
* Keys listed in `extraAssignedKeys` (e.g. from `.env.local`) count as already satisfied.
* @param {string} existing
* @param {Set<string> | undefined} [extraAssignedKeys]
* @returns {string | null}
*/
function buildAiCommitEnvAppend(existing) {
function buildAiCommitEnvAppend(existing, extraAssignedKeys) {
const keys = parseDotenvAssignedKeys(existing);
if (extraAssignedKeys && extraAssignedKeys.size > 0) {
for (const k of extraAssignedKeys) {
keys.add(k);
}
}
const hasCommitPlaceholder =
keys.has("COMMIT_AI_MODEL") ||
/^\s*#\s*COMMIT_AI_MODEL\s*=/m.test(existing) ||
Expand Down Expand Up @@ -186,11 +193,11 @@ function buildAiCommitEnvAppend(existing) {
* Merge bundled ai-commit env keys into a file. Never removes existing lines.
* @param {string} destPath
* @param {string} bundledPath
* @param {{ force?: boolean }} [options]
* @param {{ force?: boolean, extraAssignedKeys?: Set<string> }} [options]
* @returns {{ kind: 'replaced' | 'wrote' | 'merged' | 'unchanged' }}
*/
function mergeAiCommitEnvFile(destPath, bundledPath, options = {}) {
const { force = false } = options;
const { force = false, extraAssignedKeys } = options;
const bundled = fs.readFileSync(bundledPath, "utf8");

if (force) {
Expand All @@ -209,7 +216,7 @@ function mergeAiCommitEnvFile(destPath, bundledPath, options = {}) {
}

let text = injectAiCommitDocsForExistingKeys(existing);
const append = buildAiCommitEnvAppend(text);
const append = buildAiCommitEnvAppend(text, extraAssignedKeys);
if (append !== null) {
const sep = text.endsWith("\n") ? "" : "\n";
text = `${text}${sep}${append}`;
Expand Down
Loading
Loading