git-watchtower 1.6.1 → 1.7.1

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/src/index.js ADDED
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Git Watchtower - Modular Architecture
3
+ *
4
+ * This is the main entry point for the refactored modules.
5
+ * Import what you need from this file.
6
+ *
7
+ * @example
8
+ * const { Store, createStore } = require('./src');
9
+ * const { GitError, ConfigError } = require('./src');
10
+ * const { ansi, box } = require('./src');
11
+ */
12
+
13
+ // Utilities
14
+ const asyncUtils = require('./utils/async');
15
+ const errors = require('./utils/errors');
16
+
17
+ // State management
18
+ const state = require('./state/store');
19
+
20
+ // UI components
21
+ const ui = require('./ui/ansi');
22
+ const rendererModule = require('./ui/renderer');
23
+ const actionsModule = require('./ui/actions');
24
+ const keybindingsModule = require('./ui/keybindings');
25
+
26
+ // Git operations
27
+ const gitCommands = require('./git/commands');
28
+ const gitBranch = require('./git/branch');
29
+
30
+ // Configuration
31
+ const configSchema = require('./config/schema');
32
+ const configLoader = require('./config/loader');
33
+
34
+ // Server management
35
+ const serverProcess = require('./server/process');
36
+
37
+ // Telemetry
38
+ const telemetryModule = require('./telemetry');
39
+
40
+ // CLI and utilities
41
+ const cliArgs = require('./cli/args');
42
+ const timeUtils = require('./utils/time');
43
+ const browserUtils = require('./utils/browser');
44
+ const soundUtils = require('./utils/sound');
45
+ const gitRemote = require('./git/remote');
46
+
47
+ module.exports = {
48
+ // Async utilities
49
+ Mutex: asyncUtils.Mutex,
50
+ withTimeout: asyncUtils.withTimeout,
51
+ retry: asyncUtils.retry,
52
+ sleep: asyncUtils.sleep,
53
+ debounce: asyncUtils.debounce,
54
+ throttle: asyncUtils.throttle,
55
+
56
+ // Error classes
57
+ AppError: errors.AppError,
58
+ GitError: errors.GitError,
59
+ ConfigError: errors.ConfigError,
60
+ ServerError: errors.ServerError,
61
+ ValidationError: errors.ValidationError,
62
+ ErrorHandler: errors.ErrorHandler,
63
+ isAuthError: errors.isAuthError,
64
+ isMergeConflict: errors.isMergeConflict,
65
+ isNetworkError: errors.isNetworkError,
66
+
67
+ // State management
68
+ Store: state.Store,
69
+ createStore: state.createStore,
70
+ getInitialState: state.getInitialState,
71
+
72
+ // UI utilities
73
+ ansi: ui.ansi,
74
+ box: ui.box,
75
+ sparkline: ui.sparkline,
76
+ indicators: ui.indicators,
77
+ stripAnsi: ui.stripAnsi,
78
+ visibleLength: ui.visibleLength,
79
+ truncate: ui.truncate,
80
+ pad: ui.pad,
81
+ padRight: ui.padRight,
82
+ padLeft: ui.padLeft,
83
+ getMaxBranchesForScreen: ui.getMaxBranchesForScreen,
84
+ drawBox: ui.drawBox,
85
+ clearArea: ui.clearArea,
86
+ wordWrap: ui.wordWrap,
87
+ horizontalLine: ui.horizontalLine,
88
+ style: ui.style,
89
+
90
+ // Renderer
91
+ renderer: rendererModule,
92
+
93
+ // Actions (keyboard handlers)
94
+ actions: actionsModule,
95
+
96
+ // Keybindings
97
+ keybindings: keybindingsModule,
98
+
99
+ // Git commands
100
+ parseDiffStats: gitCommands.parseDiffStats,
101
+ getDiffStats: gitCommands.getDiffStats,
102
+ execGit: gitCommands.execGit,
103
+ execGitSilent: gitCommands.execGitSilent,
104
+ isGitAvailable: gitCommands.isGitAvailable,
105
+ isGitRepository: gitCommands.isGitRepository,
106
+ getRemotes: gitCommands.getRemotes,
107
+ remoteExists: gitCommands.remoteExists,
108
+ fetch: gitCommands.fetch,
109
+ pull: gitCommands.pull,
110
+ log: gitCommands.log,
111
+ getCommitsByDay: gitCommands.getCommitsByDay,
112
+ hasUncommittedChanges: gitCommands.hasUncommittedChanges,
113
+ stash: gitCommands.stash,
114
+ stashPop: gitCommands.stashPop,
115
+ getChangedFiles: gitCommands.getChangedFiles,
116
+ deleteLocalBranch: gitCommands.deleteLocalBranch,
117
+
118
+ // Git branch operations
119
+ isValidBranchName: gitBranch.isValidBranchName,
120
+ sanitizeBranchName: gitBranch.sanitizeBranchName,
121
+ getCurrentBranch: gitBranch.getCurrentBranch,
122
+ getAllBranches: gitBranch.getAllBranches,
123
+ detectBranchChanges: gitBranch.detectBranchChanges,
124
+ checkout: gitBranch.checkout,
125
+ getPreviewData: gitBranch.getPreviewData,
126
+ generateSparkline: gitBranch.generateSparkline,
127
+ getLocalBranches: gitBranch.getLocalBranches,
128
+ localBranchExists: gitBranch.localBranchExists,
129
+ getGoneBranches: gitBranch.getGoneBranches,
130
+ deleteGoneBranches: gitBranch.deleteGoneBranches,
131
+
132
+ // Configuration schema
133
+ SERVER_MODES: configSchema.SERVER_MODES,
134
+ DEFAULTS: configSchema.DEFAULTS,
135
+ LIMITS: configSchema.LIMITS,
136
+ getDefaultConfig: configSchema.getDefaultConfig,
137
+ validatePort: configSchema.validatePort,
138
+ validateServerMode: configSchema.validateServerMode,
139
+ validatePollInterval: configSchema.validatePollInterval,
140
+ validateVisibleBranches: configSchema.validateVisibleBranches,
141
+ validateConfig: configSchema.validateConfig,
142
+ migrateConfig: configSchema.migrateConfig,
143
+
144
+ // Configuration loading
145
+ CONFIG_FILE_NAME: configLoader.CONFIG_FILE_NAME,
146
+ getConfigPath: configLoader.getConfigPath,
147
+ configExists: configLoader.configExists,
148
+ loadConfig: configLoader.loadConfig,
149
+ saveConfig: configLoader.saveConfig,
150
+ deleteConfig: configLoader.deleteConfig,
151
+ applyCliArgs: configLoader.applyCliArgs,
152
+ parseCliArgs: configLoader.parseCliArgs,
153
+ ensureConfig: configLoader.ensureConfig,
154
+
155
+ // Server process management
156
+ ProcessManager: serverProcess.ProcessManager,
157
+ parseCommand: serverProcess.parseCommand,
158
+
159
+ // CLI argument parsing
160
+ parseArgs: cliArgs.parseArgs,
161
+ applyCliArgsToConfig: cliArgs.applyCliArgsToConfig,
162
+ getHelpText: cliArgs.getHelpText,
163
+ CLI_PACKAGE_VERSION: cliArgs.PACKAGE_VERSION,
164
+
165
+ // Utility modules
166
+ formatTimeAgo: timeUtils.formatTimeAgo,
167
+ openInBrowser: browserUtils.openInBrowser,
168
+ playSound: soundUtils.playSound,
169
+
170
+ // Telemetry
171
+ telemetry: telemetryModule,
172
+
173
+ // Git remote URL utilities
174
+ parseRemoteUrl: gitRemote.parseRemoteUrl,
175
+ buildBranchUrl: gitRemote.buildBranchUrl,
176
+ detectPlatform: gitRemote.detectPlatform,
177
+ buildWebUrl: gitRemote.buildWebUrl,
178
+ extractSessionUrl: gitRemote.extractSessionUrl,
179
+ };
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Polling engine — pure logic for branch change detection and sorting
3
+ * @module polling/engine
4
+ */
5
+
6
+ const { isBaseBranch } = require('../git/pr');
7
+
8
+ /**
9
+ * Detect new branches not previously known.
10
+ * @param {Array<{name: string, isNew?: boolean, newAt?: number}>} allBranches - All currently fetched branches
11
+ * @param {Set<string>} knownBranchNames - Previously known branch names
12
+ * @returns {Array<{name: string, isNew?: boolean, newAt?: number}>} Branches with isNew flag
13
+ */
14
+ function detectNewBranches(allBranches, knownBranchNames) {
15
+ const now = Date.now();
16
+ const newBranches = [];
17
+ for (const branch of allBranches) {
18
+ if (!knownBranchNames.has(branch.name)) {
19
+ branch.isNew = true;
20
+ branch.newAt = now;
21
+ newBranches.push(branch);
22
+ }
23
+ }
24
+ return newBranches;
25
+ }
26
+
27
+ /**
28
+ * Detect deleted branches (were known but no longer exist in fetched set).
29
+ * @param {Set<string>} knownBranchNames - Previously known branch names
30
+ * @param {Set<string>} fetchedBranchNames - Currently fetched branch names
31
+ * @param {Array<{name: string, isDeleted?: boolean, deletedAt?: number}>} existingBranches - Previous branch list
32
+ * @returns {Array<{name: string, isDeleted?: boolean, deletedAt?: number}>} Deleted branches
33
+ */
34
+ function detectDeletedBranches(knownBranchNames, fetchedBranchNames, existingBranches) {
35
+ const now = Date.now();
36
+ const deleted = [];
37
+ for (const knownName of knownBranchNames) {
38
+ if (!fetchedBranchNames.has(knownName)) {
39
+ const existing = existingBranches.find(b => b.name === knownName);
40
+ if (existing && !existing.isDeleted) {
41
+ existing.isDeleted = true;
42
+ existing.deletedAt = now;
43
+ deleted.push(existing);
44
+ }
45
+ }
46
+ }
47
+ return deleted;
48
+ }
49
+
50
+ /**
51
+ * Detect branches that have been updated (commit changed) since last poll.
52
+ * @param {Array<{name: string, commit: string, isDeleted?: boolean, justUpdated?: boolean}>} branches - Current branch list
53
+ * @param {Map<string, string>} previousStates - Map of branch name -> previous commit hash
54
+ * @param {string} currentBranch - Name of current branch (excluded from updates)
55
+ * @returns {Array<{name: string, commit: string, isDeleted?: boolean, justUpdated?: boolean}>} Updated branches
56
+ */
57
+ function detectUpdatedBranches(branches, previousStates, currentBranch) {
58
+ const updated = [];
59
+ for (const branch of branches) {
60
+ if (branch.isDeleted) continue;
61
+ const prevCommit = previousStates.get(branch.name);
62
+ if (prevCommit && prevCommit !== branch.commit && branch.name !== currentBranch) {
63
+ branch.justUpdated = true;
64
+ updated.push(branch);
65
+ }
66
+ }
67
+ return updated;
68
+ }
69
+
70
+ /**
71
+ * Sort branches: new first, then by date, merged near bottom, deleted at bottom.
72
+ * @param {Array} branches - Branch list to sort
73
+ * @param {Map} prStatusMap - Map of branch name -> PR status
74
+ * @returns {Array} Sorted branches (mutates and returns input)
75
+ */
76
+ function sortBranches(branches, prStatusMap) {
77
+ return branches.sort((a, b) => {
78
+ const aIsBase = isBaseBranch(a.name);
79
+ const bIsBase = isBaseBranch(b.name);
80
+ const aMerged = !aIsBase && prStatusMap.has(a.name) && prStatusMap.get(a.name).state === 'MERGED';
81
+ const bMerged = !bIsBase && prStatusMap.has(b.name) && prStatusMap.get(b.name).state === 'MERGED';
82
+ if (a.isDeleted && !b.isDeleted) return 1;
83
+ if (!a.isDeleted && b.isDeleted) return -1;
84
+ if (aMerged && !bMerged && !b.isDeleted) return 1;
85
+ if (!aMerged && bMerged && !a.isDeleted) return -1;
86
+ if (a.isNew && !b.isNew) return -1;
87
+ if (!a.isNew && b.isNew) return 1;
88
+ return b.date - a.date;
89
+ });
90
+ }
91
+
92
+ /**
93
+ * Calculate adaptive polling interval based on fetch duration.
94
+ * @param {number} fetchDuration - How long the fetch took (ms)
95
+ * @param {number} currentInterval - Current polling interval (ms)
96
+ * @param {number} baseInterval - Base/default polling interval (ms)
97
+ * @returns {{ interval: number, warning: string|null }}
98
+ */
99
+ function calculateAdaptiveInterval(fetchDuration, currentInterval, baseInterval) {
100
+ if (fetchDuration > 30000) {
101
+ return {
102
+ interval: Math.min(currentInterval * 2, 60000),
103
+ warning: 'very_slow',
104
+ };
105
+ }
106
+ if (fetchDuration > 15000) {
107
+ return {
108
+ interval: currentInterval,
109
+ warning: 'slow',
110
+ };
111
+ }
112
+ if (fetchDuration < 5000 && currentInterval > baseInterval) {
113
+ return {
114
+ interval: baseInterval,
115
+ warning: 'restored',
116
+ };
117
+ }
118
+ return {
119
+ interval: currentInterval,
120
+ warning: null,
121
+ };
122
+ }
123
+
124
+ /**
125
+ * Restore selection index after branch list reorder.
126
+ * @param {Array<{name: string}>} branches - New branch list
127
+ * @param {string|null} previousName - Previously selected branch name
128
+ * @param {number} previousIndex - Previously selected index
129
+ * @returns {{ selectedIndex: number, selectedBranchName: string|null }}
130
+ */
131
+ function restoreSelection(branches, previousName, previousIndex) {
132
+ if (previousName) {
133
+ const newIndex = branches.findIndex(b => b.name === previousName);
134
+ if (newIndex >= 0) {
135
+ return { selectedIndex: newIndex, selectedBranchName: previousName };
136
+ }
137
+ const clampedIndex = Math.min(previousIndex, Math.max(0, branches.length - 1));
138
+ return {
139
+ selectedIndex: clampedIndex,
140
+ selectedBranchName: branches[clampedIndex] ? branches[clampedIndex].name : null,
141
+ };
142
+ }
143
+ if (previousIndex >= branches.length) {
144
+ const idx = Math.max(0, branches.length - 1);
145
+ return { selectedIndex: idx, selectedBranchName: branches[idx] ? branches[idx].name : null };
146
+ }
147
+ return { selectedIndex: previousIndex, selectedBranchName: previousName };
148
+ }
149
+
150
+ module.exports = {
151
+ detectNewBranches,
152
+ detectDeletedBranches,
153
+ detectUpdatedBranches,
154
+ sortBranches,
155
+ calculateAdaptiveInterval,
156
+ restoreSelection,
157
+ };
@@ -0,0 +1,329 @@
1
+ /**
2
+ * Server process management for command mode
3
+ * Manages spawning, stopping, and monitoring of user's dev server
4
+ */
5
+
6
+ const { spawn } = require('child_process');
7
+ const { ServerError } = require('../utils/errors');
8
+
9
+ /**
10
+ * @typedef {Object} ServerProcessState
11
+ * @property {import('child_process').ChildProcess|null} process - The server process
12
+ * @property {boolean} running - Is the server running
13
+ * @property {boolean} crashed - Did the server crash
14
+ * @property {Array<{timestamp: string, line: string, isError: boolean}>} logs - Server logs
15
+ */
16
+
17
+ /**
18
+ * Maximum log lines to keep in buffer
19
+ */
20
+ const MAX_LOG_LINES = 500;
21
+
22
+ /**
23
+ * Grace period before force kill (ms)
24
+ */
25
+ const KILL_GRACE_PERIOD = 3000;
26
+
27
+ /**
28
+ * Restart delay after stop (ms)
29
+ */
30
+ const RESTART_DELAY = 500;
31
+
32
+ /**
33
+ * Parse a command string into command and arguments
34
+ * Handles quoted strings properly
35
+ * @param {string} commandString - Command string to parse
36
+ * @returns {{command: string, args: string[]}}
37
+ */
38
+ function parseCommand(commandString) {
39
+ const args = [];
40
+ let current = '';
41
+ let inQuotes = false;
42
+ let quoteChar = '';
43
+
44
+ for (let i = 0; i < commandString.length; i++) {
45
+ const char = commandString[i];
46
+
47
+ if ((char === '"' || char === "'") && !inQuotes) {
48
+ inQuotes = true;
49
+ quoteChar = char;
50
+ } else if (char === quoteChar && inQuotes) {
51
+ inQuotes = false;
52
+ quoteChar = '';
53
+ } else if (char === ' ' && !inQuotes) {
54
+ if (current) {
55
+ args.push(current);
56
+ current = '';
57
+ }
58
+ } else {
59
+ current += char;
60
+ }
61
+ }
62
+
63
+ if (current) {
64
+ args.push(current);
65
+ }
66
+
67
+ return {
68
+ command: args[0] || '',
69
+ args: args.slice(1),
70
+ };
71
+ }
72
+
73
+ /**
74
+ * Server process manager
75
+ */
76
+ class ProcessManager {
77
+ /**
78
+ * @param {Object} [options]
79
+ * @param {string} [options.cwd] - Working directory
80
+ * @param {Function} [options.onLog] - Log callback (line, isError)
81
+ * @param {Function} [options.onStateChange] - State change callback (state)
82
+ */
83
+ constructor(options = {}) {
84
+ this.cwd = options.cwd || process.cwd();
85
+ this.onLog = options.onLog || (() => {});
86
+ this.onStateChange = options.onStateChange || (() => {});
87
+
88
+ this.process = null;
89
+ this.running = false;
90
+ this.crashed = false;
91
+ this.logs = [];
92
+ this.command = '';
93
+ }
94
+
95
+ /**
96
+ * Get current state
97
+ * @returns {ServerProcessState}
98
+ */
99
+ getState() {
100
+ return {
101
+ process: this.process,
102
+ running: this.running,
103
+ crashed: this.crashed,
104
+ logs: [...this.logs],
105
+ };
106
+ }
107
+
108
+ /**
109
+ * Add a log entry
110
+ * @param {string} line - Log line
111
+ * @param {boolean} [isError=false] - Is error output
112
+ */
113
+ addLog(line, isError = false) {
114
+ const entry = {
115
+ timestamp: new Date().toLocaleTimeString(),
116
+ line,
117
+ isError,
118
+ };
119
+ this.logs.push(entry);
120
+ if (this.logs.length > MAX_LOG_LINES) {
121
+ this.logs.shift();
122
+ }
123
+ this.onLog(line, isError);
124
+ }
125
+
126
+ /**
127
+ * Clear log buffer
128
+ */
129
+ clearLogs() {
130
+ this.logs = [];
131
+ }
132
+
133
+ /**
134
+ * Start the server process
135
+ * @param {string} commandString - Command to run
136
+ * @returns {{success: boolean, error?: Error, pid?: number}}
137
+ */
138
+ start(commandString) {
139
+ if (!commandString) {
140
+ return {
141
+ success: false,
142
+ error: ServerError.startFailed(commandString, 'No command specified'),
143
+ };
144
+ }
145
+
146
+ // Stop existing process first
147
+ if (this.process) {
148
+ this.stop();
149
+ }
150
+
151
+ this.clearLogs();
152
+ this.crashed = false;
153
+ this.running = false;
154
+ this.command = commandString;
155
+
156
+ this.addLog(`$ ${commandString}`);
157
+
158
+ // Parse command
159
+ const { command, args } = parseCommand(commandString);
160
+
161
+ if (!command) {
162
+ const error = ServerError.startFailed(commandString, 'Invalid command');
163
+ this.crashed = true;
164
+ this.addLog(`Failed to start: Invalid command`, true);
165
+ this.notifyStateChange();
166
+ return { success: false, error };
167
+ }
168
+
169
+ // Spawn options
170
+ const isWindows = process.platform === 'win32';
171
+ /** @type {import('child_process').SpawnOptions} */
172
+ const spawnOptions = {
173
+ cwd: this.cwd,
174
+ env: { ...process.env, FORCE_COLOR: '1' },
175
+ shell: isWindows,
176
+ stdio: ['ignore', 'pipe', 'pipe'],
177
+ };
178
+
179
+ try {
180
+ this.process = spawn(command, args, spawnOptions);
181
+ this.running = true;
182
+
183
+ // Handle stdout
184
+ this.process.stdout?.on('data', (data) => {
185
+ const lines = data.toString().split('\n').filter(Boolean);
186
+ lines.forEach((line) => this.addLog(line));
187
+ });
188
+
189
+ // Handle stderr
190
+ this.process.stderr?.on('data', (data) => {
191
+ const lines = data.toString().split('\n').filter(Boolean);
192
+ lines.forEach((line) => this.addLog(line, true));
193
+ });
194
+
195
+ // Handle error
196
+ this.process.on('error', (err) => {
197
+ this.running = false;
198
+ this.crashed = true;
199
+ this.addLog(`Error: ${err.message}`, true);
200
+ this.notifyStateChange();
201
+ });
202
+
203
+ // Handle close
204
+ this.process.on('close', (code) => {
205
+ this.running = false;
206
+ if (code !== 0 && code !== null) {
207
+ this.crashed = true;
208
+ this.addLog(`Process exited with code ${code}`, true);
209
+ } else {
210
+ this.addLog('Process stopped');
211
+ }
212
+ this.process = null;
213
+ this.notifyStateChange();
214
+ });
215
+
216
+ this.notifyStateChange();
217
+ return { success: true, pid: this.process.pid };
218
+ } catch (err) {
219
+ this.crashed = true;
220
+ this.addLog(`Failed to start: ${err.message}`, true);
221
+ this.notifyStateChange();
222
+ return {
223
+ success: false,
224
+ error: ServerError.startFailed(commandString, err.message),
225
+ };
226
+ }
227
+ }
228
+
229
+ /**
230
+ * Stop the server process
231
+ * @returns {boolean} - True if a process was stopped
232
+ */
233
+ stop() {
234
+ if (!this.process) {
235
+ return false;
236
+ }
237
+
238
+ // Capture reference before nulling — needed for deferred SIGKILL
239
+ const proc = this.process;
240
+
241
+ // Try graceful shutdown first
242
+ if (process.platform === 'win32') {
243
+ try {
244
+ spawn('taskkill', ['/pid', proc.pid.toString(), '/f', '/t']);
245
+ } catch (e) {
246
+ // Ignore taskkill errors
247
+ }
248
+ } else {
249
+ try {
250
+ proc.kill('SIGTERM');
251
+
252
+ // Force kill after grace period
253
+ const forceKillTimeout = setTimeout(() => {
254
+ try {
255
+ proc.kill('SIGKILL');
256
+ } catch (e) {
257
+ // Process may already be dead
258
+ }
259
+ }, KILL_GRACE_PERIOD);
260
+
261
+ // Clear timeout if process exits cleanly
262
+ proc.once('close', () => {
263
+ clearTimeout(forceKillTimeout);
264
+ });
265
+ } catch (e) {
266
+ // Process may already be dead
267
+ }
268
+ }
269
+
270
+ this.process = null;
271
+ this.running = false;
272
+ this.notifyStateChange();
273
+ return true;
274
+ }
275
+
276
+ /**
277
+ * Restart the server process
278
+ * @returns {Promise<{success: boolean, error?: Error, pid?: number}>}
279
+ */
280
+ async restart() {
281
+ const command = this.command;
282
+ this.stop();
283
+
284
+ // Wait before restarting
285
+ await new Promise((resolve) => setTimeout(resolve, RESTART_DELAY));
286
+
287
+ return this.start(command);
288
+ }
289
+
290
+ /**
291
+ * Check if server is running
292
+ * @returns {boolean}
293
+ */
294
+ isRunning() {
295
+ return this.running;
296
+ }
297
+
298
+ /**
299
+ * Check if server crashed
300
+ * @returns {boolean}
301
+ */
302
+ hasCrashed() {
303
+ return this.crashed;
304
+ }
305
+
306
+ /**
307
+ * Get server PID
308
+ * @returns {number|null}
309
+ */
310
+ getPid() {
311
+ return this.process ? this.process.pid : null;
312
+ }
313
+
314
+ /**
315
+ * Notify state change
316
+ * @private
317
+ */
318
+ notifyStateChange() {
319
+ this.onStateChange(this.getState());
320
+ }
321
+ }
322
+
323
+ module.exports = {
324
+ ProcessManager,
325
+ parseCommand,
326
+ MAX_LOG_LINES,
327
+ KILL_GRACE_PERIOD,
328
+ RESTART_DELAY,
329
+ };