diff --git a/README.md b/README.md index 31b55e6..c4ab47d 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 **`/.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. @@ -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. | --- @@ -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 [source]`** | Hook: fill an empty message; skips `merge` / `squash`. | | **`ai-commit lint --edit `** | Hook: commitlint with this package’s default config. | @@ -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`**. diff --git a/bin/cli.js b/bin/cli.js index 2e49738..def5551 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -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, @@ -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). @@ -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": @@ -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`); @@ -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 @@ -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) { @@ -177,16 +214,17 @@ 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"); @@ -194,7 +232,7 @@ function cmdInit(argv) { [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 { diff --git a/lib/core/git.js b/lib/core/git.js index e7a09a6..0aab69c 100644 --- a/lib/core/git.js +++ b/lib/core/git.js @@ -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). */ @@ -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 `/.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)."); @@ -97,6 +145,8 @@ module.exports = { DIFF_EXCLUDE_PATHSPECS, execGit, isInGitRepo, + getGitRoot, + resolveGitHooksDir, assertInGitRepo, getStagedDiff, getChangedFiles, diff --git a/lib/init-env.js b/lib/init-env.js index 560f81e..a44a0e9 100644 --- a/lib/init-env.js +++ b/lib/init-env.js @@ -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 | 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) || @@ -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 }} [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) { @@ -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}`; diff --git a/lib/init-paths.js b/lib/init-paths.js new file mode 100644 index 0000000..5787bf6 --- /dev/null +++ b/lib/init-paths.js @@ -0,0 +1,65 @@ +"use strict"; + +const fs = require("fs"); +const path = require("path"); + +/** + * Pick the consumer example env file: prefer `.env.example`, else `.env-example`, else default `.env.example`. + * If both dot forms exist, merges target `.env.example` and emits a one-line stderr warning. + * @param {string} dir Absolute or resolved directory (e.g. package root) + * @returns {string} Destination path for the example/template merge + */ +function resolveEnvExamplePath(dir) { + const dotExample = path.join(dir, ".env.example"); + const dotHyphen = path.join(dir, ".env-example"); + const hasExample = fs.existsSync(dotExample); + const hasHyphen = fs.existsSync(dotHyphen); + if (hasExample && hasHyphen) { + process.stderr.write( + "warning: both .env.example and .env-example exist; using .env.example. Remove or consolidate the other file if redundant.\n", + ); + } + if (hasExample) { + return dotExample; + } + if (hasHyphen) { + return dotHyphen; + } + return dotExample; +} + +/** + * Walk from `cwd` up toward `gitRoot` (inclusive); first directory with `package.json` wins. + * If `gitRoot` is null, returns `cwd` (no upward walk). + * If none found before/at git root, returns `cwd`. + * @param {string} cwd + * @param {string | null} gitRoot + * @returns {string} + */ +function findPackageRoot(cwd, gitRoot) { + const cwdResolved = path.resolve(cwd); + if (!gitRoot) { + return cwdResolved; + } + const rootResolved = path.resolve(gitRoot); + let dir = cwdResolved; + for (;;) { + if (fs.existsSync(path.join(dir, "package.json"))) { + return dir; + } + if (dir === rootResolved) { + break; + } + const parent = path.dirname(dir); + if (parent === dir) { + break; + } + dir = parent; + } + return cwdResolved; +} + +module.exports = { + resolveEnvExamplePath, + findPackageRoot, +}; diff --git a/lib/init-workspace.js b/lib/init-workspace.js index e2f8afd..600c2b3 100644 --- a/lib/init-workspace.js +++ b/lib/init-workspace.js @@ -18,18 +18,27 @@ function detectPackageExec(cwd) { } /** + * @param {string} packageRoot Absolute package directory (where lockfile / ai-commit dep live) + * @param {string} gitRoot Absolute git repository root * @param {string} execPrefix from detectPackageExec * @param {"prepare-commit-msg" | "commit-msg"} hook */ -function hookScript(execPrefix, hook) { +function hookScript(packageRoot, gitRoot, execPrefix, hook) { const cmd = hook === "prepare-commit-msg" ? `${execPrefix} ai-commit prepare-commit-msg "$1" "$2"` : `${execPrefix} ai-commit lint --edit "$1"`; + const pkgNorm = path.resolve(packageRoot); + const gitNorm = path.resolve(gitRoot); + let cdBlock = ""; + if (pkgNorm !== gitNorm) { + const rel = path.relative(gitNorm, pkgNorm).split(path.sep).join("/"); + cdBlock = `root="$(git rev-parse --show-toplevel)"\ncd "$root/${rel}"\n`; + } return `#!/usr/bin/env sh . "$(dirname -- "$0")/_/husky.sh" -${cmd} +${cdBlock}${cmd} `; }