git-watchtower 1.12.6 → 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.
- package/bin/git-watchtower.js +66 -1
- package/package.json +1 -1
- package/src/cli/args.js +6 -0
- package/src/utils/monitor-lock.js +172 -0
package/bin/git-watchtower.js
CHANGED
|
@@ -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',
|
|
@@ -3271,6 +3276,18 @@ async function startWebDashboard(openBrowser) {
|
|
|
3271
3276
|
render();
|
|
3272
3277
|
} catch (err) {
|
|
3273
3278
|
addLog(`Web dashboard failed: ${err.message}`, 'error');
|
|
3279
|
+
// Defensive: if we got far enough to arm the state-push interval,
|
|
3280
|
+
// clear it. The current ordering starts the interval only after
|
|
3281
|
+
// webDashboard.start() resolves, but this keeps cleanup robust
|
|
3282
|
+
// against future reordering and against failures in the
|
|
3283
|
+
// post-bind statements (e.g. openInBrowser, addLog).
|
|
3284
|
+
if (webStateInterval) {
|
|
3285
|
+
clearInterval(webStateInterval);
|
|
3286
|
+
webStateInterval = null;
|
|
3287
|
+
}
|
|
3288
|
+
if (webDashboard) {
|
|
3289
|
+
try { webDashboard.stop(); } catch (_) { /* ignore */ }
|
|
3290
|
+
}
|
|
3274
3291
|
if (coordinator) {
|
|
3275
3292
|
try { coordinator.stop(); } catch (_) { /* ignore */ }
|
|
3276
3293
|
}
|
|
@@ -3356,6 +3373,9 @@ function restartProcess() {
|
|
|
3356
3373
|
|
|
3357
3374
|
let isShuttingDown = false;
|
|
3358
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;
|
|
3359
3379
|
|
|
3360
3380
|
/**
|
|
3361
3381
|
* Idempotent, best-effort cleanup of every long-lived resource we own:
|
|
@@ -3411,6 +3431,13 @@ function cleanupResources() {
|
|
|
3411
3431
|
|
|
3412
3432
|
// Web dashboard + worker/coordinator (unlinks lock file + IPC socket)
|
|
3413
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
|
+
}
|
|
3414
3441
|
}
|
|
3415
3442
|
|
|
3416
3443
|
async function shutdown() {
|
|
@@ -3477,6 +3504,44 @@ async function start() {
|
|
|
3477
3504
|
process.exit(1);
|
|
3478
3505
|
}
|
|
3479
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
|
+
|
|
3480
3545
|
// Load or create configuration
|
|
3481
3546
|
const config = await ensureConfig(cliArgs);
|
|
3482
3547
|
applyConfig(config);
|
package/package.json
CHANGED
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
|
+
};
|