git-watchtower 1.5.0 → 1.6.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.
@@ -77,7 +77,7 @@ const { parseGitHubPr, parseGitLabMr, parseGitHubPrList, parseGitLabMrList, isBa
77
77
  // ============================================================================
78
78
  // Security & Validation (imported from src/git/branch.js and src/git/commands.js)
79
79
  // ============================================================================
80
- const { isValidBranchName, sanitizeBranchName } = require('../src/git/branch');
80
+ const { isValidBranchName, sanitizeBranchName, getGoneBranches, deleteGoneBranches } = require('../src/git/branch');
81
81
  const { isGitAvailable: checkGitAvailable } = require('../src/git/commands');
82
82
 
83
83
  // ============================================================================
@@ -676,17 +676,27 @@ function stopServerProcess() {
676
676
 
677
677
  addLog('Stopping server...', 'update');
678
678
 
679
+ // Capture reference before nulling — needed for deferred SIGKILL
680
+ const proc = serverProcess;
681
+
679
682
  // Try graceful shutdown first
680
683
  if (process.platform === 'win32') {
681
- spawn('taskkill', ['/pid', serverProcess.pid, '/f', '/t']);
684
+ spawn('taskkill', ['/pid', proc.pid.toString(), '/f', '/t']);
682
685
  } else {
683
- serverProcess.kill('SIGTERM');
684
- // Force kill after timeout
685
- setTimeout(() => {
686
- if (serverProcess) {
687
- serverProcess.kill('SIGKILL');
686
+ proc.kill('SIGTERM');
687
+ // Force kill after grace period if process hasn't exited
688
+ const forceKillTimeout = setTimeout(() => {
689
+ try {
690
+ proc.kill('SIGKILL');
691
+ } catch (e) {
692
+ // Process may already be dead
688
693
  }
689
694
  }, 3000);
695
+
696
+ // Clear the force-kill timer if the process exits cleanly
697
+ proc.once('close', () => {
698
+ clearTimeout(forceKillTimeout);
699
+ });
690
700
  }
691
701
 
692
702
  serverProcess = null;
@@ -796,15 +806,28 @@ const LIVE_RELOAD_SCRIPT = `
796
806
  // Utility Functions
797
807
  // ============================================================================
798
808
 
809
+ // Default timeout for execAsync (30 seconds) — prevents hung git/CLI commands
810
+ // from permanently blocking the polling loop
811
+ const EXEC_ASYNC_TIMEOUT = 30000;
812
+
799
813
  function execAsync(command, options = {}) {
814
+ const { timeout = EXEC_ASYNC_TIMEOUT, ...restOptions } = options;
800
815
  return new Promise((resolve, reject) => {
801
- exec(command, { cwd: PROJECT_ROOT, ...options }, (error, stdout, stderr) => {
816
+ const child = exec(command, { cwd: PROJECT_ROOT, timeout, ...restOptions }, (error, stdout, stderr) => {
802
817
  if (error) {
803
818
  reject({ error, stdout, stderr });
804
819
  } else {
805
820
  resolve({ stdout: stdout.trim(), stderr: stderr.trim() });
806
821
  }
807
822
  });
823
+ // Also kill the child if the timeout fires (exec timeout sends SIGTERM
824
+ // but doesn't guarantee cleanup of process trees)
825
+ if (timeout > 0) {
826
+ const killTimer = setTimeout(() => {
827
+ try { child.kill('SIGKILL'); } catch (e) { /* already dead */ }
828
+ }, timeout + 5000);
829
+ child.on('close', () => clearTimeout(killTimer));
830
+ }
808
831
  });
809
832
  }
810
833
 
@@ -1191,6 +1214,11 @@ function render() {
1191
1214
  renderer.renderErrorToast(state, write);
1192
1215
  }
1193
1216
 
1217
+ // Cleanup confirmation dialog
1218
+ if (state.cleanupConfirmMode) {
1219
+ renderer.renderCleanupConfirm(state, write);
1220
+ }
1221
+
1194
1222
  // Stash confirmation dialog renders on top of everything
1195
1223
  if (state.stashConfirmMode) {
1196
1224
  renderer.renderStashConfirm(state, write);
@@ -2005,6 +2033,16 @@ const server = http.createServer((req, res) => {
2005
2033
  pathname = path.normalize(pathname).replace(/^(\.\.[\/\\])+/, '');
2006
2034
  let filePath = path.join(STATIC_DIR, pathname);
2007
2035
 
2036
+ // Security: ensure resolved path stays within STATIC_DIR to prevent path traversal
2037
+ const resolvedPath = path.resolve(filePath);
2038
+ const resolvedStaticDir = path.resolve(STATIC_DIR);
2039
+ if (!resolvedPath.startsWith(resolvedStaticDir + path.sep) && resolvedPath !== resolvedStaticDir) {
2040
+ res.writeHead(403, { 'Content-Type': 'text/html' });
2041
+ res.end('<h1>403 Forbidden</h1>');
2042
+ addServerLog(`GET ${logPath} → 403 (path traversal blocked)`, true);
2043
+ return;
2044
+ }
2045
+
2008
2046
  if (fs.existsSync(filePath) && fs.statSync(filePath).isDirectory()) {
2009
2047
  filePath = path.join(filePath, 'index.html');
2010
2048
  }
@@ -2329,6 +2367,59 @@ function setupKeyboardInput() {
2329
2367
  return; // Ignore other keys in action mode
2330
2368
  }
2331
2369
 
2370
+ // Handle cleanup confirmation dialog
2371
+ if (store.get('cleanupConfirmMode')) {
2372
+ const cleanupBranches = store.get('cleanupBranches') || [];
2373
+ const maxOptions = cleanupBranches.length > 0 ? 3 : 1;
2374
+ if (key === '\u001b[A' || key === 'k') { // Up
2375
+ const idx = store.get('cleanupSelectedIndex') || 0;
2376
+ if (idx > 0) {
2377
+ store.setState({ cleanupSelectedIndex: idx - 1 });
2378
+ render();
2379
+ }
2380
+ return;
2381
+ }
2382
+ if (key === '\u001b[B' || key === 'j') { // Down
2383
+ const idx = store.get('cleanupSelectedIndex') || 0;
2384
+ if (idx < maxOptions - 1) {
2385
+ store.setState({ cleanupSelectedIndex: idx + 1 });
2386
+ render();
2387
+ }
2388
+ return;
2389
+ }
2390
+ if (key === '\r' || key === '\n') { // Enter — execute selected option
2391
+ const idx = store.get('cleanupSelectedIndex') || 0;
2392
+ applyUpdates(actions.closeCleanupConfirm(getActionState()));
2393
+ render();
2394
+ if (cleanupBranches.length === 0 || idx === maxOptions - 1) {
2395
+ // Cancel or Close (no branches)
2396
+ return;
2397
+ }
2398
+ const force = idx === 1; // 0=safe delete, 1=force delete, 2=cancel
2399
+ addLog(`Cleaning up ${cleanupBranches.length} stale branch${cleanupBranches.length === 1 ? '' : 'es'}${force ? ' (force)' : ''}...`, 'update');
2400
+ render();
2401
+ const result = await deleteGoneBranches(cleanupBranches, { force });
2402
+ for (const name of result.deleted) {
2403
+ addLog(`Deleted branch: ${name}`, 'success');
2404
+ }
2405
+ for (const f of result.failed) {
2406
+ addLog(`Failed to delete ${f.name}: ${f.error}`, 'error');
2407
+ }
2408
+ if (result.deleted.length > 0) {
2409
+ addLog(`Cleaned up ${result.deleted.length} branch${result.deleted.length === 1 ? '' : 'es'}`, 'success');
2410
+ await pollGitChanges();
2411
+ }
2412
+ render();
2413
+ return;
2414
+ }
2415
+ if (key === '\u001b') { // Escape — cancel
2416
+ applyUpdates(actions.closeCleanupConfirm(getActionState()));
2417
+ render();
2418
+ return;
2419
+ }
2420
+ return; // Ignore other keys in cleanup mode
2421
+ }
2422
+
2332
2423
  // Handle stash confirmation dialog
2333
2424
  if (store.get('stashConfirmMode')) {
2334
2425
  if (key === '\u001b[A' || key === 'k') { // Up
@@ -2565,6 +2656,15 @@ function setupKeyboardInput() {
2565
2656
  break;
2566
2657
  }
2567
2658
 
2659
+ case 'd': { // Cleanup stale branches (remotes deleted)
2660
+ addLog('Scanning for stale branches...', 'info');
2661
+ render();
2662
+ const goneBranches = await getGoneBranches();
2663
+ applyUpdates(actions.openCleanupConfirm(actionState, goneBranches));
2664
+ render();
2665
+ break;
2666
+ }
2667
+
2568
2668
  // Number keys to set visible branch count
2569
2669
  case '1': case '2': case '3': case '4': case '5':
2570
2670
  case '6': case '7': case '8': case '9':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-watchtower",
3
- "version": "1.5.0",
3
+ "version": "1.6.1",
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": {