git-watchtower 1.14.4 → 1.14.6

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.
@@ -84,7 +84,7 @@ const { parseGitHubPr, parseGitLabMr, parseGitHubPrList, parseGitLabMrList, isBa
84
84
  // ============================================================================
85
85
  const { isValidBranchName, sanitizeBranchName, getGoneBranches, deleteGoneBranches, getCurrentBranch: getCurrentBranchRaw, getAllBranches: getAllBranchesRaw } = require('../src/git/branch');
86
86
  const { pruneStaleEntries } = require('../src/polling/engine');
87
- const { isGitAvailable: checkGitAvailable, execGit, execGitSilent, getDiffStats: getDiffStatsSafe, getAheadBehind, getDiffShortstat, hasUncommittedChanges: checkUncommittedChanges } = require('../src/git/commands');
87
+ const { isGitAvailable: checkGitAvailable, execGit, execGitOptional, getDiffStats: getDiffStatsSafe, getAheadBehind, getDiffShortstat, hasUncommittedChanges: checkUncommittedChanges } = require('../src/git/commands');
88
88
 
89
89
  // Session stats (always-on, non-casino stats)
90
90
  const sessionStats = require('../src/stats/session');
@@ -506,6 +506,8 @@ async function getRemoteWebUrl(branchName) {
506
506
  const parsed = parseRemoteUrl(stdout);
507
507
  return buildWebUrl(parsed, branchName);
508
508
  } catch (e) {
509
+ // No remote configured, or URL isn't parseable as github/gitlab —
510
+ // action modal hides the "view on web" link, nothing else breaks.
509
511
  return null;
510
512
  }
511
513
  }
@@ -513,10 +515,10 @@ async function getRemoteWebUrl(branchName) {
513
515
  // Extract Claude Code session URL from the most recent commit on a branch
514
516
  async function getSessionUrl(branchName) {
515
517
  // Try remote branch first, fall back to local
516
- const result = await execGitSilent(
518
+ const result = await execGitOptional(
517
519
  ['log', `${REMOTE_NAME}/${branchName}`, '-1', '--format=%B'],
518
520
  { cwd: PROJECT_ROOT }
519
- ) || await execGitSilent(
521
+ ) || await execGitOptional(
520
522
  ['log', branchName, '-1', '--format=%B'],
521
523
  { cwd: PROJECT_ROOT }
522
524
  );
@@ -866,7 +868,7 @@ const CLI_TIMEOUT = 30000;
866
868
 
867
869
  /**
868
870
  * Execute a non-git CLI command safely using execFile (no shell interpolation).
869
- * For git commands, use execGit/execGitSilent from src/git/commands.js instead.
871
+ * For git commands, use execGit/execGitOptional from src/git/commands.js instead.
870
872
  * @param {string} cmd - The executable (e.g. 'gh', 'glab', 'which')
871
873
  * @param {string[]} args - Arguments array (no shell interpolation)
872
874
  * @param {Object} [options] - Execution options
@@ -922,6 +924,9 @@ async function detectDefaultBranch() {
922
924
  const { stdout } = await execGit(['symbolic-ref', `refs/remotes/${REMOTE_NAME}/HEAD`], { cwd: PROJECT_ROOT });
923
925
  detectedDefaultBranch = stdout.trim().replace('refs/remotes/', '');
924
926
  } catch (e) {
927
+ // No remote HEAD and none of the common names exist — ahead/behind
928
+ // is hidden entirely (fetchAheadBehindForBranches short-circuits on
929
+ // a null detectedDefaultBranch).
925
930
  detectedDefaultBranch = null;
926
931
  }
927
932
  }
@@ -1051,10 +1056,10 @@ async function refreshAllSparklines() {
1051
1056
 
1052
1057
  try {
1053
1058
  // Get commit counts for last 7 days (try remote, fall back to local)
1054
- const sparkResult = await execGitSilent(
1059
+ const sparkResult = await execGitOptional(
1055
1060
  ['log', `origin/${branch.name}`, '--since=7 days ago', '--format=%ad', '--date=format:%Y-%m-%d'],
1056
1061
  { cwd: PROJECT_ROOT }
1057
- ) || await execGitSilent(
1062
+ ) || await execGitOptional(
1058
1063
  ['log', branch.name, '--since=7 days ago', '--format=%ad', '--date=format:%Y-%m-%d'],
1059
1064
  { cwd: PROJECT_ROOT }
1060
1065
  );
@@ -1090,10 +1095,10 @@ async function refreshAllSparklines() {
1090
1095
  async function getPreviewData(branchName) {
1091
1096
  try {
1092
1097
  // Get last 5 commits (try remote, fall back to local)
1093
- const logResult = await execGitSilent(
1098
+ const logResult = await execGitOptional(
1094
1099
  ['log', `origin/${branchName}`, '-5', '--oneline'],
1095
1100
  { cwd: PROJECT_ROOT }
1096
- ) || await execGitSilent(
1101
+ ) || await execGitOptional(
1097
1102
  ['log', branchName, '-5', '--oneline'],
1098
1103
  { cwd: PROJECT_ROOT }
1099
1104
  );
@@ -1106,10 +1111,10 @@ async function getPreviewData(branchName) {
1106
1111
 
1107
1112
  // Get files changed (comparing to current branch)
1108
1113
  let filesChanged = [];
1109
- const diffResult = await execGitSilent(
1114
+ const diffResult = await execGitOptional(
1110
1115
  ['diff', '--stat', '--name-only', `HEAD...origin/${branchName}`],
1111
1116
  { cwd: PROJECT_ROOT }
1112
- ) || await execGitSilent(
1117
+ ) || await execGitOptional(
1113
1118
  ['diff', '--stat', '--name-only', `HEAD...${branchName}`],
1114
1119
  { cwd: PROJECT_ROOT }
1115
1120
  );
@@ -1119,6 +1124,9 @@ async function getPreviewData(branchName) {
1119
1124
 
1120
1125
  return { commits, filesChanged };
1121
1126
  } catch (e) {
1127
+ // Preview pane is best-effort — branch may not exist on the remote yet,
1128
+ // refs may have been pruned mid-fetch. Empty pane is better than an
1129
+ // error toast for a background UI enrichment.
1122
1130
  return { commits: [], filesChanged: [] };
1123
1131
  }
1124
1132
  }
@@ -1585,7 +1593,7 @@ async function pullCurrentBranch() {
1585
1593
 
1586
1594
  // Capture HEAD before pull so we can diff against it when git pull
1587
1595
  // doesn't put a clean "already up to date" message on stdout.
1588
- const preHead = await execGitSilent(['rev-parse', 'HEAD'], { cwd: PROJECT_ROOT });
1596
+ const preHead = await execGitOptional(['rev-parse', 'HEAD'], { cwd: PROJECT_ROOT });
1589
1597
  const oldCommit = preHead && preHead.stdout ? preHead.stdout.trim() : null;
1590
1598
 
1591
1599
  const result = await execGit(['pull', REMOTE_NAME, branch], { cwd: PROJECT_ROOT, timeout: 60000 });
@@ -1958,6 +1966,8 @@ async function pollGitChanges() {
1958
1966
  lastPrStatusFetch = Date.now();
1959
1967
  prStatusFetchInFlight = false;
1960
1968
  }).catch(() => {
1969
+ // gh/glab errored (unauthed, rate-limited, network). PR indicators
1970
+ // keep their last-known state; the next poll tick will retry.
1961
1971
  prStatusFetchInFlight = false;
1962
1972
  });
1963
1973
  }
@@ -2862,6 +2872,8 @@ function setupKeyboardInput() {
2862
2872
  render();
2863
2873
  }
2864
2874
  }).catch(() => {
2875
+ // Async enrichment failed (no remote, gh/glab errored, etc.).
2876
+ // Drop the spinner so the modal shows what we have from phase 1.
2865
2877
  if (store.get('actionMode') && store.get('actionData') && store.get('actionData').branch.name === branch.name) {
2866
2878
  store.setState({ actionLoading: false });
2867
2879
  render();
@@ -3534,6 +3546,23 @@ process.on('uncaughtException', async (err) => {
3534
3546
  process.exit(1);
3535
3547
  });
3536
3548
 
3549
+ // Mirror of uncaughtException for unhandled promise rejections. Without this,
3550
+ // Node 15+ crashes the process on a missed .catch() tail with no telemetry
3551
+ // and no terminal restore — leaving the TUI user in a broken terminal. Also
3552
+ // high-signal: an unhandled rejection reaching here means we missed a .catch()
3553
+ // somewhere and telemetry will tell us where.
3554
+ process.on('unhandledRejection', async (reason) => {
3555
+ isShuttingDown = true;
3556
+
3557
+ cleanupResources();
3558
+
3559
+ const err = reason instanceof Error ? reason : new Error(String(reason));
3560
+ try { telemetry.captureError(err); } catch (_) { /* telemetry must never prevent crash cleanup */ }
3561
+ console.error('Unhandled rejection:', reason);
3562
+ try { await telemetry.shutdown(); } catch (_) { /* telemetry must never prevent crash cleanup */ }
3563
+ process.exit(1);
3564
+ });
3565
+
3537
3566
  // ============================================================================
3538
3567
  // Startup
3539
3568
  // ============================================================================
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-watchtower",
3
- "version": "1.14.4",
3
+ "version": "1.14.6",
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/git/branch.js CHANGED
@@ -3,7 +3,7 @@
3
3
  * Provides branch management and parsing
4
4
  */
5
5
 
6
- const { execGit, execGitSilent, fetch, hasUncommittedChanges, getCommitsByDay, log, deleteLocalBranch } = require('./commands');
6
+ const { execGit, execGitOptional, fetch, hasUncommittedChanges, getCommitsByDay, log, deleteLocalBranch } = require('./commands');
7
7
  const { GitError, ValidationError } = require('../utils/errors');
8
8
 
9
9
  // Valid git branch name pattern (conservative)
@@ -73,6 +73,8 @@ async function getCurrentBranch(cwd) {
73
73
 
74
74
  return { name: stdout, isDetached: false };
75
75
  } catch (error) {
76
+ // Not in a git repo, or git is broken. name:null signals "no current
77
+ // branch" to the caller, which renders as "Not in a git repository".
76
78
  return { name: null, isDetached: false };
77
79
  }
78
80
  }
@@ -100,7 +102,7 @@ async function getAllBranches(options = {}) {
100
102
  // Get local branches
101
103
  // Use \x1f (Unit Separator) as delimiter since | can appear in commit subjects
102
104
  const delimiter = '\x1f';
103
- const localResult = await execGitSilent(
105
+ const localResult = await execGitOptional(
104
106
  ['for-each-ref', '--sort=-committerdate', `--format=%(refname:short)${delimiter}%(committerdate:iso8601)${delimiter}%(objectname:short)${delimiter}%(subject)`, 'refs/heads/'],
105
107
  { cwd }
106
108
  );
@@ -125,7 +127,7 @@ async function getAllBranches(options = {}) {
125
127
  }
126
128
 
127
129
  // Get remote branches
128
- const remoteResult = await execGitSilent(
130
+ const remoteResult = await execGitOptional(
129
131
  ['for-each-ref', '--sort=-committerdate', `--format=%(refname:short)${delimiter}%(committerdate:iso8601)${delimiter}%(objectname:short)${delimiter}%(subject)`, `refs/remotes/${remoteName}/`],
130
132
  { cwd }
131
133
  );
@@ -300,6 +302,9 @@ async function getPreviewData(branchName, options = {}) {
300
302
 
301
303
  return { commits, files };
302
304
  } catch (error) {
305
+ // Preview is best-effort UI enrichment — branch may not exist on the
306
+ // remote yet, log may be empty, etc. Returning empty lists renders the
307
+ // preview pane as "no commits to show" rather than crashing the TUI.
303
308
  return { commits: [], files: [] };
304
309
  }
305
310
  }
@@ -345,6 +350,7 @@ async function getLocalBranches(cwd) {
345
350
  .map((b) => b.trim().replace(/^\* /, ''))
346
351
  .filter(Boolean);
347
352
  } catch (error) {
353
+ // Not in a git repo or git unavailable — treat as "no local branches".
348
354
  return [];
349
355
  }
350
356
  }
@@ -381,6 +387,8 @@ async function getGoneBranches(cwd) {
381
387
  }
382
388
  return gone;
383
389
  } catch (error) {
390
+ // `branch -vv` fails on a repo with no commits yet, or when git is
391
+ // broken. Caller treats "no gone branches" as "nothing to clean up".
384
392
  return [];
385
393
  }
386
394
  }
@@ -62,12 +62,20 @@ async function execGit(args, options = {}) {
62
62
  }
63
63
 
64
64
  /**
65
- * Execute git command silently (suppress errors)
65
+ * Execute a git command, collapsing any failure into a null result.
66
+ *
67
+ * Callers use this when they want "the output, or nothing": the fallback
68
+ * pattern `execGitOptional(A) || execGitOptional(B)` relies on this, as does
69
+ * every caller that treats a missing result as "no data to show." This does
70
+ * conflate "branch has no commits" (empty stdout, non-null result) with
71
+ * "git errored" (null result) — if you need to distinguish those, use
72
+ * execGit() and handle the throw yourself.
73
+ *
66
74
  * @param {string[]} command - Git arguments
67
75
  * @param {Object} [options] - Execution options
68
- * @returns {Promise<{stdout: string, stderr: string}|null>}
76
+ * @returns {Promise<{stdout: string, stderr: string}|null>} Result, or null if git failed
69
77
  */
70
- async function execGitSilent(command, options = {}) {
78
+ async function execGitOptional(command, options = {}) {
71
79
  try {
72
80
  return await execGit(command, options);
73
81
  } catch (error) {
@@ -112,6 +120,7 @@ async function getRemotes(cwd) {
112
120
  const { stdout } = await execGit(['remote'], { cwd, timeout: 5000 });
113
121
  return stdout.split('\n').filter(Boolean);
114
122
  } catch (error) {
123
+ // Not a git repo or `git remote` unavailable — treat as "no remotes".
115
124
  return [];
116
125
  }
117
126
  }
@@ -241,7 +250,9 @@ async function getCommitsByDay(branchName, days = 7, cwd) {
241
250
  }
242
251
  }
243
252
  } catch (error) {
244
- // Return zeros on error
253
+ // Sparkline is decorative — a git-log failure (missing branch, network
254
+ // hiccup) returns all-zeros and renders a flat bar rather than crashing
255
+ // the caller.
245
256
  }
246
257
 
247
258
  return counts;
@@ -331,6 +342,8 @@ async function getChangedFiles(branchName, baseBranch = 'HEAD', cwd) {
331
342
  );
332
343
  return stdout.split('\n').filter(Boolean);
333
344
  } catch (error) {
345
+ // Diff fails when branches share no common ancestor, or when either
346
+ // ref doesn't exist. Caller renders "no changed files" either way.
334
347
  return [];
335
348
  }
336
349
  }
@@ -370,6 +383,8 @@ async function getDiffStats(fromCommit, toCommit = 'HEAD', options = {}) {
370
383
  const { stdout } = await execGit(['diff', '--stat', `${fromCommit}..${toCommit}`], options);
371
384
  return parseDiffStats(stdout);
372
385
  } catch (e) {
386
+ // Diff fails when a ref is gone or commits share no ancestor. Zero
387
+ // added/deleted renders as "no change summary" in the activity log.
373
388
  return { added: 0, deleted: 0 };
374
389
  }
375
390
  }
@@ -418,6 +433,9 @@ async function getAheadBehind(branchRef, baseRef, options = {}) {
418
433
  ahead: parseInt(parts[1], 10) || 0,
419
434
  };
420
435
  } catch (e) {
436
+ // rev-list fails when baseRef is missing (no remote yet) or when the
437
+ // branches share no common ancestor. 0/0 hides the ahead/behind column
438
+ // for that row rather than crashing the background refresher.
421
439
  return { ahead: 0, behind: 0 };
422
440
  }
423
441
  }
@@ -438,13 +456,15 @@ async function getDiffShortstat(baseRef, branchRef, options = {}) {
438
456
  );
439
457
  return parseDiffStats(stdout);
440
458
  } catch (e) {
459
+ // See getAheadBehind: same background-refresh path, same 0/0 fallback
460
+ // to hide the +/- column when a ref is missing.
441
461
  return { added: 0, deleted: 0 };
442
462
  }
443
463
  }
444
464
 
445
465
  module.exports = {
446
466
  execGit,
447
- execGitSilent,
467
+ execGitOptional,
448
468
  isGitAvailable,
449
469
  isGitRepository,
450
470
  getRemotes,
package/src/index.js CHANGED
@@ -103,7 +103,7 @@ module.exports = {
103
103
  parseDiffStats: gitCommands.parseDiffStats,
104
104
  getDiffStats: gitCommands.getDiffStats,
105
105
  execGit: gitCommands.execGit,
106
- execGitSilent: gitCommands.execGitSilent,
106
+ execGitOptional: gitCommands.execGitOptional,
107
107
  isGitAvailable: gitCommands.isGitAvailable,
108
108
  isGitRepository: gitCommands.isGitRepository,
109
109
  getRemotes: gitCommands.getRemotes,
@@ -83,6 +83,9 @@ function readLock() {
83
83
  if (!data || !data.pid) return null;
84
84
  return data;
85
85
  } catch (e) {
86
+ // Lock file was unlinked between existsSync and readFileSync, or contains
87
+ // garbage (crashed mid-write). Treat as "no lock" so tryAcquireLock()
88
+ // can clean it up and retry.
86
89
  return null;
87
90
  }
88
91
  }
@@ -352,16 +352,35 @@ class ProcessManager {
352
352
  }
353
353
 
354
354
  /**
355
- * Restart the server process
355
+ * Restart the server process.
356
+ *
357
+ * Waits for the old process to fully exit (so it releases its port)
358
+ * rather than sleeping a static RESTART_DELAY that is shorter than
359
+ * the SIGKILL grace period. Bounded by KILL_GRACE_PERIOD + a small
360
+ * margin so we never hang indefinitely if 'close' doesn't fire.
361
+ *
356
362
  * @returns {Promise<{success: boolean, error?: Error, pid?: number}>}
357
363
  */
358
364
  async restart() {
359
365
  return this._restartMutex.withLock(async () => {
360
366
  const command = this.command;
367
+ // Capture before stop() nulls this.process.
368
+ const oldProc = this.process;
369
+
361
370
  this.stop();
362
371
 
363
- // Wait before restarting
364
- await new Promise((resolve) => setTimeout(resolve, RESTART_DELAY));
372
+ if (oldProc && oldProc.exitCode === null && oldProc.signalCode === null) {
373
+ // Old process hasn't exited yet — wait for 'close' with a bounded
374
+ // timeout so we don't hang if the process ignores all signals.
375
+ await new Promise((resolve) => {
376
+ const timeout = setTimeout(resolve, KILL_GRACE_PERIOD + RESTART_DELAY);
377
+ timeout.unref();
378
+ oldProc.once('close', () => {
379
+ clearTimeout(timeout);
380
+ resolve();
381
+ });
382
+ });
383
+ }
365
384
 
366
385
  return this.start(command);
367
386
  });
@@ -65,7 +65,7 @@ function getDashboardJs() {
65
65
  function loadPrefs() {
66
66
  try {
67
67
  return JSON.parse(localStorage.getItem(PREFS_KEY)) || {};
68
- } catch (e) { return {}; }
68
+ } catch (e) { /* localStorage unavailable (private mode) or stored JSON got corrupted — fall back to defaults */ return {}; }
69
69
  }
70
70
  function savePrefs(updates) {
71
71
  const prefs = loadPrefs();
@@ -64,6 +64,9 @@ function gitignorePatternToRegex(pattern) {
64
64
  try {
65
65
  return new RegExp(regexStr);
66
66
  } catch (e) {
67
+ // A malformed pattern in .gitignore shouldn't disable the file watcher
68
+ // entirely — signal "skip this line" via null, caller drops it from
69
+ // the pattern list. Extremely rare in practice.
67
70
  return null;
68
71
  }
69
72
  }
@@ -97,7 +100,10 @@ function parseGitignoreFile(gitignorePath) {
97
100
  }
98
101
  }
99
102
  } catch (err) {
100
- // Silently continue if we can't read .gitignore
103
+ // .gitignore exists but we can't read it (permissions, mid-edit truncation,
104
+ // encoding). The file watcher still runs — it just won't honor gitignore
105
+ // rules this session. Surfacing the error would be noise; the user will
106
+ // notice when they see changes to ignored files trigger reloads.
101
107
  }
102
108
 
103
109
  return patterns;
@@ -69,6 +69,9 @@ function readLock(file) {
69
69
  if (!data || typeof data.pid !== 'number') return null;
70
70
  return data;
71
71
  } catch (e) {
72
+ // File was unlinked between existsSync and readFileSync, or contains
73
+ // garbage (crashed mid-write, foreign content). Treat as "no lock" so
74
+ // acquire() can clean it up and retry.
72
75
  return null;
73
76
  }
74
77
  }