claude-git-hooks 1.5.4 → 2.0.0
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/CHANGELOG.md +89 -1
- package/README.md +130 -35
- package/bin/claude-hooks +253 -287
- package/lib/hooks/pre-commit.js +335 -0
- package/lib/hooks/prepare-commit-msg.js +283 -0
- package/lib/utils/claude-client.js +373 -0
- package/lib/utils/file-operations.js +409 -0
- package/lib/utils/git-operations.js +341 -0
- package/lib/utils/logger.js +141 -0
- package/lib/utils/prompt-builder.js +283 -0
- package/lib/utils/resolution-prompt.js +291 -0
- package/package.json +52 -40
- package/templates/pre-commit +58 -411
- package/templates/prepare-commit-msg +62 -118
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File: file-operations.js
|
|
3
|
+
* Purpose: File system operations and content filtering
|
|
4
|
+
*
|
|
5
|
+
* Key responsibilities:
|
|
6
|
+
* - Read files with size validation
|
|
7
|
+
* - Filter SKIP-ANALYSIS patterns from code
|
|
8
|
+
* - Validate file extensions
|
|
9
|
+
* - Check file sizes
|
|
10
|
+
*
|
|
11
|
+
* Dependencies:
|
|
12
|
+
* - fs/promises: Async file operations
|
|
13
|
+
* - path: Cross-platform path handling
|
|
14
|
+
* - logger: Debug and error logging
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import fs from 'fs/promises';
|
|
18
|
+
import fsSync from 'fs';
|
|
19
|
+
import path from 'path';
|
|
20
|
+
import logger from './logger.js';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Custom error for file operation failures
|
|
24
|
+
*/
|
|
25
|
+
class FileOperationError extends Error {
|
|
26
|
+
constructor(message, { filePath, cause, context } = {}) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.name = 'FileOperationError';
|
|
29
|
+
this.filePath = filePath;
|
|
30
|
+
this.cause = cause;
|
|
31
|
+
this.context = context;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Gets file size in bytes
|
|
37
|
+
* Why: Need to filter large files before reading to avoid memory issues
|
|
38
|
+
*
|
|
39
|
+
* @param {string} filePath - Path to file
|
|
40
|
+
* @returns {Promise<number>} File size in bytes
|
|
41
|
+
*/
|
|
42
|
+
const getFileSize = async (filePath) => {
|
|
43
|
+
logger.debug('file-operations - getFileSize', 'Getting file size', { filePath });
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
const stats = await fs.stat(filePath);
|
|
47
|
+
return stats.size;
|
|
48
|
+
} catch (error) {
|
|
49
|
+
throw new FileOperationError('Failed to get file size', {
|
|
50
|
+
filePath,
|
|
51
|
+
cause: error
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Checks if file exists
|
|
58
|
+
*
|
|
59
|
+
* @param {string} filePath - Path to file
|
|
60
|
+
* @returns {Promise<boolean>} True if file exists
|
|
61
|
+
*/
|
|
62
|
+
const fileExists = async (filePath) => {
|
|
63
|
+
try {
|
|
64
|
+
await fs.access(filePath);
|
|
65
|
+
return true;
|
|
66
|
+
} catch {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Reads file content with size check
|
|
73
|
+
* Why: Prevents loading huge files into memory that could cause OOM errors
|
|
74
|
+
*
|
|
75
|
+
* @param {string} filePath - Path to file
|
|
76
|
+
* @param {Object} options - Read options
|
|
77
|
+
* @param {number} options.maxSize - Maximum file size in bytes (default: 100000 = 100KB)
|
|
78
|
+
* @param {string} options.encoding - File encoding (default: 'utf8')
|
|
79
|
+
* @returns {Promise<string>} File content
|
|
80
|
+
* @throws {FileOperationError} If file too large or read fails
|
|
81
|
+
*/
|
|
82
|
+
const readFile = async (filePath, { maxSize = 100000, encoding = 'utf8' } = {}) => {
|
|
83
|
+
logger.debug(
|
|
84
|
+
'file-operations - readFile',
|
|
85
|
+
'Reading file',
|
|
86
|
+
{ filePath, maxSize, encoding }
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
const size = await getFileSize(filePath);
|
|
91
|
+
|
|
92
|
+
// Why: Check size before reading to avoid loading huge files
|
|
93
|
+
if (size > maxSize) {
|
|
94
|
+
throw new FileOperationError('File exceeds maximum size', {
|
|
95
|
+
filePath,
|
|
96
|
+
context: { size, maxSize }
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const content = await fs.readFile(filePath, encoding);
|
|
101
|
+
|
|
102
|
+
logger.debug(
|
|
103
|
+
'file-operations - readFile',
|
|
104
|
+
'File read successfully',
|
|
105
|
+
{ filePath, contentLength: content.length }
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
return content;
|
|
109
|
+
|
|
110
|
+
} catch (error) {
|
|
111
|
+
if (error instanceof FileOperationError) {
|
|
112
|
+
throw error;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
logger.error('file-operations - readFile', 'Failed to read file', error);
|
|
116
|
+
throw new FileOperationError('Failed to read file', {
|
|
117
|
+
filePath,
|
|
118
|
+
cause: error
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Filters SKIP-ANALYSIS patterns from code content
|
|
125
|
+
* Why: Allows developers to exclude specific code from analysis
|
|
126
|
+
*
|
|
127
|
+
* Supports two patterns:
|
|
128
|
+
* 1. Single line: // SKIP-ANALYSIS (excludes next line)
|
|
129
|
+
* 2. Block: // SKIP_ANALYSIS_BLOCK ... // SKIP_ANALYSIS_BLOCK (excludes block)
|
|
130
|
+
*
|
|
131
|
+
* @param {string} content - File content to filter
|
|
132
|
+
* @returns {string} Filtered content
|
|
133
|
+
*/
|
|
134
|
+
const filterSkipAnalysis = (content) => {
|
|
135
|
+
logger.debug(
|
|
136
|
+
'file-operations - filterSkipAnalysis',
|
|
137
|
+
'Filtering SKIP-ANALYSIS patterns',
|
|
138
|
+
{ originalLength: content.length }
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
const lines = content.split('\n');
|
|
142
|
+
let skipNext = false;
|
|
143
|
+
let inSkipBlock = false;
|
|
144
|
+
|
|
145
|
+
// Why: Use map instead of filter to preserve line numbers for error reporting
|
|
146
|
+
// Empty lines maintain original line number mapping
|
|
147
|
+
const filteredLines = lines.map((line) => {
|
|
148
|
+
// Detect single-line SKIP-ANALYSIS
|
|
149
|
+
if (line.includes('// SKIP-ANALYSIS')) {
|
|
150
|
+
skipNext = true;
|
|
151
|
+
return ''; // Preserve line number
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Detect SKIP_ANALYSIS_BLOCK (toggle block state)
|
|
155
|
+
if (line.includes('// SKIP_ANALYSIS_BLOCK')) {
|
|
156
|
+
inSkipBlock = !inSkipBlock;
|
|
157
|
+
return ''; // Preserve line number
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Skip lines inside block
|
|
161
|
+
if (inSkipBlock) {
|
|
162
|
+
return '';
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Skip next line after single SKIP-ANALYSIS
|
|
166
|
+
if (skipNext) {
|
|
167
|
+
skipNext = false;
|
|
168
|
+
return '';
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return line;
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const filtered = filteredLines.join('\n');
|
|
175
|
+
|
|
176
|
+
logger.debug(
|
|
177
|
+
'file-operations - filterSkipAnalysis',
|
|
178
|
+
'Filtering complete',
|
|
179
|
+
{
|
|
180
|
+
originalLength: content.length,
|
|
181
|
+
filteredLength: filtered.length,
|
|
182
|
+
linesRemoved: lines.length - filteredLines.filter(l => l !== '').length
|
|
183
|
+
}
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
return filtered;
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Validates file extension against allowed list
|
|
191
|
+
* Why: Pre-commit hook should only analyze specific file types
|
|
192
|
+
*
|
|
193
|
+
* @param {string} filePath - Path to file
|
|
194
|
+
* @param {Array<string>} allowedExtensions - Array of extensions (e.g., ['.java', '.xml'])
|
|
195
|
+
* @returns {boolean} True if extension is allowed
|
|
196
|
+
*/
|
|
197
|
+
const hasAllowedExtension = (filePath, allowedExtensions = []) => {
|
|
198
|
+
if (allowedExtensions.length === 0) {
|
|
199
|
+
return true; // No filter, all allowed
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
203
|
+
const isAllowed = allowedExtensions.some(allowed =>
|
|
204
|
+
ext === allowed.toLowerCase()
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
logger.debug(
|
|
208
|
+
'file-operations - hasAllowedExtension',
|
|
209
|
+
'Extension check',
|
|
210
|
+
{ filePath, ext, allowedExtensions, isAllowed }
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
return isAllowed;
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Filters files by size and extension
|
|
218
|
+
* Why: Batch validation of files before processing
|
|
219
|
+
*
|
|
220
|
+
* @param {Array<string>} files - Array of file paths
|
|
221
|
+
* @param {Object} options - Filter options
|
|
222
|
+
* @param {number} options.maxSize - Max file size in bytes
|
|
223
|
+
* @param {Array<string>} options.extensions - Allowed extensions
|
|
224
|
+
* @returns {Promise<Array<Object>>} Filtered files with metadata
|
|
225
|
+
*
|
|
226
|
+
* Returned object structure:
|
|
227
|
+
* [
|
|
228
|
+
* {
|
|
229
|
+
* path: string, // File path
|
|
230
|
+
* size: number, // Size in bytes
|
|
231
|
+
* valid: boolean, // Whether file passes filters
|
|
232
|
+
* reason: string // Why file was filtered (if valid=false)
|
|
233
|
+
* }
|
|
234
|
+
* ]
|
|
235
|
+
*/
|
|
236
|
+
const filterFiles = async (files, { maxSize = 100000, extensions = [] } = {}) => {
|
|
237
|
+
logger.debug(
|
|
238
|
+
'file-operations - filterFiles',
|
|
239
|
+
'Filtering files',
|
|
240
|
+
{ fileCount: files.length, maxSize, extensions }
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
const results = await Promise.allSettled(
|
|
244
|
+
files.map(async (filePath) => {
|
|
245
|
+
// Check extension first (fast)
|
|
246
|
+
if (!hasAllowedExtension(filePath, extensions)) {
|
|
247
|
+
return {
|
|
248
|
+
path: filePath,
|
|
249
|
+
size: 0,
|
|
250
|
+
valid: false,
|
|
251
|
+
reason: 'Extension not allowed'
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Check if file exists
|
|
256
|
+
const exists = await fileExists(filePath);
|
|
257
|
+
if (!exists) {
|
|
258
|
+
return {
|
|
259
|
+
path: filePath,
|
|
260
|
+
size: 0,
|
|
261
|
+
valid: false,
|
|
262
|
+
reason: 'File not found'
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Check size
|
|
267
|
+
try {
|
|
268
|
+
const size = await getFileSize(filePath);
|
|
269
|
+
|
|
270
|
+
if (size > maxSize) {
|
|
271
|
+
return {
|
|
272
|
+
path: filePath,
|
|
273
|
+
size,
|
|
274
|
+
valid: false,
|
|
275
|
+
reason: `File too large (${size} > ${maxSize})`
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return {
|
|
280
|
+
path: filePath,
|
|
281
|
+
size,
|
|
282
|
+
valid: true,
|
|
283
|
+
reason: null
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
} catch (error) {
|
|
287
|
+
return {
|
|
288
|
+
path: filePath,
|
|
289
|
+
size: 0,
|
|
290
|
+
valid: false,
|
|
291
|
+
reason: `Error reading file: ${error.message}`
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
})
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
// Extract successful results
|
|
298
|
+
const fileMetadata = results
|
|
299
|
+
.filter(r => r.status === 'fulfilled')
|
|
300
|
+
.map(r => r.value);
|
|
301
|
+
|
|
302
|
+
const validCount = fileMetadata.filter(f => f.valid).length;
|
|
303
|
+
|
|
304
|
+
logger.debug(
|
|
305
|
+
'file-operations - filterFiles',
|
|
306
|
+
'Filtering complete',
|
|
307
|
+
{
|
|
308
|
+
totalFiles: files.length,
|
|
309
|
+
validFiles: validCount,
|
|
310
|
+
invalidFiles: fileMetadata.length - validCount
|
|
311
|
+
}
|
|
312
|
+
);
|
|
313
|
+
|
|
314
|
+
return fileMetadata;
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Reads multiple files in parallel with filtering
|
|
319
|
+
* Why: Efficiently loads file contents for analysis
|
|
320
|
+
*
|
|
321
|
+
* @param {Array<string>} files - Array of file paths
|
|
322
|
+
* @param {Object} options - Read options
|
|
323
|
+
* @param {number} options.maxSize - Max file size
|
|
324
|
+
* @param {boolean} options.applySkipFilter - Apply SKIP-ANALYSIS filtering
|
|
325
|
+
* @returns {Promise<Array<Object>>} Files with content
|
|
326
|
+
*
|
|
327
|
+
* Returned object structure:
|
|
328
|
+
* [
|
|
329
|
+
* {
|
|
330
|
+
* path: string, // File path
|
|
331
|
+
* content: string, // File content (filtered if requested)
|
|
332
|
+
* size: number, // Original size in bytes
|
|
333
|
+
* error: Error // Error if read failed
|
|
334
|
+
* }
|
|
335
|
+
* ]
|
|
336
|
+
*/
|
|
337
|
+
const readMultipleFiles = async (files, { maxSize = 100000, applySkipFilter = true } = {}) => {
|
|
338
|
+
logger.debug(
|
|
339
|
+
'file-operations - readMultipleFiles',
|
|
340
|
+
'Reading multiple files',
|
|
341
|
+
{ fileCount: files.length, maxSize, applySkipFilter }
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
const results = await Promise.allSettled(
|
|
345
|
+
files.map(async (filePath) => {
|
|
346
|
+
try {
|
|
347
|
+
let content = await readFile(filePath, { maxSize });
|
|
348
|
+
|
|
349
|
+
// Apply SKIP-ANALYSIS filtering if requested
|
|
350
|
+
if (applySkipFilter) {
|
|
351
|
+
content = filterSkipAnalysis(content);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const size = await getFileSize(filePath);
|
|
355
|
+
|
|
356
|
+
return {
|
|
357
|
+
path: filePath,
|
|
358
|
+
content,
|
|
359
|
+
size,
|
|
360
|
+
error: null
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
} catch (error) {
|
|
364
|
+
logger.error(
|
|
365
|
+
'file-operations - readMultipleFiles',
|
|
366
|
+
`Failed to read file: ${filePath}`,
|
|
367
|
+
error
|
|
368
|
+
);
|
|
369
|
+
|
|
370
|
+
return {
|
|
371
|
+
path: filePath,
|
|
372
|
+
content: null,
|
|
373
|
+
size: 0,
|
|
374
|
+
error
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
})
|
|
378
|
+
);
|
|
379
|
+
|
|
380
|
+
// Extract all results (both successful and failed)
|
|
381
|
+
const fileContents = results.map(r =>
|
|
382
|
+
r.status === 'fulfilled' ? r.value : r.reason
|
|
383
|
+
);
|
|
384
|
+
|
|
385
|
+
const successCount = fileContents.filter(f => f.error === null).length;
|
|
386
|
+
|
|
387
|
+
logger.debug(
|
|
388
|
+
'file-operations - readMultipleFiles',
|
|
389
|
+
'Reading complete',
|
|
390
|
+
{
|
|
391
|
+
totalFiles: files.length,
|
|
392
|
+
successfulReads: successCount,
|
|
393
|
+
failedReads: files.length - successCount
|
|
394
|
+
}
|
|
395
|
+
);
|
|
396
|
+
|
|
397
|
+
return fileContents;
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
export {
|
|
401
|
+
FileOperationError,
|
|
402
|
+
getFileSize,
|
|
403
|
+
fileExists,
|
|
404
|
+
readFile,
|
|
405
|
+
filterSkipAnalysis,
|
|
406
|
+
hasAllowedExtension,
|
|
407
|
+
filterFiles,
|
|
408
|
+
readMultipleFiles
|
|
409
|
+
};
|