Skip to content
Open
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
22 changes: 21 additions & 1 deletion src/commands/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 <bot@apso.ai>" "$1"'
);
}
} catch {
// Never let hook installation fail the generate command.
}
}
}
27 changes: 27 additions & 0 deletions src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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}"`
Expand Down Expand Up @@ -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 <bot@apso.ai>" "$1"'
);
}
} catch {
// Never let hook installation fail init.
}
}

private async postSetup(
projectPath: string,
language: TargetLanguage
): Promise<void> {
this.installCoAuthorHook(projectPath);

switch (language) {
case "typescript": {
if (shell.which("npm")) {
Expand Down
12 changes: 12 additions & 0 deletions src/lib/apsorc-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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 => {
Expand Down Expand Up @@ -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,
Expand All @@ -91,6 +100,7 @@ const parseRc = (): ApsorcType => {
language,
emitEvents,
http,
coAuthor,
};
};

Expand All @@ -116,6 +126,7 @@ export const parseApsorc = (): ParsedApsorc => {
language: apsoConfig.language,
emitEvents: apsoConfig.emitEvents,
http: apsoConfig.http,
coAuthor: apsoConfig.coAuthor,
...parseApsorcV1(apsoConfig),
};
if (debug) {
Expand All @@ -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);
Expand Down
146 changes: 146 additions & 0 deletions src/lib/utils/git-hooks.ts
Original file line number Diff line number Diff line change
@@ -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 <bot@apso.ai>";

/**
* 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 `<projectRoot>/.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" };
}
}
Loading
Loading