git-watchtower 1.6.1 → 1.7.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,490 @@
1
+ /**
2
+ * Standardized error classes for Git Watchtower
3
+ * Provides consistent error handling across the application
4
+ */
5
+
6
+ /**
7
+ * Base error class for Git Watchtower
8
+ * @extends Error
9
+ */
10
+ class AppError extends Error {
11
+ /**
12
+ * @param {string} message - Error message
13
+ * @param {string} [code] - Error code for programmatic handling
14
+ * @param {Object} [details] - Additional error details
15
+ */
16
+ constructor(message, code = 'APP_ERROR', details = {}) {
17
+ super(message);
18
+ this.name = 'AppError';
19
+ this.code = code;
20
+ this.details = details;
21
+ this.timestamp = new Date();
22
+
23
+ // Capture stack trace (V8 specific)
24
+ if (Error.captureStackTrace) {
25
+ Error.captureStackTrace(this, this.constructor);
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Create a user-friendly message for display
31
+ * @returns {string}
32
+ */
33
+ toUserMessage() {
34
+ return this.message;
35
+ }
36
+
37
+ /**
38
+ * Serialize error for logging
39
+ * @returns {Object}
40
+ */
41
+ toJSON() {
42
+ return {
43
+ name: this.name,
44
+ code: this.code,
45
+ message: this.message,
46
+ details: this.details,
47
+ timestamp: this.timestamp.toISOString(),
48
+ };
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Error class for Git-related operations
54
+ * @extends AppError
55
+ */
56
+ class GitError extends AppError {
57
+ /**
58
+ * @param {string} message - Error message
59
+ * @param {string} [code] - Git error code
60
+ * @param {Object} [details] - Additional details including command, stderr
61
+ */
62
+ constructor(message, code = 'GIT_ERROR', details = {}) {
63
+ super(message, code, details);
64
+ this.name = 'GitError';
65
+ this.command = details.command || null;
66
+ this.stderr = details.stderr || null;
67
+ }
68
+
69
+ /**
70
+ * Check if error is due to network issues
71
+ * @returns {boolean}
72
+ */
73
+ isNetworkError() {
74
+ const networkPatterns = [
75
+ 'Could not resolve host',
76
+ 'Connection refused',
77
+ 'Connection timed out',
78
+ 'Network is unreachable',
79
+ 'fatal: unable to access',
80
+ 'SSL certificate problem',
81
+ ];
82
+ return networkPatterns.some(
83
+ (pattern) =>
84
+ this.message.includes(pattern) ||
85
+ (this.stderr && this.stderr.includes(pattern))
86
+ );
87
+ }
88
+
89
+ /**
90
+ * Check if error is due to authentication
91
+ * @returns {boolean}
92
+ */
93
+ isAuthError() {
94
+ const authPatterns = [
95
+ 'Authentication failed',
96
+ 'Permission denied',
97
+ 'Invalid username or password',
98
+ 'could not read Username',
99
+ 'fatal: Authentication',
100
+ ];
101
+ return authPatterns.some(
102
+ (pattern) =>
103
+ this.message.includes(pattern) ||
104
+ (this.stderr && this.stderr.includes(pattern))
105
+ );
106
+ }
107
+
108
+ /**
109
+ * Check if error is due to merge conflicts
110
+ * @returns {boolean}
111
+ */
112
+ isMergeConflict() {
113
+ const conflictPatterns = [
114
+ 'CONFLICT',
115
+ 'Automatic merge failed',
116
+ 'fix conflicts',
117
+ 'Merge conflict',
118
+ ];
119
+ return conflictPatterns.some(
120
+ (pattern) =>
121
+ this.message.includes(pattern) ||
122
+ (this.stderr && this.stderr.includes(pattern))
123
+ );
124
+ }
125
+
126
+ /**
127
+ * Check if error is due to dirty working directory
128
+ * @returns {boolean}
129
+ */
130
+ isDirtyWorkingDir() {
131
+ const dirtyPatterns = [
132
+ 'Your local changes',
133
+ 'uncommitted changes',
134
+ 'Please commit your changes',
135
+ 'overwritten by checkout',
136
+ ];
137
+ return dirtyPatterns.some(
138
+ (pattern) =>
139
+ this.message.includes(pattern) ||
140
+ (this.stderr && this.stderr.includes(pattern))
141
+ );
142
+ }
143
+
144
+ toUserMessage() {
145
+ if (this.isNetworkError()) {
146
+ return 'Network error - check your connection';
147
+ }
148
+ if (this.isAuthError()) {
149
+ return 'Authentication failed - check credentials';
150
+ }
151
+ if (this.isMergeConflict()) {
152
+ return 'Merge conflict - resolve conflicts first';
153
+ }
154
+ if (this.isDirtyWorkingDir()) {
155
+ return 'Uncommitted changes - commit or stash first';
156
+ }
157
+ return this.message;
158
+ }
159
+
160
+ /**
161
+ * Create GitError from exec callback error
162
+ * @param {Error} error - Original error
163
+ * @param {string} command - Git command that was executed
164
+ * @param {string} [stderr] - Standard error output
165
+ * @returns {GitError}
166
+ */
167
+ static fromExecError(error, command, stderr = '') {
168
+ const message = stderr || error.message;
169
+ let code = 'GIT_ERROR';
170
+
171
+ // Determine specific error code
172
+ // @ts-ignore - Node.js ExecException has killed and code properties
173
+ if (error.killed) {
174
+ code = 'GIT_TIMEOUT';
175
+ // @ts-ignore - Node.js ExecException has code property
176
+ } else if (error.code === 'ENOENT') {
177
+ code = 'GIT_NOT_FOUND';
178
+ }
179
+
180
+ return new GitError(message, code, { command, stderr });
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Error class for configuration-related issues
186
+ * @extends AppError
187
+ */
188
+ class ConfigError extends AppError {
189
+ /**
190
+ * @param {string} message - Error message
191
+ * @param {string} [code] - Config error code
192
+ * @param {Object} [details] - Additional details
193
+ */
194
+ constructor(message, code = 'CONFIG_ERROR', details = {}) {
195
+ super(message, code, details);
196
+ this.name = 'ConfigError';
197
+ }
198
+
199
+ /**
200
+ * Create ConfigError for missing config
201
+ * @param {string} configPath - Path to missing config
202
+ * @returns {ConfigError}
203
+ */
204
+ static missing(configPath) {
205
+ return new ConfigError(
206
+ `Configuration file not found: ${configPath}`,
207
+ 'CONFIG_NOT_FOUND',
208
+ { path: configPath }
209
+ );
210
+ }
211
+
212
+ /**
213
+ * Create ConfigError for invalid config
214
+ * @param {string} reason - Why config is invalid
215
+ * @param {Object} [details] - Validation details
216
+ * @returns {ConfigError}
217
+ */
218
+ static invalid(reason, details = {}) {
219
+ return new ConfigError(
220
+ `Invalid configuration: ${reason}`,
221
+ 'CONFIG_INVALID',
222
+ details
223
+ );
224
+ }
225
+
226
+ /**
227
+ * Create ConfigError for parse error
228
+ * @param {Error} parseError - Original parse error
229
+ * @returns {ConfigError}
230
+ */
231
+ static parseError(parseError) {
232
+ return new ConfigError(
233
+ `Failed to parse configuration: ${parseError.message}`,
234
+ 'CONFIG_PARSE_ERROR',
235
+ { originalError: parseError.message }
236
+ );
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Error class for server-related issues
242
+ * @extends AppError
243
+ */
244
+ class ServerError extends AppError {
245
+ /**
246
+ * @param {string} message - Error message
247
+ * @param {string} [code] - Server error code
248
+ * @param {Object} [details] - Additional details
249
+ */
250
+ constructor(message, code = 'SERVER_ERROR', details = {}) {
251
+ super(message, code, details);
252
+ this.name = 'ServerError';
253
+ }
254
+
255
+ /**
256
+ * Create ServerError for port in use
257
+ * @param {number} port - The port that's in use
258
+ * @returns {ServerError}
259
+ */
260
+ static portInUse(port) {
261
+ return new ServerError(
262
+ `Port ${port} is already in use`,
263
+ 'PORT_IN_USE',
264
+ { port }
265
+ );
266
+ }
267
+
268
+ /**
269
+ * Create ServerError for process crash
270
+ * @param {string} command - The command that crashed
271
+ * @param {number} exitCode - Exit code
272
+ * @returns {ServerError}
273
+ */
274
+ static processCrashed(command, exitCode) {
275
+ return new ServerError(
276
+ `Server process crashed with exit code ${exitCode}`,
277
+ 'PROCESS_CRASHED',
278
+ { command, exitCode }
279
+ );
280
+ }
281
+
282
+ /**
283
+ * Create ServerError for start failure
284
+ * @param {string} command - The command that failed to start
285
+ * @param {string} reason - Failure reason
286
+ * @returns {ServerError}
287
+ */
288
+ static startFailed(command, reason) {
289
+ return new ServerError(
290
+ `Failed to start server: ${reason}`,
291
+ 'START_FAILED',
292
+ { command, reason }
293
+ );
294
+ }
295
+ }
296
+
297
+ /**
298
+ * Error class for validation errors
299
+ * @extends AppError
300
+ */
301
+ class ValidationError extends AppError {
302
+ /**
303
+ * @param {string} message - Error message
304
+ * @param {string} field - Field that failed validation
305
+ * @param {*} value - Invalid value
306
+ */
307
+ constructor(message, field, value) {
308
+ super(message, 'VALIDATION_ERROR', { field, value });
309
+ this.name = 'ValidationError';
310
+ this.field = field;
311
+ this.value = value;
312
+ }
313
+
314
+ /**
315
+ * Create ValidationError for invalid branch name
316
+ * @param {string} name - Invalid branch name
317
+ * @returns {ValidationError}
318
+ */
319
+ static invalidBranchName(name) {
320
+ return new ValidationError(
321
+ `Invalid branch name: "${name}"`,
322
+ 'branchName',
323
+ name
324
+ );
325
+ }
326
+
327
+ /**
328
+ * Create ValidationError for invalid port
329
+ * @param {*} port - Invalid port value
330
+ * @returns {ValidationError}
331
+ */
332
+ static invalidPort(port) {
333
+ return new ValidationError(
334
+ `Invalid port: ${port}. Must be a number between 1 and 65535`,
335
+ 'port',
336
+ port
337
+ );
338
+ }
339
+ }
340
+
341
+ /**
342
+ * Standardized error handler
343
+ * Provides consistent error handling across the application
344
+ */
345
+ class ErrorHandler {
346
+ /**
347
+ * @param {Object} [options]
348
+ * @param {boolean} [options.debug=false] - Enable debug logging
349
+ * @param {Function} [options.onError] - Callback for all errors
350
+ */
351
+ constructor(options = {}) {
352
+ this.debug = options.debug || process.env.DEBUG === 'true';
353
+ this.onError = options.onError || null;
354
+ }
355
+
356
+ /**
357
+ * Handle an error and return a user-friendly message
358
+ * @param {Error} error - The error to handle
359
+ * @param {string} context - Context where error occurred
360
+ * @returns {{ message: string, severity: 'error' | 'warning' | 'info' }}
361
+ */
362
+ handle(error, context = 'unknown') {
363
+ // Log in debug mode
364
+ if (this.debug) {
365
+ console.error(`[${context}]`, error);
366
+ }
367
+
368
+ // Call error callback if provided
369
+ if (this.onError) {
370
+ this.onError(error, context);
371
+ }
372
+
373
+ // Determine severity and message
374
+ if (error instanceof AppError) {
375
+ return {
376
+ message: error.toUserMessage(),
377
+ severity: this.getSeverity(error),
378
+ };
379
+ }
380
+
381
+ // Handle standard errors
382
+ return {
383
+ message: error.message || 'An unexpected error occurred',
384
+ severity: 'error',
385
+ };
386
+ }
387
+
388
+ /**
389
+ * Determine error severity
390
+ * @param {AppError} error
391
+ * @returns {'error' | 'warning' | 'info'}
392
+ */
393
+ getSeverity(error) {
394
+ // Network errors are warnings (transient)
395
+ if (error instanceof GitError && error.isNetworkError()) {
396
+ return 'warning';
397
+ }
398
+
399
+ // Config errors are usually user-fixable
400
+ if (error instanceof ConfigError) {
401
+ return 'warning';
402
+ }
403
+
404
+ return 'error';
405
+ }
406
+
407
+ /**
408
+ * Check if error is retryable
409
+ * @param {Error} error
410
+ * @returns {boolean}
411
+ */
412
+ isRetryable(error) {
413
+ if (error instanceof GitError) {
414
+ return error.isNetworkError();
415
+ }
416
+ return false;
417
+ }
418
+ }
419
+
420
+ // ============================================================================
421
+ // Standalone error classifiers (work on raw error message strings)
422
+ // ============================================================================
423
+
424
+ /**
425
+ * Check if an error message indicates an authentication failure
426
+ * @param {string} errorMessage - Error message to check
427
+ * @returns {boolean}
428
+ */
429
+ function isAuthError(errorMessage) {
430
+ const authErrors = [
431
+ 'Authentication failed',
432
+ 'could not read Username',
433
+ 'could not read Password',
434
+ 'Permission denied',
435
+ 'invalid credentials',
436
+ 'authorization failed',
437
+ 'fatal: Authentication',
438
+ 'HTTP 401',
439
+ 'HTTP 403',
440
+ ];
441
+ const msg = (errorMessage || '').toLowerCase();
442
+ return authErrors.some(err => msg.includes(err.toLowerCase()));
443
+ }
444
+
445
+ /**
446
+ * Check if an error message indicates a merge conflict
447
+ * @param {string} errorMessage - Error message to check
448
+ * @returns {boolean}
449
+ */
450
+ function isMergeConflict(errorMessage) {
451
+ const conflictIndicators = [
452
+ 'CONFLICT',
453
+ 'Automatic merge failed',
454
+ 'fix conflicts',
455
+ 'Merge conflict',
456
+ ];
457
+ return conflictIndicators.some(ind => (errorMessage || '').includes(ind));
458
+ }
459
+
460
+ /**
461
+ * Check if an error message indicates a network error
462
+ * @param {string} errorMessage - Error message to check
463
+ * @returns {boolean}
464
+ */
465
+ function isNetworkError(errorMessage) {
466
+ const networkErrors = [
467
+ 'Could not resolve host',
468
+ 'unable to access',
469
+ 'Connection refused',
470
+ 'Network is unreachable',
471
+ 'Connection timed out',
472
+ 'Failed to connect',
473
+ 'no route to host',
474
+ 'Temporary failure in name resolution',
475
+ ];
476
+ const msg = (errorMessage || '').toLowerCase();
477
+ return networkErrors.some(err => msg.includes(err.toLowerCase()));
478
+ }
479
+
480
+ module.exports = {
481
+ AppError,
482
+ GitError,
483
+ ConfigError,
484
+ ServerError,
485
+ ValidationError,
486
+ ErrorHandler,
487
+ isAuthError,
488
+ isMergeConflict,
489
+ isNetworkError,
490
+ };
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Gitignore pattern parsing and file filtering utilities
3
+ * Used by the file watcher to ignore .git directory and .gitignore patterns
4
+ */
5
+
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+
9
+ /**
10
+ * Convert a gitignore pattern to a RegExp
11
+ * Supports basic gitignore syntax: *, **, ?, negation (!), directory markers (/)
12
+ * @param {string} pattern - The gitignore pattern
13
+ * @returns {RegExp|null} - The compiled regex or null if pattern is invalid/negation
14
+ */
15
+ function gitignorePatternToRegex(pattern) {
16
+ // Handle negation (we'll filter these out separately)
17
+ if (pattern.startsWith('!')) {
18
+ return null;
19
+ }
20
+
21
+ // Handle directory-only patterns (ending with /)
22
+ const dirOnly = pattern.endsWith('/');
23
+ if (dirOnly) {
24
+ pattern = pattern.slice(0, -1);
25
+ }
26
+
27
+ // Handle patterns starting with / (anchored to root)
28
+ const anchored = pattern.startsWith('/');
29
+ if (anchored) {
30
+ pattern = pattern.slice(1);
31
+ }
32
+
33
+ // Escape special regex characters except * and ?
34
+ let regexStr = pattern
35
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
36
+ // Convert **/ to a placeholder (matches any path prefix including empty)
37
+ .replace(/\*\*\//g, '{{GLOBSTAR_SLASH}}')
38
+ // Convert ** to a placeholder (matches any path)
39
+ .replace(/\*\*/g, '{{GLOBSTAR}}')
40
+ // Convert * to match anything except /
41
+ .replace(/\*/g, '[^/]*')
42
+ // Convert ? to match single character except /
43
+ .replace(/\?/g, '[^/]')
44
+ // Convert globstar placeholders back
45
+ // {{GLOBSTAR_SLASH}} matches "any/path/" or empty string
46
+ .replace(/\{\{GLOBSTAR_SLASH\}\}/g, '(.*\\/)?')
47
+ // {{GLOBSTAR}} matches any path including slashes
48
+ .replace(/\{\{GLOBSTAR\}\}/g, '.*');
49
+
50
+ // Build the final regex
51
+ if (anchored) {
52
+ regexStr = '^' + regexStr;
53
+ } else {
54
+ // Match anywhere in path
55
+ regexStr = '(^|/)' + regexStr;
56
+ }
57
+
58
+ if (dirOnly) {
59
+ regexStr = regexStr + '(/|$)';
60
+ } else {
61
+ regexStr = regexStr + '($|/)';
62
+ }
63
+
64
+ try {
65
+ return new RegExp(regexStr);
66
+ } catch (e) {
67
+ return null;
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Parse a .gitignore file and return an array of compiled regex patterns
73
+ * @param {string} gitignorePath - Path to the .gitignore file
74
+ * @returns {RegExp[]} - Array of compiled regex patterns
75
+ */
76
+ function parseGitignoreFile(gitignorePath) {
77
+ const patterns = [];
78
+
79
+ if (!fs.existsSync(gitignorePath)) {
80
+ return patterns;
81
+ }
82
+
83
+ try {
84
+ const content = fs.readFileSync(gitignorePath, 'utf8');
85
+ const lines = content.split('\n');
86
+
87
+ for (const line of lines) {
88
+ const trimmed = line.trim();
89
+ // Skip empty lines and comments
90
+ if (!trimmed || trimmed.startsWith('#')) {
91
+ continue;
92
+ }
93
+
94
+ const regex = gitignorePatternToRegex(trimmed);
95
+ if (regex) {
96
+ patterns.push(regex);
97
+ }
98
+ }
99
+ } catch (err) {
100
+ // Silently continue if we can't read .gitignore
101
+ }
102
+
103
+ return patterns;
104
+ }
105
+
106
+ /**
107
+ * Load gitignore patterns from multiple possible locations
108
+ * @param {string[]} searchPaths - Array of directories to search for .gitignore
109
+ * @returns {RegExp[]} - Array of compiled regex patterns
110
+ */
111
+ function loadGitignorePatterns(searchPaths) {
112
+ for (const searchPath of searchPaths) {
113
+ const gitignorePath = path.join(searchPath, '.gitignore');
114
+ const patterns = parseGitignoreFile(gitignorePath);
115
+ if (patterns.length > 0) {
116
+ return patterns;
117
+ }
118
+ }
119
+ return [];
120
+ }
121
+
122
+ /**
123
+ * Check if a filename matches the .git directory
124
+ * @param {string} filename - The filename to check
125
+ * @returns {boolean} - True if the file is in the .git directory
126
+ */
127
+ function isGitDirectory(filename) {
128
+ if (filename === '.git' || filename.startsWith('.git/') || filename.startsWith('.git\\')) {
129
+ return true;
130
+ }
131
+
132
+ // Normalize path separators for cross-platform support
133
+ const normalizedPath = filename.replace(/\\/g, '/');
134
+
135
+ // Check if path contains .git directory anywhere
136
+ if (normalizedPath.includes('/.git/') || normalizedPath.includes('/.git')) {
137
+ return true;
138
+ }
139
+
140
+ return false;
141
+ }
142
+
143
+ /**
144
+ * Check if a file path should be ignored by the file watcher
145
+ * @param {string} filename - The filename to check
146
+ * @param {RegExp[]} ignorePatterns - Array of compiled gitignore patterns
147
+ * @returns {boolean} - True if the file should be ignored
148
+ */
149
+ function shouldIgnoreFile(filename, ignorePatterns = []) {
150
+ // Always ignore .git directory
151
+ if (isGitDirectory(filename)) {
152
+ return true;
153
+ }
154
+
155
+ // Normalize path separators for cross-platform support
156
+ const normalizedPath = filename.replace(/\\/g, '/');
157
+
158
+ // Check against gitignore patterns
159
+ for (const pattern of ignorePatterns) {
160
+ if (pattern.test(normalizedPath)) {
161
+ return true;
162
+ }
163
+ }
164
+
165
+ return false;
166
+ }
167
+
168
+ module.exports = {
169
+ gitignorePatternToRegex,
170
+ parseGitignoreFile,
171
+ loadGitignorePatterns,
172
+ isGitDirectory,
173
+ shouldIgnoreFile,
174
+ };
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Cross-platform system sound playback
3
+ * @module utils/sound
4
+ */
5
+
6
+ const { exec } = require('child_process');
7
+
8
+ /**
9
+ * Play a system notification sound (non-blocking).
10
+ * Cross-platform: macOS (afplay), Linux (paplay/aplay), Windows (terminal bell).
11
+ * @param {object} [options]
12
+ * @param {string} [options.cwd] - Working directory for exec
13
+ */
14
+ function playSound(options = {}) {
15
+ const { platform } = process;
16
+ const cwd = options.cwd || process.cwd();
17
+
18
+ if (platform === 'darwin') {
19
+ exec('afplay /System/Library/Sounds/Pop.aiff 2>/dev/null', { cwd });
20
+ } else if (platform === 'linux') {
21
+ exec(
22
+ 'paplay /usr/share/sounds/freedesktop/stereo/message-new-instant.oga 2>/dev/null || ' +
23
+ 'paplay /usr/share/sounds/freedesktop/stereo/complete.oga 2>/dev/null || ' +
24
+ 'aplay /usr/share/sounds/sound-icons/prompt.wav 2>/dev/null || ' +
25
+ 'printf "\\a"',
26
+ { cwd }
27
+ );
28
+ } else {
29
+ process.stdout.write('\x07');
30
+ }
31
+ }
32
+
33
+ module.exports = { playSound };