claude-git-hooks 2.13.0 → 2.14.4

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,474 @@
1
+ /**
2
+ * File: pr-metadata-engine.js
3
+ * Purpose: PR metadata generation engine - single source of truth for PR analysis
4
+ *
5
+ * Why this exists: Both analyze-diff and create-pr need to:
6
+ * - Extract branch context (diff, commits, files)
7
+ * - Generate PR metadata (title, description, test plan)
8
+ * - Handle large diffs through tiered reduction
9
+ *
10
+ * By extracting this logic, we:
11
+ * - Eliminate code duplication between commands
12
+ * - Provide output-format agnostic data
13
+ * - Enable timeout resilience through intelligent diff reduction
14
+ * - Ensure consistent behavior across commands
15
+ *
16
+ * Dependencies:
17
+ * - git-operations: Branch comparison, diff extraction
18
+ * - claude-client: Claude CLI execution with retry
19
+ * - prompt-builder: Template loading and variable replacement
20
+ * - logger: Debug and error logging
21
+ */
22
+
23
+ import {
24
+ getCurrentBranch,
25
+ fetchRemote,
26
+ resolveBaseBranch,
27
+ getChangedFilesBetweenRefs,
28
+ getDiffBetweenRefs,
29
+ getCommitsBetweenRefs
30
+ } from './git-operations.js';
31
+ import { executeClaudeWithRetry, extractJSON } from './claude-client.js';
32
+ import { loadPrompt } from './prompt-builder.js';
33
+ import { getConfig } from '../config.js';
34
+ import logger from './logger.js';
35
+
36
+ /**
37
+ * @typedef {Object} BranchContext
38
+ * @property {string} baseBranch - e.g., 'origin/main'
39
+ * @property {string} headRef - e.g., 'HEAD'
40
+ * @property {string} currentBranch - e.g., 'feature/add-auth'
41
+ * @property {string[]} files - Changed file paths
42
+ * @property {string} diff - Full diff (possibly truncated)
43
+ * @property {string} commits - Commit log
44
+ * @property {number} filesCount
45
+ * @property {boolean} isTruncated - True if any tier of reduction was applied
46
+ * @property {{ fullDiff: number, statOnly: number, contextLines: number }} [truncationDetails] - Present when isTruncated=true
47
+ */
48
+
49
+ /**
50
+ * @typedef {Object} PRMetadata
51
+ * @property {string} prTitle
52
+ * @property {string} prDescription
53
+ * @property {string} suggestedBranchName
54
+ * @property {string} changeType - feat, fix, refactor, etc.
55
+ * @property {boolean} breakingChanges
56
+ * @property {string} testingNotes
57
+ * @property {BranchContext} context
58
+ */
59
+
60
+ /**
61
+ * Internal defaults (not documented in config.example.json)
62
+ * Why: Convention over configuration - sensible defaults for 95% of cases
63
+ */
64
+ const DEFAULTS = {
65
+ timeout: 180000, // 3 minutes
66
+ maxDiffSize: 50000, // 50KB
67
+ };
68
+
69
+ /**
70
+ * Builds diff payload with tiered reduction strategy
71
+ * Why: Large diffs cause timeouts - reduce intelligently while preserving essential info
72
+ *
73
+ * Tiered Reduction Strategy:
74
+ * - Always included: file list + per-file stats (never truncated)
75
+ * - Tier 1: Reduce context lines from -U3 to -U1
76
+ * - Tier 2: Allocate proportional budget per file
77
+ * - Tier 3: Replace oversized files with stat-only summary
78
+ *
79
+ * @param {string} baseRef - Base reference (e.g., 'origin/main')
80
+ * @param {string} headRef - Head reference (e.g., 'HEAD')
81
+ * @param {string[]} files - Changed file paths
82
+ * @param {number} maxSize - Maximum diff size in bytes (default: 50KB)
83
+ * @returns {{ payload: string, isTruncated: boolean, truncationDetails: Object }}
84
+ */
85
+ const buildDiffPayload = (baseRef, headRef, files, maxSize = DEFAULTS.maxDiffSize) => {
86
+ logger.debug('pr-metadata-engine - buildDiffPayload', 'Building diff payload', {
87
+ baseRef,
88
+ headRef,
89
+ fileCount: files.length,
90
+ maxSize
91
+ });
92
+
93
+ // Always include file stats (never truncated)
94
+ const statsOutput = getDiffBetweenRefs(baseRef, headRef, { contextLines: 0 })
95
+ .split('\n')
96
+ .filter(line => line.match(/^\s*[\w/.-]+\s*\|\s*\d+\s*[+-]*$/))
97
+ .join('\n');
98
+
99
+ const statsSummary = `Files changed: ${files.length}\n\n${statsOutput}\n\n`;
100
+ const summarySize = statsSummary.length;
101
+
102
+ logger.debug('pr-metadata-engine - buildDiffPayload', 'File stats summary created', {
103
+ summarySize,
104
+ filesCount: files.length
105
+ });
106
+
107
+ // Tier 1: Try with reduced context (U1)
108
+ logger.debug('pr-metadata-engine - buildDiffPayload', 'Tier 1: Fetching diff with -U1 context');
109
+ const fullDiff = getDiffBetweenRefs(baseRef, headRef, { contextLines: 1 });
110
+
111
+ if (fullDiff.length + summarySize <= maxSize) {
112
+ logger.debug('pr-metadata-engine - buildDiffPayload', 'Tier 1: Diff fits within budget', {
113
+ diffSize: fullDiff.length,
114
+ totalSize: fullDiff.length + summarySize
115
+ });
116
+
117
+ return {
118
+ payload: statsSummary + fullDiff,
119
+ isTruncated: false,
120
+ truncationDetails: {
121
+ fullDiff: files.length,
122
+ statOnly: 0,
123
+ contextLines: 1
124
+ }
125
+ };
126
+ }
127
+
128
+ logger.debug('pr-metadata-engine - buildDiffPayload', 'Tier 1: Exceeded budget, applying Tier 2', {
129
+ diffSize: fullDiff.length,
130
+ maxSize
131
+ });
132
+
133
+ // Tier 2: Split diff by file and allocate proportional budget
134
+ const diffPattern = /^diff --git a\/.+ b\/.+$/gm;
135
+ const fileDiffs = [];
136
+ let lastIndex = 0;
137
+ let match;
138
+
139
+ while ((match = diffPattern.exec(fullDiff)) !== null) {
140
+ if (lastIndex > 0) {
141
+ fileDiffs.push(fullDiff.substring(lastIndex, match.index));
142
+ }
143
+ lastIndex = match.index;
144
+ }
145
+ if (lastIndex > 0) {
146
+ fileDiffs.push(fullDiff.substring(lastIndex));
147
+ }
148
+
149
+ logger.debug('pr-metadata-engine - buildDiffPayload', 'Tier 2: Split diff into per-file chunks', {
150
+ chunkCount: fileDiffs.length
151
+ });
152
+
153
+ const availableBudget = maxSize - summarySize;
154
+ const budgetPerFile = Math.floor(availableBudget / files.length);
155
+
156
+ logger.debug('pr-metadata-engine - buildDiffPayload', 'Tier 2: Allocated per-file budget', {
157
+ availableBudget,
158
+ budgetPerFile,
159
+ fileCount: files.length
160
+ });
161
+
162
+ let finalDiff = '';
163
+ let fullDiffCount = 0;
164
+ let statOnlyCount = 0;
165
+ const oversizedFiles = [];
166
+
167
+ // Apply per-file budget
168
+ for (let i = 0; i < fileDiffs.length; i++) {
169
+ const fileDiff = fileDiffs[i];
170
+
171
+ if (fileDiff.length <= budgetPerFile) {
172
+ // File fits within budget - include full diff
173
+ finalDiff += fileDiff;
174
+ fullDiffCount++;
175
+ } else {
176
+ // File exceeds budget - mark for Tier 3
177
+ oversizedFiles.push({ index: i, diff: fileDiff, size: fileDiff.length });
178
+ }
179
+ }
180
+
181
+ logger.debug('pr-metadata-engine - buildDiffPayload', 'Tier 2: Applied budget allocation', {
182
+ fullDiffCount,
183
+ oversizedCount: oversizedFiles.length
184
+ });
185
+
186
+ // Tier 3: Replace oversized files with stat-only summary
187
+ if (oversizedFiles.length > 0) {
188
+ logger.debug('pr-metadata-engine - buildDiffPayload', 'Tier 3: Replacing oversized files with stat-only');
189
+
190
+ for (const { diff } of oversizedFiles) {
191
+ // Extract file path and stats from diff header
192
+ const filePathMatch = diff.match(/^diff --git a\/(.+?) b\/(.+?)$/m);
193
+ if (filePathMatch) {
194
+ const filePath = filePathMatch[2];
195
+ // Extract stat line for this file
196
+ const statLine = statsOutput.split('\n').find(line => line.includes(filePath));
197
+ if (statLine) {
198
+ finalDiff += `\n--- ${filePath} (truncated - stat only) ---\n${statLine}\n`;
199
+ statOnlyCount++;
200
+ }
201
+ }
202
+ }
203
+
204
+ logger.debug('pr-metadata-engine - buildDiffPayload', 'Tier 3: Stat-only demotion complete', {
205
+ statOnlyCount
206
+ });
207
+ }
208
+
209
+ const payload = statsSummary + finalDiff;
210
+ const isTruncated = statOnlyCount > 0 || fullDiff.length > maxSize;
211
+
212
+ logger.debug('pr-metadata-engine - buildDiffPayload', 'Diff payload built', {
213
+ totalSize: payload.length,
214
+ isTruncated,
215
+ fullDiffCount,
216
+ statOnlyCount
217
+ });
218
+
219
+ return {
220
+ payload,
221
+ isTruncated,
222
+ truncationDetails: {
223
+ fullDiff: fullDiffCount,
224
+ statOnly: statOnlyCount,
225
+ contextLines: 1
226
+ }
227
+ };
228
+ };
229
+
230
+ /**
231
+ * Gets branch context for PR creation
232
+ * Why: Extracts all git information needed for PR metadata generation
233
+ *
234
+ * @param {string} [targetBranch] - Target base branch (optional)
235
+ * @returns {Promise<{ success: boolean, context?: BranchContext, error?: string }>}
236
+ */
237
+ export const getBranchContext = async (targetBranch) => {
238
+ logger.debug('pr-metadata-engine - getBranchContext', 'Getting branch context', { targetBranch });
239
+
240
+ try {
241
+ // Get current branch
242
+ const currentBranch = getCurrentBranch();
243
+
244
+ if (!currentBranch || currentBranch === 'unknown') {
245
+ return {
246
+ success: false,
247
+ error: 'Not on a valid branch'
248
+ };
249
+ }
250
+
251
+ logger.debug('pr-metadata-engine - getBranchContext', 'Current branch identified', { currentBranch });
252
+
253
+ // Fetch remote references
254
+ try {
255
+ fetchRemote();
256
+ logger.debug('pr-metadata-engine - getBranchContext', 'Remote fetched successfully');
257
+ } catch (fetchError) {
258
+ logger.warning('pr-metadata-engine - getBranchContext', 'Failed to fetch remote', fetchError);
259
+ // Continue anyway - may work with cached remote refs
260
+ }
261
+
262
+ // Resolve base branch (throws with suggestions if not found)
263
+ const target = targetBranch || currentBranch;
264
+ const baseBranch = resolveBaseBranch(target);
265
+
266
+ logger.debug('pr-metadata-engine - getBranchContext', 'Base branch resolved', {
267
+ requestedBranch: target,
268
+ baseBranch
269
+ });
270
+
271
+ // Get changed files
272
+ const files = getChangedFilesBetweenRefs(baseBranch, 'HEAD');
273
+
274
+ if (files.length === 0) {
275
+ return {
276
+ success: false,
277
+ error: `No differences found between '${baseBranch}' and HEAD`
278
+ };
279
+ }
280
+
281
+ logger.debug('pr-metadata-engine - getBranchContext', 'Changed files retrieved', {
282
+ fileCount: files.length
283
+ });
284
+
285
+ // Get commits
286
+ const commits = getCommitsBetweenRefs(baseBranch, 'HEAD', { format: 'oneline' });
287
+
288
+ logger.debug('pr-metadata-engine - getBranchContext', 'Commits retrieved');
289
+
290
+ // Build diff payload with tiered reduction
291
+ const { payload: diff, isTruncated, truncationDetails } = buildDiffPayload(
292
+ baseBranch,
293
+ 'HEAD',
294
+ files,
295
+ DEFAULTS.maxDiffSize
296
+ );
297
+
298
+ logger.debug('pr-metadata-engine - getBranchContext', 'Diff payload built', {
299
+ diffSize: diff.length,
300
+ isTruncated
301
+ });
302
+
303
+ const context = {
304
+ baseBranch,
305
+ headRef: 'HEAD',
306
+ currentBranch,
307
+ files,
308
+ diff,
309
+ commits,
310
+ filesCount: files.length,
311
+ isTruncated,
312
+ ...(isTruncated && { truncationDetails })
313
+ };
314
+
315
+ logger.debug('pr-metadata-engine - getBranchContext', 'Branch context complete', {
316
+ filesCount: context.filesCount,
317
+ isTruncated: context.isTruncated
318
+ });
319
+
320
+ return {
321
+ success: true,
322
+ context
323
+ };
324
+
325
+ } catch (error) {
326
+ logger.error('pr-metadata-engine - getBranchContext', 'Failed to get branch context', error);
327
+
328
+ return {
329
+ success: false,
330
+ error: error.message || 'Failed to get branch context'
331
+ };
332
+ }
333
+ };
334
+
335
+ /**
336
+ * Generates PR metadata from branch context
337
+ * Why: Sends context to Claude and parses structured PR information
338
+ *
339
+ * @param {BranchContext} context - Branch context from getBranchContext
340
+ * @param {Object} [options] - Generation options
341
+ * @param {number} [options.timeout] - Claude timeout in ms (default: 180000)
342
+ * @param {string} [options.hook] - Hook name for telemetry (default: 'pr-metadata')
343
+ * @returns {Promise<PRMetadata>}
344
+ * @throws {Error} If metadata generation fails
345
+ */
346
+ export const generatePRMetadata = async (context, options = {}) => {
347
+ const config = await getConfig();
348
+ const configTimeout = config.analysis?.timeout;
349
+ const { timeout = configTimeout || DEFAULTS.timeout, hook = 'pr-metadata' } = options;
350
+
351
+ logger.debug('pr-metadata-engine - generatePRMetadata', 'Generating PR metadata', {
352
+ filesCount: context.filesCount,
353
+ timeout,
354
+ hook
355
+ });
356
+
357
+ try {
358
+ // Build context description for prompt
359
+ const contextDescription = `${context.currentBranch} vs ${context.baseBranch}`;
360
+
361
+ // Build prompt from template using existing ANALYZE_DIFF.md template
362
+ // Template variables match analyze-diff.js:148-154 for compatibility
363
+ const prompt = await loadPrompt('ANALYZE_DIFF.md', {
364
+ CONTEXT_DESCRIPTION: contextDescription,
365
+ SUBAGENT_INSTRUCTION: '', // Engine uses single Claude spawn, no subagents
366
+ COMMITS: context.commits,
367
+ DIFF_FILES: context.files.join('\n'),
368
+ FULL_DIFF: context.diff
369
+ });
370
+
371
+ logger.debug('pr-metadata-engine - generatePRMetadata', 'Prompt built', {
372
+ promptLength: prompt.length
373
+ });
374
+
375
+ // Prepare telemetry context
376
+ const telemetryContext = {
377
+ fileCount: context.filesCount,
378
+ batchSize: context.filesCount,
379
+ totalBatches: 1,
380
+ model: 'sonnet',
381
+ hook
382
+ };
383
+
384
+ logger.debug('pr-metadata-engine - generatePRMetadata', 'Calling Claude CLI');
385
+
386
+ // Call Claude with retry logic
387
+ const response = await executeClaudeWithRetry(prompt, {
388
+ timeout,
389
+ telemetryContext
390
+ });
391
+
392
+ logger.debug('pr-metadata-engine - generatePRMetadata', 'Claude response received', {
393
+ responseLength: response.length
394
+ });
395
+
396
+ // Parse JSON response
397
+ const result = extractJSON(response, telemetryContext);
398
+
399
+ logger.debug('pr-metadata-engine - generatePRMetadata', 'JSON parsed successfully', {
400
+ hasPrTitle: !!result.prTitle,
401
+ hasPrDescription: !!result.prDescription
402
+ });
403
+
404
+ // Return PRMetadata with context attached
405
+ return {
406
+ prTitle: result.prTitle,
407
+ prDescription: result.prDescription,
408
+ suggestedBranchName: result.suggestedBranchName,
409
+ changeType: result.changeType,
410
+ breakingChanges: result.breakingChanges || false,
411
+ testingNotes: result.testingNotes || '',
412
+ context
413
+ };
414
+
415
+ } catch (error) {
416
+ logger.error('pr-metadata-engine - generatePRMetadata', 'Failed to generate PR metadata', error);
417
+ throw error;
418
+ }
419
+ };
420
+
421
+ /**
422
+ * Analyzes branch for PR (convenience wrapper)
423
+ * Why: Single function call for complete PR analysis workflow
424
+ *
425
+ * @param {string} [targetBranch] - Target base branch (optional)
426
+ * @param {Object} [options] - Analysis options
427
+ * @param {number} [options.timeout] - Claude timeout in ms
428
+ * @param {string} [options.hook] - Hook name for telemetry
429
+ * @returns {Promise<{ success: boolean, result?: PRMetadata, error?: string }>}
430
+ */
431
+ export const analyzeBranchForPR = async (targetBranch, options = {}) => {
432
+ logger.debug('pr-metadata-engine - analyzeBranchForPR', 'Starting PR analysis', {
433
+ targetBranch,
434
+ options
435
+ });
436
+
437
+ try {
438
+ // Step 1: Get branch context
439
+ const contextResult = await getBranchContext(targetBranch);
440
+
441
+ if (!contextResult.success) {
442
+ logger.error('pr-metadata-engine - analyzeBranchForPR', 'Failed to get branch context', {
443
+ error: contextResult.error
444
+ });
445
+
446
+ return {
447
+ success: false,
448
+ error: contextResult.error
449
+ };
450
+ }
451
+
452
+ logger.debug('pr-metadata-engine - analyzeBranchForPR', 'Branch context retrieved', {
453
+ filesCount: contextResult.context.filesCount
454
+ });
455
+
456
+ // Step 2: Generate PR metadata
457
+ const metadata = await generatePRMetadata(contextResult.context, options);
458
+
459
+ logger.debug('pr-metadata-engine - analyzeBranchForPR', 'PR metadata generated successfully');
460
+
461
+ return {
462
+ success: true,
463
+ result: metadata
464
+ };
465
+
466
+ } catch (error) {
467
+ logger.error('pr-metadata-engine - analyzeBranchForPR', 'PR analysis failed', error);
468
+
469
+ return {
470
+ success: false,
471
+ error: error.message || 'Failed to analyze branch for PR'
472
+ };
473
+ }
474
+ };
package/package.json CHANGED
@@ -1,60 +1,60 @@
1
- {
2
- "name": "claude-git-hooks",
3
- "version": "2.13.0",
4
- "description": "Git hooks with Claude CLI for code analysis and automatic commit messages",
5
- "type": "module",
6
- "bin": {
7
- "claude-hooks": "./bin/claude-hooks"
8
- },
9
- "scripts": {
10
- "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
11
- "test:watch": "node --experimental-vm-modules node_modules/jest/bin/jest.js --watch",
12
- "test:coverage": "node --experimental-vm-modules node_modules/jest/bin/jest.js --coverage",
13
- "lint": "eslint lib/ bin/",
14
- "lint:fix": "eslint lib/ bin/ --fix",
15
- "format": "prettier --write \"lib/**/*.js\" \"bin/**\""
16
- },
17
- "keywords": [
18
- "git",
19
- "hooks",
20
- "claude",
21
- "ai",
22
- "code-review",
23
- "commit-messages",
24
- "pre-commit",
25
- "automation"
26
- ],
27
- "author": "Pablo Rovito",
28
- "license": "MIT",
29
- "repository": {
30
- "type": "git",
31
- "url": "https://github.com/pablorovito/claude-git-hooks.git"
32
- },
33
- "engines": {
34
- "node": ">=16.9.0"
35
- },
36
- "engineStrict": false,
37
- "os": [
38
- "darwin",
39
- "linux",
40
- "win32"
41
- ],
42
- "preferGlobal": true,
43
- "files": [
44
- "bin/",
45
- "lib/",
46
- "templates/",
47
- "README.md",
48
- "CHANGELOG.md",
49
- "LICENSE"
50
- ],
51
- "dependencies": {
52
- "@octokit/rest": "^21.0.0"
53
- },
54
- "devDependencies": {
55
- "@types/jest": "^29.5.0",
56
- "eslint": "^8.57.0",
57
- "jest": "^29.7.0",
58
- "prettier": "^3.2.0"
59
- }
60
- }
1
+ {
2
+ "name": "claude-git-hooks",
3
+ "version": "2.14.4",
4
+ "description": "Git hooks with Claude CLI for code analysis and automatic commit messages",
5
+ "type": "module",
6
+ "bin": {
7
+ "claude-hooks": "./bin/claude-hooks"
8
+ },
9
+ "scripts": {
10
+ "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
11
+ "test:watch": "node --experimental-vm-modules node_modules/jest/bin/jest.js --watch",
12
+ "test:coverage": "node --experimental-vm-modules node_modules/jest/bin/jest.js --coverage",
13
+ "lint": "eslint lib/ bin/",
14
+ "lint:fix": "eslint lib/ bin/ --fix",
15
+ "format": "prettier --write \"lib/**/*.js\" \"bin/**\""
16
+ },
17
+ "keywords": [
18
+ "git",
19
+ "hooks",
20
+ "claude",
21
+ "ai",
22
+ "code-review",
23
+ "commit-messages",
24
+ "pre-commit",
25
+ "automation"
26
+ ],
27
+ "author": "Pablo Rovito",
28
+ "license": "MIT",
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "https://github.com/pablorovito/claude-git-hooks.git"
32
+ },
33
+ "engines": {
34
+ "node": ">=16.9.0"
35
+ },
36
+ "engineStrict": false,
37
+ "os": [
38
+ "darwin",
39
+ "linux",
40
+ "win32"
41
+ ],
42
+ "preferGlobal": true,
43
+ "files": [
44
+ "bin/",
45
+ "lib/",
46
+ "templates/",
47
+ "README.md",
48
+ "CHANGELOG.md",
49
+ "LICENSE"
50
+ ],
51
+ "dependencies": {
52
+ "@octokit/rest": "^21.0.0"
53
+ },
54
+ "devDependencies": {
55
+ "@types/jest": "^29.5.0",
56
+ "eslint": "^8.57.0",
57
+ "jest": "^29.7.0",
58
+ "prettier": "^3.2.0"
59
+ }
60
+ }