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.
- package/bin/git-watchtower.js +62 -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',
|
|
@@ -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
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
|
+
};
|