git-watchtower 1.8.1 → 1.8.3

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.
@@ -56,7 +56,7 @@
56
56
  const http = require('http');
57
57
  const fs = require('fs');
58
58
  const path = require('path');
59
- const { exec, execSync, spawn } = require('child_process');
59
+ const { execFile, execSync, spawn } = require('child_process');
60
60
  const readline = require('readline');
61
61
 
62
62
  // Casino mode - Vegas-style feedback effects
@@ -74,7 +74,7 @@ const { formatTimeAgo } = require('../src/utils/time');
74
74
  const { openInBrowser: openUrl } = require('../src/utils/browser');
75
75
  const { playSound: playSoundEffect } = require('../src/utils/sound');
76
76
  const { parseArgs: parseCliArgs, applyCliArgsToConfig: mergeCliArgs, getHelpText, PACKAGE_VERSION } = require('../src/cli/args');
77
- const { checkForUpdate } = require('../src/utils/version-check');
77
+ const { checkForUpdate, startPeriodicUpdateCheck } = require('../src/utils/version-check');
78
78
  const { parseRemoteUrl, buildBranchUrl, detectPlatform, buildWebUrl, extractSessionUrl } = require('../src/git/remote');
79
79
  const { parseGitHubPr, parseGitLabMr, parseGitHubPrList, parseGitLabMrList, isBaseBranch } = require('../src/git/pr');
80
80
 
@@ -82,7 +82,7 @@ const { parseGitHubPr, parseGitLabMr, parseGitHubPrList, parseGitLabMrList, isBa
82
82
  // Security & Validation (imported from src/git/branch.js and src/git/commands.js)
83
83
  // ============================================================================
84
84
  const { isValidBranchName, sanitizeBranchName, getGoneBranches, deleteGoneBranches } = require('../src/git/branch');
85
- const { isGitAvailable: checkGitAvailable } = require('../src/git/commands');
85
+ const { isGitAvailable: checkGitAvailable, execGit, execGitSilent, getDiffStats: getDiffStatsSafe } = require('../src/git/commands');
86
86
 
87
87
  // ============================================================================
88
88
  // Configuration (imports from src/config/, inline wizard kept here)
@@ -436,7 +436,7 @@ function openInBrowser(url) {
436
436
 
437
437
  async function getRemoteWebUrl(branchName) {
438
438
  try {
439
- const { stdout } = await execAsync(`git remote get-url "${REMOTE_NAME}"`);
439
+ const { stdout } = await execGit(['remote', 'get-url', REMOTE_NAME], { cwd: PROJECT_ROOT });
440
440
  const parsed = parseRemoteUrl(stdout);
441
441
  return buildWebUrl(parsed, branchName);
442
442
  } catch (e) {
@@ -446,20 +446,21 @@ async function getRemoteWebUrl(branchName) {
446
446
 
447
447
  // Extract Claude Code session URL from the most recent commit on a branch
448
448
  async function getSessionUrl(branchName) {
449
- try {
450
- const { stdout } = await execAsync(
451
- `git log "${REMOTE_NAME}/${branchName}" -1 --format=%B 2>/dev/null || git log "${branchName}" -1 --format=%B 2>/dev/null`
452
- );
453
- return extractSessionUrl(stdout);
454
- } catch (e) {
455
- return null;
456
- }
449
+ // Try remote branch first, fall back to local
450
+ const result = await execGitSilent(
451
+ ['log', `${REMOTE_NAME}/${branchName}`, '-1', '--format=%B'],
452
+ { cwd: PROJECT_ROOT }
453
+ ) || await execGitSilent(
454
+ ['log', branchName, '-1', '--format=%B'],
455
+ { cwd: PROJECT_ROOT }
456
+ );
457
+ return result ? extractSessionUrl(result.stdout) : null;
457
458
  }
458
459
 
459
460
  // Check if a CLI tool is available
460
461
  async function hasCommand(cmd) {
461
462
  try {
462
- await execAsync(`which ${cmd} 2>/dev/null`);
463
+ await execCli('which', [cmd]);
463
464
  return true;
464
465
  } catch (e) {
465
466
  return false;
@@ -472,17 +473,18 @@ async function hasCommand(cmd) {
472
473
  async function getPrInfo(branchName, platform, hasGh, hasGlab) {
473
474
  if (platform === 'github' && hasGh) {
474
475
  try {
475
- const { stdout } = await execAsync(
476
- `gh pr list --head "${branchName}" --state all --json number,title,state,reviewDecision,statusCheckRollup --limit 1`
477
- );
476
+ const { stdout } = await execCli('gh', [
477
+ 'pr', 'list', '--head', branchName, '--state', 'all',
478
+ '--json', 'number,title,state,reviewDecision,statusCheckRollup', '--limit', '1',
479
+ ]);
478
480
  return parseGitHubPr(JSON.parse(stdout));
479
481
  } catch (e) { /* gh not authed or other error */ }
480
482
  }
481
483
  if (platform === 'gitlab' && hasGlab) {
482
484
  try {
483
- const { stdout } = await execAsync(
484
- `glab mr list --source-branch="${branchName}" --state all --output json 2>/dev/null`
485
- );
485
+ const { stdout } = await execCli('glab', [
486
+ 'mr', 'list', `--source-branch=${branchName}`, '--state', 'all', '--output', 'json',
487
+ ]);
486
488
  return parseGitLabMr(JSON.parse(stdout));
487
489
  } catch (e) { /* glab not authed or other error */ }
488
490
  }
@@ -492,11 +494,7 @@ async function getPrInfo(branchName, platform, hasGh, hasGlab) {
492
494
  // Check if gh/glab CLI is authenticated
493
495
  async function checkCliAuth(cmd) {
494
496
  try {
495
- if (cmd === 'gh') {
496
- await execAsync('gh auth status 2>&1');
497
- } else if (cmd === 'glab') {
498
- await execAsync('glab auth status 2>&1');
499
- }
497
+ await execCli(cmd, ['auth', 'status']);
500
498
  return true;
501
499
  } catch (e) {
502
500
  return false;
@@ -510,18 +508,19 @@ async function fetchAllPrStatuses() {
510
508
 
511
509
  if (platform === 'github' && hasGh && ghAuthed) {
512
510
  try {
513
- const { stdout } = await execAsync(
514
- 'gh pr list --state all --json headRefName,number,title,state --limit 200'
515
- );
511
+ const { stdout } = await execCli('gh', [
512
+ 'pr', 'list', '--state', 'all',
513
+ '--json', 'headRefName,number,title,state', '--limit', '200',
514
+ ]);
516
515
  return parseGitHubPrList(JSON.parse(stdout));
517
516
  } catch (e) { /* gh error */ }
518
517
  }
519
518
 
520
519
  if (platform === 'gitlab' && hasGlab && glabAuthed) {
521
520
  try {
522
- const { stdout } = await execAsync(
523
- 'glab mr list --state all --output json 2>/dev/null'
524
- );
521
+ const { stdout } = await execCli('glab', [
522
+ 'mr', 'list', '--state', 'all', '--output', 'json',
523
+ ]);
525
524
  return parseGitLabMrList(JSON.parse(stdout));
526
525
  } catch (e) { /* glab error */ }
527
526
  }
@@ -815,22 +814,30 @@ const LIVE_RELOAD_SCRIPT = `
815
814
  // Utility Functions
816
815
  // ============================================================================
817
816
 
818
- // Default timeout for execAsync (30 seconds) — prevents hung git/CLI commands
817
+ // Default timeout for CLI commands (30 seconds) — prevents hung commands
819
818
  // from permanently blocking the polling loop
820
- const EXEC_ASYNC_TIMEOUT = 30000;
819
+ const CLI_TIMEOUT = 30000;
821
820
 
822
- function execAsync(command, options = {}) {
823
- const { timeout = EXEC_ASYNC_TIMEOUT, ...restOptions } = options;
821
+ /**
822
+ * Execute a non-git CLI command safely using execFile (no shell interpolation).
823
+ * For git commands, use execGit/execGitSilent from src/git/commands.js instead.
824
+ * @param {string} cmd - The executable (e.g. 'gh', 'glab', 'which')
825
+ * @param {string[]} args - Arguments array (no shell interpolation)
826
+ * @param {Object} [options] - Execution options
827
+ * @param {number} [options.timeout] - Command timeout in ms
828
+ * @returns {Promise<{stdout: string, stderr: string}>}
829
+ */
830
+ function execCli(cmd, args = [], options = {}) {
831
+ const { timeout = CLI_TIMEOUT, ...restOptions } = options;
824
832
  return new Promise((resolve, reject) => {
825
- const child = exec(command, { cwd: PROJECT_ROOT, timeout, ...restOptions }, (error, stdout, stderr) => {
833
+ const child = execFile(cmd, args, { cwd: PROJECT_ROOT, timeout, ...restOptions }, (error, stdout, stderr) => {
826
834
  if (error) {
827
835
  reject({ error, stdout, stderr });
828
836
  } else {
829
837
  resolve({ stdout: stdout.trim(), stderr: stderr.trim() });
830
838
  }
831
839
  });
832
- // Also kill the child if the timeout fires (exec timeout sends SIGTERM
833
- // but doesn't guarantee cleanup of process trees)
840
+ // Force-kill if process outlives timeout grace period
834
841
  if (timeout > 0) {
835
842
  const killTimer = setTimeout(() => {
836
843
  try { child.kill('SIGKILL'); } catch (e) { /* already dead */ }
@@ -841,18 +848,13 @@ function execAsync(command, options = {}) {
841
848
  }
842
849
 
843
850
  /**
844
- * Get diff stats between two commits
851
+ * Get diff stats between two commits (delegates to src/git/commands.js)
845
852
  * @param {string} fromCommit - Starting commit
846
853
  * @param {string} toCommit - Ending commit (default HEAD)
847
854
  * @returns {Promise<{added: number, deleted: number}>}
848
855
  */
849
856
  async function getDiffStats(fromCommit, toCommit = 'HEAD') {
850
- try {
851
- const { stdout } = await execAsync(`git diff --stat ${fromCommit}..${toCommit}`);
852
- return parseDiffStats(stdout);
853
- } catch (e) {
854
- return { added: 0, deleted: 0 };
855
- }
857
+ return getDiffStatsSafe(fromCommit, toCommit, { cwd: PROJECT_ROOT });
856
858
  }
857
859
 
858
860
  // formatTimeAgo imported from src/utils/time.js
@@ -945,10 +947,15 @@ async function refreshAllSparklines() {
945
947
  for (const branch of currentBranches.slice(0, 20)) { // Limit to top 20
946
948
  if (branch.isDeleted) continue;
947
949
 
948
- // Get commit counts for last 7 days
949
- const { stdout } = await execAsync(
950
- `git log origin/${branch.name} --since="7 days ago" --format="%ad" --date=format:"%Y-%m-%d" 2>/dev/null || git log ${branch.name} --since="7 days ago" --format="%ad" --date=format:"%Y-%m-%d" 2>/dev/null`
951
- ).catch(() => ({ stdout: '' }));
950
+ // Get commit counts for last 7 days (try remote, fall back to local)
951
+ const sparkResult = await execGitSilent(
952
+ ['log', `origin/${branch.name}`, '--since=7 days ago', '--format=%ad', '--date=format:%Y-%m-%d'],
953
+ { cwd: PROJECT_ROOT }
954
+ ) || await execGitSilent(
955
+ ['log', branch.name, '--since=7 days ago', '--format=%ad', '--date=format:%Y-%m-%d'],
956
+ { cwd: PROJECT_ROOT }
957
+ );
958
+ const stdout = sparkResult ? sparkResult.stdout : '';
952
959
 
953
960
  // Count commits per day
954
961
  const dayCounts = new Map();
@@ -978,10 +985,15 @@ async function refreshAllSparklines() {
978
985
 
979
986
  async function getPreviewData(branchName) {
980
987
  try {
981
- // Get last 5 commits
982
- const { stdout: logOutput } = await execAsync(
983
- `git log origin/${branchName} -5 --oneline 2>/dev/null || git log ${branchName} -5 --oneline 2>/dev/null`
984
- ).catch(() => ({ stdout: '' }));
988
+ // Get last 5 commits (try remote, fall back to local)
989
+ const logResult = await execGitSilent(
990
+ ['log', `origin/${branchName}`, '-5', '--oneline'],
991
+ { cwd: PROJECT_ROOT }
992
+ ) || await execGitSilent(
993
+ ['log', branchName, '-5', '--oneline'],
994
+ { cwd: PROJECT_ROOT }
995
+ );
996
+ const logOutput = logResult ? logResult.stdout : '';
985
997
 
986
998
  const commits = logOutput.split('\n').filter(Boolean).map(line => {
987
999
  const [hash, ...msgParts] = line.split(' ');
@@ -990,13 +1002,15 @@ async function getPreviewData(branchName) {
990
1002
 
991
1003
  // Get files changed (comparing to current branch)
992
1004
  let filesChanged = [];
993
- try {
994
- const { stdout: diffOutput } = await execAsync(
995
- `git diff --stat --name-only HEAD...origin/${branchName} 2>/dev/null || git diff --stat --name-only HEAD...${branchName} 2>/dev/null`
996
- );
997
- filesChanged = diffOutput.split('\n').filter(Boolean).slice(0, 8);
998
- } catch (e) {
999
- // No diff available
1005
+ const diffResult = await execGitSilent(
1006
+ ['diff', '--stat', '--name-only', `HEAD...origin/${branchName}`],
1007
+ { cwd: PROJECT_ROOT }
1008
+ ) || await execGitSilent(
1009
+ ['diff', '--stat', '--name-only', `HEAD...${branchName}`],
1010
+ { cwd: PROJECT_ROOT }
1011
+ );
1012
+ if (diffResult) {
1013
+ filesChanged = diffResult.stdout.split('\n').filter(Boolean).slice(0, 8);
1000
1014
  }
1001
1015
 
1002
1016
  return { commits, filesChanged };
@@ -1232,6 +1246,11 @@ function render() {
1232
1246
  if (state.stashConfirmMode) {
1233
1247
  renderer.renderStashConfirm(state, write);
1234
1248
  }
1249
+
1250
+ // Update notification modal renders on top of everything
1251
+ if (state.updateModalVisible) {
1252
+ renderer.renderUpdateModal(state, write);
1253
+ }
1235
1254
  }
1236
1255
 
1237
1256
  function showFlash(message) {
@@ -1307,12 +1326,12 @@ function hideStashConfirm() {
1307
1326
 
1308
1327
  async function getCurrentBranch() {
1309
1328
  try {
1310
- const { stdout } = await execAsync('git rev-parse --abbrev-ref HEAD');
1329
+ const { stdout } = await execGit(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: PROJECT_ROOT });
1311
1330
  // Check for detached HEAD state
1312
1331
  if (stdout === 'HEAD') {
1313
1332
  store.setState({ isDetachedHead: true });
1314
1333
  // Get the short commit hash instead
1315
- const { stdout: commitHash } = await execAsync('git rev-parse --short HEAD');
1334
+ const { stdout: commitHash } = await execGit(['rev-parse', '--short', 'HEAD'], { cwd: PROJECT_ROOT });
1316
1335
  return `HEAD@${commitHash}`;
1317
1336
  }
1318
1337
  store.setState({ isDetachedHead: false });
@@ -1324,7 +1343,7 @@ async function getCurrentBranch() {
1324
1343
 
1325
1344
  async function checkRemoteExists() {
1326
1345
  try {
1327
- const { stdout } = await execAsync('git remote');
1346
+ const { stdout } = await execGit(['remote'], { cwd: PROJECT_ROOT });
1328
1347
  const remotes = stdout.split('\n').filter(Boolean);
1329
1348
  return remotes.length > 0;
1330
1349
  } catch (e) {
@@ -1334,7 +1353,7 @@ async function checkRemoteExists() {
1334
1353
 
1335
1354
  async function hasUncommittedChanges() {
1336
1355
  try {
1337
- const { stdout } = await execAsync('git status --porcelain');
1356
+ const { stdout } = await execGit(['status', '--porcelain'], { cwd: PROJECT_ROOT, timeout: 5000 });
1338
1357
  return stdout.length > 0;
1339
1358
  } catch (e) {
1340
1359
  return false;
@@ -1345,14 +1364,15 @@ async function hasUncommittedChanges() {
1345
1364
 
1346
1365
  async function getAllBranches() {
1347
1366
  try {
1348
- await execAsync('git fetch --all --prune 2>/dev/null').catch(() => {});
1367
+ await execGitSilent(['fetch', '--all', '--prune'], { cwd: PROJECT_ROOT, timeout: 60000 });
1349
1368
 
1350
1369
  const branchList = [];
1351
1370
  const seenBranches = new Set();
1352
1371
 
1353
1372
  // Get local branches
1354
- const { stdout: localOutput } = await execAsync(
1355
- 'git for-each-ref --sort=-committerdate --format="%(refname:short)|%(committerdate:iso8601)|%(objectname:short)|%(subject)" refs/heads/'
1373
+ const { stdout: localOutput } = await execGit(
1374
+ ['for-each-ref', '--sort=-committerdate', '--format=%(refname:short)|%(committerdate:iso8601)|%(objectname:short)|%(subject)', 'refs/heads/'],
1375
+ { cwd: PROJECT_ROOT }
1356
1376
  );
1357
1377
 
1358
1378
  for (const line of localOutput.split('\n').filter(Boolean)) {
@@ -1372,9 +1392,11 @@ async function getAllBranches() {
1372
1392
  }
1373
1393
 
1374
1394
  // Get remote branches (using configured remote name)
1375
- const { stdout: remoteOutput } = await execAsync(
1376
- `git for-each-ref --sort=-committerdate --format="%(refname:short)|%(committerdate:iso8601)|%(objectname:short)|%(subject)" refs/remotes/${REMOTE_NAME}/`
1377
- ).catch(() => ({ stdout: '' }));
1395
+ const remoteResult = await execGitSilent(
1396
+ ['for-each-ref', '--sort=-committerdate', '--format=%(refname:short)|%(committerdate:iso8601)|%(objectname:short)|%(subject)', `refs/remotes/${REMOTE_NAME}/`],
1397
+ { cwd: PROJECT_ROOT }
1398
+ );
1399
+ const remoteOutput = remoteResult ? remoteResult.stdout : '';
1378
1400
 
1379
1401
  const remotePrefix = `${REMOTE_NAME}/`;
1380
1402
  for (const line of remoteOutput.split('\n').filter(Boolean)) {
@@ -1437,13 +1459,14 @@ async function switchToBranch(branchName, recordHistory = true) {
1437
1459
  addLog(`Switching to ${safeBranchName}...`, 'update');
1438
1460
  render();
1439
1461
 
1440
- const { stdout: localBranches } = await execAsync('git branch --list');
1441
- const hasLocal = localBranches.split('\n').some(b => b.trim().replace('* ', '') === safeBranchName);
1462
+ const { stdout: localBranches } = await execGit(['branch', '--list'], { cwd: PROJECT_ROOT });
1463
+ const hasLocal = localBranches.split('\n').some(b => b.trim().replace(/^\* /, '') === safeBranchName);
1442
1464
 
1443
1465
  if (hasLocal) {
1444
- await execAsync(`git checkout -- . 2>/dev/null; git checkout "${safeBranchName}"`);
1466
+ await execGitSilent(['checkout', '--', '.'], { cwd: PROJECT_ROOT });
1467
+ await execGit(['checkout', safeBranchName], { cwd: PROJECT_ROOT });
1445
1468
  } else {
1446
- await execAsync(`git checkout -b "${safeBranchName}" "${REMOTE_NAME}/${safeBranchName}"`);
1469
+ await execGit(['checkout', '-b', safeBranchName, `${REMOTE_NAME}/${safeBranchName}`], { cwd: PROJECT_ROOT });
1447
1470
  }
1448
1471
 
1449
1472
  store.setState({ currentBranch: safeBranchName, isDetachedHead: false });
@@ -1535,7 +1558,7 @@ async function pullCurrentBranch() {
1535
1558
  addLog(`Pulling from ${REMOTE_NAME}/${branch}...`, 'update');
1536
1559
  render();
1537
1560
 
1538
- await execAsync(`git pull "${REMOTE_NAME}" "${branch}"`);
1561
+ await execGit(['pull', REMOTE_NAME, branch], { cwd: PROJECT_ROOT, timeout: 60000 });
1539
1562
  addLog('Pulled successfully', 'success');
1540
1563
  pendingDirtyOperation = null;
1541
1564
  notifyClients();
@@ -1862,12 +1885,12 @@ async function pollGitChanges() {
1862
1885
  const oldCommit = currentInfo.commit;
1863
1886
 
1864
1887
  try {
1865
- await execAsync(`git pull "${REMOTE_NAME}" "${autoPullBranchName}"`);
1888
+ await execGit(['pull', REMOTE_NAME, autoPullBranchName], { cwd: PROJECT_ROOT, timeout: 60000 });
1866
1889
  addLog(`Pulled successfully from ${autoPullBranchName}`, 'success');
1867
1890
  currentInfo.hasUpdates = false;
1868
1891
  store.setState({ hasMergeConflict: false });
1869
1892
  // Update the stored commit to the new one
1870
- const newCommit = await execAsync('git rev-parse --short HEAD');
1893
+ const newCommit = await execGit(['rev-parse', '--short', 'HEAD'], { cwd: PROJECT_ROOT });
1871
1894
  currentInfo.commit = newCommit.stdout.trim();
1872
1895
  previousBranchStates.set(autoPullBranchName, newCommit.stdout.trim());
1873
1896
  // Reload browsers
@@ -2268,9 +2291,9 @@ function setupKeyboardInput() {
2268
2291
  try {
2269
2292
  let result;
2270
2293
  if (platform === 'gitlab') {
2271
- result = await execAsync(`glab mr create --source-branch="${aBranch.name}" --fill --yes 2>&1`);
2294
+ result = await execCli('glab', ['mr', 'create', `--source-branch=${aBranch.name}`, '--fill', '--yes']);
2272
2295
  } else {
2273
- result = await execAsync(`gh pr create --head "${aBranch.name}" --fill 2>&1`);
2296
+ result = await execCli('gh', ['pr', 'create', '--head', aBranch.name, '--fill']);
2274
2297
  }
2275
2298
  addLog(`${prLabel} created: ${(result.stdout || '').trim().split('\n').pop()}`, 'success');
2276
2299
  // Invalidate cache and refresh modal data
@@ -2309,9 +2332,9 @@ function setupKeyboardInput() {
2309
2332
  render();
2310
2333
  try {
2311
2334
  if (platform === 'gitlab') {
2312
- await execAsync(`glab mr approve ${prInfo.number} 2>&1`);
2335
+ await execCli('glab', ['mr', 'approve', String(prInfo.number)]);
2313
2336
  } else {
2314
- await execAsync(`gh pr review ${prInfo.number} --approve 2>&1`);
2337
+ await execCli('gh', ['pr', 'review', String(prInfo.number), '--approve']);
2315
2338
  }
2316
2339
  addLog(`${prLabel} #${prInfo.number} approved`, 'success');
2317
2340
  // Refresh PR info to show updated status
@@ -2329,9 +2352,9 @@ function setupKeyboardInput() {
2329
2352
  render();
2330
2353
  try {
2331
2354
  if (platform === 'gitlab') {
2332
- await execAsync(`glab mr merge ${prInfo.number} --squash --remove-source-branch --yes 2>&1`);
2355
+ await execCli('glab', ['mr', 'merge', String(prInfo.number), '--squash', '--remove-source-branch', '--yes']);
2333
2356
  } else {
2334
- await execAsync(`gh pr merge ${prInfo.number} --squash --delete-branch 2>&1`);
2357
+ await execCli('gh', ['pr', 'merge', String(prInfo.number), '--squash', '--delete-branch']);
2335
2358
  }
2336
2359
  addLog(`${prLabel} #${prInfo.number} merged`, 'success');
2337
2360
  store.setState({ actionMode: false, actionData: null, actionLoading: false });
@@ -2351,13 +2374,13 @@ function setupKeyboardInput() {
2351
2374
  render();
2352
2375
  try {
2353
2376
  if (platform === 'gitlab') {
2354
- const result = await execAsync(`glab ci status --branch "${aBranch.name}" 2>&1`);
2377
+ const result = await execCli('glab', ['ci', 'status', '--branch', aBranch.name]);
2355
2378
  const lines = (result.stdout || '').trim().split('\n');
2356
2379
  for (const line of lines.slice(0, 3)) {
2357
2380
  addLog(line.trim(), 'info');
2358
2381
  }
2359
2382
  } else if (prInfo) {
2360
- const result = await execAsync(`gh pr checks ${prInfo.number} 2>&1`);
2383
+ const result = await execCli('gh', ['pr', 'checks', String(prInfo.number)]);
2361
2384
  const lines = (result.stdout || '').trim().split('\n');
2362
2385
  for (const line of lines.slice(0, 5)) {
2363
2386
  addLog(line.trim(), 'info');
@@ -2438,6 +2461,71 @@ function setupKeyboardInput() {
2438
2461
  return; // Ignore other keys in cleanup mode
2439
2462
  }
2440
2463
 
2464
+ // Handle update notification modal
2465
+ if (store.get('updateModalVisible')) {
2466
+ if (store.get('updateInProgress')) {
2467
+ return; // Block all keys while update is running
2468
+ }
2469
+ if (key === '\u001b') {
2470
+ store.setState({ updateModalVisible: false, updateModalSelectedIndex: 0 });
2471
+ render();
2472
+ return;
2473
+ }
2474
+ if (key === '\u001b[A' || key === 'k') { // Up
2475
+ const idx = store.get('updateModalSelectedIndex');
2476
+ if (idx > 0) {
2477
+ store.setState({ updateModalSelectedIndex: idx - 1 });
2478
+ render();
2479
+ }
2480
+ return;
2481
+ }
2482
+ if (key === '\u001b[B' || key === 'j') { // Down
2483
+ const idx = store.get('updateModalSelectedIndex');
2484
+ if (idx < 1) {
2485
+ store.setState({ updateModalSelectedIndex: idx + 1 });
2486
+ render();
2487
+ }
2488
+ return;
2489
+ }
2490
+ if (key === '\r' || key === '\n') {
2491
+ const selectedIdx = store.get('updateModalSelectedIndex') || 0;
2492
+ if (selectedIdx === 0) {
2493
+ // Update now — run npm i -g git-watchtower
2494
+ store.setState({ updateInProgress: true });
2495
+ render();
2496
+ const { spawn } = require('child_process');
2497
+ const child = spawn('npm', ['i', '-g', 'git-watchtower'], {
2498
+ stdio: 'ignore',
2499
+ detached: false,
2500
+ });
2501
+ child.on('close', (code) => {
2502
+ store.setState({ updateInProgress: false, updateModalVisible: false, updateModalSelectedIndex: 0 });
2503
+ if (code === 0) {
2504
+ store.setState({ updateAvailable: null });
2505
+ addLog('Successfully updated git-watchtower! Restart to use new version.', 'update');
2506
+ showFlash('Updated! Restart to use new version.');
2507
+ } else {
2508
+ addLog(`Update failed (exit code ${code}). Run manually: npm i -g git-watchtower`, 'error');
2509
+ showFlash('Update failed. Try manually: npm i -g git-watchtower');
2510
+ }
2511
+ render();
2512
+ });
2513
+ child.on('error', (err) => {
2514
+ store.setState({ updateInProgress: false, updateModalVisible: false, updateModalSelectedIndex: 0 });
2515
+ addLog(`Update failed: ${err.message}. Run manually: npm i -g git-watchtower`, 'error');
2516
+ showFlash('Update failed. Try manually: npm i -g git-watchtower');
2517
+ render();
2518
+ });
2519
+ } else {
2520
+ // Show update command — dismiss modal with flash showing the command
2521
+ store.setState({ updateModalVisible: false, updateModalSelectedIndex: 0 });
2522
+ showFlash('Run: npm i -g git-watchtower');
2523
+ }
2524
+ return;
2525
+ }
2526
+ return; // Block all other keys while modal is shown
2527
+ }
2528
+
2441
2529
  // Handle stash confirmation dialog
2442
2530
  if (store.get('stashConfirmMode')) {
2443
2531
  if (key === '\u001b[A' || key === 'k') { // Up
@@ -2958,11 +3046,26 @@ async function start() {
2958
3046
  // Check for newer version on npm (non-blocking, silent on failure)
2959
3047
  checkForUpdate().then((latestVersion) => {
2960
3048
  if (latestVersion) {
2961
- store.setState({ updateAvailable: latestVersion });
3049
+ store.setState({ updateAvailable: latestVersion, updateModalVisible: true });
2962
3050
  addLog(`New version available: ${latestVersion} \u2192 npm i -g git-watchtower`, 'update');
2963
3051
  render();
2964
3052
  }
2965
3053
  }).catch(() => {});
3054
+
3055
+ // Re-check for updates periodically (every 4 hours) while running
3056
+ const periodicCheck = startPeriodicUpdateCheck((latestVersion) => {
3057
+ const alreadyKnown = store.get('updateAvailable');
3058
+ store.setState({ updateAvailable: latestVersion });
3059
+ if (!alreadyKnown) {
3060
+ // First time discovering an update during this session — show modal
3061
+ store.setState({ updateModalVisible: true });
3062
+ addLog(`New version available: ${latestVersion} \u2192 npm i -g git-watchtower`, 'update');
3063
+ }
3064
+ render();
3065
+ });
3066
+
3067
+ // Clean up periodic check on exit
3068
+ process.on('exit', () => periodicCheck.stop());
2966
3069
  }
2967
3070
 
2968
3071
  start().catch(err => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-watchtower",
3
- "version": "1.8.1",
3
+ "version": "1.8.3",
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": {
@@ -102,6 +102,9 @@
102
102
  * @property {string} projectName - Project name
103
103
  * @property {number} clientCount - Connected SSE clients
104
104
  * @property {string|null} updateAvailable - Latest version if update available, or null
105
+ * @property {boolean} updateModalVisible - Whether the update notification modal is shown
106
+ * @property {number} updateModalSelectedIndex - Selected option index in the update modal
107
+ * @property {boolean} updateInProgress - Whether an update is currently being installed
105
108
  */
106
109
 
107
110
  /**
@@ -184,6 +187,9 @@ function getInitialState() {
184
187
 
185
188
  // Version check
186
189
  updateAvailable: null,
190
+ updateModalVisible: false,
191
+ updateModalSelectedIndex: 0,
192
+ updateInProgress: false,
187
193
  };
188
194
  }
189
195
 
@@ -1311,6 +1311,111 @@ function renderCleanupConfirm(state, write) {
1311
1311
  }
1312
1312
  }
1313
1313
 
1314
+ // ---------------------------------------------------------------------------
1315
+ // renderUpdateModal
1316
+ // ---------------------------------------------------------------------------
1317
+
1318
+ /**
1319
+ * Render a prominent update-available notification modal.
1320
+ *
1321
+ * @param {object} state
1322
+ * @param {function} write
1323
+ */
1324
+ function renderUpdateModal(state, write) {
1325
+ if (!state.updateModalVisible || !state.updateAvailable) return;
1326
+
1327
+ const latestVersion = state.updateAvailable;
1328
+ const currentVer = PACKAGE_VERSION;
1329
+ const updateCmd = 'npm i -g git-watchtower';
1330
+
1331
+ const options = [
1332
+ 'Update now',
1333
+ 'Show update command',
1334
+ ];
1335
+ const selectedIdx = state.updateModalSelectedIndex || 0;
1336
+
1337
+ const width = Math.min(52, state.terminalWidth - 4);
1338
+ const col = Math.floor((state.terminalWidth - width) / 2);
1339
+ const row = Math.max(2, Math.floor((state.terminalHeight - 14) / 2));
1340
+
1341
+ const lines = [];
1342
+ lines.push('Update Available');
1343
+ lines.push('');
1344
+ lines.push(`Current version: v${currentVer}`);
1345
+ lines.push(`Latest version: v${latestVersion}`);
1346
+ lines.push('');
1347
+
1348
+ if (state.updateInProgress) {
1349
+ lines.push('Updating...');
1350
+ lines.push('');
1351
+ lines.push(` ${updateCmd}`);
1352
+ lines.push('');
1353
+ lines.push('Please wait...');
1354
+ } else {
1355
+ // Option lines
1356
+ const optionStartIdx = lines.length;
1357
+ for (const opt of options) {
1358
+ lines.push(opt);
1359
+ }
1360
+ lines.push('');
1361
+ lines.push('[Enter] Select [Esc] Dismiss');
1362
+ }
1363
+
1364
+ const optionStartIdx = state.updateInProgress ? -1 : 5;
1365
+ const height = lines.length + 2;
1366
+
1367
+ // Draw magenta double-border box
1368
+ write(ansi.moveTo(row, col));
1369
+ write(ansi.magenta + ansi.bold);
1370
+ write(box.dTopLeft + box.dHorizontal.repeat(width - 2) + box.dTopRight);
1371
+
1372
+ for (let i = 1; i < height - 1; i++) {
1373
+ write(ansi.moveTo(row + i, col));
1374
+ write(ansi.magenta + box.dVertical + ansi.reset + ' ' + ' '.repeat(width - 6) + ' ' + ansi.magenta + box.dVertical + ansi.reset);
1375
+ }
1376
+
1377
+ write(ansi.moveTo(row + height - 1, col));
1378
+ write(ansi.magenta + box.dBottomLeft + box.dHorizontal.repeat(width - 2) + box.dBottomRight);
1379
+ write(ansi.reset);
1380
+
1381
+ // Render content
1382
+ let contentRow = row + 1;
1383
+ for (let i = 0; i < lines.length; i++) {
1384
+ const line = lines[i];
1385
+ write(ansi.moveTo(contentRow, col + 3));
1386
+
1387
+ if (i === 0) {
1388
+ // Title — centered, bold magenta
1389
+ const titlePadding = Math.floor((width - 6 - line.length) / 2);
1390
+ write(' '.repeat(titlePadding) + ansi.magenta + ansi.bold + line + ansi.reset + ' '.repeat(Math.max(0, width - 6 - titlePadding - line.length)));
1391
+ } else if (i >= optionStartIdx && optionStartIdx >= 0 && i < optionStartIdx + options.length) {
1392
+ // Selectable option
1393
+ const optIdx = i - optionStartIdx;
1394
+ const isSelected = optIdx === selectedIdx;
1395
+ const prefix = isSelected ? '\u25b8 ' : ' ';
1396
+ const optText = prefix + line;
1397
+ if (isSelected) {
1398
+ write(ansi.bold + ansi.cyan + padRight(optText, width - 6) + ansi.reset);
1399
+ } else {
1400
+ write(ansi.gray + padRight(optText, width - 6) + ansi.reset);
1401
+ }
1402
+ } else if (line === '[Enter] Select [Esc] Dismiss') {
1403
+ // Keyboard hints — centered, dim
1404
+ const lPadding = Math.floor((width - 6 - line.length) / 2);
1405
+ write(ansi.dim + ' '.repeat(Math.max(0, lPadding)) + line + ' '.repeat(Math.max(0, width - 6 - lPadding - line.length)) + ansi.reset);
1406
+ } else if (line === 'Updating...') {
1407
+ write(ansi.bold + ansi.yellow + padRight(line, width - 6) + ansi.reset);
1408
+ } else if (line === 'Please wait...') {
1409
+ const lPadding = Math.floor((width - 6 - line.length) / 2);
1410
+ write(ansi.dim + ' '.repeat(Math.max(0, lPadding)) + line + ' '.repeat(Math.max(0, width - 6 - lPadding - line.length)) + ansi.reset);
1411
+ } else {
1412
+ // Regular content
1413
+ write(padRight(line, width - 6));
1414
+ }
1415
+ contentRow++;
1416
+ }
1417
+ }
1418
+
1314
1419
  // ---------------------------------------------------------------------------
1315
1420
  // Exports
1316
1421
  // ---------------------------------------------------------------------------
@@ -1330,4 +1435,5 @@ module.exports = {
1330
1435
  renderActionModal,
1331
1436
  renderStashConfirm,
1332
1437
  renderCleanupConfirm,
1438
+ renderUpdateModal,
1333
1439
  };
@@ -54,4 +54,25 @@ function checkForUpdate() {
54
54
  });
55
55
  }
56
56
 
57
- module.exports = { checkForUpdate, compareVersions };
57
+ /** Default interval between periodic update checks (4 hours in ms) */
58
+ const UPDATE_CHECK_INTERVAL = 4 * 60 * 60 * 1000;
59
+
60
+ /**
61
+ * Create a periodic update checker that re-checks npm at a fixed interval.
62
+ * @param {(latestVersion: string) => void} onUpdateFound - Called when a new version is detected
63
+ * @param {number} [interval] - Check interval in ms (default: 4 hours)
64
+ * @returns {{ stop: () => void }} Controller with stop() to clear the timer
65
+ */
66
+ function startPeriodicUpdateCheck(onUpdateFound, interval = UPDATE_CHECK_INTERVAL) {
67
+ const timerId = setInterval(() => {
68
+ checkForUpdate()
69
+ .then((latestVersion) => {
70
+ if (latestVersion) onUpdateFound(latestVersion);
71
+ })
72
+ .catch(() => {});
73
+ }, interval);
74
+
75
+ return { stop: () => clearInterval(timerId) };
76
+ }
77
+
78
+ module.exports = { checkForUpdate, compareVersions, startPeriodicUpdateCheck, UPDATE_CHECK_INTERVAL };