diff --git a/bin/cli.js b/bin/cli.js index 7ca7a14..058f05f 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -32,6 +32,7 @@ const { removeHuskyDefaultPreCommitIfPresent, mergePackageJsonForAiCommit, warnIfPrepareMissingHusky, + initWorkspaceFlat, } = require("../lib/init-workspace.js"); function presetPath() { @@ -195,6 +196,14 @@ function cmdInit(argv) { return; } + // --workspace (subdir layout): install the flat, husky-free CN layout at the git root — + // git-config core.hooksPath + committed normalized hooks + a root package.json (created if + // absent) carrying a commit delegator and a git-config prepare. No husky is needed at the root. + if (workspace && path.resolve(packageRoot) !== path.resolve(gitRoot)) { + initWorkspaceFlat({ cwd, gitRoot, packageRoot, force }); + return; + } + let { dir: huskyDir } = resolveGitHooksDir(gitRoot); let ranHuskyInit = false; diff --git a/lib/init-workspace.js b/lib/init-workspace.js index 22b928a..32e7775 100644 --- a/lib/init-workspace.js +++ b/lib/init-workspace.js @@ -143,16 +143,25 @@ function huskyRunnerSourceLine(hooksDirAbs) { * @returns {{ cdBlock: string, cmd: string }} */ function userHookCommands(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); + const nested = pkgNorm !== gitNorm; + // Git passes the message-file path relative to the worktree root. When the hook cd's + // into a subdir (workspace layout) that relative path no longer resolves, so absolutize + // it first ($msg); at the git root the raw "$1" is already correct. + const msgArg = nested ? '"$msg"' : '"$1"'; + const cmd = + hook === "prepare-commit-msg" + ? `${execPrefix} ai-commit prepare-commit-msg ${msgArg} "$2"` + : `${execPrefix} ai-commit lint --edit ${msgArg}`; let cdBlock = ""; - if (pkgNorm !== gitNorm) { + if (nested) { const rel = path.relative(gitNorm, pkgNorm).split(path.sep).join("/"); - cdBlock = `root="$(git rev-parse --show-toplevel)"\ncd "$root/${rel}"\n`; + cdBlock = + `root="$(git rev-parse --show-toplevel)"\n` + + `msg="$1"\n` + + `case "$msg" in\n /*) ;;\n *) msg="$root/$msg" ;;\nesac\n` + + `cd "$root/${rel}"\n`; } return { cdBlock, cmd }; } @@ -360,9 +369,156 @@ function warnIfPrepareMissingHusky(packageJsonPath) { } } +/** + * Root `prepare` for the flat (`core.hooksPath=.husky`) workspace layout: point Git at the + * committed hooks with plain git config — no husky is needed at the repo root, and a fresh + * clone re-activates hooks on install. The `git rev-parse` guard makes it a no-op outside a + * work tree (e.g. when installed as a dependency / in a tarball). + * @returns {string} + */ +function workspaceRootPrepareScript() { + return "git rev-parse --git-dir > /dev/null 2>&1 && git config core.hooksPath .husky || true"; +} + +/** + * ` commit` delegator that runs the subdir's `commit` script from the repo root. + * @param {string} pm pnpm | npm | yarn | bun + * @param {string} subdirRel POSIX-relative subdir path + * @returns {string} + */ +function commitDelegator(pm, subdirRel) { + switch (pm) { + case "yarn": + return `yarn --cwd ${subdirRel} commit`; + case "npm": + return `npm --prefix ${subdirRel} run commit`; + case "bun": + return `bun --cwd ${subdirRel} run commit`; + default: + return `pnpm --dir ${subdirRel} commit`; + } +} + +/** + * Add (missing-only) the flat-workspace root scripts — a `commit` delegator into the subdir and + * a git-config `prepare` — to the repo-root package.json, creating a minimal `{ private: true }` + * one if absent. Never clobbers an existing `commit`/`prepare`. @returns {{ changed: boolean }} + */ +function mergeWorkspaceRootPackageJson(rootPkgPath, subdirRel, pm) { + let pkg = {}; + let existed = false; + try { + if (fs.statSync(rootPkgPath).isFile()) { + pkg = JSON.parse(fs.readFileSync(rootPkgPath, "utf8")); + existed = true; + } + } catch (_) { + /* absent — create a minimal private root */ + } + if (pkg === null || typeof pkg !== "object" || Array.isArray(pkg)) { + throw new Error(`${rootPkgPath} is not a JSON object`); + } + let changed = !existed; + if (!existed && pkg.private === undefined) pkg.private = true; + pkg.scripts = pkg.scripts && typeof pkg.scripts === "object" ? pkg.scripts : {}; + if (!pkg.scripts.commit) { + pkg.scripts.commit = commitDelegator(pm, subdirRel); + changed = true; + } + if (!pkg.scripts.prepare) { + pkg.scripts.prepare = workspaceRootPrepareScript(); + changed = true; + } + if (changed) { + fs.writeFileSync(rootPkgPath, `${JSON.stringify(pkg, null, 2)}\n`, "utf8"); + } + return { changed }; +} + +/** + * Install the flat, husky-free workspace hook layout (the "CN" pattern) at the git root: + * - `core.hooksPath=.husky` via plain git config (no husky at the root; fresh clones + * re-activate hooks from `prepare` alone); + * - committed standalone hooks under `.husky/` that normalize the message path, cd into the + * subdir, and run ai-commit; + * - a root package.json (created `{ private: true }` if absent) with a `commit` delegator + a + * git-config `prepare`; + * - the subdir package.json wired for ai-commit (commit script + husky/dotenv devDeps). + * @param {{ cwd: string, gitRoot: string, packageRoot: string, force: boolean }} opts + */ +function initWorkspaceFlat({ cwd, gitRoot, packageRoot, force }) { + const huskyDir = path.join(gitRoot, ".husky"); + const subdirRel = path + .relative(path.resolve(gitRoot), path.resolve(packageRoot)) + .split(path.sep) + .join("/"); + const execPrefix = detectPackageExec(packageRoot); + const pm = detectPackageInstallInfo(packageRoot, gitRoot).cmd.split(" ")[0]; + + const cfg = spawnSync("git", ["config", "core.hooksPath", ".husky"], { cwd: gitRoot }); + if (cfg.status !== 0) { + process.stderr.write( + "warning: could not set core.hooksPath=.husky; run `git config core.hooksPath .husky` at the repo root.\n", + ); + } else { + process.stdout.write("Set core.hooksPath=.husky (flat, husky-free layout).\n"); + } + + if (!fs.existsSync(huskyDir)) fs.mkdirSync(huskyDir, { recursive: true }); + for (const abs of removeHuskyDefaultPreCommitIfPresent(gitRoot, huskyDir)) { + const rel = path.relative(cwd, abs) || path.basename(abs); + process.stdout.write(`Removed Husky default pre-commit (${rel}).\n`); + } + + for (const hookKind of ["prepare-commit-msg", "commit-msg"]) { + const hookPath = path.join(huskyDir, hookKind); + if (fs.existsSync(hookPath) && !force) { + process.stderr.write( + `Skipped ${path.relative(cwd, hookPath)} (already exists). Use --force to overwrite.\n`, + ); + continue; + } + fs.writeFileSync(hookPath, userHookScript(packageRoot, gitRoot, execPrefix, hookKind), { + encoding: "utf8", + }); + try { + fs.chmodSync(hookPath, 0o755); + } catch { + /* ignore on platforms without chmod */ + } + process.stdout.write(`Wrote ${path.relative(cwd, hookPath)}.\n`); + } + + const subdirPkg = path.join(packageRoot, "package.json"); + if (fs.existsSync(subdirPkg)) { + const { changed } = mergePackageJsonForAiCommit(subdirPkg); + if (changed) { + process.stdout.write( + "Updated subdir package.json (commit script + husky/dotenv devDependencies).\n", + ); + } + } else { + process.stdout.write("No package.json in the subdir; skipped its merge (hooks still written).\n"); + } + + const rootPkg = path.join(gitRoot, "package.json"); + const { changed: rootChanged } = mergeWorkspaceRootPackageJson(rootPkg, subdirRel, pm); + if (rootChanged) { + process.stdout.write( + `Wrote root package.json (commit delegator + git-config prepare) at ${path.relative(cwd, rootPkg) || "package.json"}.\n`, + ); + } + + process.stdout.write(`${formatPackageInstallLine(detectPackageInstallInfo(packageRoot, gitRoot), cwd)}\n`); +} + module.exports = { HUSKY_RANGE, DOTENV_RANGE, + initWorkspaceFlat, + mergeWorkspaceRootPackageJson, + workspaceRootPrepareScript, + commitDelegator, detectPackageExec, detectPackageInstallInfo, formatPackageInstallLine,