@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.
- package/dist/__tests__/executor.test.d.ts +1 -0
- package/dist/__tests__/executor.test.js +183 -0
- package/dist/__tests__/git.test.d.ts +1 -0
- package/dist/__tests__/git.test.js +93 -0
- package/dist/__tests__/state.test.d.ts +1 -0
- package/dist/__tests__/state.test.js +190 -0
- package/dist/cli.js +8 -2
- package/dist/executor.d.ts +2 -2
- package/dist/executor.js +4 -3
- package/dist/state.js +2 -2
- package/dist/types.d.ts +1 -0
- package/package.json +6 -3
|
@@ -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
|
|
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);
|
package/dist/executor.d.ts
CHANGED
|
@@ -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
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fcamblor/on-changes-run",
|
|
3
|
-
"version": "0.0.
|
|
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
|
}
|