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
2 changes: 2 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ program
.description('Set up git-mem: hooks, MCP config, .gitignore, and extract from history')
.option('-y, --yes', 'Accept defaults without prompting')
.option('--commit-count <n>', 'Number of commits to extract', '100')
.option('--hooks', 'Install prepare-commit-msg git hook for AI-Agent trailers')
.option('--uninstall-hooks', 'Remove the prepare-commit-msg git hook')
.action((options) => initCommand(options, logger));

program
Expand Down
26 changes: 25 additions & 1 deletion src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,15 @@ import {
deepMergeGitMemConfig,
} from './init-hooks';
import { buildMcpConfig } from './init-mcp';
import { installHook, uninstallHook } from '../hooks/prepare-commit-msg';
import { createContainer } from '../infrastructure/di';
import { createStderrProgressHandler } from './progress';

interface IInitCommandOptions {
yes?: boolean;
commitCount?: string;
hooks?: boolean;
uninstallHooks?: boolean;
}

// ── Pure helpers (exported for testing) ──────────────────────────────
Expand Down Expand Up @@ -112,7 +115,18 @@ export async function initCommand(options: IInitCommandOptions, logger?: ILogger
const log = logger?.child({ command: 'init' });
const cwd = process.cwd();

log?.info('Command invoked', { yes: options.yes, commitCount: options.commitCount });
log?.info('Command invoked', { yes: options.yes, commitCount: options.commitCount, hooks: options.hooks, uninstallHooks: options.uninstallHooks });

// ── Git hook uninstall (early exit) ─────────────────────────────
if (options.uninstallHooks) {
const removed = uninstallHook(cwd);
if (removed) {
console.log('✓ Removed prepare-commit-msg hook');
} else {
console.log('No git-mem prepare-commit-msg hook found.');
}
return;
}

// ── Prompts (skipped with --yes) ───────────────────────────────
let commitCount = options.commitCount ? parseInt(options.commitCount, 10) : 100;
Expand Down Expand Up @@ -171,6 +185,16 @@ export async function initCommand(options: IInitCommandOptions, logger?: ILogger
console.log('✓ Created .git-mem.json');
}

// ── Git hook (prepare-commit-msg) ─────────────────────────────
if (options.hooks) {
const hookResult = installHook(cwd);
if (hookResult.installed) {
console.log(`✓ Installed prepare-commit-msg hook${hookResult.wrapped ? ' (wrapped existing hook)' : ''}`);
} else {
console.log('✓ prepare-commit-msg hook already installed (skipped)');
}
}

// ── MCP config (skip if already exists) ────────────────────────
const mcpPath = join(cwd, '.mcp.json');
if (existsSync(mcpPath)) {
Expand Down
1 change: 1 addition & 0 deletions src/domain/entities/ITrailer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export const AI_TRAILER_KEYS = {
TAGS: 'AI-Tags',
LIFECYCLE: 'AI-Lifecycle',
MEMORY_ID: 'AI-Memory-Id',
AGENT: 'AI-Agent',
} as const;

/**
Expand Down
149 changes: 149 additions & 0 deletions src/hooks/prepare-commit-msg.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
/**
* prepare-commit-msg git hook
*
* Installs/uninstalls a git hook that injects AI-Agent trailers
* into commit messages when an AI-assisted session is detected.
*
* Detection heuristics (checked in order):
* - $GIT_MEM_AGENT env var (explicit, user-defined agent string)
* - $CLAUDE_CODE env var (Claude Code session)
*
* The hook uses `git interpret-trailers` for proper formatting.
*/

import { existsSync, readFileSync, writeFileSync, renameSync, unlinkSync, chmodSync } from 'fs';
import { join, resolve } from 'path';
import { execFileSync } from 'child_process';

/** Fingerprint comment used to detect our hook. */
const HOOK_FINGERPRINT = '# git-mem:prepare-commit-msg v1';

/**
* The shell hook script.
* Uses `git interpret-trailers` for correct trailer formatting.
*/
const HOOK_SCRIPT = `#!/bin/sh
${HOOK_FINGERPRINT}
# Injects AI-Agent trailer when an AI-assisted session is detected.

COMMIT_MSG_FILE="$1"
COMMIT_SOURCE="$2"

# Skip merge/squash/amend commits
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment says "Skip merge/squash/amend commits" but the case statement only skips merge and squash. If amend commits should also be skipped, add it to the case statement. Otherwise, update the comment to match the actual behavior.

Suggested change
# Skip merge/squash/amend commits
# Skip merge/squash commits

Copilot uses AI. Check for mistakes.
case "$COMMIT_SOURCE" in
merge|squash) exit 0 ;;
esac
Comment thread
coderabbitai[bot] marked this conversation as resolved.

# Detect AI agent
AGENT=""
if [ -n "$GIT_MEM_AGENT" ]; then
AGENT="$GIT_MEM_AGENT"
elif [ -n "$CLAUDE_CODE" ]; then
AGENT="Claude-Code"
fi

# No agent detected — exit silently
[ -z "$AGENT" ] && exit 0

# Skip if AI-Agent trailer already present
grep -q "^AI-Agent:" "$COMMIT_MSG_FILE" && exit 0

# Append trailer using git's built-in formatter
git interpret-trailers --in-place --trailer "AI-Agent: $AGENT" "$COMMIT_MSG_FILE"
`;

/**
* Find the .git directory for the repository at cwd.
* Handles worktrees where .git is a file pointing to the real git dir.
*/
function findGitDir(cwd: string): string {
try {
const gitDir = execFileSync(
'git', ['rev-parse', '--git-dir'],
{ encoding: 'utf8', cwd, stdio: ['pipe', 'pipe', 'pipe'] },
).trim();
// git rev-parse may return a relative path — resolve it against cwd
return resolve(cwd, gitDir);
} catch {
return join(cwd, '.git');
}
}

/**
* Check if an existing hook file was installed by git-mem.
*/
function isGitMemHook(hookPath: string): boolean {
if (!existsSync(hookPath)) return false;
const content = readFileSync(hookPath, 'utf8');
return content.includes(HOOK_FINGERPRINT);
}

export interface IHookInstallResult {
/** Whether the hook was installed (false if already present). */
readonly installed: boolean;
/** Whether an existing user hook was wrapped. */
readonly wrapped: boolean;
/** Path to the installed hook. */
readonly hookPath: string;
}

/**
* Install the prepare-commit-msg hook.
* Idempotent: re-running is safe.
* Wraps existing non-git-mem hooks by renaming them to .user-backup.
*/
export function installHook(cwd?: string): IHookInstallResult {
const gitDir = findGitDir(cwd || process.cwd());
const hooksDir = join(gitDir, 'hooks');
const hookPath = join(hooksDir, 'prepare-commit-msg');
const backupPath = join(hooksDir, 'prepare-commit-msg.user-backup');

// Already installed — idempotent
if (isGitMemHook(hookPath)) {
return { installed: false, wrapped: false, hookPath };
}

let wrapped = false;

// Existing non-git-mem hook — wrap it
if (existsSync(hookPath)) {
renameSync(hookPath, backupPath);
wrapped = true;

// Create a wrapper that calls the user's hook first, then ours
const wrapperScript = HOOK_SCRIPT.replace(
'#!/bin/sh',
`#!/bin/sh\n# Wrapped existing hook — original saved as prepare-commit-msg.user-backup\nif [ -x "${backupPath}" ]; then\n "${backupPath}" "$@" || exit $?\nfi`,
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The wrapper script uses an absolute path ${backupPath} in the shell script. This creates a hardcoded absolute path in the hook file, which will break if the repository is moved or cloned. Consider using a relative path like ./prepare-commit-msg.user-backup or using $(dirname "$0")/prepare-commit-msg.user-backup instead.

Suggested change
`#!/bin/sh\n# Wrapped existing hook — original saved as prepare-commit-msg.user-backup\nif [ -x "${backupPath}" ]; then\n "${backupPath}" "$@" || exit $?\nfi`,
`#!/bin/sh\n# Wrapped existing hook — original saved as prepare-commit-msg.user-backup\nBACKUP_HOOK="$(dirname "$0")/prepare-commit-msg.user-backup"\nif [ -x "$BACKUP_HOOK" ]; then\n "$BACKUP_HOOK" "$@" || exit $?\nfi`,

Copilot uses AI. Check for mistakes.
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
writeFileSync(hookPath, wrapperScript);
} else {
writeFileSync(hookPath, HOOK_SCRIPT);
}

chmodSync(hookPath, 0o755);
return { installed: true, wrapped, hookPath };
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Comment on lines +95 to +125
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The installHook function assumes the hooks directory exists, but this may not always be true (e.g., in bare repositories or corrupted git directories). Consider adding mkdirSync(hooksDir, { recursive: true }) before line 109 to ensure the directory exists before attempting to write files to it.

Copilot uses AI. Check for mistakes.

/**
* Uninstall the prepare-commit-msg hook.
* Restores wrapped user hooks if a backup exists.
*/
export function uninstallHook(cwd?: string): boolean {
const gitDir = findGitDir(cwd || process.cwd());
const hooksDir = join(gitDir, 'hooks');
const hookPath = join(hooksDir, 'prepare-commit-msg');
const backupPath = join(hooksDir, 'prepare-commit-msg.user-backup');

if (!isGitMemHook(hookPath)) {
return false;
}

unlinkSync(hookPath);

// Restore user's original hook if it was wrapped
if (existsSync(backupPath)) {
renameSync(backupPath, hookPath);
}

return true;
}
Loading
Loading