git-watchtower 1.12.7 → 1.13.0

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.
@@ -102,6 +102,7 @@ const store = new Store();
102
102
  // Web dashboard server
103
103
  const { WebDashboardServer } = require('../src/server/web');
104
104
  const { Coordinator, Worker, generateProjectId, getActiveCoordinator, tryAcquireLock, finalizeLock, removeLock, removeSocket, isProcessAlive } = require('../src/server/coordinator');
105
+ const monitorLock = require('../src/utils/monitor-lock');
105
106
 
106
107
  const PROJECT_ROOT = process.cwd();
107
108
 
@@ -1003,8 +1004,12 @@ function getCasinoMessage(type) {
1003
1004
  function addLog(message, type = 'info') {
1004
1005
  const icons = { info: '○', success: '✓', warning: '●', error: '✗', update: '⟳' };
1005
1006
  const colors = { info: 'white', success: 'green', warning: 'yellow', error: 'red', update: 'cyan' };
1007
+ // Collapse any whitespace (newlines, tabs, CRs) into a single space so that
1008
+ // multi-line content (e.g. git stderr from a failed auto-pull) cannot leak
1009
+ // cursor movement into the rendered box and corrupt the surrounding UI.
1010
+ const safeMessage = String(message == null ? '' : message).replace(/\s+/g, ' ').trim();
1006
1011
  const entry = {
1007
- message, type,
1012
+ message: safeMessage, type,
1008
1013
  timestamp: new Date().toLocaleTimeString(),
1009
1014
  icon: icons[type] || '○',
1010
1015
  color: colors[type] || 'white',
@@ -3368,6 +3373,9 @@ function restartProcess() {
3368
3373
 
3369
3374
  let isShuttingDown = false;
3370
3375
  let _resourcesCleaned = false;
3376
+ // Path of the per-repo monitor lock we own, if any. Set during start() and
3377
+ // cleared in cleanupResources() after release.
3378
+ let monitorLockFile = null;
3371
3379
 
3372
3380
  /**
3373
3381
  * Idempotent, best-effort cleanup of every long-lived resource we own:
@@ -3423,6 +3431,13 @@ function cleanupResources() {
3423
3431
 
3424
3432
  // Web dashboard + worker/coordinator (unlinks lock file + IPC socket)
3425
3433
  try { stopWebDashboard(); } catch (_) { /* ignore */ }
3434
+
3435
+ // Per-repo monitor lock — release last so the slot stays reserved for the
3436
+ // entire lifetime of this process, including any errors in the steps above.
3437
+ if (monitorLockFile) {
3438
+ try { monitorLock.release(monitorLockFile); } catch (_) { /* ignore */ }
3439
+ monitorLockFile = null;
3440
+ }
3426
3441
  }
3427
3442
 
3428
3443
  async function shutdown() {
@@ -3489,6 +3504,44 @@ async function start() {
3489
3504
  process.exit(1);
3490
3505
  }
3491
3506
 
3507
+ // Single-instance guard (per repo). Two TUIs rendering to the same terminal
3508
+ // stomp on each other's frames — selection cursor bounces between their
3509
+ // independent selectedIndex values, the activity log flips between two
3510
+ // buffers, and each sees the other's `git checkout` as an "external" switch.
3511
+ // Acquire before any TTY writes so a refusal leaves the user's prompt clean.
3512
+ const lockResult = monitorLock.acquire(PROJECT_ROOT);
3513
+ if (!lockResult.acquired) {
3514
+ if (cliArgs.force) {
3515
+ console.error(
3516
+ ansi.yellow + '⚠ Warning: another git-watchtower (PID ' +
3517
+ lockResult.existing.pid + ') is already running against this repo. ' +
3518
+ 'Continuing due to --force.' + ansi.reset
3519
+ );
3520
+ } else {
3521
+ const existing = lockResult.existing || {};
3522
+ console.error('\n' + ansi.red + ansi.bold +
3523
+ '✗ Error: git-watchtower is already running against this repository' + ansi.reset);
3524
+ console.error('\n Existing PID: ' + ansi.bold + (existing.pid || 'unknown') + ansi.reset);
3525
+ if (existing.startedAt) {
3526
+ const ageMs = Date.now() - existing.startedAt;
3527
+ const ageStr = ageMs < 60000
3528
+ ? Math.round(ageMs / 1000) + 's'
3529
+ : Math.round(ageMs / 60000) + 'm';
3530
+ console.error(' Started: ' + ageStr + ' ago');
3531
+ }
3532
+ console.error(' Repo: ' + PROJECT_ROOT);
3533
+ console.error(' Lock file: ' + lockResult.file);
3534
+ console.error('\n Running two instances against the same repo makes the TUI');
3535
+ console.error(' unusable (selection bouncing, flipping logs, CURRENT label');
3536
+ console.error(' snap-back). Stop the other instance first:');
3537
+ console.error('\n ' + ansi.bold + 'kill ' + (existing.pid || '<pid>') + ansi.reset);
3538
+ console.error('\n Or pass --force to override (not recommended).\n');
3539
+ process.exit(1);
3540
+ }
3541
+ } else {
3542
+ monitorLockFile = lockResult.file;
3543
+ }
3544
+
3492
3545
  // Load or create configuration
3493
3546
  const config = await ensureConfig(cliArgs);
3494
3547
  applyConfig(config);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-watchtower",
3
- "version": "1.12.7",
3
+ "version": "1.13.0",
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": {
package/src/cli/args.js CHANGED
@@ -22,6 +22,7 @@ const { version: PACKAGE_VERSION } = require('../../package.json');
22
22
  * @property {boolean} casino - Enable casino mode
23
23
  * @property {boolean} web - Enable web dashboard mode
24
24
  * @property {number|null} webPort - Web dashboard port override
25
+ * @property {boolean} force - Bypass the single-instance lock for this repo
25
26
  * @property {string[]} errors - Validation errors encountered during parsing
26
27
  */
27
28
 
@@ -56,6 +57,7 @@ function parseArgs(argv, options = {}) {
56
57
  // Actions
57
58
  init: false,
58
59
  casino: false,
60
+ force: false,
59
61
  // Parsing errors
60
62
  errors: [],
61
63
  };
@@ -151,6 +153,8 @@ function parseArgs(argv, options = {}) {
151
153
  // Actions and info
152
154
  else if (args[i] === '--init') {
153
155
  result.init = true;
156
+ } else if (args[i] === '--force') {
157
+ result.force = true;
154
158
  } else if (args[i] === '--version' || args[i] === '-v') {
155
159
  if (options.onVersion) {
156
160
  options.onVersion(PACKAGE_VERSION);
@@ -277,6 +281,8 @@ Web Dashboard:
277
281
 
278
282
  General:
279
283
  --init Run the configuration wizard
284
+ --force Allow starting even if another instance is running
285
+ against this repo (not recommended)
280
286
  -v, --version Show version number
281
287
  -h, --help Show this help message
282
288
 
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Per-repository single-instance lock for the CLI monitor.
3
+ *
4
+ * Prevents two `git-watchtower` TUI processes from running against the same
5
+ * repository at the same time — without this, both render to the same TTY,
6
+ * stomping on each other's frames (selection cursor bounces between their
7
+ * independent selectedIndex values, CURRENT label flips while one process
8
+ * lags the other's checkout, activity log flips between two buffers, etc.).
9
+ *
10
+ * The lock file lives at `~/.watchtower/monitor-<sha1(repoRoot)>.lock` and
11
+ * contains `{ pid, startedAt, cwd }`. We use the same atomic
12
+ * `fs.openSync(..., 'wx')` pattern as {@link module:server/coordinator} so
13
+ * two processes racing to acquire cannot both succeed. Dead-owner locks are
14
+ * treated as stale and cleaned up.
15
+ *
16
+ * Zero runtime dependencies — only Node built-ins.
17
+ *
18
+ * @module utils/monitor-lock
19
+ */
20
+
21
+ const fs = require('fs');
22
+ const path = require('path');
23
+ const os = require('os');
24
+ const crypto = require('crypto');
25
+
26
+ const WATCHTOWER_DIR = path.join(os.homedir(), '.watchtower');
27
+
28
+ /**
29
+ * Check if a process with the given PID is alive.
30
+ * @param {number} pid
31
+ * @returns {boolean}
32
+ */
33
+ function isProcessAlive(pid) {
34
+ if (!pid || typeof pid !== 'number') return false;
35
+ try {
36
+ process.kill(pid, 0);
37
+ return true;
38
+ } catch (e) {
39
+ // ESRCH = no such process; EPERM = exists but owned by another user
40
+ return e.code === 'EPERM';
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Compute the lock file path for a given repo root.
46
+ * @param {string} repoRoot - Absolute path to the repo
47
+ * @returns {string}
48
+ */
49
+ function lockFilePath(repoRoot) {
50
+ const hash = crypto.createHash('sha1').update(repoRoot).digest('hex').slice(0, 16);
51
+ return path.join(WATCHTOWER_DIR, `monitor-${hash}.lock`);
52
+ }
53
+
54
+ function ensureDir() {
55
+ if (!fs.existsSync(WATCHTOWER_DIR)) {
56
+ fs.mkdirSync(WATCHTOWER_DIR, { recursive: true });
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Read and parse a lock file. Returns null on any I/O or parse error.
62
+ * @param {string} file
63
+ * @returns {{ pid: number, startedAt?: number, cwd?: string } | null}
64
+ */
65
+ function readLock(file) {
66
+ try {
67
+ if (!fs.existsSync(file)) return null;
68
+ const data = JSON.parse(fs.readFileSync(file, 'utf8'));
69
+ if (!data || typeof data.pid !== 'number') return null;
70
+ return data;
71
+ } catch (e) {
72
+ return null;
73
+ }
74
+ }
75
+
76
+ function removeLock(file) {
77
+ try { fs.unlinkSync(file); } catch (e) { /* ignore */ }
78
+ }
79
+
80
+ /**
81
+ * @typedef {Object} AcquireResult
82
+ * @property {true} acquired
83
+ * @property {string} file - Path of the lock we now own
84
+ *
85
+ * @typedef {Object} ConflictResult
86
+ * @property {false} acquired
87
+ * @property {'busy'} reason
88
+ * @property {string} file
89
+ * @property {{ pid: number, startedAt?: number, cwd?: string }} existing
90
+ */
91
+
92
+ /**
93
+ * Atomically try to acquire the monitor lock for the given repo.
94
+ *
95
+ * - If the lock file doesn't exist, create it exclusively and return acquired.
96
+ * - If it exists but the owning PID is dead (stale), remove it and retry.
97
+ * - If it exists and the owning PID is alive, return busy.
98
+ *
99
+ * @param {string} repoRoot - Absolute path to the repo
100
+ * @param {Object} [opts]
101
+ * @param {number} [opts.pid] - PID to record (defaults to process.pid)
102
+ * @returns {AcquireResult | ConflictResult}
103
+ */
104
+ function acquire(repoRoot, opts = {}) {
105
+ if (typeof repoRoot !== 'string' || !repoRoot) {
106
+ throw new TypeError('acquire: repoRoot must be a non-empty string');
107
+ }
108
+ ensureDir();
109
+ const pid = opts.pid || process.pid;
110
+ const file = lockFilePath(repoRoot);
111
+ const payload = JSON.stringify({ pid, startedAt: Date.now(), cwd: repoRoot });
112
+
113
+ // One retry after stale-lock cleanup — matches coordinator.js's approach.
114
+ // A second race loss after cleanup is surfaced as busy rather than looping
115
+ // indefinitely against a hostile or rapidly-respawning peer.
116
+ for (let attempt = 0; attempt < 2; attempt++) {
117
+ try {
118
+ const fd = fs.openSync(file, 'wx');
119
+ try {
120
+ fs.writeSync(fd, payload + '\n');
121
+ } finally {
122
+ fs.closeSync(fd);
123
+ }
124
+ return { acquired: true, file };
125
+ } catch (err) {
126
+ if (err.code !== 'EEXIST') throw err;
127
+
128
+ const existing = readLock(file);
129
+ if (existing && isProcessAlive(existing.pid)) {
130
+ return { acquired: false, reason: 'busy', file, existing };
131
+ }
132
+ // Stale lock — owner is dead or file is unreadable garbage. Clean up and retry.
133
+ removeLock(file);
134
+ }
135
+ }
136
+
137
+ // Lost the retry race to another starter; treat as busy.
138
+ const existing = readLock(file);
139
+ return {
140
+ acquired: false,
141
+ reason: 'busy',
142
+ file,
143
+ existing: existing || { pid: 0 },
144
+ };
145
+ }
146
+
147
+ /**
148
+ * Release a previously acquired lock. Only removes the file if the PID inside
149
+ * matches the given (or current) PID — guards against deleting a lock that
150
+ * was reacquired by a different process after our own stale cleanup.
151
+ *
152
+ * @param {string} file - Lock file path returned from acquire()
153
+ * @param {Object} [opts]
154
+ * @param {number} [opts.pid] - PID to check against (defaults to process.pid)
155
+ */
156
+ function release(file, opts = {}) {
157
+ if (!file) return;
158
+ const pid = opts.pid || process.pid;
159
+ const existing = readLock(file);
160
+ if (existing && existing.pid === pid) {
161
+ removeLock(file);
162
+ }
163
+ }
164
+
165
+ module.exports = {
166
+ acquire,
167
+ release,
168
+ lockFilePath,
169
+ readLock,
170
+ isProcessAlive,
171
+ WATCHTOWER_DIR,
172
+ };