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.
- package/bin/git-watchtower.js +18 -136
- package/package.json +8 -8
- package/src/polling/engine.js +2 -0
- package/src/telemetry/analytics.js +41 -25
- package/src/telemetry/index.js +1 -0
package/bin/git-watchtower.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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:
|
|
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.
|
|
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": {
|
package/src/polling/engine.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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,
|