git-watchtower 1.12.0 → 1.12.2

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.
@@ -389,6 +389,20 @@ let AUTO_PULL = true;
389
389
  const MAX_LOG_ENTRIES = 10;
390
390
  const MAX_SERVER_LOG_LINES = 500;
391
391
 
392
+ // Timing constants (ms)
393
+ /** Grace period before SIGKILLing a process after SIGTERM. */
394
+ const FORCE_KILL_GRACE_MS = 3000;
395
+ /** Additional grace period added to a command's timeout before SIGKILL. */
396
+ const SIGKILL_GRACE_AFTER_TIMEOUT_MS = 5000;
397
+ /** Delay between stopping and restarting the dev server. */
398
+ const SERVER_RESTART_DELAY_MS = 500;
399
+ /** How long a transient flash message stays on screen. */
400
+ const FLASH_MESSAGE_DURATION_MS = 3000;
401
+ /** Debounce window for file watcher events before notifying clients. */
402
+ const FILE_WATCHER_DEBOUNCE_MS = 100;
403
+ /** Max time to wait for the static HTTP server to close on shutdown. */
404
+ const SERVER_CLOSE_TIMEOUT_MS = 2000;
405
+
392
406
  // Telemetry session tracking
393
407
  let branchSwitchCount = 0;
394
408
  let sessionStartTime = null;
@@ -405,6 +419,10 @@ let worker = null;
405
419
  let projectId = null;
406
420
  let webStateInterval = null;
407
421
 
422
+ // Periodic update check controller — hoisted to module scope so the exit
423
+ // handler can clean it up regardless of where in start() we are.
424
+ let periodicUpdateCheck = null;
425
+
408
426
  function applyConfig(config) {
409
427
  // Server settings
410
428
  SERVER_MODE = config.server?.mode || 'static';
@@ -467,6 +485,17 @@ function openInBrowser(url) {
467
485
  });
468
486
  }
469
487
 
488
+ /**
489
+ * Build a localhost URL for the given port.
490
+ * Centralizes the `http://localhost:${port}` pattern so it's easy to adjust
491
+ * (e.g. switch protocol or host) in one place.
492
+ * @param {number} port
493
+ * @returns {string}
494
+ */
495
+ function localhostUrl(port) {
496
+ return `http://localhost:${port}`;
497
+ }
498
+
470
499
  // parseRemoteUrl, buildBranchUrl, detectPlatform, buildWebUrl, extractSessionUrl
471
500
  // imported from src/git/remote.js
472
501
 
@@ -742,7 +771,7 @@ function stopServerProcess() {
742
771
  } catch (e) {
743
772
  // Process group may already be dead
744
773
  }
745
- }, 3000);
774
+ }, FORCE_KILL_GRACE_MS);
746
775
 
747
776
  // Clear the force-kill timer if the process exits cleanly
748
777
  proc.once('close', () => {
@@ -760,7 +789,7 @@ function restartServerProcess() {
760
789
  setTimeout(() => {
761
790
  startServerProcess();
762
791
  render();
763
- }, 500);
792
+ }, SERVER_RESTART_DELAY_MS);
764
793
  }
765
794
 
766
795
  // Network and polling state
@@ -857,7 +886,7 @@ function execCli(cmd, args = [], options = {}) {
857
886
  if (timeout > 0) {
858
887
  const killTimer = setTimeout(() => {
859
888
  try { child.kill('SIGKILL'); } catch (e) { /* already dead */ }
860
- }, timeout + 5000);
889
+ }, timeout + SIGKILL_GRACE_AFTER_TIMEOUT_MS);
861
890
  child.on('close', () => clearTimeout(killTimer));
862
891
  }
863
892
  });
@@ -1336,7 +1365,7 @@ function showFlash(message) {
1336
1365
  flashTimeout = setTimeout(() => {
1337
1366
  store.setState({ flashMessage: null });
1338
1367
  render();
1339
- }, 3000);
1368
+ }, FLASH_MESSAGE_DURATION_MS);
1340
1369
  }
1341
1370
 
1342
1371
  function hideFlash() {
@@ -2109,7 +2138,7 @@ function createStaticServer() {
2109
2138
  return;
2110
2139
  }
2111
2140
 
2112
- const url = new URL(req.url, `http://localhost:${PORT}`);
2141
+ const url = new URL(req.url, localhostUrl(PORT));
2113
2142
  let pathname = url.pathname;
2114
2143
  const logPath = pathname; // Keep original for logging
2115
2144
 
@@ -2194,7 +2223,7 @@ function setupFileWatcher() {
2194
2223
  addLog(`File changed: ${filename}`, 'info');
2195
2224
  notifyClients();
2196
2225
  render();
2197
- }, 100);
2226
+ }, FILE_WATCHER_DEBOUNCE_MS);
2198
2227
  });
2199
2228
 
2200
2229
  fileWatcher.on('error', (err) => {
@@ -2759,7 +2788,7 @@ function setupKeyboardInput() {
2759
2788
 
2760
2789
  case 'o': // Open live server in browser
2761
2790
  if (!NO_SERVER) {
2762
- const serverUrl = `http://localhost:${PORT}`;
2791
+ const serverUrl = localhostUrl(PORT);
2763
2792
  addLog(`Opening ${serverUrl} in browser...`, 'info');
2764
2793
  openInBrowser(serverUrl);
2765
2794
  render();
@@ -3002,7 +3031,7 @@ async function handleWebAction(action, payload) {
3002
3031
  break;
3003
3032
  case 'openBrowser':
3004
3033
  if (!NO_SERVER) {
3005
- openInBrowser(`http://localhost:${PORT}`);
3034
+ openInBrowser(localhostUrl(PORT));
3006
3035
  sendResult(true, 'Opened in browser');
3007
3036
  }
3008
3037
  break;
@@ -3089,7 +3118,7 @@ async function startWebDashboard(openBrowser) {
3089
3118
  });
3090
3119
  worker.onCommand = (action, payload) => handleWebAction(action, payload);
3091
3120
  await worker.connect();
3092
- addLog(`Joined web dashboard at http://localhost:${existing.port} (tab)`, 'success');
3121
+ addLog(`Joined web dashboard at ${localhostUrl(existing.port)} (tab)`, 'success');
3093
3122
 
3094
3123
  // Push state periodically
3095
3124
  webStateInterval = setInterval(() => {
@@ -3140,8 +3169,8 @@ async function startWebDashboard(openBrowser) {
3140
3169
  WEB_PORT = port;
3141
3170
  writeLock(process.pid, port, coordinator.socketPath);
3142
3171
 
3143
- addLog(`Web dashboard: http://localhost:${port}`, 'success');
3144
- if (openBrowser) openInBrowser(`http://localhost:${port}`);
3172
+ addLog(`Web dashboard: ${localhostUrl(port)}`, 'success');
3173
+ if (openBrowser) openInBrowser(localhostUrl(port));
3145
3174
  render();
3146
3175
  } catch (err) {
3147
3176
  addLog(`Web dashboard failed: ${err.message}`, 'error');
@@ -3250,7 +3279,7 @@ async function shutdown() {
3250
3279
  clients.clear();
3251
3280
 
3252
3281
  const serverClosePromise = new Promise(resolve => server.close(resolve));
3253
- const timeoutPromise = new Promise(resolve => setTimeout(resolve, 2000));
3282
+ const timeoutPromise = new Promise(resolve => setTimeout(resolve, SERVER_CLOSE_TIMEOUT_MS));
3254
3283
  await Promise.race([serverClosePromise, timeoutPromise]);
3255
3284
  }
3256
3285
 
@@ -3271,6 +3300,11 @@ async function shutdown() {
3271
3300
 
3272
3301
  process.on('SIGINT', shutdown);
3273
3302
  process.on('SIGTERM', shutdown);
3303
+ // Clean up long-lived timers on exit, regardless of which code path got us
3304
+ // here (normal exit, uncaught exception, early failure in start()).
3305
+ process.on('exit', () => {
3306
+ if (periodicUpdateCheck) periodicUpdateCheck.stop();
3307
+ });
3274
3308
  process.on('uncaughtException', async (err) => {
3275
3309
  telemetry.captureError(err);
3276
3310
  write(ansi.showCursor);
@@ -3399,11 +3433,11 @@ async function start() {
3399
3433
  // Static mode
3400
3434
  server = createStaticServer();
3401
3435
  server.listen(PORT, '127.0.0.1', () => {
3402
- addLog(`Server started on http://localhost:${PORT}`, 'success');
3436
+ addLog(`Server started on ${localhostUrl(PORT)}`, 'success');
3403
3437
  addLog(`Serving ${STATIC_DIR.replace(PROJECT_ROOT, '.')}`, 'info');
3404
3438
  addLog(`Current branch: ${store.get('currentBranch')}`, 'info');
3405
3439
  // Add server log entries for static server
3406
- addServerLog(`Static server started on http://localhost:${PORT}`);
3440
+ addServerLog(`Static server started on ${localhostUrl(PORT)}`);
3407
3441
  addServerLog(`Serving files from: ${STATIC_DIR.replace(PROJECT_ROOT, '.')}`);
3408
3442
  addServerLog(`Live reload enabled - waiting for browser connections...`);
3409
3443
  render();
@@ -3454,8 +3488,9 @@ async function start() {
3454
3488
  }
3455
3489
  }).catch(() => {});
3456
3490
 
3457
- // Re-check for updates periodically (every 4 hours) while running
3458
- const periodicCheck = startPeriodicUpdateCheck((latestVersion) => {
3491
+ // Re-check for updates periodically (every 4 hours) while running.
3492
+ // Assigned to module scope so the top-level exit handler can stop it.
3493
+ periodicUpdateCheck = startPeriodicUpdateCheck((latestVersion) => {
3459
3494
  const alreadyKnown = store.get('updateAvailable');
3460
3495
  store.setState({ updateAvailable: latestVersion });
3461
3496
  if (!alreadyKnown) {
@@ -3465,9 +3500,6 @@ async function start() {
3465
3500
  }
3466
3501
  render();
3467
3502
  });
3468
-
3469
- // Clean up periodic check on exit
3470
- process.on('exit', () => periodicCheck.stop());
3471
3503
  }
3472
3504
 
3473
3505
  start().catch(err => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-watchtower",
3
- "version": "1.12.0",
3
+ "version": "1.12.2",
4
4
  "description": "Terminal-based Git branch monitor with activity sparklines and optional dev server with live reload",
5
5
  "main": "bin/git-watchtower.js",
6
6
  "bin": {
package/src/cli/args.js CHANGED
@@ -175,7 +175,15 @@ function parseArgs(argv, options = {}) {
175
175
  * @returns {object} Merged config
176
176
  */
177
177
  function applyCliArgsToConfig(config, cliArgs) {
178
- const merged = JSON.parse(JSON.stringify(config)); // deep clone
178
+ // Shallow clone with nested object spreading — the config is at most two
179
+ // levels deep and all values are primitives, so this is equivalent to a deep
180
+ // clone but avoids the JSON round-trip (which silently drops `undefined`,
181
+ // functions, and throws on circular refs).
182
+ const merged = {
183
+ ...config,
184
+ server: { ...config.server },
185
+ web: { ...config.web },
186
+ };
179
187
 
180
188
  // Server settings
181
189
  if (cliArgs.mode !== null) {
@@ -31,8 +31,17 @@ const KILL_GRACE_PERIOD = 3000;
31
31
  const RESTART_DELAY = 500;
32
32
 
33
33
  /**
34
- * Parse a command string into command and arguments
35
- * Handles quoted strings properly
34
+ * Parse a command string into command and arguments.
35
+ * Handles quoted strings, backslash escapes (e.g. `\"`, `\\`, `\ `),
36
+ * and empty quoted arguments (`""`).
37
+ *
38
+ * Rules (POSIX-ish):
39
+ * - Inside single quotes, characters are literal — backslashes do NOT escape.
40
+ * - Inside double quotes or outside any quotes, a backslash causes the next
41
+ * character to be treated literally (so `\"` yields `"`, `\\` yields `\`,
42
+ * and `\ ` yields a literal space that doesn't split the argument).
43
+ * - A trailing backslash with no following character is left literal.
44
+ *
36
45
  * @param {string} commandString - Command string to parse
37
46
  * @returns {{command: string, args: string[]}}
38
47
  */
@@ -41,27 +50,43 @@ function parseCommand(commandString) {
41
50
  let current = '';
42
51
  let inQuotes = false;
43
52
  let quoteChar = '';
53
+ // Tracks whether we've started accumulating an argument — distinguishes
54
+ // `""` (empty argument) from whitespace between arguments.
55
+ let hasCurrent = false;
44
56
 
45
57
  for (let i = 0; i < commandString.length; i++) {
46
58
  const char = commandString[i];
47
59
 
60
+ // Backslash escapes: unless we're inside single quotes, a backslash
61
+ // causes the next character to be treated literally. A trailing
62
+ // backslash (no following character) falls through and is kept literal.
63
+ if (char === '\\' && quoteChar !== "'" && i + 1 < commandString.length) {
64
+ current += commandString[i + 1];
65
+ hasCurrent = true;
66
+ i++;
67
+ continue;
68
+ }
69
+
48
70
  if ((char === '"' || char === "'") && !inQuotes) {
49
71
  inQuotes = true;
50
72
  quoteChar = char;
73
+ hasCurrent = true;
51
74
  } else if (char === quoteChar && inQuotes) {
52
75
  inQuotes = false;
53
76
  quoteChar = '';
54
77
  } else if (char === ' ' && !inQuotes) {
55
- if (current) {
78
+ if (hasCurrent) {
56
79
  args.push(current);
57
80
  current = '';
81
+ hasCurrent = false;
58
82
  }
59
83
  } else {
60
84
  current += char;
85
+ hasCurrent = true;
61
86
  }
62
87
  }
63
88
 
64
- if (current) {
89
+ if (hasCurrent) {
65
90
  args.push(current);
66
91
  }
67
92