git-watchtower 2.1.14 → 2.1.16
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 +33 -2
- package/src/utils/monitor-lock.js +9 -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
|
@@ -81,11 +81,28 @@ const LOCK_FILE = path.join(WATCHTOWER_DIR, 'web.lock');
|
|
|
81
81
|
const SOCKET_PATH = path.join(WATCHTOWER_DIR, 'web.sock');
|
|
82
82
|
|
|
83
83
|
/**
|
|
84
|
-
* Ensure the ~/.watchtower directory exists.
|
|
84
|
+
* Ensure the ~/.watchtower directory exists, restricted to the owner.
|
|
85
|
+
*
|
|
86
|
+
* The directory hosts the lock file and the Unix-domain IPC socket;
|
|
87
|
+
* both should only be reachable by the user running git-watchtower.
|
|
88
|
+
* On Linux, connect() to a Unix socket requires read+execute on the
|
|
89
|
+
* containing directory, so 0o700 is sufficient defense-in-depth even
|
|
90
|
+
* when the socket file itself ends up world-readable due to umask.
|
|
91
|
+
*
|
|
92
|
+
* mkdirSync's mode argument only applies when the directory is being
|
|
93
|
+
* created — it has no effect on a pre-existing dir. We chmod after
|
|
94
|
+
* the create check so installs that ran before this fix get migrated
|
|
95
|
+
* to the tighter mode on next launch.
|
|
85
96
|
*/
|
|
86
97
|
function ensureDir() {
|
|
87
98
|
if (!fs.existsSync(WATCHTOWER_DIR)) {
|
|
88
|
-
fs.mkdirSync(WATCHTOWER_DIR, { recursive: true });
|
|
99
|
+
fs.mkdirSync(WATCHTOWER_DIR, { recursive: true, mode: 0o700 });
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
fs.chmodSync(WATCHTOWER_DIR, 0o700);
|
|
103
|
+
} catch (e) {
|
|
104
|
+
// chmod may fail on Windows or filesystems without POSIX perms; the
|
|
105
|
+
// permission semantics don't apply there anyway, so silently skip.
|
|
89
106
|
}
|
|
90
107
|
}
|
|
91
108
|
|
|
@@ -290,6 +307,20 @@ class Coordinator {
|
|
|
290
307
|
});
|
|
291
308
|
|
|
292
309
|
this.ipcServer.listen(this.socketPath, () => {
|
|
310
|
+
// Defense-in-depth: tighten the socket file's own perms after
|
|
311
|
+
// bind. Node's net.Server.listen() creates the socket file with
|
|
312
|
+
// perms derived from umask, which on shared workstations
|
|
313
|
+
// (umask 0022) yields 0o755 — world-readable. Linux ignores
|
|
314
|
+
// socket-file perms for connect() (the directory's perms are
|
|
315
|
+
// authoritative), but BSD honours them and other tooling may
|
|
316
|
+
// surface them in audit reports. Setting 0o600 explicitly
|
|
317
|
+
// matches the 0o700 directory perms set in ensureDir().
|
|
318
|
+
try {
|
|
319
|
+
fs.chmodSync(this.socketPath, 0o600);
|
|
320
|
+
} catch (e) {
|
|
321
|
+
// Non-POSIX filesystem or Windows — perm semantics don't
|
|
322
|
+
// apply, skip silently.
|
|
323
|
+
}
|
|
293
324
|
resolve();
|
|
294
325
|
});
|
|
295
326
|
});
|
|
@@ -52,8 +52,16 @@ function lockFilePath(repoRoot) {
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
function ensureDir() {
|
|
55
|
+
// 0o700 keeps lock files (which contain pids and the cwd of running
|
|
56
|
+
// git-watchtower instances) unreadable to other local users. See
|
|
57
|
+
// src/server/coordinator.js for the rationale and Windows note.
|
|
55
58
|
if (!fs.existsSync(WATCHTOWER_DIR)) {
|
|
56
|
-
fs.mkdirSync(WATCHTOWER_DIR, { recursive: true });
|
|
59
|
+
fs.mkdirSync(WATCHTOWER_DIR, { recursive: true, mode: 0o700 });
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
fs.chmodSync(WATCHTOWER_DIR, 0o700);
|
|
63
|
+
} catch (e) {
|
|
64
|
+
// chmod may fail on Windows / non-POSIX filesystems; skip silently.
|
|
57
65
|
}
|
|
58
66
|
}
|
|
59
67
|
|