From 156a0479e03b339d94479fc48d4d4c6ab81bdb02 Mon Sep 17 00:00:00 2001 From: ACQ Build Date: Mon, 15 Jun 2026 08:46:34 -0500 Subject: [PATCH] feat: co-author hook crediting Apso on generated commits Install a `prepare-commit-msg` git hook into target projects that appends `Co-authored-by: Apso ` to commits whose staged changes touch an Apso-generated `autogen/` path. As a git hook it works for any committer (Claude, other agents, humans), not just one tool. - src/lib/utils/git-hooks.ts: pure `buildCoAuthorHookScript()` (merge/squash skip, APSO_NO_COAUTHOR opt-out, autogen guard, idempotent `git interpret-trailers`) and best-effort `installCoAuthorHook()` that writes `.apso/hooks/prepare-commit-msg` (0755) and sets a relative `core.hooksPath`, never clobbering a custom one (husky etc.). - generate.ts / init.ts: best-effort install, logged, never fails the command; honors `.apsorc coAuthor:false` and the env opt-out. - apsorc-parser.ts: thread `coAuthor?: boolean` through the config. - test/lib/utils/git-hooks.test.ts: unit + temp-repo integration + e2e. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/commands/generate.ts | 22 +++- src/commands/init.ts | 27 +++++ src/lib/apsorc-parser.ts | 12 ++ src/lib/utils/git-hooks.ts | 146 +++++++++++++++++++++++ test/lib/utils/git-hooks.test.ts | 197 +++++++++++++++++++++++++++++++ 5 files changed, 403 insertions(+), 1 deletion(-) create mode 100644 src/lib/utils/git-hooks.ts create mode 100644 test/lib/utils/git-hooks.test.ts diff --git a/src/commands/generate.ts b/src/commands/generate.ts index bf297be..e2223a3 100644 --- a/src/commands/generate.ts +++ b/src/commands/generate.ts @@ -11,6 +11,7 @@ import { TargetLanguage, GeneratorConfig } from "../lib/types"; import BaseCommand from "../lib/base-command"; import { performance } from "perf_hooks"; import { createFile } from "../lib/utils/file-system"; +import { installCoAuthorHook } from "../lib/utils/git-hooks"; export default class Generate extends BaseCommand { static description = "Generate code from .apsorc schema"; @@ -42,7 +43,7 @@ export default class Generate extends BaseCommand { const skipFormat = flags["skip-format"]; const totalBuildStart = performance.now(); - const { rootFolder, entities, relationshipMap, apiType, auth, emitEvents, http, language: configLanguage } = parseApsorc(); + const { rootFolder, entities, relationshipMap, apiType, auth, emitEvents, http, coAuthor, language: configLanguage } = parseApsorc(); // Resolve language: flag > .apsorc > prompt let language: TargetLanguage; @@ -268,5 +269,24 @@ export default class Generate extends BaseCommand { console.log( `[apso] Finished building all entities in ${totalBuildTime.toFixed(2)} ms` ); + + // Best-effort: install the commit co-author hook so commits that include + // Apso-generated files credit Apso as a co-author. Never fails the command. + try { + const result = installCoAuthorHook(process.cwd(), { + disabled: coAuthor === false, + }); + if (result.installed) { + console.log("[apso] Commit co-author hook installed (.apso/hooks)"); + } else if (result.reason === "custom-hookspath") { + console.log( + "[apso] A custom git core.hooksPath is already configured (e.g. husky), so the Apso co-author hook was not installed.\n" + + "[apso] To credit Apso on commits that include generated files, add this trailer to your existing prepare-commit-msg hook when the staged diff touches an `autogen/` path:\n" + + '[apso] git interpret-trailers --in-place --if-exists addIfDifferent --trailer "Co-authored-by: Apso " "$1"' + ); + } + } catch { + // Never let hook installation fail the generate command. + } } } diff --git a/src/commands/init.ts b/src/commands/init.ts index be50b39..6956b68 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -14,6 +14,7 @@ import { cloneTemplate, initGitRepo, } from "../lib/utils/template"; +import { installCoAuthorHook } from "../lib/utils/git-hooks"; export default class Init extends BaseCommand { static description = "Create a new Apso project or clone an existing one"; @@ -172,6 +173,8 @@ export default class Init extends BaseCommand { }; projectLink.write(link, projectPath); + this.installCoAuthorHook(projectPath); + this.log(`\nProject cloned to ${projectPath}`); this.log( `Linked to workspace "${workspace.name}" / service "${service.name}"` @@ -355,10 +358,34 @@ export default class Init extends BaseCommand { ); } + /** + * Best-effort install of the Apso commit co-author hook. Never throws, never + * fails the command. No `.apsorc` may exist yet at init time, so we only + * respect the `APSO_NO_COAUTHOR=1` env opt-out (handled inside the installer). + */ + private installCoAuthorHook(projectPath: string): void { + try { + const result = installCoAuthorHook(projectPath); + if (result.installed) { + this.log("[apso] Commit co-author hook installed (.apso/hooks)"); + } else if (result.reason === "custom-hookspath") { + this.log( + "[apso] A custom git core.hooksPath is already configured (e.g. husky), so the Apso co-author hook was not installed.\n" + + "[apso] To credit Apso on commits that include generated files, add this trailer to your existing prepare-commit-msg hook when the staged diff touches an `autogen/` path:\n" + + '[apso] git interpret-trailers --in-place --if-exists addIfDifferent --trailer "Co-authored-by: Apso " "$1"' + ); + } + } catch { + // Never let hook installation fail init. + } + } + private async postSetup( projectPath: string, language: TargetLanguage ): Promise { + this.installCoAuthorHook(projectPath); + switch (language) { case "typescript": { if (shell.which("npm")) { diff --git a/src/lib/apsorc-parser.ts b/src/lib/apsorc-parser.ts index 970772e..0039d82 100644 --- a/src/lib/apsorc-parser.ts +++ b/src/lib/apsorc-parser.ts @@ -35,6 +35,12 @@ export type ApsorcType = { * gets a generated controller unless it opts back in with `http: true`. */ http?: boolean; + /** + * Opt-out for the Apso commit co-author hook. When false, the CLI will not + * install the `prepare-commit-msg` hook that appends the Apso co-author + * trailer. Defaults to enabled. + */ + coAuthor?: boolean; }; type ParsedApsorcData = { @@ -52,6 +58,8 @@ type ParsedApsorc = { emitEvents?: boolean; /** Top-level default for HTTP controller generation. */ http?: boolean; + /** Opt-out for the Apso commit co-author hook (defaults to enabled). */ + coAuthor?: boolean; }; export const parseApsorcV1 = (apsorc: ApsorcType): ParsedApsorcData => { @@ -80,6 +88,7 @@ const parseRc = (): ApsorcType => { const language = apsoConfig.language as TargetLanguage | undefined; const emitEvents = apsoConfig.emitEvents as boolean | undefined; const http = apsoConfig.http as boolean | undefined; + const coAuthor = apsoConfig.coAuthor as boolean | undefined; return { rootFolder, @@ -91,6 +100,7 @@ const parseRc = (): ApsorcType => { language, emitEvents, http, + coAuthor, }; }; @@ -116,6 +126,7 @@ export const parseApsorc = (): ParsedApsorc => { language: apsoConfig.language, emitEvents: apsoConfig.emitEvents, http: apsoConfig.http, + coAuthor: apsoConfig.coAuthor, ...parseApsorcV1(apsoConfig), }; if (debug) { @@ -142,6 +153,7 @@ export const parseApsorc = (): ParsedApsorc => { language: apsoConfig.language, emitEvents: apsoConfig.emitEvents, http: apsoConfig.http, + coAuthor: apsoConfig.coAuthor, ...(() => { const relStart = performance.now(); const parsed = parseApsorcV2(apsoConfig); diff --git a/src/lib/utils/git-hooks.ts b/src/lib/utils/git-hooks.ts new file mode 100644 index 0000000..525a79e --- /dev/null +++ b/src/lib/utils/git-hooks.ts @@ -0,0 +1,146 @@ +import { execFileSync } from "child_process"; +import * as fs from "fs"; +import * as path from "path"; + +/** + * The Apso co-author git trailer. Appended to commits that include + * Apso-generated work so Apso is credited as a co-author. + */ +export const APSO_COAUTHOR_TRAILER = "Co-authored-by: Apso "; + +/** + * Relative path (from the repo top-level) where the managed hook is installed. + * Modern git resolves a relative `core.hooksPath` from the repository root. + */ +export const APSO_HOOKS_DIR = ".apso/hooks"; + +/** + * Builds the `prepare-commit-msg` bash script that appends the Apso co-author + * trailer to commits that touch Apso-generated paths. + * + * Pure function (no side effects) so it can be unit-tested directly. + * + * Behaviour of the generated script: + * - Skips merge/squash commits (`$2` is `merge` or `squash`). + * - Honors the `APSO_NO_COAUTHOR=1` opt-out. + * - Only acts when the staged changes include an Apso-generated path + * (a path segment named `autogen/`). + * - Adds the trailer idempotently via `git interpret-trailers`, which handles + * trailer-block formatting + dedupe and coexists with other `Co-authored-by` + * trailers (e.g. Claude). + */ +export function buildCoAuthorHookScript(): string { + return `#!/usr/bin/env bash +set -euo pipefail + +# Managed by Apso (@apso/cli). Appends an Apso co-author trailer to commits +# that include Apso-generated files. To opt out, set APSO_NO_COAUTHOR=1 or +# remove this hook (and unset core.hooksPath). + +COMMIT_MSG_FILE="\${1:-}" +COMMIT_SOURCE="\${2:-}" + +# Don't trailer merge or squash commit messages. +if [ "$COMMIT_SOURCE" = "merge" ] || [ "$COMMIT_SOURCE" = "squash" ]; then + exit 0 +fi + +# Opt-out. +if [ "\${APSO_NO_COAUTHOR:-}" = "1" ]; then + exit 0 +fi + +# Only act when the staged changes include an Apso-generated path. +if ! git diff --cached --name-only | grep -qE '(^|/)autogen/'; then + exit 0 +fi + +# Add the trailer idempotently. git handles formatting + dedupe and coexists +# with an existing "Co-authored-by: Claude" trailer. +git interpret-trailers --in-place --if-exists addIfDifferent \\ + --trailer "${APSO_COAUTHOR_TRAILER}" "$COMMIT_MSG_FILE" +`; +} + +export interface InstallCoAuthorHookOptions { + /** When true, skip installation (e.g. `.apsorc` set `coAuthor: false`). */ + disabled?: boolean; +} + +export interface InstallCoAuthorHookResult { + installed: boolean; + reason?: "disabled" | "not-a-git-repo" | "custom-hookspath" | "error"; +} + +/** + * Installs the Apso co-author `prepare-commit-msg` hook into a project. + * + * Best-effort: never throws. Callers can log the result. + * + * - Respects the `disabled` option and the `APSO_NO_COAUTHOR=1` env opt-out. + * - No-ops (returns `not-a-git-repo`) outside a git work tree. + * - Writes `/.apso/hooks/prepare-commit-msg` (mode 0755). + * - Sets the repo-local `core.hooksPath` to `.apso/hooks` when unset; leaves it + * alone (and reports success) when it's already `.apso/hooks`; refuses to + * clobber a custom `core.hooksPath` (husky etc.) and reports + * `custom-hookspath` so the caller can guide the user. + */ +export function installCoAuthorHook( + projectRoot: string, + opts: InstallCoAuthorHookOptions = {} +): InstallCoAuthorHookResult { + try { + if (opts.disabled || process.env.APSO_NO_COAUTHOR === "1") { + return { installed: false, reason: "disabled" }; + } + + // Verify we're inside a git work tree. + try { + execFileSync("git", ["-C", projectRoot, "rev-parse", "--is-inside-work-tree"], { + stdio: "ignore", + }); + } catch { + return { installed: false, reason: "not-a-git-repo" }; + } + + // Write the hook script. + const hooksDir = path.join(projectRoot, ".apso", "hooks"); + fs.mkdirSync(hooksDir, { recursive: true }); + const hookPath = path.join(hooksDir, "prepare-commit-msg"); + fs.writeFileSync(hookPath, buildCoAuthorHookScript(), { mode: 0o755 }); + // writeFileSync's mode is subject to umask on create and ignored on + // overwrite; chmod explicitly so the hook is always executable. + fs.chmodSync(hookPath, 0o755); + + // Inspect the current core.hooksPath. + let currentHooksPath = ""; + try { + currentHooksPath = execFileSync( + "git", + ["-C", projectRoot, "config", "--get", "core.hooksPath"], + { encoding: "utf8" } + ).trim(); + } catch { + // `git config --get` exits non-zero when the key is unset. + currentHooksPath = ""; + } + + if (currentHooksPath === "") { + execFileSync( + "git", + ["-C", projectRoot, "config", "core.hooksPath", APSO_HOOKS_DIR], + { stdio: "ignore" } + ); + return { installed: true }; + } + + if (currentHooksPath === APSO_HOOKS_DIR) { + return { installed: true }; + } + + // Some other hooks path is configured (husky, etc.). Don't clobber it. + return { installed: false, reason: "custom-hookspath" }; + } catch { + return { installed: false, reason: "error" }; + } +} diff --git a/test/lib/utils/git-hooks.test.ts b/test/lib/utils/git-hooks.test.ts new file mode 100644 index 0000000..556e738 --- /dev/null +++ b/test/lib/utils/git-hooks.test.ts @@ -0,0 +1,197 @@ +import { + expect, + describe, + it, + beforeEach, + afterEach, + afterAll, +} from "@jest/globals"; +import { execFileSync } from "child_process"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { + buildCoAuthorHookScript, + installCoAuthorHook, + APSO_COAUTHOR_TRAILER, + APSO_HOOKS_DIR, +} from "../../../src/lib/utils/git-hooks"; + +describe("buildCoAuthorHookScript", () => { + const script = buildCoAuthorHookScript(); + + it("is a bash script with strict mode", () => { + expect(script.startsWith("#!/usr/bin/env bash")).toBe(true); + expect(script).toContain("set -euo pipefail"); + }); + + it("contains the Apso co-author trailer", () => { + expect(script).toContain("Co-authored-by: Apso "); + expect(script).toContain(APSO_COAUTHOR_TRAILER); + }); + + it("guards on an autogen/ staged path", () => { + expect(script).toContain("git diff --cached --name-only"); + expect(script).toContain("grep -qE '(^|/)autogen/'"); + }); + + it("honors the APSO_NO_COAUTHOR opt-out", () => { + expect(script).toContain("APSO_NO_COAUTHOR"); + }); + + it("skips merge and squash commits", () => { + expect(script).toContain('"merge"'); + expect(script).toContain('"squash"'); + }); + + it("uses git interpret-trailers to add the trailer idempotently", () => { + expect(script).toContain("git interpret-trailers"); + expect(script).toContain("--if-exists addIfDifferent"); + }); +}); + +const tmpDirs: string[] = []; + +const makeRepo = (): string => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "apso-hook-")); + tmpDirs.push(dir); + execFileSync("git", ["-C", dir, "init"], { stdio: "ignore" }); + execFileSync("git", ["-C", dir, "config", "user.email", "test@apso.ai"], { + stdio: "ignore", + }); + execFileSync("git", ["-C", dir, "config", "user.name", "Apso Test"], { + stdio: "ignore", + }); + return dir; +}; + +const getHooksPath = (dir: string): string => { + try { + return execFileSync( + "git", + ["-C", dir, "config", "--get", "core.hooksPath"], + { encoding: "utf8" } + ).trim(); + } catch { + return ""; + } +}; + +const commit = (dir: string, msg: string): string => { + execFileSync( + "git", + [ + "-C", + dir, + "-c", + "user.email=test@apso.ai", + "-c", + "user.name=Apso Test", + "commit", + "-m", + msg, + ], + { stdio: "ignore" } + ); + return execFileSync("git", ["-C", dir, "log", "-1", "--pretty=%B"], { + encoding: "utf8", + }); +}; + +describe("installCoAuthorHook", () => { + let tmpDir: string; + const origEnv = process.env.APSO_NO_COAUTHOR; + + beforeEach(() => { + delete process.env.APSO_NO_COAUTHOR; + tmpDir = makeRepo(); + }); + + afterEach(() => { + if (origEnv === undefined) { + delete process.env.APSO_NO_COAUTHOR; + } else { + process.env.APSO_NO_COAUTHOR = origEnv; + } + }); + + afterAll(() => { + for (const dir of tmpDirs) { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it("writes an executable hook and sets core.hooksPath", () => { + const result = installCoAuthorHook(tmpDir); + expect(result).toEqual({ installed: true }); + + const hookPath = path.join(tmpDir, ".apso", "hooks", "prepare-commit-msg"); + expect(fs.existsSync(hookPath)).toBe(true); + // Executable bit set for the owner. + const mode = fs.statSync(hookPath).mode; + expect(mode & 0o100).toBe(0o100); + expect(fs.readFileSync(hookPath, "utf8")).toContain(APSO_COAUTHOR_TRAILER); + + expect(getHooksPath(tmpDir)).toBe(APSO_HOOKS_DIR); + }); + + it("is idempotent on a second call", () => { + expect(installCoAuthorHook(tmpDir)).toEqual({ installed: true }); + expect(installCoAuthorHook(tmpDir)).toEqual({ installed: true }); + expect(getHooksPath(tmpDir)).toBe(APSO_HOOKS_DIR); + }); + + it("does not clobber a custom core.hooksPath", () => { + execFileSync("git", ["-C", tmpDir, "config", "core.hooksPath", ".husky"], { + stdio: "ignore", + }); + const result = installCoAuthorHook(tmpDir); + expect(result).toEqual({ installed: false, reason: "custom-hookspath" }); + expect(getHooksPath(tmpDir)).toBe(".husky"); + }); + + it("returns disabled when opts.disabled is set", () => { + expect(installCoAuthorHook(tmpDir, { disabled: true })).toEqual({ + installed: false, + reason: "disabled", + }); + }); + + it("returns disabled when APSO_NO_COAUTHOR=1", () => { + process.env.APSO_NO_COAUTHOR = "1"; + expect(installCoAuthorHook(tmpDir)).toEqual({ + installed: false, + reason: "disabled", + }); + }); + + it("returns not-a-git-repo outside a work tree", () => { + const nonRepo = fs.mkdtempSync(path.join(os.tmpdir(), "apso-nonrepo-")); + tmpDirs.push(nonRepo); + expect(installCoAuthorHook(nonRepo)).toEqual({ + installed: false, + reason: "not-a-git-repo", + }); + }); + + describe("end-to-end commit behaviour", () => { + it("adds the trailer only when an autogen/ file is staged", () => { + installCoAuthorHook(tmpDir); + + // Commit with an autogen/ file staged -> trailer expected. + const autogenFile = path.join(tmpDir, "src", "autogen", "thing.ts"); + fs.mkdirSync(path.dirname(autogenFile), { recursive: true }); + fs.writeFileSync(autogenFile, "export const x = 1;\n"); + execFileSync("git", ["-C", tmpDir, "add", "."], { stdio: "ignore" }); + const autogenMsg = commit(tmpDir, "feat: generated entity"); + expect(autogenMsg).toContain(APSO_COAUTHOR_TRAILER); + + // Commit with only a non-autogen file staged -> no trailer. + const handFile = path.join(tmpDir, "src", "hand.ts"); + fs.writeFileSync(handFile, "export const y = 2;\n"); + execFileSync("git", ["-C", tmpDir, "add", "."], { stdio: "ignore" }); + const handMsg = commit(tmpDir, "chore: hand written"); + expect(handMsg).not.toContain(APSO_COAUTHOR_TRAILER); + }); + }); +});