git-watchtower 1.9.4 → 1.9.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.
@@ -81,9 +81,9 @@ const { parseGitHubPr, parseGitLabMr, parseGitHubPrList, parseGitLabMrList, isBa
81
81
  // ============================================================================
82
82
  // Security & Validation (imported from src/git/branch.js and src/git/commands.js)
83
83
  // ============================================================================
84
- const { isValidBranchName, sanitizeBranchName, getGoneBranches, deleteGoneBranches } = require('../src/git/branch');
84
+ const { isValidBranchName, sanitizeBranchName, getGoneBranches, deleteGoneBranches, getCurrentBranch: getCurrentBranchRaw, getAllBranches: getAllBranchesRaw } = require('../src/git/branch');
85
85
  const { pruneStaleEntries } = require('../src/polling/engine');
86
- const { isGitAvailable: checkGitAvailable, execGit, execGitSilent, getDiffStats: getDiffStatsSafe, getAheadBehind, getDiffShortstat } = require('../src/git/commands');
86
+ const { isGitAvailable: checkGitAvailable, execGit, execGitSilent, getDiffStats: getDiffStatsSafe, getAheadBehind, getDiffShortstat, hasUncommittedChanges: checkUncommittedChanges } = require('../src/git/commands');
87
87
 
88
88
  // Session stats (always-on, non-casino stats)
89
89
  const sessionStats = require('../src/stats/session');
@@ -743,8 +743,9 @@ const actions = require('../src/ui/actions');
743
743
  // Diff stats parsing and stash imported from src/git/commands.js
744
744
  const { parseDiffStats, stash: gitStash, stashPop: gitStashPop } = require('../src/git/commands');
745
745
 
746
- // Server process command parsing
746
+ // Server process command parsing and static server utilities
747
747
  const { parseCommand } = require('../src/server/process');
748
+ const { getMimeType, injectLiveReload } = require('../src/server/static');
748
749
 
749
750
  // State (non-store globals)
750
751
  let previousBranchStates = new Map(); // branch name -> commit hash
@@ -781,39 +782,7 @@ const MAX_HISTORY = 20;
781
782
  let lastSparklineUpdate = 0;
782
783
  const SPARKLINE_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
783
784
 
784
- // MIME types
785
- const MIME_TYPES = {
786
- '.html': 'text/html',
787
- '.css': 'text/css',
788
- '.js': 'application/javascript',
789
- '.json': 'application/json',
790
- '.png': 'image/png',
791
- '.jpg': 'image/jpeg',
792
- '.jpeg': 'image/jpeg',
793
- '.gif': 'image/gif',
794
- '.svg': 'image/svg+xml',
795
- '.ico': 'image/x-icon',
796
- '.webp': 'image/webp',
797
- '.woff': 'font/woff',
798
- '.woff2': 'font/woff2',
799
- '.ttf': 'font/ttf',
800
- '.xml': 'application/xml',
801
- '.txt': 'text/plain',
802
- '.md': 'text/markdown',
803
- '.pdf': 'application/pdf',
804
- };
805
-
806
- // Live reload script
807
- const LIVE_RELOAD_SCRIPT = `
808
- <script>
809
- (function() {
810
- var source = new EventSource('/livereload');
811
- source.onmessage = function(e) {
812
- if (e.data === 'reload') location.reload();
813
- };
814
- })();
815
- </script>
816
- </body>`;
785
+ // MIME_TYPES and LIVE_RELOAD_SCRIPT imported from src/server/static.js (via getMimeType and injectLiveReload)
817
786
 
818
787
  // ============================================================================
819
788
  // Utility Functions
@@ -1379,20 +1348,9 @@ function hideStashConfirm() {
1379
1348
  // ============================================================================
1380
1349
 
1381
1350
  async function getCurrentBranch() {
1382
- try {
1383
- const { stdout } = await execGit(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: PROJECT_ROOT });
1384
- // Check for detached HEAD state
1385
- if (stdout === 'HEAD') {
1386
- store.setState({ isDetachedHead: true });
1387
- // Get the short commit hash instead
1388
- const { stdout: commitHash } = await execGit(['rev-parse', '--short', 'HEAD'], { cwd: PROJECT_ROOT });
1389
- return `HEAD@${commitHash}`;
1390
- }
1391
- store.setState({ isDetachedHead: false });
1392
- return stdout;
1393
- } catch (e) {
1394
- return null;
1395
- }
1351
+ const result = await getCurrentBranchRaw(PROJECT_ROOT);
1352
+ store.setState({ isDetachedHead: result.isDetached });
1353
+ return result.name;
1396
1354
  }
1397
1355
 
1398
1356
  async function checkRemoteExists() {
@@ -1406,91 +1364,14 @@ async function checkRemoteExists() {
1406
1364
  }
1407
1365
 
1408
1366
  async function hasUncommittedChanges() {
1409
- try {
1410
- const { stdout } = await execGit(['status', '--porcelain'], { cwd: PROJECT_ROOT, timeout: 5000 });
1411
- return stdout.length > 0;
1412
- } catch (e) {
1413
- return false;
1414
- }
1367
+ return checkUncommittedChanges(PROJECT_ROOT);
1415
1368
  }
1416
1369
 
1417
1370
  // isAuthError, isMergeConflict, isNetworkError imported from src/utils/errors.js
1418
1371
 
1419
1372
  async function getAllBranches() {
1420
1373
  try {
1421
- await execGitSilent(['fetch', '--all', '--prune'], { cwd: PROJECT_ROOT, timeout: 60000 });
1422
-
1423
- const branchList = [];
1424
- const seenBranches = new Set();
1425
-
1426
- // Get local branches
1427
- // Use \x1f (Unit Separator) as delimiter since | can appear in commit subjects
1428
- const delimiter = '\x1f';
1429
- const { stdout: localOutput } = await execGit(
1430
- ['for-each-ref', '--sort=-committerdate', `--format=%(refname:short)${delimiter}%(committerdate:iso8601)${delimiter}%(objectname:short)${delimiter}%(subject)`, 'refs/heads/'],
1431
- { cwd: PROJECT_ROOT }
1432
- );
1433
-
1434
- for (const line of localOutput.split('\n').filter(Boolean)) {
1435
- const [name, dateStr, commit, ...subjectParts] = line.split(delimiter);
1436
- const subject = subjectParts.join(delimiter);
1437
- if (!seenBranches.has(name) && isValidBranchName(name)) {
1438
- seenBranches.add(name);
1439
- branchList.push({
1440
- name,
1441
- commit,
1442
- subject: subject || '',
1443
- date: new Date(dateStr),
1444
- isLocal: true,
1445
- hasRemote: false,
1446
- hasUpdates: false,
1447
- });
1448
- }
1449
- }
1450
-
1451
- // Get remote branches (using configured remote name)
1452
- const remoteResult = await execGitSilent(
1453
- ['for-each-ref', '--sort=-committerdate', `--format=%(refname:short)${delimiter}%(committerdate:iso8601)${delimiter}%(objectname:short)${delimiter}%(subject)`, `refs/remotes/${REMOTE_NAME}/`],
1454
- { cwd: PROJECT_ROOT }
1455
- );
1456
- const remoteOutput = remoteResult ? remoteResult.stdout : '';
1457
-
1458
- const remotePrefix = `${REMOTE_NAME}/`;
1459
- for (const line of remoteOutput.split('\n').filter(Boolean)) {
1460
- const [fullName, dateStr, commit, ...subjectParts] = line.split(delimiter);
1461
- const subject = subjectParts.join(delimiter);
1462
- const name = fullName.replace(remotePrefix, '');
1463
- if (name === 'HEAD') continue;
1464
- if (!isValidBranchName(name)) continue;
1465
-
1466
- const existing = branchList.find(b => b.name === name);
1467
- if (existing) {
1468
- existing.hasRemote = true;
1469
- existing.remoteCommit = commit;
1470
- existing.remoteDate = new Date(dateStr);
1471
- existing.remoteSubject = subject || '';
1472
- if (commit !== existing.commit) {
1473
- existing.hasUpdates = true;
1474
- // Use remote's date when it has updates (so it sorts to top)
1475
- existing.date = new Date(dateStr);
1476
- existing.subject = subject || existing.subject;
1477
- }
1478
- } else if (!seenBranches.has(name)) {
1479
- seenBranches.add(name);
1480
- branchList.push({
1481
- name,
1482
- commit,
1483
- subject: subject || '',
1484
- date: new Date(dateStr),
1485
- isLocal: false,
1486
- hasRemote: true,
1487
- hasUpdates: false,
1488
- });
1489
- }
1490
- }
1491
-
1492
- branchList.sort((a, b) => b.date - a.date);
1493
- return branchList; // Return all branches, caller will slice
1374
+ return await getAllBranchesRaw({ remoteName: REMOTE_NAME, fetch: true, cwd: PROJECT_ROOT });
1494
1375
  } catch (e) {
1495
1376
  addLog(`Failed to get branches: ${e.message || e}`, 'error');
1496
1377
  return [];
@@ -1841,6 +1722,8 @@ async function pollGitChanges() {
1841
1722
  const updatedBranches = [];
1842
1723
  const currentBranchName = store.get('currentBranch');
1843
1724
  for (const branch of pollFilteredBranches) {
1725
+ // Clear previous cycle's flag so only freshly-updated branches are highlighted
1726
+ branch.justUpdated = false;
1844
1727
  if (branch.isDeleted) continue;
1845
1728
  const prevCommit = previousBranchStates.get(branch.name);
1846
1729
  if (prevCommit && prevCommit !== branch.commit && branch.name !== currentBranchName) {
@@ -2109,7 +1992,7 @@ function handleLiveReload(req, res) {
2109
1992
 
2110
1993
  function serveFile(res, filePath, logPath) {
2111
1994
  const ext = path.extname(filePath).toLowerCase();
2112
- const mimeType = MIME_TYPES[ext] || 'application/octet-stream';
1995
+ const mimeType = getMimeType(ext);
2113
1996
 
2114
1997
  fs.readFile(filePath, (err, data) => {
2115
1998
  if (err) {
@@ -2120,10 +2003,7 @@ function serveFile(res, filePath, logPath) {
2120
2003
  }
2121
2004
 
2122
2005
  if (mimeType === 'text/html') {
2123
- let html = data.toString();
2124
- if (html.includes('</body>')) {
2125
- html = html.replace('</body>', LIVE_RELOAD_SCRIPT);
2126
- }
2006
+ const html = injectLiveReload(data.toString());
2127
2007
  res.writeHead(200, { 'Content-Type': mimeType });
2128
2008
  res.end(html);
2129
2009
  } else {
@@ -2959,13 +2839,14 @@ async function shutdown() {
2959
2839
 
2960
2840
  process.on('SIGINT', shutdown);
2961
2841
  process.on('SIGTERM', shutdown);
2962
- process.on('uncaughtException', (err) => {
2842
+ process.on('uncaughtException', async (err) => {
2963
2843
  telemetry.captureError(err);
2964
2844
  write(ansi.showCursor);
2965
2845
  write(ansi.restoreScreen);
2966
2846
  restoreTerminalTitle();
2967
2847
  if (process.stdin.isTTY) process.stdin.setRawMode(false);
2968
2848
  console.error('Uncaught exception:', err);
2849
+ await telemetry.shutdown();
2969
2850
  process.exit(1);
2970
2851
  });
2971
2852
 
@@ -2987,7 +2868,8 @@ async function start() {
2987
2868
  const config = await ensureConfig(cliArgs);
2988
2869
  applyConfig(config);
2989
2870
 
2990
- // Telemetry: opt-in prompt (first run only) and initialization
2871
+ // Telemetry: set version early so consent events include $lib_version
2872
+ telemetry.setVersion(PACKAGE_VERSION);
2991
2873
  await telemetry.promptIfNeeded(promptYesNo);
2992
2874
  telemetry.init({ version: PACKAGE_VERSION });
2993
2875
  sessionStartTime = Date.now();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-watchtower",
3
- "version": "1.9.4",
3
+ "version": "1.9.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": {
@@ -8,13 +8,13 @@
8
8
  },
9
9
  "scripts": {
10
10
  "start": "node bin/git-watchtower.js",
11
- "test": "node --test tests/unit/**/*.test.js tests/integration/**/*.test.js",
12
- "test:unit": "node --test tests/unit/**/*.test.js",
13
- "test:integration": "node --test tests/integration/**/*.test.js",
14
- "test:watch": "node --test --watch tests/unit/**/*.test.js",
15
- "test:coverage": "c8 --reporter=text --reporter=html --reporter=lcov node --test tests/unit/**/*.test.js tests/integration/**/*.test.js",
16
- "test:coverage:text": "c8 --reporter=text node --test tests/unit/**/*.test.js tests/integration/**/*.test.js",
17
- "test:coverage:html": "c8 --reporter=html node --test tests/unit/**/*.test.js tests/integration/**/*.test.js && echo 'Coverage report: coverage/index.html'",
11
+ "test": "node --require ./tests/setup.js --test tests/unit/**/*.test.js tests/integration/**/*.test.js",
12
+ "test:unit": "node --require ./tests/setup.js --test tests/unit/**/*.test.js",
13
+ "test:integration": "node --require ./tests/setup.js --test tests/integration/**/*.test.js",
14
+ "test:watch": "node --require ./tests/setup.js --test --watch tests/unit/**/*.test.js",
15
+ "test:coverage": "c8 --reporter=text --reporter=html --reporter=lcov node --require ./tests/setup.js --test tests/unit/**/*.test.js tests/integration/**/*.test.js",
16
+ "test:coverage:text": "c8 --reporter=text node --require ./tests/setup.js --test tests/unit/**/*.test.js tests/integration/**/*.test.js",
17
+ "test:coverage:html": "c8 --reporter=html node --require ./tests/setup.js --test tests/unit/**/*.test.js tests/integration/**/*.test.js && echo 'Coverage report: coverage/index.html'",
18
18
  "typecheck": "tsc --noEmit"
19
19
  },
20
20
  "devDependencies": {
@@ -57,6 +57,8 @@ function detectDeletedBranches(knownBranchNames, fetchedBranchNames, existingBra
57
57
  function detectUpdatedBranches(branches, previousStates, currentBranch) {
58
58
  const updated = [];
59
59
  for (const branch of branches) {
60
+ // Clear previous cycle's flag so only freshly-updated branches are highlighted
61
+ branch.justUpdated = false;
60
62
  if (branch.isDeleted) continue;
61
63
  const prevCommit = previousStates.get(branch.name);
62
64
  if (prevCommit && prevCommit !== branch.commit && branch.name !== currentBranch) {
@@ -22,40 +22,46 @@ const FLUSH_INTERVAL = 30000; // 30 seconds
22
22
  const FLUSH_AT = 10; // flush when 10 events accumulated
23
23
 
24
24
  /**
25
- * Send a batch of events to PostHog via HTTPS POST (fire-and-forget)
25
+ * Send a batch of events to PostHog via HTTPS POST.
26
+ * Returns a promise that resolves when the request completes (or fails).
27
+ * Callers that don't need to wait can ignore the return value.
26
28
  * @param {Array<Record<string, any>>} events
29
+ * @returns {Promise<void>}
27
30
  */
28
31
  function sendBatch(events) {
29
- if (events.length === 0) return;
30
-
31
- const payload = JSON.stringify({ api_key: POSTHOG_API_KEY, batch: events });
32
-
33
- const req = https.request({
34
- hostname: POSTHOG_HOST,
35
- port: 443,
36
- path: '/batch',
37
- method: 'POST',
38
- headers: {
39
- 'Content-Type': 'application/json',
40
- 'Content-Length': Buffer.byteLength(payload),
41
- },
42
- timeout: 5000,
43
- });
32
+ if (events.length === 0) return Promise.resolve();
33
+
34
+ return new Promise((resolve) => {
35
+ const payload = JSON.stringify({ api_key: POSTHOG_API_KEY, batch: events });
36
+
37
+ const req = https.request({
38
+ hostname: POSTHOG_HOST,
39
+ port: 443,
40
+ path: '/batch',
41
+ method: 'POST',
42
+ headers: {
43
+ 'Content-Type': 'application/json',
44
+ 'Content-Length': Buffer.byteLength(payload),
45
+ },
46
+ timeout: 5000,
47
+ });
44
48
 
45
- // Fire-and-forget: ignore all errors and responses
46
- req.on('error', () => {});
47
- req.on('timeout', () => req.destroy());
48
- req.end(payload);
49
+ req.on('error', () => resolve());
50
+ req.on('timeout', () => { req.destroy(); resolve(); });
51
+ req.on('response', () => resolve());
52
+ req.end(payload);
53
+ });
49
54
  }
50
55
 
51
56
  /**
52
- * Flush pending events
57
+ * Flush pending events.
58
+ * @returns {Promise<void>} Resolves when the batch has been sent (or fails).
53
59
  */
54
60
  function flush() {
55
- if (eventQueue.length === 0) return;
61
+ if (eventQueue.length === 0) return Promise.resolve();
56
62
  const batch = eventQueue;
57
63
  eventQueue = [];
58
- sendBatch(batch);
64
+ return sendBatch(batch);
59
65
  }
60
66
 
61
67
  /**
@@ -81,6 +87,15 @@ function queueEvent(event, properties, overrideDistinctId) {
81
87
  }
82
88
  }
83
89
 
90
+ /**
91
+ * Set the app version so that even pre-init events include $lib_version.
92
+ * Call this before promptIfNeeded() so consent events carry the version.
93
+ * @param {string} version
94
+ */
95
+ function setVersion(version) {
96
+ appVersion = version;
97
+ }
98
+
84
99
  /**
85
100
  * Initialize the analytics client if telemetry is enabled
86
101
  * @param {{ version: string }} options
@@ -190,7 +205,7 @@ function captureAlways(event, userDistinctId, properties = {}) {
190
205
  }
191
206
 
192
207
  /**
193
- * Flush pending events and shutdown
208
+ * Flush pending events and shutdown.
194
209
  * Call this before process exit to ensure events are sent.
195
210
  * @returns {Promise<void>}
196
211
  */
@@ -203,7 +218,7 @@ async function shutdown() {
203
218
  if (!enabled) return;
204
219
 
205
220
  try {
206
- flush();
221
+ await flush();
207
222
  } catch {
208
223
  // Best-effort flush
209
224
  } finally {
@@ -220,6 +235,7 @@ function isEnabled() {
220
235
  }
221
236
 
222
237
  module.exports = {
238
+ setVersion,
223
239
  init,
224
240
  capture,
225
241
  captureError,
@@ -83,6 +83,7 @@ async function promptIfNeeded(promptYesNo) {
83
83
 
84
84
  module.exports = {
85
85
  // Analytics
86
+ setVersion: analytics.setVersion,
86
87
  init: analytics.init,
87
88
  capture: analytics.capture,
88
89
  captureError: analytics.captureError,