@fcamblor/on-changes-run 0.0.1 → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,183 @@
1
+ import { vi, describe, it, expect, beforeEach } from 'vitest';
2
+ vi.mock('node:child_process', () => ({
3
+ exec: vi.fn(),
4
+ }));
5
+ import { exec } from 'node:child_process';
6
+ import { executeCommand, executeAll, printFailures } from '../executor.js';
7
+ const mockExec = vi.mocked(exec);
8
+ // promisify resolves with { stdout, stderr } only if the original function has
9
+ // util.promisify.custom — which the real exec does but vi.fn() does not.
10
+ // Passing a single object as the callback value makes promisify resolve with it directly.
11
+ function stubExecSuccess(stdout, stderr = '') {
12
+ return mockExec.mockImplementationOnce((_cmd, _opts, callback) => {
13
+ callback(null, { stdout, stderr });
14
+ return undefined;
15
+ });
16
+ }
17
+ function stubExecFailure(exitCode, stdout = '', stderr = '') {
18
+ return mockExec.mockImplementationOnce((_cmd, _opts, callback) => {
19
+ const err = Object.assign(new Error('Command failed'), { code: exitCode, stdout, stderr });
20
+ callback(err, { stdout, stderr });
21
+ return undefined;
22
+ });
23
+ }
24
+ beforeEach(() => {
25
+ mockExec.mockReset();
26
+ });
27
+ // ---------------------------------------------------------------------------
28
+ // executeCommand
29
+ // ---------------------------------------------------------------------------
30
+ describe('executeCommand', () => {
31
+ it('returns exitCode 0 and captured output on success', async () => {
32
+ stubExecSuccess('hello stdout', 'some stderr');
33
+ const result = await executeCommand('echo hello', 5000);
34
+ expect(result).toEqual({
35
+ command: 'echo hello',
36
+ exitCode: 0,
37
+ stdout: 'hello stdout',
38
+ stderr: 'some stderr',
39
+ });
40
+ });
41
+ it('returns non-zero exitCode and output on failure', async () => {
42
+ stubExecFailure(1, 'fail stdout', 'fail stderr');
43
+ const result = await executeCommand('false', 5000);
44
+ expect(result).toEqual({
45
+ command: 'false',
46
+ exitCode: 1,
47
+ stdout: 'fail stdout',
48
+ stderr: 'fail stderr',
49
+ });
50
+ });
51
+ it('uses the provided exit code from the error', async () => {
52
+ stubExecFailure(42, '', 'error message');
53
+ const result = await executeCommand('exit 42', 5000);
54
+ expect(result.exitCode).toBe(42);
55
+ });
56
+ it('defaults exitCode to 1 when error has no code', async () => {
57
+ mockExec.mockImplementationOnce((_cmd, _opts, callback) => {
58
+ callback(new Error('no code'), '', '');
59
+ return undefined;
60
+ });
61
+ const result = await executeCommand('bad', 5000);
62
+ expect(result.exitCode).toBe(1);
63
+ });
64
+ it('returns empty strings for stdout/stderr when missing from error', async () => {
65
+ mockExec.mockImplementationOnce((_cmd, _opts, callback) => {
66
+ const err = Object.assign(new Error('fail'), { code: 1 });
67
+ callback(err, undefined, undefined);
68
+ return undefined;
69
+ });
70
+ const result = await executeCommand('bad', 5000);
71
+ expect(result.stdout).toBe('');
72
+ expect(result.stderr).toBe('');
73
+ });
74
+ it('passes the command string through to the result', async () => {
75
+ stubExecSuccess('');
76
+ const result = await executeCommand('cd frontend && npm run lint', 5000);
77
+ expect(result.command).toBe('cd frontend && npm run lint');
78
+ });
79
+ it('passes env vars to the child process', async () => {
80
+ stubExecSuccess('');
81
+ const env = { MY_VAR: 'my_value' };
82
+ await executeCommand('echo $MY_VAR', 5000, env);
83
+ expect(mockExec).toHaveBeenCalledWith('echo $MY_VAR', expect.objectContaining({ env: expect.objectContaining({ MY_VAR: 'my_value' }) }), expect.any(Function));
84
+ });
85
+ it('merges env vars with process.env', async () => {
86
+ stubExecSuccess('');
87
+ await executeCommand('cmd', 5000, { CUSTOM: 'value' });
88
+ const callOpts = vi.mocked(mockExec).mock.calls[0][1];
89
+ expect(callOpts.env).toMatchObject({ CUSTOM: 'value', ...process.env });
90
+ });
91
+ it('passes timeout to exec options', async () => {
92
+ stubExecSuccess('');
93
+ await executeCommand('cmd', 12345);
94
+ const callOpts = vi.mocked(mockExec).mock.calls[0][1];
95
+ expect(callOpts.timeout).toBe(12345);
96
+ });
97
+ });
98
+ // ---------------------------------------------------------------------------
99
+ // executeAll
100
+ // ---------------------------------------------------------------------------
101
+ describe('executeAll', () => {
102
+ it('returns results for all commands', async () => {
103
+ stubExecSuccess('out1');
104
+ stubExecSuccess('out2');
105
+ const results = await executeAll(['cmd1', 'cmd2'], 5000);
106
+ expect(results).toHaveLength(2);
107
+ expect(results[0].command).toBe('cmd1');
108
+ expect(results[1].command).toBe('cmd2');
109
+ });
110
+ it('returns results for both successes and failures', async () => {
111
+ stubExecSuccess('ok');
112
+ stubExecFailure(1, '', 'err');
113
+ const results = await executeAll(['ok-cmd', 'fail-cmd'], 5000);
114
+ expect(results[0].exitCode).toBe(0);
115
+ expect(results[1].exitCode).toBe(1);
116
+ });
117
+ it('returns empty array for empty command list', async () => {
118
+ expect(await executeAll([], 5000)).toEqual([]);
119
+ });
120
+ it('passes env vars to all commands', async () => {
121
+ stubExecSuccess('');
122
+ stubExecSuccess('');
123
+ const env = { FOO: 'bar' };
124
+ await executeAll(['cmd1', 'cmd2'], 5000, env);
125
+ for (const call of mockExec.mock.calls) {
126
+ expect(call[1].env).toMatchObject({ FOO: 'bar' });
127
+ }
128
+ });
129
+ });
130
+ // ---------------------------------------------------------------------------
131
+ // printFailures
132
+ // ---------------------------------------------------------------------------
133
+ describe('printFailures', () => {
134
+ it('writes failure header with command and exit code', () => {
135
+ const spy = vi.spyOn(process.stderr, 'write').mockReturnValue(true);
136
+ printFailures([{ command: 'npm run lint', exitCode: 1, stdout: '', stderr: '' }]);
137
+ const output = spy.mock.calls.map((c) => c[0]).join('');
138
+ expect(output).toContain('FAILED: npm run lint');
139
+ expect(output).toContain('exit code 1');
140
+ spy.mockRestore();
141
+ });
142
+ it('includes stdout when present', () => {
143
+ const spy = vi.spyOn(process.stderr, 'write').mockReturnValue(true);
144
+ printFailures([{ command: 'cmd', exitCode: 1, stdout: 'some output', stderr: '' }]);
145
+ const output = spy.mock.calls.map((c) => c[0]).join('');
146
+ expect(output).toContain('[stdout]');
147
+ expect(output).toContain('some output');
148
+ spy.mockRestore();
149
+ });
150
+ it('includes stderr when present', () => {
151
+ const spy = vi.spyOn(process.stderr, 'write').mockReturnValue(true);
152
+ printFailures([{ command: 'cmd', exitCode: 2, stdout: '', stderr: 'error details' }]);
153
+ const output = spy.mock.calls.map((c) => c[0]).join('');
154
+ expect(output).toContain('[stderr]');
155
+ expect(output).toContain('error details');
156
+ spy.mockRestore();
157
+ });
158
+ it('omits [stdout] block when stdout is empty', () => {
159
+ const spy = vi.spyOn(process.stderr, 'write').mockReturnValue(true);
160
+ printFailures([{ command: 'cmd', exitCode: 1, stdout: '', stderr: 'err' }]);
161
+ const output = spy.mock.calls.map((c) => c[0]).join('');
162
+ expect(output).not.toContain('[stdout]');
163
+ spy.mockRestore();
164
+ });
165
+ it('omits [stderr] block when stderr is empty', () => {
166
+ const spy = vi.spyOn(process.stderr, 'write').mockReturnValue(true);
167
+ printFailures([{ command: 'cmd', exitCode: 1, stdout: 'out', stderr: '' }]);
168
+ const output = spy.mock.calls.map((c) => c[0]).join('');
169
+ expect(output).not.toContain('[stderr]');
170
+ spy.mockRestore();
171
+ });
172
+ it('prints a section for each failed command', () => {
173
+ const spy = vi.spyOn(process.stderr, 'write').mockReturnValue(true);
174
+ printFailures([
175
+ { command: 'cmd1', exitCode: 1, stdout: '', stderr: 'err1' },
176
+ { command: 'cmd2', exitCode: 2, stdout: '', stderr: 'err2' },
177
+ ]);
178
+ const output = spy.mock.calls.map((c) => c[0]).join('');
179
+ expect(output).toContain('FAILED: cmd1');
180
+ expect(output).toContain('FAILED: cmd2');
181
+ spy.mockRestore();
182
+ });
183
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,93 @@
1
+ import { vi, describe, it, expect, beforeEach } from 'vitest';
2
+ vi.mock('node:child_process', () => ({
3
+ execFile: vi.fn(),
4
+ }));
5
+ import { execFile } from 'node:child_process';
6
+ import { getGitRoot, getHeadSha, getDiffFiles } from '../git.js';
7
+ const mockExecFile = vi.mocked(execFile);
8
+ // promisify resolves with { stdout, stderr } only if the original function has
9
+ // util.promisify.custom — which the real execFile does but vi.fn() does not.
10
+ // Passing a single object as the callback value makes promisify resolve with it directly.
11
+ function stubExecFile(stdout, stderr = '') {
12
+ return mockExecFile.mockImplementationOnce((_cmd, _args, callback) => {
13
+ callback(null, { stdout, stderr });
14
+ return undefined;
15
+ });
16
+ }
17
+ function stubExecFileError(error) {
18
+ return mockExecFile.mockImplementationOnce((_cmd, _args, callback) => {
19
+ callback(error, { stdout: '', stderr: '' });
20
+ return undefined;
21
+ });
22
+ }
23
+ beforeEach(() => {
24
+ mockExecFile.mockReset();
25
+ });
26
+ describe('getGitRoot', () => {
27
+ it('returns trimmed git root path', async () => {
28
+ stubExecFile('/home/user/project\n');
29
+ expect(await getGitRoot()).toBe('/home/user/project');
30
+ });
31
+ it('calls git rev-parse --show-toplevel', async () => {
32
+ stubExecFile('/some/path\n');
33
+ await getGitRoot();
34
+ expect(mockExecFile).toHaveBeenCalledWith('git', ['rev-parse', '--show-toplevel'], expect.any(Function));
35
+ });
36
+ it('throws when git command fails', async () => {
37
+ stubExecFileError(new Error('not a git repository'));
38
+ await expect(getGitRoot()).rejects.toThrow('not a git repository');
39
+ });
40
+ });
41
+ describe('getHeadSha', () => {
42
+ it('returns trimmed HEAD SHA', async () => {
43
+ stubExecFile('a4872f4584ce55be198c06cc1c33c2894b47dbe3\n');
44
+ expect(await getHeadSha()).toBe('a4872f4584ce55be198c06cc1c33c2894b47dbe3');
45
+ });
46
+ it('calls git rev-parse HEAD', async () => {
47
+ stubExecFile('abc123\n');
48
+ await getHeadSha();
49
+ expect(mockExecFile).toHaveBeenCalledWith('git', ['rev-parse', 'HEAD'], expect.any(Function));
50
+ });
51
+ it('throws when git command fails', async () => {
52
+ stubExecFileError(new Error('no HEAD'));
53
+ await expect(getHeadSha()).rejects.toThrow('no HEAD');
54
+ });
55
+ });
56
+ describe('getDiffFiles', () => {
57
+ it('returns empty array when no files are in diff', async () => {
58
+ stubExecFile(''); // unstaged
59
+ stubExecFile(''); // staged
60
+ expect(await getDiffFiles()).toEqual([]);
61
+ });
62
+ it('returns unstaged files only', async () => {
63
+ stubExecFile('src/a.ts\nsrc/b.ts\n'); // unstaged
64
+ stubExecFile(''); // staged
65
+ expect(await getDiffFiles()).toEqual(['src/a.ts', 'src/b.ts']);
66
+ });
67
+ it('returns staged files only', async () => {
68
+ stubExecFile(''); // unstaged
69
+ stubExecFile('src/c.ts\nsrc/d.ts\n'); // staged
70
+ expect(await getDiffFiles()).toEqual(['src/c.ts', 'src/d.ts']);
71
+ });
72
+ it('returns deduplicated union of unstaged and staged files', async () => {
73
+ stubExecFile('src/a.ts\nsrc/b.ts\n'); // unstaged
74
+ stubExecFile('src/b.ts\nsrc/c.ts\n'); // staged — b.ts appears in both
75
+ const result = await getDiffFiles();
76
+ expect(result).toHaveLength(3);
77
+ expect(result).toContain('src/a.ts');
78
+ expect(result).toContain('src/b.ts');
79
+ expect(result).toContain('src/c.ts');
80
+ });
81
+ it('silently returns empty array when git fails', async () => {
82
+ mockExecFile.mockImplementation((_cmd, _args, callback) => {
83
+ callback(new Error('git not found'), '', '');
84
+ return undefined;
85
+ });
86
+ expect(await getDiffFiles()).toEqual([]);
87
+ });
88
+ it('ignores empty lines in git output', async () => {
89
+ stubExecFile('\nsrc/a.ts\n\n'); // unstaged with empty lines
90
+ stubExecFile(''); // staged
91
+ expect(await getDiffFiles()).toEqual(['src/a.ts']);
92
+ });
93
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,190 @@
1
+ import { vi, describe, it, expect, beforeEach } from 'vitest';
2
+ import { createHash } from 'node:crypto';
3
+ import { join } from 'node:path';
4
+ vi.mock('node:fs/promises', () => ({
5
+ readFile: vi.fn(),
6
+ writeFile: vi.fn(),
7
+ mkdir: vi.fn(),
8
+ }));
9
+ vi.mock('node:fs', () => ({
10
+ readFileSync: vi.fn(),
11
+ }));
12
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
13
+ import { readFileSync } from 'node:fs';
14
+ import { computeFileHash, computeHashes, loadState, saveState, findChangedFiles, } from '../state.js';
15
+ const mockReadFile = vi.mocked(readFile);
16
+ const mockWriteFile = vi.mocked(writeFile);
17
+ const mockMkdir = vi.mocked(mkdir);
18
+ const mockReadFileSync = vi.mocked(readFileSync);
19
+ beforeEach(() => {
20
+ vi.clearAllMocks();
21
+ mockWriteFile.mockResolvedValue(undefined);
22
+ mockMkdir.mockResolvedValue(undefined);
23
+ });
24
+ // ---------------------------------------------------------------------------
25
+ // findChangedFiles — pure function, no mocks needed
26
+ // ---------------------------------------------------------------------------
27
+ describe('findChangedFiles', () => {
28
+ it('returns empty array when both snapshots are identical', () => {
29
+ const hashes = { 'src/a.ts': 'hash1', 'src/b.ts': 'hash2' };
30
+ expect(findChangedFiles(hashes, { ...hashes })).toEqual([]);
31
+ });
32
+ it('detects a file with a different hash', () => {
33
+ const previous = { 'src/a.ts': 'hash1' };
34
+ const current = { 'src/a.ts': 'hash2' };
35
+ expect(findChangedFiles(previous, current)).toEqual(['src/a.ts']);
36
+ });
37
+ it('detects a newly modified file (not in previous snapshot)', () => {
38
+ const previous = { 'src/a.ts': 'hash1' };
39
+ const current = { 'src/a.ts': 'hash1', 'src/b.ts': 'hash2' };
40
+ expect(findChangedFiles(previous, current)).toEqual(['src/b.ts']);
41
+ });
42
+ it('detects a file that was cleaned/committed since last run (not in current snapshot)', () => {
43
+ const previous = { 'src/a.ts': 'hash1', 'src/b.ts': 'hash2' };
44
+ const current = { 'src/a.ts': 'hash1' };
45
+ expect(findChangedFiles(previous, current)).toEqual(['src/b.ts']);
46
+ });
47
+ it('handles multiple changes at once', () => {
48
+ const previous = { 'src/a.ts': 'hash1', 'src/b.ts': 'hash2', 'src/c.ts': 'hash3' };
49
+ const current = { 'src/a.ts': 'hash1-new', 'src/d.ts': 'hash4' };
50
+ const changed = findChangedFiles(previous, current);
51
+ expect(changed).toContain('src/a.ts'); // modified
52
+ expect(changed).toContain('src/b.ts'); // deleted
53
+ expect(changed).toContain('src/c.ts'); // deleted
54
+ expect(changed).toContain('src/d.ts'); // new
55
+ expect(changed).toHaveLength(4);
56
+ });
57
+ it('returns empty array when both snapshots are empty', () => {
58
+ expect(findChangedFiles({}, {})).toEqual([]);
59
+ });
60
+ });
61
+ // ---------------------------------------------------------------------------
62
+ // computeFileHash
63
+ // ---------------------------------------------------------------------------
64
+ describe('computeFileHash', () => {
65
+ it('returns sha256 hex hash of file content', async () => {
66
+ const content = Buffer.from('hello world');
67
+ mockReadFile.mockResolvedValue(content);
68
+ const expected = createHash('sha256').update(content).digest('hex');
69
+ expect(await computeFileHash('/some/file.ts')).toBe(expected);
70
+ expect(mockReadFile).toHaveBeenCalledWith('/some/file.ts');
71
+ });
72
+ it('produces different hashes for different content', async () => {
73
+ mockReadFile.mockResolvedValueOnce(Buffer.from('content A'));
74
+ const hash1 = await computeFileHash('/a.ts');
75
+ mockReadFile.mockResolvedValueOnce(Buffer.from('content B'));
76
+ const hash2 = await computeFileHash('/b.ts');
77
+ expect(hash1).not.toBe(hash2);
78
+ });
79
+ it('produces the same hash for identical content', async () => {
80
+ const content = Buffer.from('same content');
81
+ mockReadFile.mockResolvedValue(content);
82
+ const hash1 = await computeFileHash('/a.ts');
83
+ const hash2 = await computeFileHash('/b.ts');
84
+ expect(hash1).toBe(hash2);
85
+ });
86
+ it('throws when file cannot be read', async () => {
87
+ mockReadFile.mockRejectedValue(new Error('ENOENT: no such file'));
88
+ await expect(computeFileHash('/missing.ts')).rejects.toThrow('ENOENT');
89
+ });
90
+ });
91
+ // ---------------------------------------------------------------------------
92
+ // computeHashes
93
+ // ---------------------------------------------------------------------------
94
+ describe('computeHashes', () => {
95
+ it('returns empty record for empty file list', async () => {
96
+ expect(await computeHashes('/git/root', [])).toEqual({});
97
+ });
98
+ it('returns hashes keyed by relative paths', async () => {
99
+ const contentA = Buffer.from('content A');
100
+ const contentB = Buffer.from('content B');
101
+ mockReadFile.mockResolvedValueOnce(contentA);
102
+ mockReadFile.mockResolvedValueOnce(contentB);
103
+ const result = await computeHashes('/root', ['src/a.ts', 'src/b.ts']);
104
+ expect(result).toHaveProperty('src/a.ts');
105
+ expect(result).toHaveProperty('src/b.ts');
106
+ expect(result['src/a.ts']).toBe(createHash('sha256').update(contentA).digest('hex'));
107
+ expect(result['src/b.ts']).toBe(createHash('sha256').update(contentB).digest('hex'));
108
+ });
109
+ it('reads files using absolute paths (gitRoot + relative)', async () => {
110
+ mockReadFile.mockResolvedValue(Buffer.from('x'));
111
+ await computeHashes('/my/project', ['src/file.ts']);
112
+ expect(mockReadFile).toHaveBeenCalledWith(join('/my/project', 'src/file.ts'));
113
+ });
114
+ it('skips files that cannot be read (e.g. deleted since diff was run)', async () => {
115
+ mockReadFile.mockResolvedValueOnce(Buffer.from('ok'));
116
+ mockReadFile.mockRejectedValueOnce(new Error('ENOENT'));
117
+ const result = await computeHashes('/root', ['src/a.ts', 'src/deleted.ts']);
118
+ expect(result).toHaveProperty('src/a.ts');
119
+ expect(result).not.toHaveProperty('src/deleted.ts');
120
+ });
121
+ });
122
+ // ---------------------------------------------------------------------------
123
+ // loadState
124
+ // ---------------------------------------------------------------------------
125
+ describe('loadState', () => {
126
+ it('returns null when state file does not exist', () => {
127
+ mockReadFileSync.mockImplementation(() => { throw new Error('ENOENT'); });
128
+ expect(loadState('/root', 'src/**/*.ts')).toBeNull();
129
+ });
130
+ it('returns null when state file contains invalid JSON', () => {
131
+ mockReadFileSync.mockReturnValue('{ invalid json }');
132
+ expect(loadState('/root', 'src/**/*.ts')).toBeNull();
133
+ });
134
+ it('returns null when pattern is not found in state file', () => {
135
+ const state = { 'other/**/*.ts': { headSha: 'abc', fileHashes: {} } };
136
+ mockReadFileSync.mockReturnValue(JSON.stringify(state));
137
+ expect(loadState('/root', 'src/**/*.ts')).toBeNull();
138
+ });
139
+ it('returns the pattern state when found', () => {
140
+ const patternState = {
141
+ headSha: 'a4872f4',
142
+ fileHashes: { 'src/a.ts': 'hash1' },
143
+ };
144
+ const stateFile = { 'src/**/*.ts': patternState };
145
+ mockReadFileSync.mockReturnValue(JSON.stringify(stateFile));
146
+ expect(loadState('/root', 'src/**/*.ts')).toEqual(patternState);
147
+ });
148
+ it('reads from .claude/on-changes-run/state.local.json in git root', () => {
149
+ mockReadFileSync.mockImplementation(() => { throw new Error('ENOENT'); });
150
+ loadState('/my/project', 'src/**/*.ts');
151
+ expect(mockReadFileSync).toHaveBeenCalledWith(join('/my/project', '.claude/on-changes-run/state.local.json'), 'utf-8');
152
+ });
153
+ });
154
+ // ---------------------------------------------------------------------------
155
+ // saveState
156
+ // ---------------------------------------------------------------------------
157
+ describe('saveState', () => {
158
+ const patternState = { headSha: 'abc123', fileHashes: { 'src/a.ts': 'hash1' } };
159
+ it('creates a new state file when none exists', async () => {
160
+ mockReadFile.mockRejectedValue(new Error('ENOENT'));
161
+ await saveState('/root', 'src/**/*.ts', patternState);
162
+ const written = JSON.parse(vi.mocked(mockWriteFile).mock.calls[0][1]);
163
+ expect(written['src/**/*.ts']).toEqual(patternState);
164
+ });
165
+ it('merges into existing state file without overwriting other patterns', async () => {
166
+ const existing = { 'backend/**/*.kt': { headSha: 'def456', fileHashes: {} } };
167
+ mockReadFile.mockResolvedValue(JSON.stringify(existing));
168
+ await saveState('/root', 'src/**/*.ts', patternState);
169
+ const written = JSON.parse(vi.mocked(mockWriteFile).mock.calls[0][1]);
170
+ expect(written['backend/**/*.kt']).toEqual(existing['backend/**/*.kt']);
171
+ expect(written['src/**/*.ts']).toEqual(patternState);
172
+ });
173
+ it('overwrites the state for an existing pattern', async () => {
174
+ const oldState = { 'src/**/*.ts': { headSha: 'old', fileHashes: {} } };
175
+ mockReadFile.mockResolvedValue(JSON.stringify(oldState));
176
+ await saveState('/root', 'src/**/*.ts', patternState);
177
+ const written = JSON.parse(vi.mocked(mockWriteFile).mock.calls[0][1]);
178
+ expect(written['src/**/*.ts']).toEqual(patternState);
179
+ });
180
+ it('ensures the .claude/on-changes-run directory exists', async () => {
181
+ mockReadFile.mockRejectedValue(new Error('ENOENT'));
182
+ await saveState('/root', 'src/**/*.ts', patternState);
183
+ expect(mockMkdir).toHaveBeenCalledWith(join('/root', '.claude/on-changes-run'), { recursive: true });
184
+ });
185
+ it('writes to .claude/on-changes-run/state.local.json in git root', async () => {
186
+ mockReadFile.mockRejectedValue(new Error('ENOENT'));
187
+ await saveState('/my/project', 'src/**/*.ts', patternState);
188
+ expect(mockWriteFile).toHaveBeenCalledWith(join('/my/project', '.claude/on-changes-run/state.local.json'), expect.any(String));
189
+ });
190
+ });
package/dist/cli.js CHANGED
@@ -15,12 +15,14 @@ function parseCliArgs() {
15
15
  .requiredOption('--on <glob>', 'Glob pattern to match changed files against')
16
16
  .requiredOption('--exec <command>', 'Command to execute (repeatable, run in parallel)', collect, [])
17
17
  .option('--exec-timeout <seconds>', 'Timeout per command in seconds', '300')
18
+ .option('--files-separator <sep>', 'Separator used in ON_CHANGES_RUN_* env vars', '\n')
18
19
  .parse(process.argv);
19
20
  const opts = program.opts();
20
21
  return {
21
22
  on: opts.on,
22
23
  exec: opts.exec,
23
24
  execTimeout: parseInt(opts.execTimeout, 10),
25
+ filesSeparator: opts.filesSeparator,
24
26
  };
25
27
  }
26
28
  async function readStdin() {
@@ -68,9 +70,13 @@ async function main() {
68
70
  process.exit(0);
69
71
  }
70
72
  process.stderr.write(`on-changes-run: ${changedFiles.length} file(s) changed matching "${args.on}", running ${args.exec.length} command(s)\n`);
71
- // Run all commands in parallel
73
+ // Run all commands in parallel, exposing file lists as env vars
72
74
  const timeoutMs = args.execTimeout * 1000;
73
- const results = await executeAll(args.exec, timeoutMs);
75
+ const env = {
76
+ ON_CHANGES_RUN_DIFF_FILES: matchingFiles.join(args.filesSeparator),
77
+ ON_CHANGES_RUN_CHANGED_FILES: changedFiles.join(args.filesSeparator),
78
+ };
79
+ const results = await executeAll(args.exec, timeoutMs, env);
74
80
  const failures = results.filter((r) => r.exitCode !== 0);
75
81
  if (failures.length > 0) {
76
82
  printFailures(failures);
@@ -1,7 +1,7 @@
1
1
  import type { CommandResult } from './types.js';
2
2
  /** Execute a single shell command and capture its output */
3
- export declare function executeCommand(command: string, timeoutMs: number): Promise<CommandResult>;
3
+ export declare function executeCommand(command: string, timeoutMs: number, env?: Record<string, string>): Promise<CommandResult>;
4
4
  /** Execute multiple commands in parallel */
5
- export declare function executeAll(commands: string[], timeoutMs: number): Promise<CommandResult[]>;
5
+ export declare function executeAll(commands: string[], timeoutMs: number, env?: Record<string, string>): Promise<CommandResult[]>;
6
6
  /** Print details of failed commands to stderr */
7
7
  export declare function printFailures(failures: CommandResult[]): void;
package/dist/executor.js CHANGED
@@ -2,11 +2,12 @@ import { exec } from 'node:child_process';
2
2
  import { promisify } from 'node:util';
3
3
  const execAsync = promisify(exec);
4
4
  /** Execute a single shell command and capture its output */
5
- export async function executeCommand(command, timeoutMs) {
5
+ export async function executeCommand(command, timeoutMs, env = {}) {
6
6
  try {
7
7
  const { stdout, stderr } = await execAsync(command, {
8
8
  maxBuffer: 10 * 1024 * 1024,
9
9
  timeout: timeoutMs,
10
+ env: { ...process.env, ...env },
10
11
  });
11
12
  return { command, exitCode: 0, stdout, stderr };
12
13
  }
@@ -21,8 +22,8 @@ export async function executeCommand(command, timeoutMs) {
21
22
  }
22
23
  }
23
24
  /** Execute multiple commands in parallel */
24
- export async function executeAll(commands, timeoutMs) {
25
- return Promise.all(commands.map((cmd) => executeCommand(cmd, timeoutMs)));
25
+ export async function executeAll(commands, timeoutMs, env = {}) {
26
+ return Promise.all(commands.map((cmd) => executeCommand(cmd, timeoutMs, env)));
26
27
  }
27
28
  /** Print details of failed commands to stderr */
28
29
  export function printFailures(failures) {
package/dist/state.js CHANGED
@@ -2,7 +2,7 @@ import { createHash } from 'node:crypto';
2
2
  import { readFile, writeFile, mkdir } from 'node:fs/promises';
3
3
  import { readFileSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
- const STATE_FILENAME = '.claude/on-changes-run.state.json';
5
+ const STATE_FILENAME = '.claude/on-changes-run/state.local.json';
6
6
  /** Compute SHA-256 hash of a file's content on disk */
7
7
  export async function computeFileHash(absolutePath) {
8
8
  const content = await readFile(absolutePath);
@@ -46,7 +46,7 @@ export async function saveState(gitRoot, pattern, state) {
46
46
  // File doesn't exist yet, start fresh
47
47
  }
48
48
  stateFile[pattern] = state;
49
- await mkdir(join(gitRoot, '.claude'), { recursive: true });
49
+ await mkdir(join(gitRoot, '.claude/on-changes-run'), { recursive: true });
50
50
  await writeFile(statePath, JSON.stringify(stateFile, null, 2) + '\n');
51
51
  }
52
52
  /** Find files that changed between two snapshots */
package/dist/types.d.ts CHANGED
@@ -14,6 +14,7 @@ export interface CliArgs {
14
14
  on: string;
15
15
  exec: string[];
16
16
  execTimeout: number;
17
+ filesSeparator: string;
17
18
  }
18
19
  /** Result of running a single command */
19
20
  export interface CommandResult {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fcamblor/on-changes-run",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "description": "Claude Code lifecycle hook: run commands when file changes are detected",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -15,7 +15,9 @@
15
15
  ],
16
16
  "scripts": {
17
17
  "build": "tsc",
18
- "prepublishOnly": "npm run build"
18
+ "prepublishOnly": "npm run build",
19
+ "test": "vitest run",
20
+ "test:watch": "vitest"
19
21
  },
20
22
  "engines": {
21
23
  "node": ">=18.0.0"
@@ -27,6 +29,7 @@
27
29
  "devDependencies": {
28
30
  "typescript": "~5.8.0",
29
31
  "@types/node": "^22.0.0",
30
- "@types/picomatch": "^3.0.0"
32
+ "@types/picomatch": "^3.0.0",
33
+ "vitest": "^3.0.0"
31
34
  }
32
35
  }