Skip to content

Commit b631287

Browse files
committed
feat: Add direct execution mode for commit-msg hook
- Add default command to accept file path directly as argument - Enable commit-msg.js to be symlinked as Git commit-msg hook - Process commit message file when invoked without subcommand - Add comprehensive tests for direct hook execution scenarios This allows the built commit-msg.js to be directly symlinked as a Git commit-msg hook, where it will process the commit message file passed by Git as the first argument. Added tests cover: - Direct execution with file argument - Help display when no arguments provided - Error handling for non-existent files - Symlink usage as commit-msg hook - Verbose mode support Change-Id: I4fad4ae08fd2629b0b906849deb1f9f181cc5830 Co-developed-by: Claude <noreply@anthropic.com> Signed-off-by: Jiang Xin <zhiyou.jx@alibaba-inc.com>
1 parent dfb020b commit b631287

3 files changed

Lines changed: 298 additions & 0 deletions

File tree

src/bin/commit-msg.dev.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,56 @@ async function main() {
144144
}
145145
});
146146

147+
// Add default command to handle commit message file directly
148+
program
149+
.description('CLI tool for managing Git commit-msg hooks')
150+
.arguments('[message-file]')
151+
.action(async (messageFile, options) => {
152+
// If no message file is provided, show help
153+
if (!messageFile) {
154+
program.help();
155+
return;
156+
}
157+
158+
const verbose = getVerboseMode({ ...program.opts(), ...options });
159+
160+
try {
161+
// Check if file exists
162+
const { existsSync } = await import('fs');
163+
if (!existsSync(messageFile)) {
164+
throw new Error(`File not found: ${messageFile}`);
165+
}
166+
167+
await exec(messageFile);
168+
// Check for updates after successful execution
169+
try {
170+
await checkAndUpgrade({ verbose });
171+
} catch (updateError) {
172+
// Version check failure should not affect main command status
173+
if (verbose) {
174+
console.log('Version check failed:', updateError);
175+
}
176+
}
177+
} catch (error) {
178+
// Check for updates when exec command fails
179+
if (verbose) {
180+
console.log(
181+
'\n🔍 Checking for updates (in case a newer version fixes this issue)...'
182+
);
183+
}
184+
try {
185+
await checkAndUpgrade({ verbose, silent: !verbose });
186+
} catch (updateError) {
187+
if (verbose) {
188+
console.log('Version check failed:', updateError);
189+
}
190+
}
191+
192+
// Throw error instead of process.exit to allow update operations
193+
throw error;
194+
}
195+
});
196+
147197
program.parse();
148198
}
149199

src/bin/commit-msg.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,56 @@ async function main() {
178178
}
179179
});
180180

181+
// Add default command to handle commit message file directly
182+
program
183+
.description('CLI tool for managing Git commit-msg hooks')
184+
.arguments('[message-file]')
185+
.action(async (messageFile, options) => {
186+
// If no message file is provided, show help
187+
if (!messageFile) {
188+
program.help();
189+
return;
190+
}
191+
192+
const verbose = getVerboseMode({ ...program.opts(), ...options });
193+
194+
try {
195+
// Check if file exists
196+
const { existsSync } = await import('fs');
197+
if (!existsSync(messageFile)) {
198+
throw new CleanError(`Command or file not found: ${messageFile}`);
199+
}
200+
201+
await exec(messageFile);
202+
// Check for updates after successful execution
203+
try {
204+
await checkAndUpgrade({ verbose });
205+
} catch (updateError) {
206+
// Version check failure should not affect main command status
207+
if (verbose) {
208+
console.log('Version check failed:', updateError);
209+
}
210+
}
211+
} catch (error) {
212+
// Check for updates when exec command fails
213+
if (verbose) {
214+
console.log(
215+
'\n🔍 Checking for updates (in case a newer version fixes this issue)...'
216+
);
217+
}
218+
try {
219+
await checkAndUpgrade({ verbose, silent: !verbose });
220+
} catch (updateError) {
221+
if (verbose) {
222+
console.log('Version check failed:', updateError);
223+
}
224+
}
225+
226+
// Throw clean error to trigger exitOverride for update check
227+
throw new CleanError((error as Error).message);
228+
}
229+
});
230+
181231
program.parse();
182232
}
183233

test/direct-hook.test.ts

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2+
import { execSync, spawnSync } from 'child_process';
3+
import {
4+
mkdirSync,
5+
rmSync,
6+
existsSync,
7+
writeFileSync,
8+
chmodSync,
9+
symlinkSync,
10+
readFileSync,
11+
} from 'fs';
12+
import { join } from 'path';
13+
import { tmpdir } from 'os';
14+
15+
describe('direct commit-msg hook execution tests', () => {
16+
let tempDir: string;
17+
let testRepoDir: string;
18+
let originalCwd: string;
19+
20+
beforeEach(() => {
21+
// Create a unique temporary directory for each test
22+
tempDir = join(
23+
tmpdir(),
24+
`direct-commit-msg-hook-test-${Date.now()}-${Math.random()}`
25+
);
26+
testRepoDir = join(tempDir, 'test-repo');
27+
originalCwd = process.cwd();
28+
29+
// Create test directory and git repo
30+
mkdirSync(testRepoDir, { recursive: true });
31+
process.chdir(testRepoDir);
32+
33+
// Initialize git repo
34+
execSync('git -c init.defaultBranch=master init', { stdio: 'ignore' });
35+
execSync('git config user.name "Test User"', { stdio: 'ignore' });
36+
execSync('git config user.email "test@example.com"', { stdio: 'ignore' });
37+
});
38+
39+
afterEach(() => {
40+
// Restore original working directory
41+
process.chdir(originalCwd);
42+
43+
// Clean up temporary directory
44+
if (existsSync(tempDir)) {
45+
rmSync(tempDir, { recursive: true, force: true });
46+
}
47+
});
48+
49+
describe('direct execution as commit-msg hook', () => {
50+
it('should process commit message file when executed directly with file argument', () => {
51+
// Create the commit message file
52+
const messageFile = join(tempDir, 'COMMIT_EDITMSG');
53+
writeFileSync(
54+
messageFile,
55+
'feat: Add new feature\n\nThis is a test commit message.\n'
56+
);
57+
58+
// Execute commit-msg directly with the message file as argument
59+
const result = spawnSync(
60+
'node',
61+
[join(originalCwd, 'dist/bin/commit-msg.js'), messageFile],
62+
{
63+
cwd: testRepoDir,
64+
encoding: 'utf-8',
65+
timeout: 30000,
66+
env: {
67+
...process.env,
68+
CLAUDECODE: '1',
69+
},
70+
}
71+
);
72+
73+
expect(result.status).toBe(0);
74+
expect(result.stderr).toBe('');
75+
76+
// Verify the file was processed and contains Change-Id
77+
const processedContent = result.stdout;
78+
expect(processedContent).toContain('Executing commit-msg hook on file:');
79+
expect(processedContent).toContain(
80+
'Commit message processed and saved successfully!'
81+
);
82+
83+
// Read the actual file content to verify it was modified
84+
const fileContent = readFileSync(messageFile, 'utf-8');
85+
expect(fileContent).toContain('Change-Id:');
86+
expect(fileContent).toContain(
87+
'Co-developed-by: Claude <noreply@anthropic.com>'
88+
);
89+
});
90+
91+
it('should show help when executed without arguments', () => {
92+
// Execute commit-msg directly without arguments
93+
const result = spawnSync(
94+
'node',
95+
[join(originalCwd, 'dist/bin/commit-msg.js')],
96+
{
97+
cwd: testRepoDir,
98+
encoding: 'utf-8',
99+
timeout: 30000,
100+
}
101+
);
102+
103+
expect(result.status).toBe(0);
104+
expect(result.stdout).toContain('Usage: commit-msg');
105+
expect(result.stdout).toContain(
106+
'CLI tool for managing Git commit-msg hooks'
107+
);
108+
});
109+
110+
it('should show error when file does not exist', () => {
111+
// Execute commit-msg with non-existent file
112+
const result = spawnSync(
113+
'node',
114+
[join(originalCwd, 'dist/bin/commit-msg.js'), 'non-existent-file.txt'],
115+
{
116+
cwd: testRepoDir,
117+
encoding: 'utf-8',
118+
timeout: 30000,
119+
}
120+
);
121+
122+
expect(result.status).toBe(1);
123+
expect(result.stderr).toContain(
124+
'Error: Command or file not found: non-existent-file.txt'
125+
);
126+
});
127+
128+
it('should work when symlinked as commit-msg hook', () => {
129+
// Create symlink to commit-msg in .git/hooks directory
130+
const hooksDir = join(testRepoDir, '.git', 'hooks');
131+
mkdirSync(hooksDir, { recursive: true });
132+
133+
const commitMsgHook = join(hooksDir, 'commit-msg');
134+
symlinkSync(join(originalCwd, 'dist/bin/commit-msg.js'), commitMsgHook);
135+
136+
// Create the commit message file
137+
const messageFile = join(tempDir, 'COMMIT_EDITMSG');
138+
writeFileSync(
139+
messageFile,
140+
'fix: Correct bug in user authentication\n\nThis fixes an issue where users could not log in.\n'
141+
);
142+
143+
// Execute the symlinked hook directly with the message file as argument
144+
const result = spawnSync(commitMsgHook, [messageFile], {
145+
cwd: testRepoDir,
146+
encoding: 'utf-8',
147+
timeout: 30000,
148+
env: {
149+
...process.env,
150+
CLAUDECODE: '1',
151+
},
152+
});
153+
154+
expect(result.status).toBe(0);
155+
expect(result.stderr).toBe('');
156+
157+
// Read the file content to verify it was modified
158+
const fileContent = readFileSync(messageFile, 'utf-8');
159+
expect(fileContent).toContain('Change-Id:');
160+
expect(fileContent).toContain(
161+
'Co-developed-by: Claude <noreply@anthropic.com>'
162+
);
163+
});
164+
});
165+
166+
describe('verbose mode in direct execution', () => {
167+
it('should show verbose output when --verbose flag is used', () => {
168+
// Create the commit message file
169+
const messageFile = join(tempDir, 'COMMIT_EDITMSG');
170+
writeFileSync(
171+
messageFile,
172+
'chore: Update dependencies\n\nUpdate package versions.\n'
173+
);
174+
175+
// Execute commit-msg with verbose flag
176+
const result = spawnSync(
177+
'node',
178+
[join(originalCwd, 'dist/bin/commit-msg.js'), '--verbose', messageFile],
179+
{
180+
cwd: testRepoDir,
181+
encoding: 'utf-8',
182+
timeout: 30000,
183+
env: {
184+
...process.env,
185+
CLAUDECODE: '1',
186+
},
187+
}
188+
);
189+
190+
expect(result.status).toBe(0);
191+
expect(result.stdout).toContain('Executing commit-msg hook on file:');
192+
expect(result.stdout).toContain(
193+
'Commit message processed and saved successfully!'
194+
);
195+
// Add more specific verbose output checks if needed
196+
});
197+
});
198+
});

0 commit comments

Comments
 (0)