git-watchtower 1.5.0 → 1.6.1
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 +108 -8
- package/package.json +1 -1
package/bin/git-watchtower.js
CHANGED
|
@@ -77,7 +77,7 @@ const { parseGitHubPr, parseGitLabMr, parseGitHubPrList, parseGitLabMrList, isBa
|
|
|
77
77
|
// ============================================================================
|
|
78
78
|
// Security & Validation (imported from src/git/branch.js and src/git/commands.js)
|
|
79
79
|
// ============================================================================
|
|
80
|
-
const { isValidBranchName, sanitizeBranchName } = require('../src/git/branch');
|
|
80
|
+
const { isValidBranchName, sanitizeBranchName, getGoneBranches, deleteGoneBranches } = require('../src/git/branch');
|
|
81
81
|
const { isGitAvailable: checkGitAvailable } = require('../src/git/commands');
|
|
82
82
|
|
|
83
83
|
// ============================================================================
|
|
@@ -676,17 +676,27 @@ function stopServerProcess() {
|
|
|
676
676
|
|
|
677
677
|
addLog('Stopping server...', 'update');
|
|
678
678
|
|
|
679
|
+
// Capture reference before nulling — needed for deferred SIGKILL
|
|
680
|
+
const proc = serverProcess;
|
|
681
|
+
|
|
679
682
|
// Try graceful shutdown first
|
|
680
683
|
if (process.platform === 'win32') {
|
|
681
|
-
spawn('taskkill', ['/pid',
|
|
684
|
+
spawn('taskkill', ['/pid', proc.pid.toString(), '/f', '/t']);
|
|
682
685
|
} else {
|
|
683
|
-
|
|
684
|
-
// Force kill after
|
|
685
|
-
setTimeout(() => {
|
|
686
|
-
|
|
687
|
-
|
|
686
|
+
proc.kill('SIGTERM');
|
|
687
|
+
// Force kill after grace period if process hasn't exited
|
|
688
|
+
const forceKillTimeout = setTimeout(() => {
|
|
689
|
+
try {
|
|
690
|
+
proc.kill('SIGKILL');
|
|
691
|
+
} catch (e) {
|
|
692
|
+
// Process may already be dead
|
|
688
693
|
}
|
|
689
694
|
}, 3000);
|
|
695
|
+
|
|
696
|
+
// Clear the force-kill timer if the process exits cleanly
|
|
697
|
+
proc.once('close', () => {
|
|
698
|
+
clearTimeout(forceKillTimeout);
|
|
699
|
+
});
|
|
690
700
|
}
|
|
691
701
|
|
|
692
702
|
serverProcess = null;
|
|
@@ -796,15 +806,28 @@ const LIVE_RELOAD_SCRIPT = `
|
|
|
796
806
|
// Utility Functions
|
|
797
807
|
// ============================================================================
|
|
798
808
|
|
|
809
|
+
// Default timeout for execAsync (30 seconds) — prevents hung git/CLI commands
|
|
810
|
+
// from permanently blocking the polling loop
|
|
811
|
+
const EXEC_ASYNC_TIMEOUT = 30000;
|
|
812
|
+
|
|
799
813
|
function execAsync(command, options = {}) {
|
|
814
|
+
const { timeout = EXEC_ASYNC_TIMEOUT, ...restOptions } = options;
|
|
800
815
|
return new Promise((resolve, reject) => {
|
|
801
|
-
exec(command, { cwd: PROJECT_ROOT, ...
|
|
816
|
+
const child = exec(command, { cwd: PROJECT_ROOT, timeout, ...restOptions }, (error, stdout, stderr) => {
|
|
802
817
|
if (error) {
|
|
803
818
|
reject({ error, stdout, stderr });
|
|
804
819
|
} else {
|
|
805
820
|
resolve({ stdout: stdout.trim(), stderr: stderr.trim() });
|
|
806
821
|
}
|
|
807
822
|
});
|
|
823
|
+
// Also kill the child if the timeout fires (exec timeout sends SIGTERM
|
|
824
|
+
// but doesn't guarantee cleanup of process trees)
|
|
825
|
+
if (timeout > 0) {
|
|
826
|
+
const killTimer = setTimeout(() => {
|
|
827
|
+
try { child.kill('SIGKILL'); } catch (e) { /* already dead */ }
|
|
828
|
+
}, timeout + 5000);
|
|
829
|
+
child.on('close', () => clearTimeout(killTimer));
|
|
830
|
+
}
|
|
808
831
|
});
|
|
809
832
|
}
|
|
810
833
|
|
|
@@ -1191,6 +1214,11 @@ function render() {
|
|
|
1191
1214
|
renderer.renderErrorToast(state, write);
|
|
1192
1215
|
}
|
|
1193
1216
|
|
|
1217
|
+
// Cleanup confirmation dialog
|
|
1218
|
+
if (state.cleanupConfirmMode) {
|
|
1219
|
+
renderer.renderCleanupConfirm(state, write);
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1194
1222
|
// Stash confirmation dialog renders on top of everything
|
|
1195
1223
|
if (state.stashConfirmMode) {
|
|
1196
1224
|
renderer.renderStashConfirm(state, write);
|
|
@@ -2005,6 +2033,16 @@ const server = http.createServer((req, res) => {
|
|
|
2005
2033
|
pathname = path.normalize(pathname).replace(/^(\.\.[\/\\])+/, '');
|
|
2006
2034
|
let filePath = path.join(STATIC_DIR, pathname);
|
|
2007
2035
|
|
|
2036
|
+
// Security: ensure resolved path stays within STATIC_DIR to prevent path traversal
|
|
2037
|
+
const resolvedPath = path.resolve(filePath);
|
|
2038
|
+
const resolvedStaticDir = path.resolve(STATIC_DIR);
|
|
2039
|
+
if (!resolvedPath.startsWith(resolvedStaticDir + path.sep) && resolvedPath !== resolvedStaticDir) {
|
|
2040
|
+
res.writeHead(403, { 'Content-Type': 'text/html' });
|
|
2041
|
+
res.end('<h1>403 Forbidden</h1>');
|
|
2042
|
+
addServerLog(`GET ${logPath} → 403 (path traversal blocked)`, true);
|
|
2043
|
+
return;
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2008
2046
|
if (fs.existsSync(filePath) && fs.statSync(filePath).isDirectory()) {
|
|
2009
2047
|
filePath = path.join(filePath, 'index.html');
|
|
2010
2048
|
}
|
|
@@ -2329,6 +2367,59 @@ function setupKeyboardInput() {
|
|
|
2329
2367
|
return; // Ignore other keys in action mode
|
|
2330
2368
|
}
|
|
2331
2369
|
|
|
2370
|
+
// Handle cleanup confirmation dialog
|
|
2371
|
+
if (store.get('cleanupConfirmMode')) {
|
|
2372
|
+
const cleanupBranches = store.get('cleanupBranches') || [];
|
|
2373
|
+
const maxOptions = cleanupBranches.length > 0 ? 3 : 1;
|
|
2374
|
+
if (key === '\u001b[A' || key === 'k') { // Up
|
|
2375
|
+
const idx = store.get('cleanupSelectedIndex') || 0;
|
|
2376
|
+
if (idx > 0) {
|
|
2377
|
+
store.setState({ cleanupSelectedIndex: idx - 1 });
|
|
2378
|
+
render();
|
|
2379
|
+
}
|
|
2380
|
+
return;
|
|
2381
|
+
}
|
|
2382
|
+
if (key === '\u001b[B' || key === 'j') { // Down
|
|
2383
|
+
const idx = store.get('cleanupSelectedIndex') || 0;
|
|
2384
|
+
if (idx < maxOptions - 1) {
|
|
2385
|
+
store.setState({ cleanupSelectedIndex: idx + 1 });
|
|
2386
|
+
render();
|
|
2387
|
+
}
|
|
2388
|
+
return;
|
|
2389
|
+
}
|
|
2390
|
+
if (key === '\r' || key === '\n') { // Enter — execute selected option
|
|
2391
|
+
const idx = store.get('cleanupSelectedIndex') || 0;
|
|
2392
|
+
applyUpdates(actions.closeCleanupConfirm(getActionState()));
|
|
2393
|
+
render();
|
|
2394
|
+
if (cleanupBranches.length === 0 || idx === maxOptions - 1) {
|
|
2395
|
+
// Cancel or Close (no branches)
|
|
2396
|
+
return;
|
|
2397
|
+
}
|
|
2398
|
+
const force = idx === 1; // 0=safe delete, 1=force delete, 2=cancel
|
|
2399
|
+
addLog(`Cleaning up ${cleanupBranches.length} stale branch${cleanupBranches.length === 1 ? '' : 'es'}${force ? ' (force)' : ''}...`, 'update');
|
|
2400
|
+
render();
|
|
2401
|
+
const result = await deleteGoneBranches(cleanupBranches, { force });
|
|
2402
|
+
for (const name of result.deleted) {
|
|
2403
|
+
addLog(`Deleted branch: ${name}`, 'success');
|
|
2404
|
+
}
|
|
2405
|
+
for (const f of result.failed) {
|
|
2406
|
+
addLog(`Failed to delete ${f.name}: ${f.error}`, 'error');
|
|
2407
|
+
}
|
|
2408
|
+
if (result.deleted.length > 0) {
|
|
2409
|
+
addLog(`Cleaned up ${result.deleted.length} branch${result.deleted.length === 1 ? '' : 'es'}`, 'success');
|
|
2410
|
+
await pollGitChanges();
|
|
2411
|
+
}
|
|
2412
|
+
render();
|
|
2413
|
+
return;
|
|
2414
|
+
}
|
|
2415
|
+
if (key === '\u001b') { // Escape — cancel
|
|
2416
|
+
applyUpdates(actions.closeCleanupConfirm(getActionState()));
|
|
2417
|
+
render();
|
|
2418
|
+
return;
|
|
2419
|
+
}
|
|
2420
|
+
return; // Ignore other keys in cleanup mode
|
|
2421
|
+
}
|
|
2422
|
+
|
|
2332
2423
|
// Handle stash confirmation dialog
|
|
2333
2424
|
if (store.get('stashConfirmMode')) {
|
|
2334
2425
|
if (key === '\u001b[A' || key === 'k') { // Up
|
|
@@ -2565,6 +2656,15 @@ function setupKeyboardInput() {
|
|
|
2565
2656
|
break;
|
|
2566
2657
|
}
|
|
2567
2658
|
|
|
2659
|
+
case 'd': { // Cleanup stale branches (remotes deleted)
|
|
2660
|
+
addLog('Scanning for stale branches...', 'info');
|
|
2661
|
+
render();
|
|
2662
|
+
const goneBranches = await getGoneBranches();
|
|
2663
|
+
applyUpdates(actions.openCleanupConfirm(actionState, goneBranches));
|
|
2664
|
+
render();
|
|
2665
|
+
break;
|
|
2666
|
+
}
|
|
2667
|
+
|
|
2568
2668
|
// Number keys to set visible branch count
|
|
2569
2669
|
case '1': case '2': case '3': case '4': case '5':
|
|
2570
2670
|
case '6': case '7': case '8': case '9':
|
package/package.json
CHANGED