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.
- package/bin/git-watchtower.js +188 -85
- package/package.json +1 -1
- package/src/state/store.js +6 -0
- package/src/ui/renderer.js +106 -0
- package/src/utils/version-check.js +22 -1
package/bin/git-watchtower.js
CHANGED
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
const http = require('http');
|
|
57
57
|
const fs = require('fs');
|
|
58
58
|
const path = require('path');
|
|
59
|
-
const {
|
|
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
|
|
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
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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
|
|
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
|
|
476
|
-
|
|
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
|
|
484
|
-
|
|
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
|
-
|
|
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
|
|
514
|
-
'
|
|
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
|
|
523
|
-
'
|
|
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
|
|
817
|
+
// Default timeout for CLI commands (30 seconds) — prevents hung commands
|
|
819
818
|
// from permanently blocking the polling loop
|
|
820
|
-
const
|
|
819
|
+
const CLI_TIMEOUT = 30000;
|
|
821
820
|
|
|
822
|
-
|
|
823
|
-
|
|
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 =
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
950
|
-
|
|
951
|
-
|
|
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
|
|
983
|
-
|
|
984
|
-
|
|
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
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1355
|
-
'
|
|
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
|
|
1376
|
-
|
|
1377
|
-
|
|
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
|
|
1441
|
-
const hasLocal = localBranches.split('\n').some(b => b.trim().replace(
|
|
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
|
|
1466
|
+
await execGitSilent(['checkout', '--', '.'], { cwd: PROJECT_ROOT });
|
|
1467
|
+
await execGit(['checkout', safeBranchName], { cwd: PROJECT_ROOT });
|
|
1445
1468
|
} else {
|
|
1446
|
-
await
|
|
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
|
|
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
|
|
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
|
|
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
|
|
2294
|
+
result = await execCli('glab', ['mr', 'create', `--source-branch=${aBranch.name}`, '--fill', '--yes']);
|
|
2272
2295
|
} else {
|
|
2273
|
-
result = await
|
|
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
|
|
2335
|
+
await execCli('glab', ['mr', 'approve', String(prInfo.number)]);
|
|
2313
2336
|
} else {
|
|
2314
|
-
await
|
|
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
|
|
2355
|
+
await execCli('glab', ['mr', 'merge', String(prInfo.number), '--squash', '--remove-source-branch', '--yes']);
|
|
2333
2356
|
} else {
|
|
2334
|
-
await
|
|
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
|
|
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
|
|
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
package/src/state/store.js
CHANGED
|
@@ -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
|
|
package/src/ui/renderer.js
CHANGED
|
@@ -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
|
-
|
|
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 };
|