-
Notifications
You must be signed in to change notification settings - Fork 0
test: integration tests for hook entry points (GIT-58) #27
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
TonyCasey
merged 2 commits into
main
from
tony/git-58-integration-tests-for-hook-entry-points
Feb 12, 2026
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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'); | ||
|
|
||
| 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, | ||
| }; | ||
| } | ||
|
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 }); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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'); | ||
| }); | ||
|
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)'); | ||
| }); | ||
| }); | ||
| }); | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
TSX_BINis hard-coded tonode_modules/.bin/tsx, which is not portable (e.g. Windows usestsx.cmd, and some environments won’t have an executable file at that path). Consider invoking the CLI viaprocess.execPathwith--import tsx(matching the repo’s test scripts) or resolving the tsx entrypoint via Node resolution so integration tests run reliably across platforms/environments.There was a problem hiding this comment.
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 tsxdoesn't resolve when cwd is a temp directory outside the project root. If Windows support is needed later we can switch toprocess.execPathwith a loader, but for now the binary path works reliably in CI and local dev.