git-watchtower 1.8.6 → 1.9.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,7 +66,7 @@ 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)
69
+ // Telemetry (opt-in analytics via PostHog HTTP API — zero dependencies)
70
70
  const telemetry = require('../src/telemetry');
71
71
 
72
72
  // Extracted modules
@@ -82,7 +82,10 @@ 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, execGit, execGitSilent, getDiffStats: getDiffStatsSafe } = require('../src/git/commands');
85
+ const { isGitAvailable: checkGitAvailable, execGit, execGitSilent, getDiffStats: getDiffStatsSafe, getAheadBehind, getDiffShortstat } = require('../src/git/commands');
86
+
87
+ // Session stats (always-on, non-casino stats)
88
+ const sessionStats = require('../src/stats/session');
86
89
 
87
90
  // ============================================================================
88
91
  // Configuration (imports from src/config/, inline wizard kept here)
@@ -858,6 +861,53 @@ async function getDiffStats(fromCommit, toCommit = 'HEAD') {
858
861
  return getDiffStatsSafe(fromCommit, toCommit, { cwd: PROJECT_ROOT });
859
862
  }
860
863
 
864
+ // Ahead/behind: detect default branch and fetch counts
865
+ let detectedDefaultBranch = null;
866
+
867
+ async function detectDefaultBranch() {
868
+ const candidates = ['main', 'master', 'develop', 'development', 'trunk'];
869
+ for (const name of candidates) {
870
+ try {
871
+ await execGit(['rev-parse', '--verify', `${REMOTE_NAME}/${name}`], { cwd: PROJECT_ROOT });
872
+ detectedDefaultBranch = `${REMOTE_NAME}/${name}`;
873
+ return;
874
+ } catch (e) {
875
+ // Try next candidate
876
+ }
877
+ }
878
+ // Fallback: try HEAD of remote
879
+ try {
880
+ const { stdout } = await execGit(['symbolic-ref', `refs/remotes/${REMOTE_NAME}/HEAD`], { cwd: PROJECT_ROOT });
881
+ detectedDefaultBranch = stdout.trim().replace('refs/remotes/', '');
882
+ } catch (e) {
883
+ detectedDefaultBranch = null;
884
+ }
885
+ }
886
+
887
+ async function fetchAheadBehindForBranches(branches) {
888
+ if (!detectedDefaultBranch) return;
889
+ const visible = branches.slice(0, store.get('visibleBranchCount'));
890
+ const cache = new Map(store.get('aheadBehindCache'));
891
+ const promises = visible.map(async (branch) => {
892
+ if (isBaseBranch(branch.name)) return;
893
+ // Use local ref if local, otherwise remote ref
894
+ const branchRef = branch.isLocal ? branch.name : `${REMOTE_NAME}/${branch.name}`;
895
+ const [abResult, diffResult] = await Promise.all([
896
+ getAheadBehind(branchRef, detectedDefaultBranch, { cwd: PROJECT_ROOT }),
897
+ getDiffShortstat(detectedDefaultBranch, branchRef, { cwd: PROJECT_ROOT }),
898
+ ]);
899
+ cache.set(branch.name, {
900
+ ahead: abResult.ahead,
901
+ behind: abResult.behind,
902
+ linesAdded: diffResult.added,
903
+ linesDeleted: diffResult.deleted,
904
+ });
905
+ });
906
+ await Promise.all(promises);
907
+ store.setState({ aheadBehindCache: cache });
908
+ render();
909
+ }
910
+
861
911
  // formatTimeAgo imported from src/utils/time.js
862
912
 
863
913
  // truncate imported from src/ui/ansi.js
@@ -1118,6 +1168,7 @@ function renderCasinoStats(startRow) {
1118
1168
  function getRenderState() {
1119
1169
  const s = store.getState();
1120
1170
  s.clientCount = clients.size;
1171
+ s.sessionStats = sessionStats.getStats();
1121
1172
  return s;
1122
1173
  }
1123
1174
 
@@ -1141,7 +1192,8 @@ function render() {
1141
1192
  renderer.renderHeader(state, write);
1142
1193
  const logStart = renderer.renderBranchList(state, write);
1143
1194
  const statsStart = renderer.renderActivityLog(state, write, logStart);
1144
- renderCasinoStats(statsStart);
1195
+ const casinoStart = renderer.renderSessionStats(state, write, statsStart);
1196
+ renderCasinoStats(casinoStart);
1145
1197
  renderer.renderFooter(state, write);
1146
1198
 
1147
1199
  // Casino mode: full border (top, bottom, left, right)
@@ -1819,6 +1871,9 @@ async function pollGitChanges() {
1819
1871
  casino.recordPoll(false);
1820
1872
  }
1821
1873
 
1874
+ // Session stats: always track polls (independent of casino mode)
1875
+ sessionStats.recordPoll(notifyBranches.length > 0);
1876
+
1822
1877
  // Remember which branch was selected before updating the list
1823
1878
  const { selectedBranchName: prevSelName, selectedIndex: prevSelIdx } = store.getState();
1824
1879
  const previouslySelectedName = prevSelName || (currentBranches[prevSelIdx] ? currentBranches[prevSelIdx].name : null);
@@ -1875,6 +1930,9 @@ async function pollGitChanges() {
1875
1930
  });
1876
1931
  }
1877
1932
 
1933
+ // Background ahead/behind fetch for visible branches
1934
+ fetchAheadBehindForBranches(pollFilteredBranches).catch(() => {});
1935
+
1878
1936
  // AUTO-PULL: If current branch has remote updates, pull automatically (if enabled)
1879
1937
  const autoPullBranchName = store.get('currentBranch');
1880
1938
  const currentInfo = store.get('branches').find(b => b.name === autoPullBranchName);
@@ -1897,11 +1955,14 @@ async function pollGitChanges() {
1897
1955
  // Reload browsers
1898
1956
  notifyClients();
1899
1957
 
1900
- // Casino mode: calculate actual diff and trigger win effect
1901
- if (store.get('casinoModeEnabled') && oldCommit) {
1958
+ // Calculate actual diff for stats tracking
1959
+ if (oldCommit) {
1902
1960
  const diffStats = await getDiffStats(oldCommit, 'HEAD');
1903
1961
  const totalLines = diffStats.added + diffStats.deleted;
1904
- if (totalLines > 0) {
1962
+ // Always track session churn
1963
+ sessionStats.recordChurn(diffStats.added, diffStats.deleted);
1964
+ // Casino mode: trigger win effect
1965
+ if (store.get('casinoModeEnabled') && totalLines > 0) {
1905
1966
  casino.triggerWin(diffStats.added, diffStats.deleted, render);
1906
1967
  const winLevel = casino.getWinLevel(totalLines);
1907
1968
  if (winLevel) {
@@ -2975,6 +3036,11 @@ async function start() {
2975
3036
  store.setState({ selectedBranchName: initBranches[0].name });
2976
3037
  }
2977
3038
 
3039
+ // Detect default branch for ahead/behind counts, then fetch initial data
3040
+ detectDefaultBranch().then(() => {
3041
+ fetchAheadBehindForBranches(initBranches).catch(() => {});
3042
+ }).catch(() => {});
3043
+
2978
3044
  // Load sparklines and action cache in background
2979
3045
  refreshAllSparklines().catch(() => {});
2980
3046
  initActionCache().then(() => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-watchtower",
3
- "version": "1.8.6",
3
+ "version": "1.9.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": {
@@ -87,8 +87,5 @@
87
87
  }
88
88
  ]
89
89
  ]
90
- },
91
- "dependencies": {
92
- "posthog-node": "^5.28.0"
93
90
  }
94
91
  }
@@ -13,6 +13,9 @@ const DEFAULT_TIMEOUT = 30000;
13
13
  // Longer timeout for fetch operations (60 seconds)
14
14
  const FETCH_TIMEOUT = 60000;
15
15
 
16
+ // Short timeout for quick local operations (5 seconds)
17
+ const SHORT_TIMEOUT = 5000;
18
+
16
19
  /**
17
20
  * Execute a git command safely using execFile (no shell).
18
21
  * @param {string | string[]} args - Git arguments as an array (e.g. ['log', '--oneline'])
@@ -393,6 +396,51 @@ async function deleteLocalBranch(branchName, options = {}) {
393
396
  }
394
397
  }
395
398
 
399
+ /**
400
+ * Get ahead/behind counts for a branch relative to a base ref.
401
+ * Uses `git rev-list --left-right --count base...branch`.
402
+ * @param {string} branchRef - Branch ref (e.g. "feature/foo" or "origin/feature/foo")
403
+ * @param {string} baseRef - Base ref to compare against (e.g. "origin/main")
404
+ * @param {Object} [options] - Options
405
+ * @param {string} [options.cwd] - Working directory
406
+ * @returns {Promise<{ahead: number, behind: number}>}
407
+ */
408
+ async function getAheadBehind(branchRef, baseRef, options = {}) {
409
+ try {
410
+ const { stdout } = await execGit(
411
+ ['rev-list', '--left-right', '--count', `${baseRef}...${branchRef}`],
412
+ { ...options, timeout: SHORT_TIMEOUT }
413
+ );
414
+ const parts = stdout.trim().split(/\s+/);
415
+ return {
416
+ behind: parseInt(parts[0], 10) || 0,
417
+ ahead: parseInt(parts[1], 10) || 0,
418
+ };
419
+ } catch (e) {
420
+ return { ahead: 0, behind: 0 };
421
+ }
422
+ }
423
+
424
+ /**
425
+ * Get diff stats (lines added/deleted) between two refs using three-dot syntax.
426
+ * @param {string} baseRef - Base ref (e.g. "origin/main")
427
+ * @param {string} branchRef - Branch ref (e.g. "feature/foo")
428
+ * @param {Object} [options] - Options
429
+ * @param {string} [options.cwd] - Working directory
430
+ * @returns {Promise<{added: number, deleted: number}>}
431
+ */
432
+ async function getDiffShortstat(baseRef, branchRef, options = {}) {
433
+ try {
434
+ const { stdout } = await execGit(
435
+ ['diff', '--shortstat', `${baseRef}...${branchRef}`],
436
+ { ...options, timeout: SHORT_TIMEOUT }
437
+ );
438
+ return parseDiffStats(stdout);
439
+ } catch (e) {
440
+ return { added: 0, deleted: 0 };
441
+ }
442
+ }
443
+
396
444
  module.exports = {
397
445
  execGit,
398
446
  execGitSilent,
@@ -411,6 +459,8 @@ module.exports = {
411
459
  parseDiffStats,
412
460
  getDiffStats,
413
461
  deleteLocalBranch,
462
+ getAheadBehind,
463
+ getDiffShortstat,
414
464
  DEFAULT_TIMEOUT,
415
465
  FETCH_TIMEOUT,
416
466
  };
@@ -95,6 +95,7 @@
95
95
  * @property {boolean} casinoModeEnabled - Casino mode enabled
96
96
  * @property {Map<string, string>} sparklineCache - Branch sparkline cache
97
97
  * @property {Map<string, Object>} branchPrStatusMap - Branch PR status cache
98
+ * @property {Map<string, Object>} aheadBehindCache - Branch ahead/behind cache
98
99
  * @property {string} serverMode - Server mode ('static' | 'command' | 'none')
99
100
  * @property {boolean} noServer - No server mode
100
101
  * @property {number} port - Server port
@@ -176,6 +177,7 @@ function getInitialState() {
176
177
  // Caches (Maps — shallow-copied by getState())
177
178
  sparklineCache: new Map(),
178
179
  branchPrStatusMap: new Map(),
180
+ aheadBehindCache: new Map(),
179
181
 
180
182
  // Config (set once at startup, treated as read-only after)
181
183
  serverMode: 'static',
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Session statistics tracker for Git Watchtower.
3
+ *
4
+ * Tracks real, grounded stats about repository activity during the current
5
+ * session — independent of casino mode. These stats are always available
6
+ * in normal mode.
7
+ *
8
+ * @module stats/session
9
+ */
10
+
11
+ // ============================================================================
12
+ // Internal state
13
+ // ============================================================================
14
+
15
+ let sessionStart = Date.now();
16
+ let totalLinesAdded = 0;
17
+ let totalLinesDeleted = 0;
18
+ let totalPolls = 0;
19
+ let pollsWithUpdates = 0;
20
+ let lastUpdateTime = null;
21
+
22
+ // ============================================================================
23
+ // Recording
24
+ // ============================================================================
25
+
26
+ /**
27
+ * Record that a poll cycle completed.
28
+ * @param {boolean} hadUpdates - Whether any branch had updates
29
+ */
30
+ function recordPoll(hadUpdates) {
31
+ totalPolls++;
32
+ if (hadUpdates) {
33
+ pollsWithUpdates++;
34
+ lastUpdateTime = Date.now();
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Record line changes from a detected update.
40
+ * @param {number} added - Lines added
41
+ * @param {number} deleted - Lines deleted
42
+ */
43
+ function recordChurn(added, deleted) {
44
+ totalLinesAdded += added;
45
+ totalLinesDeleted += deleted;
46
+ }
47
+
48
+ // ============================================================================
49
+ // Queries
50
+ // ============================================================================
51
+
52
+ /**
53
+ * Format a duration in ms to a human-readable string.
54
+ * @param {number} ms
55
+ * @returns {string}
56
+ */
57
+ function formatDuration(ms) {
58
+ const hours = Math.floor(ms / 3600000);
59
+ const minutes = Math.floor((ms % 3600000) / 60000);
60
+ if (hours > 0) return `${hours}h ${minutes}m`;
61
+ return `${minutes}m`;
62
+ }
63
+
64
+ /**
65
+ * Get current session stats snapshot.
66
+ * @returns {{sessionDuration: string, linesAdded: number, linesDeleted: number, totalPolls: number, pollsWithUpdates: number, hitRate: number, lastUpdate: string|null}}
67
+ */
68
+ function getStats() {
69
+ const elapsed = Date.now() - sessionStart;
70
+
71
+ const hitRate = totalPolls > 0
72
+ ? Math.round((pollsWithUpdates / totalPolls) * 100)
73
+ : 0;
74
+
75
+ let lastUpdate = null;
76
+ if (lastUpdateTime) {
77
+ const sinceUpdate = Date.now() - lastUpdateTime;
78
+ lastUpdate = formatDuration(sinceUpdate) + ' ago';
79
+ }
80
+
81
+ return {
82
+ sessionDuration: formatDuration(elapsed),
83
+ linesAdded: totalLinesAdded,
84
+ linesDeleted: totalLinesDeleted,
85
+ totalPolls,
86
+ pollsWithUpdates,
87
+ hitRate,
88
+ lastUpdate,
89
+ };
90
+ }
91
+
92
+ /**
93
+ * Reset all session stats (e.g. for testing).
94
+ */
95
+ function reset() {
96
+ sessionStart = Date.now();
97
+ totalLinesAdded = 0;
98
+ totalLinesDeleted = 0;
99
+ totalPolls = 0;
100
+ pollsWithUpdates = 0;
101
+ lastUpdateTime = null;
102
+ }
103
+
104
+ module.exports = {
105
+ recordPoll,
106
+ recordChurn,
107
+ getStats,
108
+ reset,
109
+ };
@@ -1,23 +1,88 @@
1
1
  /**
2
- * PostHog analytics wrapper (singleton)
2
+ * Analytics wrapper using direct PostHog HTTP API (zero dependencies)
3
3
  *
4
4
  * All methods are safe no-ops when telemetry is disabled.
5
5
  * Events are fire-and-forget — never blocks the TUI.
6
6
  */
7
7
 
8
+ const https = require('https');
8
9
  const { isTelemetryEnabled, getOrCreateDistinctId } = require('./config');
9
10
 
10
11
  const POSTHOG_API_KEY = 'phc_fdGL8TVN5aFPXmQ4f1hI8y6sqnscD7dy9j5SM5gTylG';
11
- const POSTHOG_HOST = 'https://us.i.posthog.com';
12
+ const POSTHOG_HOST = 'us.i.posthog.com';
12
13
 
13
- /** @type {import('posthog-node').PostHog | null} */
14
- let client = null;
15
14
  let distinctId = '';
16
15
  let appVersion = '';
17
16
  let enabled = false;
18
17
 
18
+ /** @type {Array<Record<string, any>>} */
19
+ let eventQueue = [];
20
+ let flushTimer = null;
21
+ const FLUSH_INTERVAL = 30000; // 30 seconds
22
+ const FLUSH_AT = 10; // flush when 10 events accumulated
23
+
24
+ /**
25
+ * Send a batch of events to PostHog via HTTPS POST (fire-and-forget)
26
+ * @param {Array<Record<string, any>>} events
27
+ */
28
+ 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
+ });
44
+
45
+ // Fire-and-forget: ignore all errors and responses
46
+ req.on('error', () => {});
47
+ req.on('timeout', () => req.destroy());
48
+ req.end(payload);
49
+ }
50
+
51
+ /**
52
+ * Flush pending events
53
+ */
54
+ function flush() {
55
+ if (eventQueue.length === 0) return;
56
+ const batch = eventQueue;
57
+ eventQueue = [];
58
+ sendBatch(batch);
59
+ }
60
+
61
+ /**
62
+ * Queue an event for sending
63
+ * @param {string} event - Event name
64
+ * @param {Record<string, any>} properties - Event properties
65
+ * @param {string} [overrideDistinctId] - Override distinct ID (for pre-consent events)
66
+ */
67
+ function queueEvent(event, properties, overrideDistinctId) {
68
+ eventQueue.push({
69
+ event,
70
+ distinct_id: overrideDistinctId || distinctId,
71
+ properties: {
72
+ ...properties,
73
+ $lib: 'git-watchtower',
74
+ $lib_version: appVersion,
75
+ },
76
+ timestamp: new Date().toISOString(),
77
+ });
78
+
79
+ if (eventQueue.length >= FLUSH_AT) {
80
+ flush();
81
+ }
82
+ }
83
+
19
84
  /**
20
- * Initialize the PostHog client if telemetry is enabled
85
+ * Initialize the analytics client if telemetry is enabled
21
86
  * @param {{ version: string }} options
22
87
  */
23
88
  function init({ version }) {
@@ -28,22 +93,12 @@ function init({ version }) {
28
93
  return;
29
94
  }
30
95
 
31
- try {
32
- const { PostHog } = require('posthog-node');
33
- distinctId = getOrCreateDistinctId();
34
- client = new PostHog(POSTHOG_API_KEY, {
35
- host: POSTHOG_HOST,
36
- flushAt: 10,
37
- flushInterval: 30000,
38
- requestTimeout: 5000,
39
- disableGeoip: true,
40
- });
41
- enabled = true;
42
- } catch {
43
- // If posthog-node fails to load, silently disable telemetry
44
- enabled = false;
45
- client = null;
46
- }
96
+ distinctId = getOrCreateDistinctId();
97
+ enabled = true;
98
+
99
+ // Periodic flush
100
+ flushTimer = setInterval(flush, FLUSH_INTERVAL);
101
+ if (flushTimer.unref) flushTimer.unref(); // Don't keep process alive
47
102
  }
48
103
 
49
104
  /**
@@ -52,18 +107,10 @@ function init({ version }) {
52
107
  * @param {Record<string, any>} [properties] - Event properties
53
108
  */
54
109
  function capture(event, properties = {}) {
55
- if (!enabled || !client) return;
110
+ if (!enabled) return;
56
111
 
57
112
  try {
58
- client.capture({
59
- distinctId,
60
- event,
61
- properties: {
62
- ...properties,
63
- $lib: 'git-watchtower',
64
- $lib_version: appVersion,
65
- },
66
- });
113
+ queueEvent(event, properties);
67
114
  } catch {
68
115
  // Never let telemetry errors affect the app
69
116
  }
@@ -74,7 +121,7 @@ function capture(event, properties = {}) {
74
121
  * @param {Error} error
75
122
  */
76
123
  function captureError(error) {
77
- if (!enabled || !client) return;
124
+ if (!enabled) return;
78
125
 
79
126
  try {
80
127
  const errorType = error.constructor?.name || 'Error';
@@ -86,41 +133,80 @@ function captureError(error) {
86
133
  $exception_type: errorType,
87
134
  $exception_message: errorMessage,
88
135
  $exception_source: 'node',
89
- $lib: 'git-watchtower',
90
- $lib_version: appVersion,
91
136
  };
92
137
 
93
- // Include stack trace — contains only our package's file paths and
94
- // line numbers, no user data. Required for PostHog error tracking
95
- // to group, deduplicate, and show useful backtraces.
96
138
  if (error.stack) {
97
139
  properties.$exception_stack_trace_raw = error.stack;
98
140
  }
99
141
 
100
- client.capture({
101
- distinctId,
102
- event: '$exception',
103
- properties,
142
+ queueEvent('$exception', properties);
143
+ } catch {
144
+ // Never let telemetry errors affect the app
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Send a one-off event that bypasses the enabled check.
150
+ * Used for prompt_shown and analytics_decision events that fire
151
+ * before the user has made their telemetry choice.
152
+ * @param {string} event - Event name
153
+ * @param {string} userDistinctId - The user's distinct ID
154
+ * @param {Record<string, any>} [properties] - Event properties
155
+ */
156
+ function captureAlways(event, userDistinctId, properties = {}) {
157
+ try {
158
+ const payload = JSON.stringify({
159
+ api_key: POSTHOG_API_KEY,
160
+ batch: [{
161
+ event,
162
+ distinct_id: userDistinctId,
163
+ properties: {
164
+ ...properties,
165
+ $lib: 'git-watchtower',
166
+ $lib_version: appVersion,
167
+ },
168
+ timestamp: new Date().toISOString(),
169
+ }],
104
170
  });
171
+
172
+ const req = https.request({
173
+ hostname: POSTHOG_HOST,
174
+ port: 443,
175
+ path: '/batch',
176
+ method: 'POST',
177
+ headers: {
178
+ 'Content-Type': 'application/json',
179
+ 'Content-Length': Buffer.byteLength(payload),
180
+ },
181
+ timeout: 5000,
182
+ });
183
+
184
+ req.on('error', () => {});
185
+ req.on('timeout', () => req.destroy());
186
+ req.end(payload);
105
187
  } catch {
106
188
  // Never let telemetry errors affect the app
107
189
  }
108
190
  }
109
191
 
110
192
  /**
111
- * Flush pending events and shutdown the PostHog client
193
+ * Flush pending events and shutdown
112
194
  * Call this before process exit to ensure events are sent.
113
195
  * @returns {Promise<void>}
114
196
  */
115
197
  async function shutdown() {
116
- if (!enabled || !client) return;
198
+ if (flushTimer) {
199
+ clearInterval(flushTimer);
200
+ flushTimer = null;
201
+ }
202
+
203
+ if (!enabled) return;
117
204
 
118
205
  try {
119
- await client.shutdown();
206
+ flush();
120
207
  } catch {
121
208
  // Best-effort flush
122
209
  } finally {
123
- client = null;
124
210
  enabled = false;
125
211
  }
126
212
  }
@@ -137,6 +223,7 @@ module.exports = {
137
223
  init,
138
224
  capture,
139
225
  captureError,
226
+ captureAlways,
140
227
  shutdown,
141
228
  isEnabled,
142
229
  };
@@ -58,9 +58,16 @@ async function promptIfNeeded(promptYesNo) {
58
58
  console.log('\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518');
59
59
  console.log('');
60
60
 
61
+ const distinctId = config.getOrCreateDistinctId();
62
+
63
+ // Fire analytics_prompt_shown event — always sent regardless of user's choice
64
+ analytics.captureAlways('analytics_prompt_shown', distinctId);
65
+
61
66
  const answer = await promptYesNo('Enable anonymous telemetry to help improve Git Watchtower?', false);
62
67
 
63
- const distinctId = config.getOrCreateDistinctId();
68
+ // Fire analytics_decision event — always sent so we know opt-in/out rates
69
+ analytics.captureAlways('analytics_decision', distinctId, { opted_in: answer });
70
+
64
71
  config.saveTelemetryConfig({
65
72
  telemetryEnabled: answer,
66
73
  distinctId,
@@ -79,6 +86,7 @@ module.exports = {
79
86
  init: analytics.init,
80
87
  capture: analytics.capture,
81
88
  captureError: analytics.captureError,
89
+ captureAlways: analytics.captureAlways,
82
90
  shutdown: analytics.shutdown,
83
91
  isEnabled: analytics.isEnabled,
84
92
 
@@ -21,10 +21,28 @@ const {
21
21
  visibleLength,
22
22
  stripAnsi,
23
23
  } = require('../ui/ansi');
24
- const { formatTimeAgo } = require('../utils/time');
24
+ const { formatTimeAgo, formatTimeCompact } = require('../utils/time');
25
25
  const { isBaseBranch } = require('../git/pr');
26
26
  const { version: PACKAGE_VERSION } = require('../../package.json');
27
27
 
28
+ // ---------------------------------------------------------------------------
29
+ // Compact number formatting
30
+ // ---------------------------------------------------------------------------
31
+
32
+ /**
33
+ * Format a number compactly: 0-999 as-is, 1k-9.9k with decimal,
34
+ * 10k-999k without decimal, 1m+ with decimal.
35
+ * @param {number} n
36
+ * @returns {string}
37
+ */
38
+ function fmtCompact(n) {
39
+ if (n < 1000) return String(n);
40
+ if (n < 10000) return (n / 1000).toFixed(1) + 'k';
41
+ if (n < 1000000) return Math.round(n / 1000) + 'k';
42
+ if (n < 10000000) return (n / 1000000).toFixed(1) + 'm';
43
+ return Math.round(n / 1000000) + 'm';
44
+ }
45
+
28
46
  // ---------------------------------------------------------------------------
29
47
  // renderHeader
30
48
  // ---------------------------------------------------------------------------
@@ -167,16 +185,58 @@ function renderBranchList(state, write) {
167
185
  const branch = displayBranches[i];
168
186
  const isSelected = i === state.selectedIndex;
169
187
  const isCurrent = branch.name === state.currentBranch;
170
- const timeAgo = formatTimeAgo(branch.date);
188
+ const timeAgo = formatTimeCompact(branch.date);
171
189
  const sparkline = state.sparklineCache.get(branch.name) || ' ';
172
190
  const prStatus = state.branchPrStatusMap.get(branch.name);
173
191
  const isBranchBase = isBaseBranch(branch.name);
174
192
  const isMerged = !isBranchBase && prStatus && prStatus.state === 'MERGED';
175
193
  const hasOpenPr = prStatus && prStatus.state === 'OPEN';
194
+ const aheadBehind = state.aheadBehindCache ? state.aheadBehindCache.get(branch.name) : null;
195
+
196
+ // Diff stats: two right-justified columns, always reserving space for alignment
197
+ // Col 1: "+N/-N commits" right-justified in 16 chars
198
+ // Col 2: "+N/-N lines" right-justified in 14 chars
199
+ // Total: 16 + 1(gap) + 14 + 1(gap) = 32 chars always reserved
200
+ const COL_COMMITS = 16;
201
+ const COL_LINES = 14;
202
+ const DIFF_TAG_FIXED_LEN = COL_COMMITS + 1 + COL_LINES + 1; // 32 total
203
+ let diffTag = ' '.repeat(DIFF_TAG_FIXED_LEN); // blank by default for alignment
204
+ if (!isBranchBase && !branch.isDeleted && aheadBehind) {
205
+ const a = aheadBehind.ahead;
206
+ const b = aheadBehind.behind;
207
+ const la = aheadBehind.linesAdded || 0;
208
+ const ld = aheadBehind.linesDeleted || 0;
209
+
210
+ // Commits: "+N/-N commits", right-justified in COL_COMMITS
211
+ const aFmt = fmtCompact(a);
212
+ const bFmt = fmtCompact(b);
213
+ const commitText = '+' + aFmt + '/-' + bFmt + ' commits';
214
+ const cPad = ' '.repeat(Math.max(0, COL_COMMITS - commitText.length));
215
+ const commitColored = cPad +
216
+ (a > 0 ? ansi.brightCyan : ansi.dim + ansi.gray) + '+' + aFmt + ansi.reset +
217
+ ansi.gray + '/' + ansi.reset +
218
+ (b > 0 ? ansi.fg256(209) : ansi.dim + ansi.gray) + '-' + bFmt + ansi.reset +
219
+ ansi.gray + ' commits' + ansi.reset;
220
+
221
+ // Lines: "+N/-N lines", right-justified in COL_LINES
222
+ const laFmt = fmtCompact(la);
223
+ const ldFmt = fmtCompact(ld);
224
+ const linesText = '+' + laFmt + '/-' + ldFmt + ' lines';
225
+ const lPad = ' '.repeat(Math.max(0, COL_LINES - linesText.length));
226
+ const linesColored = lPad +
227
+ (la > 0 ? ansi.green : ansi.dim + ansi.gray) + '+' + laFmt + ansi.reset +
228
+ ansi.gray + '/' + ansi.reset +
229
+ (ld > 0 ? ansi.red : ansi.dim + ansi.gray) + '-' + ldFmt + ansi.reset +
230
+ ansi.gray + ' lines' + ansi.reset;
231
+
232
+ diffTag = commitColored + ' ' + linesColored + ' ';
233
+ }
176
234
 
177
235
  write(ansi.moveTo(row, 2));
178
236
  const cursor = isSelected ? ' \u25B6 ' : ' ';
179
- const maxNameLen = contentWidth - 38;
237
+ // Reserve: cursor(3) + sparkline(7) + PR dot(1) + diffTag(32) + status(9) + gap(1) + time(4) + padding(2)
238
+ const fixedWidth = 27 + DIFF_TAG_FIXED_LEN;
239
+ const maxNameLen = contentWidth - fixedWidth;
180
240
  const displayName = truncate(branch.name, maxNameLen);
181
241
  const namePadding = Math.max(1, maxNameLen - displayName.length + 2);
182
242
 
@@ -201,17 +261,18 @@ function renderBranchList(state, write) {
201
261
 
202
262
  write(' '.repeat(namePadding));
203
263
 
204
- // Sparkline
264
+ // Sparkline (inside highlight)
205
265
  if (isSelected) write(ansi.reset);
206
266
  if (isMerged && !isCurrent) {
207
267
  write(ansi.dim + ansi.fg256(60) + sparkline + ansi.reset);
208
268
  } else {
209
269
  write(ansi.fg256(39) + sparkline + ansi.reset);
210
270
  }
211
- if (isSelected) write(ansi.inverse);
212
271
 
213
- // PR status dot
272
+ // End the selected-row highlight here — everything after is outside
214
273
  if (isSelected) write(ansi.reset);
274
+
275
+ // PR status dot (just before columns)
215
276
  if (isMerged) {
216
277
  write(ansi.dim + ansi.magenta + '\u25CF' + ansi.reset);
217
278
  } else if (hasOpenPr) {
@@ -219,38 +280,28 @@ function renderBranchList(state, write) {
219
280
  } else {
220
281
  write(' ');
221
282
  }
222
- if (isSelected) write(ansi.inverse);
283
+
284
+ // Diff stats columns
285
+ write(diffTag);
223
286
 
224
287
  // Status badge
225
288
  if (branch.isDeleted) {
226
- if (isSelected) write(ansi.reset);
227
289
  write(ansi.red + ansi.dim + '\u2717 DELETED' + ansi.reset);
228
- if (isSelected) write(ansi.inverse);
229
290
  } else if (isMerged && !isCurrent && !branch.isNew && !branch.hasUpdates) {
230
- if (isSelected) write(ansi.reset);
231
291
  write(ansi.dim + ansi.magenta + '\u2713 MERGED ' + ansi.reset);
232
- if (isSelected) write(ansi.inverse);
233
292
  } else if (isCurrent) {
234
- if (isSelected) write(ansi.reset);
235
293
  write(ansi.green + '\u2605 CURRENT' + ansi.reset);
236
- if (isSelected) write(ansi.inverse);
237
294
  } else if (branch.isNew) {
238
- if (isSelected) write(ansi.reset);
239
295
  write(ansi.magenta + '\u2726 NEW ' + ansi.reset);
240
- if (isSelected) write(ansi.inverse);
241
296
  } else if (branch.hasUpdates) {
242
- if (isSelected) write(ansi.reset);
243
297
  write(ansi.yellow + '\u2193 UPDATES' + ansi.reset);
244
- if (isSelected) write(ansi.inverse);
245
298
  } else {
246
299
  write(' ');
247
300
  }
248
301
 
249
- // Time ago
250
- write(' ');
251
- if (isSelected) write(ansi.reset);
252
- write(ansi.gray + padLeft(timeAgo, 10) + ansi.reset);
253
- if (isSelected) write(ansi.reset);
302
+ // Time ago (compact)
303
+ write(' ');
304
+ write(ansi.gray + padLeft(timeAgo, 4) + ansi.reset);
254
305
 
255
306
  row++;
256
307
 
@@ -322,6 +373,110 @@ function renderActivityLog(state, write, startRow) {
322
373
  return startRow + height;
323
374
  }
324
375
 
376
+ // ---------------------------------------------------------------------------
377
+ // renderSessionStats
378
+ // ---------------------------------------------------------------------------
379
+
380
+ /**
381
+ * Render the session statistics panel (always visible in normal mode).
382
+ *
383
+ * @param {object} state
384
+ * @param {function} write
385
+ * @param {number} startRow - Row where the box should begin.
386
+ * @returns {number} The row immediately after the session stats box.
387
+ */
388
+ function renderSessionStats(state, write, startRow) {
389
+ if (state.casinoModeEnabled) return startRow;
390
+
391
+ const stats = state.sessionStats;
392
+ if (!stats) return startRow;
393
+
394
+ const boxWidth = state.terminalWidth;
395
+ const contentWidth = boxWidth - 4;
396
+ const STALE_DAYS = 30;
397
+ const STALE_WARNING_THRESHOLD = 5;
398
+
399
+ // Count active vs stale branches
400
+ const branches = state.branches || [];
401
+ const now = Date.now();
402
+ const staleMs = STALE_DAYS * 24 * 60 * 60 * 1000;
403
+ let staleBranches = 0;
404
+ let activeBranches = 0;
405
+ for (const b of branches) {
406
+ if (b.date && (now - b.date.getTime()) > staleMs) {
407
+ staleBranches++;
408
+ } else {
409
+ activeBranches++;
410
+ }
411
+ }
412
+
413
+ const showStaleHint = staleBranches >= STALE_WARNING_THRESHOLD;
414
+
415
+ // Build the stats line to measure if it fits in one row
416
+ // Parts: Duration | Lines | Polls (hits, %) | Last update | Branches: N active, N stale
417
+ let partsPlain = 'Duration: ' + stats.sessionDuration
418
+ + ' | Lines: +' + stats.linesAdded + '/-' + stats.linesDeleted
419
+ + ' | Polls: ' + stats.totalPolls;
420
+ if (stats.pollsWithUpdates > 0) {
421
+ partsPlain += ' (' + stats.pollsWithUpdates + ' hits, ' + stats.hitRate + '%)';
422
+ }
423
+ if (stats.lastUpdate) {
424
+ partsPlain += ' | Last update: ' + stats.lastUpdate;
425
+ }
426
+ partsPlain += ' | Branches: ' + activeBranches + ' active';
427
+ if (staleBranches > 0) {
428
+ partsPlain += ', ' + staleBranches + ' stale (>' + STALE_DAYS + 'd)';
429
+ }
430
+ if (showStaleHint) {
431
+ partsPlain += ' — press d to clean up';
432
+ }
433
+
434
+ const fitOneLine = partsPlain.length <= contentWidth;
435
+ const height = fitOneLine ? 4 : 5;
436
+
437
+ // Don't draw if not enough space
438
+ if (startRow + height > state.terminalHeight - 3) return startRow;
439
+
440
+ write(drawBox(startRow, 1, boxWidth, height, 'SESSION STATS', ansi.gray));
441
+
442
+ // Clear content area
443
+ for (let i = 1; i < height - 1; i++) {
444
+ write(ansi.moveTo(startRow + i, 2));
445
+ write(' '.repeat(boxWidth - 2));
446
+ }
447
+
448
+ // Line 1: duration, lines, polls, last update
449
+ write(ansi.moveTo(startRow + 1, 3));
450
+ write(ansi.gray + 'Duration: ' + ansi.reset + ansi.cyan + stats.sessionDuration + ansi.reset);
451
+ write(ansi.gray + ' | Lines: ' + ansi.reset);
452
+ write(ansi.green + '+' + stats.linesAdded + ansi.reset);
453
+ write(ansi.gray + '/' + ansi.reset);
454
+ write(ansi.red + '-' + stats.linesDeleted + ansi.reset);
455
+ write(ansi.gray + ' | Polls: ' + ansi.reset + stats.totalPolls);
456
+ if (stats.pollsWithUpdates > 0) {
457
+ write(ansi.gray + ' (' + ansi.reset + ansi.green + stats.pollsWithUpdates + ' hits' + ansi.reset + ansi.gray + ', ' + stats.hitRate + '%)' + ansi.reset);
458
+ }
459
+ if (stats.lastUpdate) {
460
+ write(ansi.gray + ' | Last update: ' + ansi.reset + ansi.yellow + stats.lastUpdate + ansi.reset);
461
+ }
462
+
463
+ // Branch counts — same line or next line depending on width
464
+ const branchRow = fitOneLine ? startRow + 1 : startRow + 2;
465
+ if (!fitOneLine) {
466
+ write(ansi.moveTo(branchRow, 3));
467
+ }
468
+ write(ansi.gray + ' | Branches: ' + ansi.reset + ansi.green + activeBranches + ' active' + ansi.reset);
469
+ if (staleBranches > 0) {
470
+ write(ansi.gray + ', ' + ansi.reset + ansi.yellow + staleBranches + ' stale' + ansi.reset);
471
+ write(ansi.gray + ' (>' + STALE_DAYS + 'd)' + ansi.reset);
472
+ }
473
+ if (showStaleHint) {
474
+ write(ansi.gray + ' \u2014 press ' + ansi.reset + ansi.white + 'd' + ansi.reset + ansi.gray + ' to clean up' + ansi.reset);
475
+ }
476
+
477
+ return startRow + height;
478
+ }
479
+
325
480
  // ---------------------------------------------------------------------------
326
481
  // renderCasinoStats (stub)
327
482
  // ---------------------------------------------------------------------------
@@ -1424,6 +1579,7 @@ module.exports = {
1424
1579
  renderHeader,
1425
1580
  renderBranchList,
1426
1581
  renderActivityLog,
1582
+ renderSessionStats,
1427
1583
  renderCasinoStats,
1428
1584
  renderFooter,
1429
1585
  renderFlash,
package/src/utils/time.js CHANGED
@@ -24,4 +24,24 @@ function formatTimeAgo(date) {
24
24
  return `${diffDay} days ago`;
25
25
  }
26
26
 
27
- module.exports = { formatTimeAgo };
27
+ /**
28
+ * Format a date as a compact relative time string (no "ago").
29
+ * @param {Date} date - The date to format
30
+ * @returns {string} Compact time string (e.g., "5m", "2h", "6d")
31
+ */
32
+ function formatTimeCompact(date) {
33
+ const now = new Date();
34
+ const diffMs = now.getTime() - date.getTime();
35
+ const diffSec = Math.floor(diffMs / 1000);
36
+ const diffMin = Math.floor(diffSec / 60);
37
+ const diffHr = Math.floor(diffMin / 60);
38
+ const diffDay = Math.floor(diffHr / 24);
39
+
40
+ if (diffSec < 10) return 'now';
41
+ if (diffSec < 60) return `${diffSec}s`;
42
+ if (diffMin < 60) return `${diffMin}m`;
43
+ if (diffHr < 24) return `${diffHr}h`;
44
+ return `${diffDay}d`;
45
+ }
46
+
47
+ module.exports = { formatTimeAgo, formatTimeCompact };