Repository navigation
feat: worktree DX — node_modules symlink, broadcast discovery, new hook handlers #1054
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
Changes from all commits
11bfa8c
7e57fc7
7c72853
1ec8157
e34e9b8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,99 @@ | ||
| import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; | ||
| import { execSync } from 'node:child_process'; | ||
| import { mkdtempSync, writeFileSync } from 'node:fs'; | ||
| import { tmpdir } from 'node:os'; | ||
| import { join } from 'node:path'; | ||
| import type { HookPayload } from '../../types.js'; | ||
| import { auditContext } from '../audit-context.js'; | ||
|
|
||
| describe('audit-context handler', () => { | ||
| let repoDir: string; | ||
|
|
||
| beforeEach(() => { | ||
| // Create a temp git repo with a committed file | ||
| repoDir = mkdtempSync(join(tmpdir(), 'audit-ctx-')); | ||
| execSync('git init', { cwd: repoDir, stdio: 'pipe' }); | ||
| execSync('git config user.email "test@test.com"', { cwd: repoDir, stdio: 'pipe' }); | ||
| execSync('git config user.name "Test"', { cwd: repoDir, stdio: 'pipe' }); | ||
|
|
||
| const testFile = join(repoDir, 'example.ts'); | ||
| writeFileSync(testFile, 'const x = 1;\n'); | ||
| execSync('git add . && git commit -m "initial commit"', { cwd: repoDir, stdio: 'pipe' }); | ||
|
|
||
| // Add a second commit | ||
| writeFileSync(testFile, 'const x = 2;\n'); | ||
| execSync('git add . && git commit -m "update x to 2"', { cwd: repoDir, stdio: 'pipe' }); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| execSync(`rm -rf ${repoDir}`); | ||
| }); | ||
|
|
||
| test('returns git history for a tracked file', async () => { | ||
| const payload: HookPayload = { | ||
| hook_event_name: 'PreToolUse', | ||
| tool_name: 'Edit', | ||
| tool_input: { file_path: join(repoDir, 'example.ts') }, | ||
| cwd: repoDir, | ||
| }; | ||
|
|
||
| const result = await auditContext(payload); | ||
| expect(result).toBeDefined(); | ||
| expect(result!.hookSpecificOutput).toBeDefined(); | ||
| expect(result!.hookSpecificOutput!.permissionDecision).toBe('allow'); | ||
|
|
||
| const context = result!.hookSpecificOutput!.additionalContext!; | ||
| expect(context).toContain('[audit-context]'); | ||
| expect(context).toContain('example.ts'); | ||
| expect(context).toContain('update x to 2'); | ||
| expect(context).toContain('initial commit'); | ||
| }); | ||
|
|
||
| test('returns undefined for untracked file', async () => { | ||
| const payload: HookPayload = { | ||
| hook_event_name: 'PreToolUse', | ||
| tool_name: 'Write', | ||
| tool_input: { file_path: join(repoDir, 'new-file.ts') }, | ||
| cwd: repoDir, | ||
| }; | ||
|
|
||
| const result = await auditContext(payload); | ||
| expect(result).toBeUndefined(); | ||
| }); | ||
|
|
||
| test('returns undefined when no file_path in input', async () => { | ||
| const payload: HookPayload = { | ||
| hook_event_name: 'PreToolUse', | ||
| tool_name: 'Write', | ||
| tool_input: { content: 'hello' }, | ||
| cwd: repoDir, | ||
| }; | ||
|
|
||
| const result = await auditContext(payload); | ||
| expect(result).toBeUndefined(); | ||
| }); | ||
|
|
||
| test('returns undefined when tool_input is missing', async () => { | ||
| const payload: HookPayload = { | ||
| hook_event_name: 'PreToolUse', | ||
| tool_name: 'Edit', | ||
| }; | ||
|
|
||
| const result = await auditContext(payload); | ||
| expect(result).toBeUndefined(); | ||
| }); | ||
|
|
||
| test('returns undefined for non-git directory', async () => { | ||
| const nonGitDir = mkdtempSync(join(tmpdir(), 'audit-nongit-')); | ||
| const payload: HookPayload = { | ||
| hook_event_name: 'PreToolUse', | ||
| tool_name: 'Edit', | ||
| tool_input: { file_path: join(nonGitDir, 'test.ts') }, | ||
| cwd: nonGitDir, | ||
| }; | ||
|
|
||
| const result = await auditContext(payload); | ||
| expect(result).toBeUndefined(); | ||
| execSync(`rm -rf ${nonGitDir}`); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,81 @@ | ||
| import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; | ||
| import type { HookPayload } from '../../types.js'; | ||
| import { _resetEnrichedSessions, brainInject } from '../brain-inject.js'; | ||
|
|
||
| describe('brain-inject handler', () => { | ||
| const originalEnv = { ...process.env }; | ||
|
|
||
| beforeEach(() => { | ||
| _resetEnrichedSessions(); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| process.env = { ...originalEnv }; | ||
| }); | ||
|
|
||
| test('returns undefined when brain is not installed', async () => { | ||
| const payload: HookPayload = { | ||
| hook_event_name: 'PreToolUse', | ||
| tool_name: 'Read', | ||
| tool_input: { file_path: '/tmp/test.ts' }, | ||
| session_id: 'test-session-1', | ||
| cwd: '/tmp', | ||
| }; | ||
| const result = await brainInject(payload); | ||
| expect(result).toBeUndefined(); | ||
| }); | ||
|
|
||
| test('only fires once per session', async () => { | ||
| const payload: HookPayload = { | ||
| hook_event_name: 'PreToolUse', | ||
| tool_name: 'Read', | ||
| tool_input: { file_path: '/tmp/test.ts' }, | ||
| session_id: 'test-session-2', | ||
| cwd: '/tmp', | ||
| }; | ||
|
|
||
| // First call — runs (returns undefined because brain isn't installed) | ||
| await brainInject(payload); | ||
|
|
||
| // Second call — should skip immediately (session already enriched) | ||
| const result = await brainInject(payload); | ||
| expect(result).toBeUndefined(); | ||
| }); | ||
|
|
||
| test('different sessions get separate enrichment', async () => { | ||
| const payload1: HookPayload = { | ||
| hook_event_name: 'PreToolUse', | ||
| tool_name: 'Read', | ||
| tool_input: { file_path: '/tmp/test.ts' }, | ||
| session_id: 'session-a', | ||
| cwd: '/tmp', | ||
| }; | ||
|
|
||
| const payload2: HookPayload = { | ||
| hook_event_name: 'PreToolUse', | ||
| tool_name: 'Read', | ||
| tool_input: { file_path: '/tmp/test.ts' }, | ||
| session_id: 'session-b', | ||
| cwd: '/tmp', | ||
| }; | ||
|
|
||
| // Both should run (not skip), returning undefined since brain isn't installed | ||
| const result1 = await brainInject(payload1); | ||
| const result2 = await brainInject(payload2); | ||
| expect(result1).toBeUndefined(); | ||
| expect(result2).toBeUndefined(); | ||
| }); | ||
|
|
||
| test('uses process.pid when session_id is missing', async () => { | ||
| const payload: HookPayload = { | ||
| hook_event_name: 'PreToolUse', | ||
| tool_name: 'Read', | ||
| tool_input: { file_path: '/tmp/test.ts' }, | ||
| cwd: '/tmp', | ||
| }; | ||
|
|
||
| // Should not throw when session_id is undefined | ||
| const result = await brainInject(payload); | ||
| expect(result).toBeUndefined(); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,153 @@ | ||||||
| import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; | ||||||
| import { execSync } from 'node:child_process'; | ||||||
| import { mkdtempSync, writeFileSync } from 'node:fs'; | ||||||
| import { tmpdir } from 'node:os'; | ||||||
| import { join } from 'node:path'; | ||||||
| import type { HookPayload } from '../../types.js'; | ||||||
| import { freshness } from '../freshness.js'; | ||||||
|
|
||||||
| describe('freshness handler', () => { | ||||||
| const originalEnv = { ...process.env }; | ||||||
| let repoDir: string; | ||||||
|
|
||||||
| beforeEach(() => { | ||||||
| process.env.GENIE_AGENT_NAME = 'test-agent'; | ||||||
|
|
||||||
| // Create a temp git repo with a committed file | ||||||
| repoDir = mkdtempSync(join(tmpdir(), 'freshness-')); | ||||||
| execSync('git init', { cwd: repoDir, stdio: 'pipe' }); | ||||||
| execSync('git config user.email "test@test.com"', { cwd: repoDir, stdio: 'pipe' }); | ||||||
| execSync('git config user.name "other-agent"', { cwd: repoDir, stdio: 'pipe' }); | ||||||
| }); | ||||||
|
|
||||||
| afterEach(() => { | ||||||
| process.env = { ...originalEnv }; | ||||||
| execSync(`rm -rf ${repoDir}`); | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For safety and cross-platform compatibility, it's better to use Node.js's built-in filesystem functions for cleanup instead of shelling out to rm -rf. This avoids potential command injection vulnerabilities and works on Windows. You'll need to add rmSync to your import from node:fs.
Suggested change
|
||||||
| }); | ||||||
|
|
||||||
| test('returns undefined for missing tool_input', async () => { | ||||||
| const payload: HookPayload = { | ||||||
| hook_event_name: 'PreToolUse', | ||||||
| tool_name: 'Read', | ||||||
| }; | ||||||
| const result = await freshness(payload); | ||||||
| expect(result).toBeUndefined(); | ||||||
| }); | ||||||
|
|
||||||
| test('returns undefined for missing file_path', async () => { | ||||||
| const payload: HookPayload = { | ||||||
| hook_event_name: 'PreToolUse', | ||||||
| tool_name: 'Read', | ||||||
| tool_input: { pattern: '*.ts' }, | ||||||
| }; | ||||||
| const result = await freshness(payload); | ||||||
| expect(result).toBeUndefined(); | ||||||
| }); | ||||||
|
|
||||||
| test('returns undefined for non-existent file', async () => { | ||||||
| const payload: HookPayload = { | ||||||
| hook_event_name: 'PreToolUse', | ||||||
| tool_name: 'Read', | ||||||
| tool_input: { file_path: join(repoDir, 'does-not-exist.ts') }, | ||||||
| cwd: repoDir, | ||||||
| }; | ||||||
| const result = await freshness(payload); | ||||||
| expect(result).toBeUndefined(); | ||||||
| }); | ||||||
|
|
||||||
| test('warns when file has uncommitted changes from old commit', async () => { | ||||||
| // Create and commit a file with an old date so the commit itself doesn't trigger | ||||||
| const testFile = join(repoDir, 'target.ts'); | ||||||
| writeFileSync(testFile, 'const x = 1;\n'); | ||||||
| execSync('git add .', { cwd: repoDir, stdio: 'pipe' }); | ||||||
| execSync('GIT_COMMITTER_DATE="2020-01-01T00:00:00" git commit -m "old initial" --date="2020-01-01T00:00:00"', { | ||||||
| cwd: repoDir, | ||||||
| stdio: 'pipe', | ||||||
| }); | ||||||
|
|
||||||
| // Modify without committing (simulates another agent's uncommitted edit) | ||||||
| writeFileSync(testFile, 'const x = 2;\n'); | ||||||
|
|
||||||
| const payload: HookPayload = { | ||||||
| hook_event_name: 'PreToolUse', | ||||||
| tool_name: 'Read', | ||||||
| tool_input: { file_path: testFile }, | ||||||
| cwd: repoDir, | ||||||
| }; | ||||||
|
|
||||||
| const result = await freshness(payload); | ||||||
| // File was just modified on disk and has uncommitted changes | ||||||
| expect(result).toBeDefined(); | ||||||
| expect(result!.hookSpecificOutput).toBeDefined(); | ||||||
| expect(result!.hookSpecificOutput!.additionalContext).toContain('[freshness]'); | ||||||
| expect(result!.hookSpecificOutput!.additionalContext).toContain('uncommitted changes'); | ||||||
| expect(result!.hookSpecificOutput!.permissionDecision).toBe('allow'); | ||||||
| }); | ||||||
|
|
||||||
| test('warns for recently committed file by another agent', async () => { | ||||||
| // Create and commit a file very recently (within threshold) | ||||||
| const testFile = join(repoDir, 'recent.ts'); | ||||||
| writeFileSync(testFile, 'const y = 1;\n'); | ||||||
| execSync('git add . && git commit -m "recent change"', { cwd: repoDir, stdio: 'pipe' }); | ||||||
|
|
||||||
| const payload: HookPayload = { | ||||||
| hook_event_name: 'PreToolUse', | ||||||
| tool_name: 'Read', | ||||||
| tool_input: { file_path: testFile }, | ||||||
| cwd: repoDir, | ||||||
| }; | ||||||
|
|
||||||
| const result = await freshness(payload); | ||||||
| // The commit was just made (< 120s ago) by "other-agent" (not "test-agent") | ||||||
| if (result) { | ||||||
| expect(result.hookSpecificOutput).toBeDefined(); | ||||||
| expect(result.hookSpecificOutput!.additionalContext).toContain('[freshness]'); | ||||||
| expect(result.hookSpecificOutput!.permissionDecision).toBe('allow'); | ||||||
| } | ||||||
| // May also be undefined if the commit age check is borderline — both are valid | ||||||
| }); | ||||||
|
|
||||||
| test('returns undefined for old files', async () => { | ||||||
| // Create a file that was committed long ago — use GIT_COMMITTER_DATE | ||||||
| const testFile = join(repoDir, 'old.ts'); | ||||||
| writeFileSync(testFile, 'const old = true;\n'); | ||||||
| execSync('git add .', { cwd: repoDir, stdio: 'pipe' }); | ||||||
| execSync('GIT_COMMITTER_DATE="2020-01-01T00:00:00" git commit -m "old commit" --date="2020-01-01T00:00:00"', { | ||||||
| cwd: repoDir, | ||||||
| stdio: 'pipe', | ||||||
| }); | ||||||
|
|
||||||
| // Touch the file to set mtime to a distant past | ||||||
| execSync(`touch -t 202001010000 ${testFile}`, { stdio: 'pipe' }); | ||||||
|
|
||||||
| const payload: HookPayload = { | ||||||
| hook_event_name: 'PreToolUse', | ||||||
| tool_name: 'Read', | ||||||
| tool_input: { file_path: testFile }, | ||||||
| cwd: repoDir, | ||||||
| }; | ||||||
|
|
||||||
| const result = await freshness(payload); | ||||||
| expect(result).toBeUndefined(); | ||||||
| }); | ||||||
|
|
||||||
| test('skips warning when current agent is the author', async () => { | ||||||
| // Set git user to match GENIE_AGENT_NAME | ||||||
| execSync('git config user.name "test-agent"', { cwd: repoDir, stdio: 'pipe' }); | ||||||
|
|
||||||
| const testFile = join(repoDir, 'mine.ts'); | ||||||
| writeFileSync(testFile, 'const mine = true;\n'); | ||||||
| execSync('git add . && git commit -m "my change"', { cwd: repoDir, stdio: 'pipe' }); | ||||||
|
|
||||||
| const payload: HookPayload = { | ||||||
| hook_event_name: 'PreToolUse', | ||||||
| tool_name: 'Read', | ||||||
| tool_input: { file_path: testFile }, | ||||||
| cwd: repoDir, | ||||||
| }; | ||||||
|
|
||||||
| const result = await freshness(payload); | ||||||
| // Should not warn because the current agent made the commit | ||||||
| expect(result).toBeUndefined(); | ||||||
| }); | ||||||
| }); | ||||||
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.
For safety and cross-platform compatibility, it's better to use Node.js's built-in filesystem functions for cleanup instead of shelling out to rm -rf. This avoids potential command injection vulnerabilities and works on Windows. This applies here and also to the cleanup on line 97. You'll need to add rmSync to your import from node:fs.