git-watchtower 1.6.0 → 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.
@@ -66,6 +66,9 @@ const casinoSounds = require('../src/casino/sounds');
66
66
  // Gitignore utilities for file watcher
67
67
  const { loadGitignorePatterns, shouldIgnoreFile } = require('../src/utils/gitignore');
68
68
 
69
+ // Telemetry (opt-in PostHog analytics)
70
+ const telemetry = require('../src/telemetry');
71
+
69
72
  // Extracted modules
70
73
  const { formatTimeAgo } = require('../src/utils/time');
71
74
  const { openInBrowser: openUrl } = require('../src/utils/browser');
@@ -195,6 +198,7 @@ async function runConfigurationWizard() {
195
198
 
196
199
  // Save configuration
197
200
  saveConfig(config);
201
+ telemetry.capture('config_wizard_completed', { server_mode: config.server.mode });
198
202
 
199
203
  console.log('\n✓ Configuration saved to ' + CONFIG_FILE_NAME);
200
204
  console.log(' You can edit this file manually or delete it to reconfigure.\n');
@@ -367,6 +371,10 @@ let AUTO_PULL = true;
367
371
  const MAX_LOG_ENTRIES = 10;
368
372
  const MAX_SERVER_LOG_LINES = 500;
369
373
 
374
+ // Telemetry session tracking
375
+ let branchSwitchCount = 0;
376
+ let sessionStartTime = null;
377
+
370
378
  // Server process management (for command mode)
371
379
  let serverProcess = null;
372
380
 
@@ -676,17 +684,27 @@ function stopServerProcess() {
676
684
 
677
685
  addLog('Stopping server...', 'update');
678
686
 
687
+ // Capture reference before nulling — needed for deferred SIGKILL
688
+ const proc = serverProcess;
689
+
679
690
  // Try graceful shutdown first
680
691
  if (process.platform === 'win32') {
681
- spawn('taskkill', ['/pid', serverProcess.pid, '/f', '/t']);
692
+ spawn('taskkill', ['/pid', proc.pid.toString(), '/f', '/t']);
682
693
  } else {
683
- serverProcess.kill('SIGTERM');
684
- // Force kill after timeout
685
- setTimeout(() => {
686
- if (serverProcess) {
687
- serverProcess.kill('SIGKILL');
694
+ proc.kill('SIGTERM');
695
+ // Force kill after grace period if process hasn't exited
696
+ const forceKillTimeout = setTimeout(() => {
697
+ try {
698
+ proc.kill('SIGKILL');
699
+ } catch (e) {
700
+ // Process may already be dead
688
701
  }
689
702
  }, 3000);
703
+
704
+ // Clear the force-kill timer if the process exits cleanly
705
+ proc.once('close', () => {
706
+ clearTimeout(forceKillTimeout);
707
+ });
690
708
  }
691
709
 
692
710
  serverProcess = null;
@@ -796,15 +814,28 @@ const LIVE_RELOAD_SCRIPT = `
796
814
  // Utility Functions
797
815
  // ============================================================================
798
816
 
817
+ // Default timeout for execAsync (30 seconds) — prevents hung git/CLI commands
818
+ // from permanently blocking the polling loop
819
+ const EXEC_ASYNC_TIMEOUT = 30000;
820
+
799
821
  function execAsync(command, options = {}) {
822
+ const { timeout = EXEC_ASYNC_TIMEOUT, ...restOptions } = options;
800
823
  return new Promise((resolve, reject) => {
801
- exec(command, { cwd: PROJECT_ROOT, ...options }, (error, stdout, stderr) => {
824
+ const child = exec(command, { cwd: PROJECT_ROOT, timeout, ...restOptions }, (error, stdout, stderr) => {
802
825
  if (error) {
803
826
  reject({ error, stdout, stderr });
804
827
  } else {
805
828
  resolve({ stdout: stdout.trim(), stderr: stderr.trim() });
806
829
  }
807
830
  });
831
+ // Also kill the child if the timeout fires (exec timeout sends SIGTERM
832
+ // but doesn't guarantee cleanup of process trees)
833
+ if (timeout > 0) {
834
+ const killTimer = setTimeout(() => {
835
+ try { child.kill('SIGKILL'); } catch (e) { /* already dead */ }
836
+ }, timeout + 5000);
837
+ child.on('close', () => clearTimeout(killTimer));
838
+ }
808
839
  });
809
840
  }
810
841
 
@@ -1396,6 +1427,7 @@ async function switchToBranch(branchName, recordHistory = true) {
1396
1427
  addLog(`Cannot switch: uncommitted changes in working directory`, 'error');
1397
1428
  pendingDirtyOperation = { type: 'switch', branch: branchName };
1398
1429
  showStashConfirm(`switch to ${branchName}`);
1430
+ telemetry.capture('dirty_repo_encountered');
1399
1431
  return { success: false, reason: 'dirty' };
1400
1432
  }
1401
1433
 
@@ -1428,6 +1460,8 @@ async function switchToBranch(branchName, recordHistory = true) {
1428
1460
  }
1429
1461
 
1430
1462
  addLog(`Switched to ${safeBranchName}`, 'success');
1463
+ telemetry.capture('branch_switched');
1464
+ branchSwitchCount++;
1431
1465
  pendingDirtyOperation = null;
1432
1466
 
1433
1467
  // Restart server if configured (command mode)
@@ -1457,6 +1491,7 @@ async function switchToBranch(branchName, recordHistory = true) {
1457
1491
  truncate(errMsg, 100),
1458
1492
  'Check the activity log for details'
1459
1493
  );
1494
+ telemetry.captureError(e);
1460
1495
  }
1461
1496
  return { success: false };
1462
1497
  }
@@ -1565,6 +1600,7 @@ async function stashAndRetry() {
1565
1600
  }
1566
1601
 
1567
1602
  addLog('Changes stashed successfully', 'success');
1603
+ telemetry.capture('stash_performed');
1568
1604
 
1569
1605
  if (operation.type === 'switch') {
1570
1606
  const switchResult = await switchToBranch(operation.branch);
@@ -2010,6 +2046,16 @@ const server = http.createServer((req, res) => {
2010
2046
  pathname = path.normalize(pathname).replace(/^(\.\.[\/\\])+/, '');
2011
2047
  let filePath = path.join(STATIC_DIR, pathname);
2012
2048
 
2049
+ // Security: ensure resolved path stays within STATIC_DIR to prevent path traversal
2050
+ const resolvedPath = path.resolve(filePath);
2051
+ const resolvedStaticDir = path.resolve(STATIC_DIR);
2052
+ if (!resolvedPath.startsWith(resolvedStaticDir + path.sep) && resolvedPath !== resolvedStaticDir) {
2053
+ res.writeHead(403, { 'Content-Type': 'text/html' });
2054
+ res.end('<h1>403 Forbidden</h1>');
2055
+ addServerLog(`GET ${logPath} → 403 (path traversal blocked)`, true);
2056
+ return;
2057
+ }
2058
+
2013
2059
  if (fs.existsSync(filePath) && fs.statSync(filePath).isDirectory()) {
2014
2060
  filePath = path.join(filePath, 'index.html');
2015
2061
  }
@@ -2215,6 +2261,7 @@ function setupKeyboardInput() {
2215
2261
  openInBrowser(prUrl);
2216
2262
  } else if (!prInfo && prLoaded && cliReady) {
2217
2263
  // Create PR — only if we've confirmed no PR exists (prLoaded=true)
2264
+ telemetry.capture('pr_action', { action: 'create' });
2218
2265
  addLog(`Creating ${prLabel} for ${aBranch.name}...`, 'update');
2219
2266
  render();
2220
2267
  try {
@@ -2256,6 +2303,7 @@ function setupKeyboardInput() {
2256
2303
  return;
2257
2304
  }
2258
2305
  if (key === 'a' && prInfo && cliReady) { // Approve PR
2306
+ telemetry.capture('pr_action', { action: 'approve' });
2259
2307
  addLog(`Approving ${prLabel} #${prInfo.number}...`, 'update');
2260
2308
  render();
2261
2309
  try {
@@ -2275,6 +2323,7 @@ function setupKeyboardInput() {
2275
2323
  return;
2276
2324
  }
2277
2325
  if (key === 'm' && prInfo && cliReady) { // Merge PR
2326
+ telemetry.capture('pr_action', { action: 'merge' });
2278
2327
  addLog(`Merging ${prLabel} #${prInfo.number}...`, 'update');
2279
2328
  render();
2280
2329
  try {
@@ -2373,6 +2422,7 @@ function setupKeyboardInput() {
2373
2422
  addLog(`Failed to delete ${f.name}: ${f.error}`, 'error');
2374
2423
  }
2375
2424
  if (result.deleted.length > 0) {
2425
+ telemetry.capture('cleanup_branches_deleted', { count: result.deleted.length });
2376
2426
  addLog(`Cleaned up ${result.deleted.length} branch${result.deleted.length === 1 ? '' : 'es'}`, 'success');
2377
2427
  await pollGitChanges();
2378
2428
  }
@@ -2495,12 +2545,14 @@ function setupKeyboardInput() {
2495
2545
  render();
2496
2546
  const pvData = await getPreviewData(branch.name);
2497
2547
  store.setState({ previewData: pvData, previewMode: true });
2548
+ telemetry.capture('preview_opened');
2498
2549
  render();
2499
2550
  }
2500
2551
  break;
2501
2552
 
2502
2553
  case '/': // Search mode
2503
2554
  applyUpdates(actions.enterSearchMode(actionState));
2555
+ telemetry.capture('search_used');
2504
2556
  render();
2505
2557
  break;
2506
2558
 
@@ -2515,11 +2567,13 @@ function setupKeyboardInput() {
2515
2567
  break;
2516
2568
 
2517
2569
  case 'u': // Undo last switch
2570
+ telemetry.capture('undo_branch_switch');
2518
2571
  await undoLastSwitch();
2519
2572
  await pollGitChanges();
2520
2573
  break;
2521
2574
 
2522
2575
  case 'p':
2576
+ telemetry.capture('pull_forced');
2523
2577
  await pullCurrentBranch();
2524
2578
  await pollGitChanges();
2525
2579
  break;
@@ -2558,6 +2612,7 @@ function setupKeyboardInput() {
2558
2612
  const branch = displayBranches.length > 0 && curSelIdx < displayBranches.length
2559
2613
  ? displayBranches[curSelIdx] : null;
2560
2614
  if (branch) {
2615
+ telemetry.capture('branch_actions_opened');
2561
2616
  // Phase 1: Open modal instantly with local/cached data
2562
2617
  const localData = gatherLocalActionData(branch);
2563
2618
  store.setState({ actionData: localData, actionMode: true, actionLoading: !localData.prLoaded });
@@ -2592,8 +2647,10 @@ function setupKeyboardInput() {
2592
2647
 
2593
2648
  case 's': {
2594
2649
  applyUpdates(actions.toggleSound(actionState));
2595
- addLog(`Sound notifications ${store.get('soundEnabled') ? 'enabled' : 'disabled'}`, 'info');
2596
- if (store.get('soundEnabled')) playSound();
2650
+ const soundNowEnabled = store.get('soundEnabled');
2651
+ addLog(`Sound notifications ${soundNowEnabled ? 'enabled' : 'disabled'}`, 'info');
2652
+ telemetry.capture('sound_toggled', { enabled: soundNowEnabled });
2653
+ if (soundNowEnabled) playSound();
2597
2654
  render();
2598
2655
  break;
2599
2656
  }
@@ -2612,6 +2669,7 @@ function setupKeyboardInput() {
2612
2669
  case 'c': { // Toggle casino mode
2613
2670
  const newCasinoState = casino.toggle();
2614
2671
  store.setState({ casinoModeEnabled: newCasinoState });
2672
+ telemetry.capture('casino_mode_toggled', { enabled: newCasinoState });
2615
2673
  addLog(`Casino mode ${newCasinoState ? '🎰 ENABLED' : 'disabled'}`, newCasinoState ? 'success' : 'info');
2616
2674
  if (newCasinoState) {
2617
2675
  addLog(`Have you noticed this game has that 'variable rewards' thing going on? 🤔😉`, 'info');
@@ -2721,6 +2779,14 @@ async function shutdown() {
2721
2779
  await Promise.race([serverClosePromise, timeoutPromise]);
2722
2780
  }
2723
2781
 
2782
+ // Flush telemetry
2783
+ telemetry.capture('session_ended', {
2784
+ duration_seconds: sessionStartTime ? Math.round((Date.now() - sessionStartTime) / 1000) : 0,
2785
+ branch_switches: branchSwitchCount,
2786
+ branches_count: store.get('branches').length,
2787
+ });
2788
+ await telemetry.shutdown();
2789
+
2724
2790
  console.log('\n✓ Git Watchtower stopped\n');
2725
2791
  process.exit(0);
2726
2792
  }
@@ -2728,6 +2794,7 @@ async function shutdown() {
2728
2794
  process.on('SIGINT', shutdown);
2729
2795
  process.on('SIGTERM', shutdown);
2730
2796
  process.on('uncaughtException', (err) => {
2797
+ telemetry.captureError(err);
2731
2798
  write(ansi.showCursor);
2732
2799
  write(ansi.restoreScreen);
2733
2800
  restoreTerminalTitle();
@@ -2754,6 +2821,19 @@ async function start() {
2754
2821
  const config = await ensureConfig(cliArgs);
2755
2822
  applyConfig(config);
2756
2823
 
2824
+ // Telemetry: opt-in prompt (first run only) and initialization
2825
+ await telemetry.promptIfNeeded(promptYesNo);
2826
+ telemetry.init({ version: PACKAGE_VERSION });
2827
+ sessionStartTime = Date.now();
2828
+ telemetry.capture('tool_launched', {
2829
+ version: PACKAGE_VERSION,
2830
+ node_version: process.version,
2831
+ os: process.platform,
2832
+ server_mode: SERVER_MODE,
2833
+ has_config: !!loadConfig(),
2834
+ casino_mode: config.casinoMode || false,
2835
+ });
2836
+
2757
2837
  // Set up casino mode render callback for animations
2758
2838
  casino.setRenderCallback(render);
2759
2839
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-watchtower",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
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": {
@@ -52,6 +52,8 @@
52
52
  },
53
53
  "files": [
54
54
  "bin/git-watchtower.js",
55
+ "src/",
56
+ "sounds/",
55
57
  "README.md",
56
58
  "LICENSE"
57
59
  ],
@@ -85,5 +87,8 @@
85
87
  }
86
88
  ]
87
89
  ]
90
+ },
91
+ "dependencies": {
92
+ "posthog-node": "^5.28.0"
88
93
  }
89
94
  }
@@ -0,0 +1,34 @@
1
+ # Casino Mode Sound Effects
2
+
3
+ Casino mode uses system sounds by default. For custom sounds, add `.wav` files to this directory:
4
+
5
+ ## Sound Files
6
+
7
+ | File | Description |
8
+ |------|-------------|
9
+ | `win.wav` | Short victory sound (small/medium wins) |
10
+ | `jackpot.wav` | Exciting fanfare (big wins, jackpots) |
11
+ | `spin.wav` | Slot machine spinning sound |
12
+ | `loss.wav` | Sad trombone / failure sound |
13
+
14
+ ## Free Sound Sources
15
+
16
+ - [Freesound.org](https://freesound.org/) - Search for "slot machine", "casino win", "coin"
17
+ - [Mixkit](https://mixkit.co/free-sound-effects/) - Game sounds category
18
+ - [Zapsplat](https://www.zapsplat.com/) - Royalty-free effects
19
+
20
+ ## Recommended Search Terms
21
+
22
+ - "slot machine win"
23
+ - "casino jackpot"
24
+ - "coin drop"
25
+ - "cha-ching"
26
+ - "sad trombone"
27
+ - "wah wah"
28
+ - "game over"
29
+
30
+ ## Format Notes
31
+
32
+ - WAV format recommended for best compatibility
33
+ - Keep files short (1-3 seconds)
34
+ - Mono or stereo, any sample rate works