Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

install-hook.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

install-hook.tsBlame182 lines · 1 contributor
9018b1fClaude1/**
2 * `gluecron hook install commit-msg` / `gluecron hook uninstall commit-msg`
3 *
4 * Installs a `.git/hooks/prepare-commit-msg` script that calls
5 * `gluecron ai commit-msg` and writes the AI-drafted message into the
6 * commit-message file — but only when the file is empty (so explicit
7 * `-m` and amends are untouched).
8 *
9 * Idempotent: re-running `install` overwrites the file; `uninstall`
10 * only removes a file that carries our marker (so a hand-written hook
11 * is never clobbered).
12 */
13
14import {
15 existsSync,
16 readFileSync,
17 writeFileSync,
18 chmodSync,
19 unlinkSync,
20 mkdirSync,
21} from "node:fs";
22import { spawnSync } from "node:child_process";
23import { join } from "node:path";
24
25export const HOOK_MARKER = "# gluecron-prepare-commit-msg-hook";
26
27/**
28 * Body of the hook script. Kept in a separate function so the test
29 * suite can assert on the contents without touching the filesystem.
30 *
31 * $1 — path to the message file
32 * $2 — source ("message" | "template" | "commit" | "merge" | "squash" | "")
33 * $3 — sha (when amending)
34 *
35 * We only fire when $2 is empty (a vanilla `git commit` with no -m).
36 * Otherwise we leave the message untouched so amends, merges, and
37 * `-m "..."` keep their existing behaviour.
38 */
39export function hookScript(binPath: string): string {
40 return `#!/usr/bin/env bash
41${HOOK_MARKER}
42#
43# Installed by \`gluecron hook install commit-msg\`. Calls the gluecron CLI
44# to draft a commit message when none was supplied.
45
46set -e
47
48COMMIT_MSG_FILE="$1"
49COMMIT_SOURCE="$2"
50
51# Only act on a plain \`git commit\` (no -m, no template, no amend).
52if [ -n "$COMMIT_SOURCE" ]; then
53 exit 0
54fi
55
56# Bail if the user already typed something.
57if [ -s "$COMMIT_MSG_FILE" ]; then
58 existing=$(grep -v '^#' "$COMMIT_MSG_FILE" | tr -d '[:space:]' || true)
59 if [ -n "$existing" ]; then
60 exit 0
61 fi
62fi
63
64DRAFT=$(${binPath} ai commit-msg 2>/dev/null || true)
65if [ -n "$DRAFT" ]; then
66 # Preserve any trailing comment lines (status summary git appends).
67 TRAILER=$(grep '^#' "$COMMIT_MSG_FILE" || true)
68 {
69 printf '%s\\n' "$DRAFT"
70 if [ -n "$TRAILER" ]; then
71 printf '\\n'
72 printf '%s\\n' "$TRAILER"
73 fi
74 } > "$COMMIT_MSG_FILE"
75fi
76
77exit 0
78`;
79}
80
81export function findGitDir(cwd: string = process.cwd()): string | null {
82 const r = spawnSync("git", ["rev-parse", "--git-dir"], {
83 cwd,
84 encoding: "utf8",
85 });
86 if (r.status !== 0) return null;
87 const dir = (r.stdout || "").trim();
88 if (!dir) return null;
89 return dir.startsWith("/") ? dir : join(cwd, dir);
90}
91
92export interface HookDeps {
93 out: (msg: string) => void;
94 cwd?: string;
95 /** Override the path used inside the generated hook script (defaults to "gluecron"). */
96 binPath?: string;
97 /** Override findGitDir for tests. */
98 findGitDirImpl?: typeof findGitDir;
99}
100
101export async function runHookInstall(
102 argv: string[],
103 deps: HookDeps
104): Promise<number> {
105 const target = argv[0];
106 if (target !== "commit-msg") {
107 deps.out("usage: gluecron hook install commit-msg");
108 return 1;
109 }
110 const gitDir = (deps.findGitDirImpl ?? findGitDir)(deps.cwd);
111 if (!gitDir) {
112 deps.out("error: not a git repository (run from inside a git checkout)");
113 return 1;
114 }
115 const hooksDir = join(gitDir, "hooks");
116 mkdirSync(hooksDir, { recursive: true });
117 const hookPath = join(hooksDir, "prepare-commit-msg");
118
119 // Refuse to clobber a hand-written hook unless it carries our marker.
120 if (existsSync(hookPath)) {
121 const existing = readFileSync(hookPath, "utf8");
122 if (!existing.includes(HOOK_MARKER)) {
123 deps.out(
124 `error: ${hookPath} already exists and was not installed by gluecron. ` +
125 "Remove it manually if you want gluecron to manage it."
126 );
127 return 1;
128 }
129 }
130
131 const bin = deps.binPath || "gluecron";
132 writeFileSync(hookPath, hookScript(bin), "utf8");
133 chmodSync(hookPath, 0o755);
134 deps.out(`installed ${hookPath}`);
135 deps.out(
136 "next commit with an empty message will auto-fill from the AI draft."
137 );
138 return 0;
139}
140
141export async function runHookUninstall(
142 argv: string[],
143 deps: HookDeps
144): Promise<number> {
145 const target = argv[0];
146 if (target !== "commit-msg") {
147 deps.out("usage: gluecron hook uninstall commit-msg");
148 return 1;
149 }
150 const gitDir = (deps.findGitDirImpl ?? findGitDir)(deps.cwd);
151 if (!gitDir) {
152 deps.out("error: not a git repository");
153 return 1;
154 }
155 const hookPath = join(gitDir, "hooks", "prepare-commit-msg");
156 if (!existsSync(hookPath)) {
157 deps.out("nothing to uninstall.");
158 return 0;
159 }
160 const existing = readFileSync(hookPath, "utf8");
161 if (!existing.includes(HOOK_MARKER)) {
162 deps.out(
163 `error: ${hookPath} exists but was not installed by gluecron — refusing to remove.`
164 );
165 return 1;
166 }
167 unlinkSync(hookPath);
168 deps.out(`removed ${hookPath}`);
169 return 0;
170}
171
172export async function runHook(
173 argv: string[],
174 deps: HookDeps
175): Promise<number> {
176 const sub = argv[0];
177 const rest = argv.slice(1);
178 if (sub === "install") return runHookInstall(rest, deps);
179 if (sub === "uninstall") return runHookUninstall(rest, deps);
180 deps.out("usage: gluecron hook (install|uninstall) commit-msg");
181 return 1;
182}