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.
@@ -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
- pendingDirtyOperation = { type: 'switch', branch: branchName };
1528
- showStashConfirm(`switch to ${branchName}`);
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
- pendingDirtyOperation = null;
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
- pendingDirtyOperation = { type: 'switch', branch: branchName };
1587
- showStashConfirm(`switch to ${branchName}`);
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
- pendingDirtyOperation = { type: 'switch', branch: lastSwitch.from };
1622
- showStashConfirm(`undo to detached HEAD ${hash}`);
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
- pendingDirtyOperation = null;
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
- pendingDirtyOperation = null;
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
- pendingDirtyOperation = { type: 'pull' };
1711
- showStashConfirm('pull');
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
- pendingDirtyOperation = null;
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
- pendingDirtyOperation = null;
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
- pendingDirtyOperation = null;
2895
+ clearPendingDirtyOp();
2848
2896
  render();
2849
2897
  return;
2850
2898
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-watchtower",
3
- "version": "2.1.14",
3
+ "version": "2.1.16",
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": {
@@ -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