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
117 changes: 117 additions & 0 deletions tests/integration/hooks/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/**
* Shared helpers for hook integration tests.
*
* Provides child-process wrappers to invoke `git-mem hook <event>`
* and `git-mem init-hooks` against real temp git repos.
*/

import { spawnSync, execFileSync } from 'child_process';
import { mkdtempSync, writeFileSync, rmSync } from 'fs';
import { join, resolve } from 'path';
import { tmpdir } from 'os';

const PROJECT_ROOT = resolve(__dirname, '../../..');
const CLI_PATH = resolve(PROJECT_ROOT, 'src/cli.ts');

// Use tsx binary from project node_modules — works even when cwd is a temp dir
const TSX_BIN = resolve(PROJECT_ROOT, 'node_modules/.bin/tsx');

Comment on lines +13 to +18
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

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

TSX_BIN is hard-coded to node_modules/.bin/tsx, which is not portable (e.g. Windows uses tsx.cmd, and some environments won’t have an executable file at that path). Consider invoking the CLI via process.execPath with --import tsx (matching the repo’s test scripts) or resolving the tsx entrypoint via Node resolution so integration tests run reliably across platforms/environments.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Acknowledged — this is a macOS/Linux-only CLI tool at the moment (git notes backend). The tsx binary approach was chosen because node --import tsx doesn't resolve when cwd is a temp directory outside the project root. If Windows support is needed later we can switch to process.execPath with a loader, but for now the binary path works reliably in CI and local dev.

export interface IRunResult {
stdout: string;
stderr: string;
status: number;
}

/** Run `git-mem hook <eventName>` with JSON piped to stdin. */
export function runHook(eventName: string, input: Record<string, unknown>): IRunResult {
const result = spawnSync(TSX_BIN, [CLI_PATH, 'hook', eventName], {
input: JSON.stringify(input),
encoding: 'utf8',
timeout: 15_000,
});

return {
stdout: result.stdout ?? '',
stderr: result.stderr ?? '',
status: result.status ?? 1,
};
}
Comment thread
TonyCasey marked this conversation as resolved.

/** Run `git-mem <args>` as a child process. */
export function runCli(args: string[], opts?: { cwd?: string; input?: string }): IRunResult {
const result = spawnSync(TSX_BIN, [CLI_PATH, ...args], {
input: opts?.input,
cwd: opts?.cwd,
encoding: 'utf8',
timeout: 15_000,
});

return {
stdout: result.stdout ?? '',
stderr: result.stderr ?? '',
status: result.status ?? 1,
};
}

function git(args: string[], cwd: string): string {
return execFileSync('git', args, { encoding: 'utf8', cwd }).trim();
}

/** Create a temp git repo with an initial commit. Returns dir and HEAD sha. */
export function createTestRepo(prefix = 'git-mem-hook-integ-'): { dir: string; sha: string } {
const dir = mkdtempSync(join(tmpdir(), prefix));

git(['init'], dir);
git(['config', 'user.email', 'test@test.com'], dir);
git(['config', 'user.name', 'Test User'], dir);

writeFileSync(join(dir, 'file.txt'), 'initial content');
git(['add', '.'], dir);
git(['commit', '-m', 'feat: initial commit'], dir);
const sha = git(['rev-parse', 'HEAD'], dir);

return { dir, sha };
}

/** Add a commit to an existing test repo. Returns the new sha. */
export function addCommit(dir: string, filename: string, content: string, message: string): string {
writeFileSync(join(dir, filename), content);
git(['add', '.'], dir);
git(['commit', '-m', message], dir);
return git(['rev-parse', 'HEAD'], dir);
}

/** Write .git-mem.json into a directory with optional per-hook overrides. */
export function writeGitMemConfig(
dir: string,
overrides?: Partial<Record<'enabled' | 'sessionStart' | 'sessionStop' | 'promptSubmit', unknown>>,
): void {
const defaults = {
hooks: {
enabled: true,
sessionStart: { enabled: true, memoryLimit: 20 },
sessionStop: { enabled: true, autoLiberate: true, threshold: 3 },
promptSubmit: { enabled: false, recordPrompts: false, surfaceContext: true },
},
};

if (!overrides) {
writeFileSync(join(dir, '.git-mem.json'), JSON.stringify(defaults, null, 2) + '\n');
return;
}

const merged = {
hooks: {
enabled: overrides.enabled ?? defaults.hooks.enabled,
sessionStart: { ...defaults.hooks.sessionStart, ...(overrides.sessionStart as object) },
sessionStop: { ...defaults.hooks.sessionStop, ...(overrides.sessionStop as object) },
promptSubmit: { ...defaults.hooks.promptSubmit, ...(overrides.promptSubmit as object) },
},
};
writeFileSync(join(dir, '.git-mem.json'), JSON.stringify(merged, null, 2) + '\n');
}

/** Remove a temp directory. */
export function cleanupRepo(dir: string): void {
rmSync(dir, { recursive: true, force: true });
}
185 changes: 185 additions & 0 deletions tests/integration/hooks/hook-init-hooks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
/**
* Integration test: init-hooks command
*
* Exercises `git-mem init-hooks --yes` and `git-mem init-hooks --remove`
* end-to-end in temp directories. Verifies file creation, schema
* correctness, and cleanup.
*/

import { describe, it, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, readFileSync, mkdirSync, writeFileSync, mkdtempSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { runCli } from './helpers';

describe('Integration: init-hooks', () => {
let workDir: string;

before(() => {
workDir = mkdtempSync(join(tmpdir(), 'git-mem-init-hooks-'));
});

after(() => {
rmSync(workDir, { recursive: true, force: true });
});

describe('install mode (--yes)', () => {
it('should create .claude/settings.json and .git-mem.json', () => {
const result = runCli(['init-hooks', '--yes'], { cwd: workDir });

assert.equal(result.status, 0);
assert.ok(existsSync(join(workDir, '.claude', 'settings.json')), '.claude/settings.json should exist');
assert.ok(existsSync(join(workDir, '.git-mem.json')), '.git-mem.json should exist');
});

it('should write correct hook commands in settings.json', () => {
const settingsPath = join(workDir, '.claude', 'settings.json');
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));

assert.ok(settings.hooks, 'settings should have hooks key');
assert.ok(settings.hooks.SessionStart, 'should have SessionStart');
assert.ok(settings.hooks.SessionStop, 'should have SessionStop');
assert.ok(settings.hooks.UserPromptSubmit, 'should have UserPromptSubmit');

// Verify hook command format
const startHook = settings.hooks.SessionStart[0];
assert.equal(startHook.matcher, '');
assert.equal(startHook.hooks[0].type, 'command');
assert.equal(startHook.hooks[0].command, 'git-mem hook session-start');

const stopHook = settings.hooks.SessionStop[0];
assert.equal(stopHook.hooks[0].command, 'git-mem hook session-stop');

const promptHook = settings.hooks.UserPromptSubmit[0];
assert.equal(promptHook.hooks[0].command, 'git-mem hook prompt-submit');
});
Comment thread
TonyCasey marked this conversation as resolved.

it('should write correct defaults in .git-mem.json', () => {
const configPath = join(workDir, '.git-mem.json');
const config = JSON.parse(readFileSync(configPath, 'utf8'));

assert.ok(config.hooks, 'should have hooks key');
assert.equal(config.hooks.enabled, true);

assert.equal(config.hooks.sessionStart.enabled, true);
assert.equal(config.hooks.sessionStart.memoryLimit, 20);

assert.equal(config.hooks.sessionStop.enabled, true);
assert.equal(config.hooks.sessionStop.autoLiberate, true);
assert.equal(config.hooks.sessionStop.threshold, 3);

assert.equal(config.hooks.promptSubmit.enabled, false);
assert.equal(config.hooks.promptSubmit.recordPrompts, false);
assert.equal(config.hooks.promptSubmit.surfaceContext, true);
});
});

describe('remove mode (--remove)', () => {
let removeDir: string;

before(() => {
removeDir = mkdtempSync(join(tmpdir(), 'git-mem-init-hooks-remove-'));

// First install hooks
runCli(['init-hooks', '--yes'], { cwd: removeDir });
// Verify they exist before testing removal
assert.ok(existsSync(join(removeDir, '.claude', 'settings.json')));
assert.ok(existsSync(join(removeDir, '.git-mem.json')));
});

after(() => {
rmSync(removeDir, { recursive: true, force: true });
});

it('should remove both config files', () => {
const result = runCli(['init-hooks', '--remove'], { cwd: removeDir });

assert.equal(result.status, 0);
assert.ok(!existsSync(join(removeDir, '.git-mem.json')), '.git-mem.json should be removed');
// settings.json is deleted entirely when only git-mem hooks were present
assert.ok(!existsSync(join(removeDir, '.claude', 'settings.json')), 'settings.json should be removed');
});
});

describe('preserves other tools hooks', () => {
let preserveDir: string;

before(() => {
preserveDir = mkdtempSync(join(tmpdir(), 'git-mem-init-hooks-preserve-'));

// Create existing settings.json with another tool's hooks
const claudeDir = join(preserveDir, '.claude');
mkdirSync(claudeDir, { recursive: true });

const existingSettings = {
hooks: {
SessionStart: [
{
matcher: '',
hooks: [{ type: 'command', command: 'other-tool start' }],
},
],
PreToolUse: [
{
matcher: 'Bash',
hooks: [{ type: 'command', command: 'other-tool guard' }],
},
],
},
};
writeFileSync(
join(claudeDir, 'settings.json'),
JSON.stringify(existingSettings, null, 2) + '\n',
);
});

after(() => {
rmSync(preserveDir, { recursive: true, force: true });
});

it('should preserve other tools hooks on install', () => {
const result = runCli(['init-hooks', '--yes'], { cwd: preserveDir });
assert.equal(result.status, 0);

const settings = JSON.parse(readFileSync(join(preserveDir, '.claude', 'settings.json'), 'utf8'));

// SessionStart should have both: other-tool entry + git-mem entry
const sessionStartEntries = settings.hooks.SessionStart;
assert.equal(sessionStartEntries.length, 2);

const otherToolEntry = sessionStartEntries.find(
(e: Record<string, unknown>) => Array.isArray(e.hooks) && (e.hooks as Array<Record<string, string>>).some((h) => h.command === 'other-tool start'),
);
assert.ok(otherToolEntry, 'other-tool SessionStart entry should be preserved');

const gitMemEntry = sessionStartEntries.find(
(e: Record<string, unknown>) => Array.isArray(e.hooks) && (e.hooks as Array<Record<string, string>>).some((h) => h.command === 'git-mem hook session-start'),
);
assert.ok(gitMemEntry, 'git-mem SessionStart entry should be added');

// PreToolUse should be untouched
assert.ok(settings.hooks.PreToolUse, 'PreToolUse should be preserved');
assert.equal(settings.hooks.PreToolUse[0].hooks[0].command, 'other-tool guard');
});

it('should preserve other tools hooks on remove', () => {
const result = runCli(['init-hooks', '--remove'], { cwd: preserveDir });
assert.equal(result.status, 0);

const settings = JSON.parse(readFileSync(join(preserveDir, '.claude', 'settings.json'), 'utf8'));

// Other tool's SessionStart entry should survive
assert.ok(settings.hooks.SessionStart, 'SessionStart should still exist');
assert.equal(settings.hooks.SessionStart.length, 1);
assert.equal(settings.hooks.SessionStart[0].hooks[0].command, 'other-tool start');

// PreToolUse untouched
assert.ok(settings.hooks.PreToolUse);

// git-mem event types with no remaining entries should be removed
assert.ok(!settings.hooks.SessionStop, 'SessionStop should be removed (was only git-mem)');
assert.ok(!settings.hooks.UserPromptSubmit, 'UserPromptSubmit should be removed (was only git-mem)');
});
});
});
Loading