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
9 changes: 9 additions & 0 deletions bin/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const {
removeHuskyDefaultPreCommitIfPresent,
mergePackageJsonForAiCommit,
warnIfPrepareMissingHusky,
initWorkspaceFlat,
} = require("../lib/init-workspace.js");

function presetPath() {
Expand Down Expand Up @@ -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;

Expand Down
168 changes: 162 additions & 6 deletions lib/init-workspace.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}
Expand Down Expand Up @@ -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";
}

/**
* `<pm> 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,
Expand Down
Loading