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.
@@ -0,0 +1,341 @@
1
+ /**
2
+ * File: git-operations.js
3
+ * Purpose: Provides abstraction layer for git commands
4
+ *
5
+ * Key responsibilities:
6
+ * - Execute git commands safely with error handling
7
+ * - Provide cross-platform git operations
8
+ * - Abstract git complexity from business logic
9
+ *
10
+ * Dependencies:
11
+ * - child_process: For executing git commands
12
+ * - logger: For debug and error logging
13
+ */
14
+
15
+ import { execSync } from 'child_process';
16
+ import path from 'path';
17
+ import logger from './logger.js';
18
+
19
+ /**
20
+ * Custom error for git operation failures
21
+ * Why: Provides structured error handling with git-specific context
22
+ */
23
+ class GitError extends Error {
24
+ constructor(message, { command, cause, output } = {}) {
25
+ super(message);
26
+ this.name = 'GitError';
27
+ this.command = command;
28
+ this.cause = cause;
29
+ this.output = output;
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Executes a git command safely with error handling
35
+ * Why: Centralizes git command execution with consistent error handling
36
+ * and logging across all git operations
37
+ *
38
+ * @param {string} command - Git command to execute (without 'git' prefix)
39
+ * @param {Object} options - Options for execSync
40
+ * @returns {string} Command output (trimmed)
41
+ * @throws {GitError} If command fails
42
+ */
43
+ const execGitCommand = (command, options = {}) => {
44
+ const fullCommand = `git ${command}`;
45
+
46
+ logger.debug(
47
+ 'git-operations - execGitCommand',
48
+ 'Executing git command',
49
+ { command: fullCommand }
50
+ );
51
+
52
+ try {
53
+ const output = execSync(fullCommand, {
54
+ encoding: 'utf8',
55
+ stdio: ['pipe', 'pipe', 'pipe'], // Capture stderr
56
+ ...options
57
+ });
58
+
59
+ logger.debug(
60
+ 'git-operations - execGitCommand',
61
+ 'Command executed successfully',
62
+ { command: fullCommand, outputLength: output.length }
63
+ );
64
+
65
+ return output.trim();
66
+
67
+ } catch (error) {
68
+ logger.error(
69
+ 'git-operations - execGitCommand',
70
+ `Git command failed: ${fullCommand}`,
71
+ error
72
+ );
73
+
74
+ throw new GitError('Git command failed', {
75
+ command: fullCommand,
76
+ cause: error,
77
+ output: error.stderr || error.stdout
78
+ });
79
+ }
80
+ };
81
+
82
+ /**
83
+ * Gets list of staged files
84
+ * Why: Pre-commit hooks need to analyze only staged changes, not all working tree
85
+ *
86
+ * @param {Object} options - Filter options
87
+ * @param {Array<string>} options.extensions - File extensions to filter (e.g., ['.java', '.xml'])
88
+ * @param {boolean} options.includeDeleted - Include deleted files (default: false)
89
+ * @returns {Array<string>} Array of staged file paths
90
+ *
91
+ * Git diff filter codes:
92
+ * A = Added, C = Copied, M = Modified, R = Renamed
93
+ * D = Deleted, T = Type changed, U = Unmerged, X = Unknown
94
+ */
95
+ const getStagedFiles = ({ extensions = [], includeDeleted = false } = {}) => {
96
+ logger.debug(
97
+ 'git-operations - getStagedFiles',
98
+ 'Getting staged files',
99
+ { extensions, includeDeleted }
100
+ );
101
+
102
+ // Why: --diff-filter excludes deleted files unless explicitly requested
103
+ // ACM = Added, Copied, Modified (excludes Deleted, Renamed, etc.)
104
+ const filter = includeDeleted ? 'ACMR' : 'ACM';
105
+ const output = execGitCommand(`diff --cached --name-only --diff-filter=${filter}`);
106
+
107
+ if (!output) {
108
+ logger.debug('git-operations - getStagedFiles', 'No staged files found');
109
+ return [];
110
+ }
111
+
112
+ // Why: Split by LF or CRLF to handle Windows line endings
113
+ const files = output.split(/\r?\n/).filter(f => f.length > 0);
114
+
115
+ // Filter by extensions if provided
116
+ if (extensions.length > 0) {
117
+ const filtered = files.filter(file =>
118
+ extensions.some(ext => file.endsWith(ext))
119
+ );
120
+
121
+ logger.debug(
122
+ 'git-operations - getStagedFiles',
123
+ 'Filtered files by extension',
124
+ { totalFiles: files.length, filteredFiles: filtered.length, extensions }
125
+ );
126
+
127
+ return filtered;
128
+ }
129
+
130
+ return files;
131
+ };
132
+
133
+ /**
134
+ * Gets the diff for a specific file
135
+ * Why: Shows what changed in a file, essential for code review
136
+ *
137
+ * @param {string} filePath - Path to the file
138
+ * @param {Object} options - Diff options
139
+ * @param {boolean} options.cached - Get staged changes (default: true)
140
+ * @param {number} options.context - Lines of context around changes (default: 3)
141
+ * @returns {string} Diff output
142
+ */
143
+ const getFileDiff = (filePath, { cached = true, context = 3 } = {}) => {
144
+ logger.debug(
145
+ 'git-operations - getFileDiff',
146
+ 'Getting file diff',
147
+ { filePath, cached, context }
148
+ );
149
+
150
+ const cachedFlag = cached ? '--cached' : '';
151
+ const contextFlag = `-U${context}`;
152
+
153
+ try {
154
+ return execGitCommand(`diff ${cachedFlag} ${contextFlag} -- "${filePath}"`);
155
+ } catch (error) {
156
+ // Why: Empty diff is valid (e.g., new file with no changes), don't throw error
157
+ if (error.output && error.output.includes('')) {
158
+ logger.debug('git-operations - getFileDiff', 'Empty diff', { filePath });
159
+ return '';
160
+ }
161
+ throw error;
162
+ }
163
+ };
164
+
165
+ /**
166
+ * Gets file content from git staging area
167
+ * Why: Reads the staged version of a file, not the working directory version
168
+ * This ensures we analyze what will be committed, not uncommitted changes
169
+ *
170
+ * @param {string} filePath - Path to the file
171
+ * @returns {string} File content from staging area
172
+ */
173
+ const getFileContentFromStaging = (filePath) => {
174
+ logger.debug(
175
+ 'git-operations - getFileContentFromStaging',
176
+ 'Reading file from staging area',
177
+ { filePath }
178
+ );
179
+
180
+ // Why: git show :path reads from index (staging area), not working tree
181
+ return execGitCommand(`show ":${filePath}"`);
182
+ };
183
+
184
+ /**
185
+ * Checks if a file is newly added (not in previous commits)
186
+ * Why: New files need full content analysis, not just diffs
187
+ *
188
+ * @param {string} filePath - Path to the file
189
+ * @returns {boolean} True if file is newly added
190
+ */
191
+ const isNewFile = (filePath) => {
192
+ logger.debug(
193
+ 'git-operations - isNewFile',
194
+ 'Checking if file is new',
195
+ { filePath }
196
+ );
197
+
198
+ try {
199
+ const status = execGitCommand(`diff --cached --name-status -- "${filePath}"`);
200
+ // Why: Status starts with 'A' for Added files
201
+ const isNew = status.startsWith('A\t');
202
+
203
+ logger.debug(
204
+ 'git-operations - isNewFile',
205
+ 'File status checked',
206
+ { filePath, isNew, status }
207
+ );
208
+
209
+ return isNew;
210
+
211
+ } catch (error) {
212
+ logger.error('git-operations - isNewFile', 'Failed to check file status', error);
213
+ return false;
214
+ }
215
+ };
216
+
217
+ /**
218
+ * Gets repository root directory
219
+ * Why: Needed to resolve relative paths and locate configuration files
220
+ *
221
+ * @returns {string} Absolute path to repository root
222
+ */
223
+ const getRepoRoot = () => {
224
+ logger.debug('git-operations - getRepoRoot', 'Getting repository root');
225
+
226
+ try {
227
+ return execGitCommand('rev-parse --show-toplevel');
228
+ } catch (error) {
229
+ throw new GitError('Not a git repository or no git found', { cause: error });
230
+ }
231
+ };
232
+
233
+ /**
234
+ * Gets current branch name
235
+ * Why: Used for context in prompts and logging
236
+ *
237
+ * @returns {string} Current branch name
238
+ */
239
+ const getCurrentBranch = () => {
240
+ logger.debug('git-operations - getCurrentBranch', 'Getting current branch');
241
+
242
+ try {
243
+ return execGitCommand('branch --show-current');
244
+ } catch (error) {
245
+ logger.error('git-operations - getCurrentBranch', 'Failed to get branch', error);
246
+ return 'unknown';
247
+ }
248
+ };
249
+
250
+ /**
251
+ * Gets repository name from root directory
252
+ * Why: Used for context in prompts and reports
253
+ *
254
+ * @returns {string} Repository name (last component of path)
255
+ */
256
+ const getRepoName = () => {
257
+ logger.debug('git-operations - getRepoName', 'Getting repository name');
258
+
259
+ try {
260
+ const repoRoot = getRepoRoot();
261
+ // Why: Use path.basename() for cross-platform path handling
262
+ return path.basename(repoRoot);
263
+ } catch (error) {
264
+ logger.error('git-operations - getRepoName', 'Failed to get repo name', error);
265
+ return 'unknown';
266
+ }
267
+ };
268
+
269
+ /**
270
+ * Gets statistics about staged files
271
+ * Why: Provides overview for commit message generation
272
+ *
273
+ * @returns {Object} Statistics object with file counts and changes
274
+ * Statistics structure:
275
+ * {
276
+ * totalFiles: number, // Total staged files
277
+ * addedFiles: number, // Newly added files
278
+ * modifiedFiles: number, // Modified files
279
+ * deletedFiles: number, // Deleted files
280
+ * insertions: number, // Total lines added
281
+ * deletions: number // Total lines deleted
282
+ * }
283
+ */
284
+ const getStagedStats = () => {
285
+ logger.debug('git-operations - getStagedStats', 'Getting staged file statistics');
286
+
287
+ try {
288
+ const shortstat = execGitCommand('diff --cached --shortstat');
289
+ const numstat = execGitCommand('diff --cached --numstat');
290
+ const nameStatus = execGitCommand('diff --cached --name-status');
291
+
292
+ // Parse shortstat: "X files changed, Y insertions(+), Z deletions(-)"
293
+ const statsMatch = shortstat.match(/(\d+) files? changed(?:, (\d+) insertions?\(\+\))?(?:, (\d+) deletions?\(-\))?/);
294
+ const insertions = statsMatch?.[2] ? parseInt(statsMatch[2], 10) : 0;
295
+ const deletions = statsMatch?.[3] ? parseInt(statsMatch[3], 10) : 0;
296
+
297
+ // Count file types from name-status
298
+ // Why: Split by LF or CRLF to handle Windows line endings
299
+ const statusLines = nameStatus.split(/\r?\n/).filter(l => l.length > 0);
300
+ const added = statusLines.filter(l => l.startsWith('A\t')).length;
301
+ const modified = statusLines.filter(l => l.startsWith('M\t')).length;
302
+ const deleted = statusLines.filter(l => l.startsWith('D\t')).length;
303
+
304
+ const stats = {
305
+ totalFiles: statusLines.length,
306
+ addedFiles: added,
307
+ modifiedFiles: modified,
308
+ deletedFiles: deleted,
309
+ insertions,
310
+ deletions
311
+ };
312
+
313
+ logger.debug('git-operations - getStagedStats', 'Statistics calculated', stats);
314
+
315
+ return stats;
316
+
317
+ } catch (error) {
318
+ logger.error('git-operations - getStagedStats', 'Failed to get statistics', error);
319
+ // Return empty stats on error
320
+ return {
321
+ totalFiles: 0,
322
+ addedFiles: 0,
323
+ modifiedFiles: 0,
324
+ deletedFiles: 0,
325
+ insertions: 0,
326
+ deletions: 0
327
+ };
328
+ }
329
+ };
330
+
331
+ export {
332
+ GitError,
333
+ getStagedFiles,
334
+ getFileDiff,
335
+ getFileContentFromStaging,
336
+ isNewFile,
337
+ getRepoRoot,
338
+ getCurrentBranch,
339
+ getRepoName,
340
+ getStagedStats
341
+ };
@@ -0,0 +1,141 @@
1
+ /**
2
+ * File: logger.js
3
+ * Purpose: Provides three-level logging system (user, debug, error)
4
+ *
5
+ * Key features:
6
+ * - User logs: Simple, colored messages for end users
7
+ * - Debug logs: Technical details with [file/class - method] format
8
+ * - Error logs: Full error context with [file/class - method] format
9
+ *
10
+ * Usage:
11
+ * import logger from './logger.js';
12
+ * logger.info('User message');
13
+ * logger.debug('file-name - methodName', 'Debug message', { data });
14
+ * logger.error('file-name - methodName', 'Error message', error);
15
+ */
16
+
17
+ class Logger {
18
+ constructor({ debugMode = false } = {}) {
19
+ this.debugMode = debugMode || process.env.DEBUG === 'true';
20
+ this.colors = {
21
+ reset: '\x1b[0m',
22
+ red: '\x1b[31m',
23
+ green: '\x1b[32m',
24
+ yellow: '\x1b[33m',
25
+ blue: '\x1b[34m',
26
+ gray: '\x1b[90m'
27
+ };
28
+ }
29
+
30
+ /**
31
+ * User-facing informational message (always shown)
32
+ * Why: Provides feedback about what the tool is doing
33
+ *
34
+ * @param {string} message - Simple message for end users
35
+ */
36
+ info(message) {
37
+ console.log(`${this.colors.blue}ℹ️ ${message}${this.colors.reset}`);
38
+ }
39
+
40
+ /**
41
+ * User-facing success message (always shown)
42
+ * Why: Confirms successful operations to the user
43
+ *
44
+ * @param {string} message - Success message
45
+ */
46
+ success(message) {
47
+ console.log(`${this.colors.green}✅ ${message}${this.colors.reset}`);
48
+ }
49
+
50
+ /**
51
+ * User-facing warning message (always shown)
52
+ * Why: Alerts user to non-critical issues
53
+ *
54
+ * @param {string} message - Warning message
55
+ */
56
+ warning(message) {
57
+ console.log(`${this.colors.yellow}⚠️ ${message}${this.colors.reset}`);
58
+ }
59
+
60
+ /**
61
+ * Developer-facing debug message (only in debug mode)
62
+ * Why: Provides technical details for troubleshooting without cluttering normal output
63
+ *
64
+ * Format: [DEBUG timestamp] [context] message
65
+ * Context format: "file/class - method"
66
+ *
67
+ * @param {string} context - Location identifier (e.g., "file-analyzer - analyzeFile")
68
+ * @param {string} message - Debug message
69
+ * @param {Object} data - Optional data to log as JSON
70
+ */
71
+ debug(context, message, data = {}) {
72
+ if (!this.debugMode) return;
73
+
74
+ const timestamp = new Date().toISOString();
75
+ console.log(
76
+ `${this.colors.gray}[DEBUG ${timestamp}] [${context}] ${message}${this.colors.reset}`
77
+ );
78
+
79
+ if (Object.keys(data).length > 0) {
80
+ console.log(this.colors.gray, JSON.stringify(data, null, 2), this.colors.reset);
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Error message with full context (always shown)
86
+ * Why: Provides complete error information for debugging issues
87
+ *
88
+ * Format: ❌ [context] message
89
+ * Context format: "file/class - method"
90
+ *
91
+ * @param {string} context - Location identifier (e.g., "file-analyzer - analyzeFile")
92
+ * @param {string} message - Error message
93
+ * @param {Error} error - Optional error object with stack trace and context
94
+ */
95
+ error(context, message, error = null) {
96
+ console.error(`${this.colors.red}❌ [${context}] ${message}${this.colors.reset}`);
97
+
98
+ if (error) {
99
+ console.error(`${this.colors.red}Error details:${this.colors.reset}`, error.message || error);
100
+
101
+ // Show stack trace only in debug mode to avoid overwhelming users
102
+ if (error.stack && this.debugMode) {
103
+ console.error(`${this.colors.gray}Stack trace:${this.colors.reset}`);
104
+ console.error(error.stack);
105
+ }
106
+
107
+ // Log additional context if available (custom error properties)
108
+ if (error.context) {
109
+ console.error(`${this.colors.gray}Context:${this.colors.reset}`, error.context);
110
+ }
111
+
112
+ // Log cause chain if available (Error.cause from Node.js 16.9+)
113
+ if (error.cause) {
114
+ console.error(`${this.colors.gray}Caused by:${this.colors.reset}`, error.cause);
115
+ }
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Set debug mode dynamically
121
+ * Why: Allows enabling debug mode at runtime
122
+ *
123
+ * @param {boolean} enabled - Whether to enable debug mode
124
+ */
125
+ setDebugMode(enabled) {
126
+ this.debugMode = enabled;
127
+ }
128
+
129
+ /**
130
+ * Check if debug mode is enabled
131
+ *
132
+ * @returns {boolean} True if debug mode is active
133
+ */
134
+ isDebugMode() {
135
+ return this.debugMode;
136
+ }
137
+ }
138
+
139
+ // Export singleton instance
140
+ // Why: Single logger instance ensures consistent debug mode across entire application
141
+ export default new Logger({ debugMode: process.env.DEBUG === 'true' });