git-watchtower 1.12.7 → 1.13.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.
@@ -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',
@@ -3347,6 +3352,14 @@ function restartProcess() {
3347
3352
  }
3348
3353
  stopWebDashboard();
3349
3354
 
3355
+ // Release the per-repo monitor lock before spawning the replacement, so the
3356
+ // child can acquire it. The parent stays alive waiting on child.on('close'),
3357
+ // so without this the child sees the parent as an active owner and refuses.
3358
+ if (monitorLockFile) {
3359
+ try { monitorLock.release(monitorLockFile); } catch (_) { /* ignore */ }
3360
+ monitorLockFile = null;
3361
+ }
3362
+
3350
3363
  console.log('\n♻ Restarting git-watchtower...\n');
3351
3364
 
3352
3365
  const { spawn: spawnChild } = require('child_process');
@@ -3368,6 +3381,9 @@ function restartProcess() {
3368
3381
 
3369
3382
  let isShuttingDown = false;
3370
3383
  let _resourcesCleaned = false;
3384
+ // Path of the per-repo monitor lock we own, if any. Set during start() and
3385
+ // cleared in cleanupResources() after release.
3386
+ let monitorLockFile = null;
3371
3387
 
3372
3388
  /**
3373
3389
  * Idempotent, best-effort cleanup of every long-lived resource we own:
@@ -3423,6 +3439,13 @@ function cleanupResources() {
3423
3439
 
3424
3440
  // Web dashboard + worker/coordinator (unlinks lock file + IPC socket)
3425
3441
  try { stopWebDashboard(); } catch (_) { /* ignore */ }
3442
+
3443
+ // Per-repo monitor lock — release last so the slot stays reserved for the
3444
+ // entire lifetime of this process, including any errors in the steps above.
3445
+ if (monitorLockFile) {
3446
+ try { monitorLock.release(monitorLockFile); } catch (_) { /* ignore */ }
3447
+ monitorLockFile = null;
3448
+ }
3426
3449
  }
3427
3450
 
3428
3451
  async function shutdown() {
@@ -3489,6 +3512,44 @@ async function start() {
3489
3512
  process.exit(1);
3490
3513
  }
3491
3514
 
3515
+ // Single-instance guard (per repo). Two TUIs rendering to the same terminal
3516
+ // stomp on each other's frames — selection cursor bounces between their
3517
+ // independent selectedIndex values, the activity log flips between two
3518
+ // buffers, and each sees the other's `git checkout` as an "external" switch.
3519
+ // Acquire before any TTY writes so a refusal leaves the user's prompt clean.
3520
+ const lockResult = monitorLock.acquire(PROJECT_ROOT);
3521
+ if (!lockResult.acquired) {
3522
+ if (cliArgs.force) {
3523
+ console.error(
3524
+ ansi.yellow + '⚠ Warning: another git-watchtower (PID ' +
3525
+ lockResult.existing.pid + ') is already running against this repo. ' +
3526
+ 'Continuing due to --force.' + ansi.reset
3527
+ );
3528
+ } else {
3529
+ const existing = lockResult.existing || {};
3530
+ console.error('\n' + ansi.red + ansi.bold +
3531
+ '✗ Error: git-watchtower is already running against this repository' + ansi.reset);
3532
+ console.error('\n Existing PID: ' + ansi.bold + (existing.pid || 'unknown') + ansi.reset);
3533
+ if (existing.startedAt) {
3534
+ const ageMs = Date.now() - existing.startedAt;
3535
+ const ageStr = ageMs < 60000
3536
+ ? Math.round(ageMs / 1000) + 's'
3537
+ : Math.round(ageMs / 60000) + 'm';
3538
+ console.error(' Started: ' + ageStr + ' ago');
3539
+ }
3540
+ console.error(' Repo: ' + PROJECT_ROOT);
3541
+ console.error(' Lock file: ' + lockResult.file);
3542
+ console.error('\n Running two instances against the same repo makes the TUI');
3543
+ console.error(' unusable (selection bouncing, flipping logs, CURRENT label');
3544
+ console.error(' snap-back). Stop the other instance first:');
3545
+ console.error('\n ' + ansi.bold + 'kill ' + (existing.pid || '<pid>') + ansi.reset);
3546
+ console.error('\n Or pass --force to override (not recommended).\n');
3547
+ process.exit(1);
3548
+ }
3549
+ } else {
3550
+ monitorLockFile = lockResult.file;
3551
+ }
3552
+
3492
3553
  // Load or create configuration
3493
3554
  const config = await ensureConfig(cliArgs);
3494
3555
  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.1",
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
+ };