git-watchtower 2.1.13 → 2.1.15
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 +62 -14
- package/package.json +1 -1
- package/src/server/coordinator.js +31 -1
package/bin/git-watchtower.js
CHANGED
|
@@ -881,6 +881,50 @@ let errorToastTimeout = null;
|
|
|
881
881
|
// Shape: { type: 'switch', branch: string } | { type: 'pull' } | null
|
|
882
882
|
let pendingDirtyOperation = null;
|
|
883
883
|
|
|
884
|
+
/**
|
|
885
|
+
* Setter for pendingDirtyOperation that refuses to overwrite an
|
|
886
|
+
* already-pending operation. Returns true if the assignment succeeded,
|
|
887
|
+
* false if a different operation was already pending and the caller
|
|
888
|
+
* should skip its follow-up (e.g. the showStashConfirm modal).
|
|
889
|
+
*
|
|
890
|
+
* Why this matters: process.stdin's 'data' listener is async, so two
|
|
891
|
+
* near-simultaneous keypresses spawn concurrent handler bodies. If the
|
|
892
|
+
* user presses Enter (switch) and then 'p' (pull) within the ~100 ms
|
|
893
|
+
* it takes hasUncommittedChanges to await, both bodies reach the
|
|
894
|
+
* dirty-repo branch and the second one's pendingDirtyOperation
|
|
895
|
+
* silently clobbers the first. The user then sees a stash-confirm
|
|
896
|
+
* modal labeled "pull" even though they pressed Enter, and the
|
|
897
|
+
* original switch intent is lost. Refusing the overwrite preserves
|
|
898
|
+
* the first user action and surfaces the conflict via the activity
|
|
899
|
+
* log rather than dropping intent on the floor.
|
|
900
|
+
*
|
|
901
|
+
* Note: the check + assignment here is synchronous, so JavaScript's
|
|
902
|
+
* single-threaded event loop guarantees no interleaving — two callers
|
|
903
|
+
* will see consistent state.
|
|
904
|
+
*
|
|
905
|
+
* @param {{type: 'switch', branch: string} | {type: 'pull'} | null} op
|
|
906
|
+
* @returns {boolean} true if assigned, false if refused (already pending)
|
|
907
|
+
*/
|
|
908
|
+
function setPendingDirtyOp(op) {
|
|
909
|
+
if (op !== null && pendingDirtyOperation !== null) {
|
|
910
|
+
addLog(
|
|
911
|
+
`Another operation already pending (${pendingDirtyOperation.type}) — resolve the stash dialog first`,
|
|
912
|
+
'warning',
|
|
913
|
+
);
|
|
914
|
+
return false;
|
|
915
|
+
}
|
|
916
|
+
pendingDirtyOperation = op;
|
|
917
|
+
return true;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
/**
|
|
921
|
+
* Clear pendingDirtyOperation. Symmetric with setPendingDirtyOp so all
|
|
922
|
+
* mutations route through the same surface.
|
|
923
|
+
*/
|
|
924
|
+
function clearPendingDirtyOp() {
|
|
925
|
+
pendingDirtyOperation = null;
|
|
926
|
+
}
|
|
927
|
+
|
|
884
928
|
// Cached environment info (populated once at startup, doesn't change during session)
|
|
885
929
|
let cachedEnv = null; // { hasGh, hasGlab, ghAuthed, glabAuthed, webUrlBase, platform }
|
|
886
930
|
|
|
@@ -1524,8 +1568,9 @@ async function switchToBranch(branchName, recordHistory = true) {
|
|
|
1524
1568
|
const isDirty = await hasUncommittedChanges();
|
|
1525
1569
|
if (isDirty) {
|
|
1526
1570
|
addLog(`Cannot switch: uncommitted changes in working directory`, 'error');
|
|
1527
|
-
|
|
1528
|
-
|
|
1571
|
+
if (setPendingDirtyOp({ type: 'switch', branch: branchName })) {
|
|
1572
|
+
showStashConfirm(`switch to ${branchName}`);
|
|
1573
|
+
}
|
|
1529
1574
|
telemetry.capture('dirty_repo_encountered');
|
|
1530
1575
|
return { success: false, reason: 'dirty' };
|
|
1531
1576
|
}
|
|
@@ -1563,7 +1608,7 @@ async function switchToBranch(branchName, recordHistory = true) {
|
|
|
1563
1608
|
addLog(`Switched to ${safeBranchName}`, 'success');
|
|
1564
1609
|
telemetry.capture('branch_switched');
|
|
1565
1610
|
branchSwitchCount++;
|
|
1566
|
-
|
|
1611
|
+
clearPendingDirtyOp();
|
|
1567
1612
|
|
|
1568
1613
|
// Restart server if configured (command mode)
|
|
1569
1614
|
if (SERVER_MODE === 'command' && RESTART_ON_SWITCH && serverProcess) {
|
|
@@ -1583,8 +1628,9 @@ async function switchToBranch(branchName, recordHistory = true) {
|
|
|
1583
1628
|
);
|
|
1584
1629
|
} else if (errMsg.includes('local changes') || errMsg.includes('overwritten')) {
|
|
1585
1630
|
addLog(`Cannot switch: local changes would be overwritten`, 'error');
|
|
1586
|
-
|
|
1587
|
-
|
|
1631
|
+
if (setPendingDirtyOp({ type: 'switch', branch: branchName })) {
|
|
1632
|
+
showStashConfirm(`switch to ${branchName}`);
|
|
1633
|
+
}
|
|
1588
1634
|
} else {
|
|
1589
1635
|
addLog(`Failed to switch: ${errMsg}`, 'error');
|
|
1590
1636
|
showErrorToast(
|
|
@@ -1618,8 +1664,9 @@ async function undoLastSwitch() {
|
|
|
1618
1664
|
|
|
1619
1665
|
if (await hasUncommittedChanges()) {
|
|
1620
1666
|
addLog('Cannot undo: uncommitted changes in working directory', 'error');
|
|
1621
|
-
|
|
1622
|
-
|
|
1667
|
+
if (setPendingDirtyOp({ type: 'switch', branch: lastSwitch.from })) {
|
|
1668
|
+
showStashConfirm(`undo to detached HEAD ${hash}`);
|
|
1669
|
+
}
|
|
1623
1670
|
return { success: false, reason: 'dirty' };
|
|
1624
1671
|
}
|
|
1625
1672
|
|
|
@@ -1632,7 +1679,7 @@ async function undoLastSwitch() {
|
|
|
1632
1679
|
});
|
|
1633
1680
|
addLog(`Undone: detached HEAD at ${hash}`, 'success');
|
|
1634
1681
|
branchSwitchCount++;
|
|
1635
|
-
|
|
1682
|
+
clearPendingDirtyOp();
|
|
1636
1683
|
notifyClients();
|
|
1637
1684
|
return { success: true };
|
|
1638
1685
|
} catch (e) {
|
|
@@ -1699,7 +1746,7 @@ async function pullCurrentBranch() {
|
|
|
1699
1746
|
}
|
|
1700
1747
|
addLog(`Pulled ${REMOTE_NAME}/${branch}${summary}`, 'success');
|
|
1701
1748
|
}
|
|
1702
|
-
|
|
1749
|
+
clearPendingDirtyOp();
|
|
1703
1750
|
notifyClients();
|
|
1704
1751
|
return { success: true };
|
|
1705
1752
|
} catch (e) {
|
|
@@ -1707,8 +1754,9 @@ async function pullCurrentBranch() {
|
|
|
1707
1754
|
addLog(`Pull failed: ${errMsg}`, 'error');
|
|
1708
1755
|
|
|
1709
1756
|
if (errMsg.includes('local changes') || errMsg.includes('overwritten') || errMsg.includes('uncommitted changes')) {
|
|
1710
|
-
|
|
1711
|
-
|
|
1757
|
+
if (setPendingDirtyOp({ type: 'pull' })) {
|
|
1758
|
+
showStashConfirm('pull');
|
|
1759
|
+
}
|
|
1712
1760
|
} else if (isMergeConflict(errMsg)) {
|
|
1713
1761
|
store.setState({ hasMergeConflict: true });
|
|
1714
1762
|
showErrorToast(
|
|
@@ -1747,7 +1795,7 @@ async function stashAndRetry() {
|
|
|
1747
1795
|
return;
|
|
1748
1796
|
}
|
|
1749
1797
|
|
|
1750
|
-
|
|
1798
|
+
clearPendingDirtyOp();
|
|
1751
1799
|
hideErrorToast();
|
|
1752
1800
|
hideStashConfirm();
|
|
1753
1801
|
|
|
@@ -2830,7 +2878,7 @@ function setupKeyboardInput() {
|
|
|
2830
2878
|
await stashAndRetry();
|
|
2831
2879
|
} else {
|
|
2832
2880
|
addLog('Stash cancelled — handle changes manually', 'info');
|
|
2833
|
-
|
|
2881
|
+
clearPendingDirtyOp();
|
|
2834
2882
|
}
|
|
2835
2883
|
return;
|
|
2836
2884
|
}
|
|
@@ -2844,7 +2892,7 @@ function setupKeyboardInput() {
|
|
|
2844
2892
|
if (key === '\u001b') { // Escape — cancel
|
|
2845
2893
|
hideStashConfirm();
|
|
2846
2894
|
addLog('Stash cancelled — handle changes manually', 'info');
|
|
2847
|
-
|
|
2895
|
+
clearPendingDirtyOp();
|
|
2848
2896
|
render();
|
|
2849
2897
|
return;
|
|
2850
2898
|
}
|
package/package.json
CHANGED
|
@@ -34,6 +34,18 @@ const WATCHTOWER_DIR = path.join(os.homedir(), '.watchtower');
|
|
|
34
34
|
*/
|
|
35
35
|
const MAX_IPC_BUFFER = 1024 * 1024;
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* How long the coordinator gives an accepted connection to send a
|
|
39
|
+
* `register` frame before destroying the socket. Legitimate workers
|
|
40
|
+
* send register immediately on connect (sub-100 ms in practice), so
|
|
41
|
+
* 5 s is generous. Without this, a peer that opens a connection and
|
|
42
|
+
* sits idle without registering consumes one of the
|
|
43
|
+
* MAX_WORKER_CONNECTIONS slots indefinitely — so a runaway peer that
|
|
44
|
+
* never sends data could lock out legitimate workers even though it
|
|
45
|
+
* is below the connection cap.
|
|
46
|
+
*/
|
|
47
|
+
const WORKER_REGISTER_TIMEOUT_MS = 5000;
|
|
48
|
+
|
|
37
49
|
/**
|
|
38
50
|
* Maximum number of concurrent worker connections the coordinator will
|
|
39
51
|
* accept. The legitimate ceiling is "one git-watchtower instance per
|
|
@@ -379,6 +391,21 @@ class Coordinator {
|
|
|
379
391
|
let workerId = null;
|
|
380
392
|
let buffer = '';
|
|
381
393
|
|
|
394
|
+
// Drop the socket if the peer doesn't complete the register handshake
|
|
395
|
+
// in time. Cleared by setWorkerId on a successful 'register' frame
|
|
396
|
+
// and on close/error to avoid acting on a destroyed socket.
|
|
397
|
+
const registerTimer = setTimeout(() => {
|
|
398
|
+
if (!workerId) {
|
|
399
|
+
socket.destroy();
|
|
400
|
+
}
|
|
401
|
+
}, WORKER_REGISTER_TIMEOUT_MS);
|
|
402
|
+
if (registerTimer.unref) registerTimer.unref();
|
|
403
|
+
|
|
404
|
+
const setWorkerId = (id) => {
|
|
405
|
+
workerId = id;
|
|
406
|
+
clearTimeout(registerTimer);
|
|
407
|
+
};
|
|
408
|
+
|
|
382
409
|
socket.on('data', (data) => {
|
|
383
410
|
buffer += data.toString();
|
|
384
411
|
if (buffer.length > MAX_IPC_BUFFER) {
|
|
@@ -392,7 +419,7 @@ class Coordinator {
|
|
|
392
419
|
if (line.trim()) {
|
|
393
420
|
try {
|
|
394
421
|
const msg = JSON.parse(line);
|
|
395
|
-
this._handleWorkerMessage(socket, msg,
|
|
422
|
+
this._handleWorkerMessage(socket, msg, setWorkerId, () => workerId);
|
|
396
423
|
} catch (e) {
|
|
397
424
|
// Both sides of this socket are our own code, so a JSON-parse
|
|
398
425
|
// failure indicates a protocol/version bug worth diagnosing.
|
|
@@ -404,6 +431,7 @@ class Coordinator {
|
|
|
404
431
|
});
|
|
405
432
|
|
|
406
433
|
socket.on('close', () => {
|
|
434
|
+
clearTimeout(registerTimer);
|
|
407
435
|
if (workerId) {
|
|
408
436
|
this.projects.delete(workerId);
|
|
409
437
|
this.workerSockets.delete(workerId);
|
|
@@ -412,6 +440,7 @@ class Coordinator {
|
|
|
412
440
|
});
|
|
413
441
|
|
|
414
442
|
socket.on('error', () => {
|
|
443
|
+
clearTimeout(registerTimer);
|
|
415
444
|
if (workerId) {
|
|
416
445
|
this.projects.delete(workerId);
|
|
417
446
|
this.workerSockets.delete(workerId);
|
|
@@ -741,4 +770,5 @@ module.exports = {
|
|
|
741
770
|
SOCKET_PATH,
|
|
742
771
|
MAX_IPC_BUFFER,
|
|
743
772
|
MAX_WORKER_CONNECTIONS,
|
|
773
|
+
WORKER_REGISTER_TIMEOUT_MS,
|
|
744
774
|
};
|