git-watchtower 1.14.5 → 1.14.6
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 +19 -11
- package/package.json +1 -1
- package/src/git/branch.js +11 -3
- package/src/git/commands.js +25 -5
- package/src/index.js +1 -1
- package/src/server/coordinator.js +3 -0
- package/src/server/process.js +22 -3
- package/src/server/web-ui/js.js +1 -1
- package/src/utils/gitignore.js +7 -1
- package/src/utils/monitor-lock.js +3 -0
package/bin/git-watchtower.js
CHANGED
|
@@ -84,7 +84,7 @@ const { parseGitHubPr, parseGitLabMr, parseGitHubPrList, parseGitLabMrList, isBa
|
|
|
84
84
|
// ============================================================================
|
|
85
85
|
const { isValidBranchName, sanitizeBranchName, getGoneBranches, deleteGoneBranches, getCurrentBranch: getCurrentBranchRaw, getAllBranches: getAllBranchesRaw } = require('../src/git/branch');
|
|
86
86
|
const { pruneStaleEntries } = require('../src/polling/engine');
|
|
87
|
-
const { isGitAvailable: checkGitAvailable, execGit,
|
|
87
|
+
const { isGitAvailable: checkGitAvailable, execGit, execGitOptional, getDiffStats: getDiffStatsSafe, getAheadBehind, getDiffShortstat, hasUncommittedChanges: checkUncommittedChanges } = require('../src/git/commands');
|
|
88
88
|
|
|
89
89
|
// Session stats (always-on, non-casino stats)
|
|
90
90
|
const sessionStats = require('../src/stats/session');
|
|
@@ -506,6 +506,8 @@ async function getRemoteWebUrl(branchName) {
|
|
|
506
506
|
const parsed = parseRemoteUrl(stdout);
|
|
507
507
|
return buildWebUrl(parsed, branchName);
|
|
508
508
|
} catch (e) {
|
|
509
|
+
// No remote configured, or URL isn't parseable as github/gitlab —
|
|
510
|
+
// action modal hides the "view on web" link, nothing else breaks.
|
|
509
511
|
return null;
|
|
510
512
|
}
|
|
511
513
|
}
|
|
@@ -513,10 +515,10 @@ async function getRemoteWebUrl(branchName) {
|
|
|
513
515
|
// Extract Claude Code session URL from the most recent commit on a branch
|
|
514
516
|
async function getSessionUrl(branchName) {
|
|
515
517
|
// Try remote branch first, fall back to local
|
|
516
|
-
const result = await
|
|
518
|
+
const result = await execGitOptional(
|
|
517
519
|
['log', `${REMOTE_NAME}/${branchName}`, '-1', '--format=%B'],
|
|
518
520
|
{ cwd: PROJECT_ROOT }
|
|
519
|
-
) || await
|
|
521
|
+
) || await execGitOptional(
|
|
520
522
|
['log', branchName, '-1', '--format=%B'],
|
|
521
523
|
{ cwd: PROJECT_ROOT }
|
|
522
524
|
);
|
|
@@ -866,7 +868,7 @@ const CLI_TIMEOUT = 30000;
|
|
|
866
868
|
|
|
867
869
|
/**
|
|
868
870
|
* Execute a non-git CLI command safely using execFile (no shell interpolation).
|
|
869
|
-
* For git commands, use execGit/
|
|
871
|
+
* For git commands, use execGit/execGitOptional from src/git/commands.js instead.
|
|
870
872
|
* @param {string} cmd - The executable (e.g. 'gh', 'glab', 'which')
|
|
871
873
|
* @param {string[]} args - Arguments array (no shell interpolation)
|
|
872
874
|
* @param {Object} [options] - Execution options
|
|
@@ -922,6 +924,9 @@ async function detectDefaultBranch() {
|
|
|
922
924
|
const { stdout } = await execGit(['symbolic-ref', `refs/remotes/${REMOTE_NAME}/HEAD`], { cwd: PROJECT_ROOT });
|
|
923
925
|
detectedDefaultBranch = stdout.trim().replace('refs/remotes/', '');
|
|
924
926
|
} catch (e) {
|
|
927
|
+
// No remote HEAD and none of the common names exist — ahead/behind
|
|
928
|
+
// is hidden entirely (fetchAheadBehindForBranches short-circuits on
|
|
929
|
+
// a null detectedDefaultBranch).
|
|
925
930
|
detectedDefaultBranch = null;
|
|
926
931
|
}
|
|
927
932
|
}
|
|
@@ -1051,10 +1056,10 @@ async function refreshAllSparklines() {
|
|
|
1051
1056
|
|
|
1052
1057
|
try {
|
|
1053
1058
|
// Get commit counts for last 7 days (try remote, fall back to local)
|
|
1054
|
-
const sparkResult = await
|
|
1059
|
+
const sparkResult = await execGitOptional(
|
|
1055
1060
|
['log', `origin/${branch.name}`, '--since=7 days ago', '--format=%ad', '--date=format:%Y-%m-%d'],
|
|
1056
1061
|
{ cwd: PROJECT_ROOT }
|
|
1057
|
-
) || await
|
|
1062
|
+
) || await execGitOptional(
|
|
1058
1063
|
['log', branch.name, '--since=7 days ago', '--format=%ad', '--date=format:%Y-%m-%d'],
|
|
1059
1064
|
{ cwd: PROJECT_ROOT }
|
|
1060
1065
|
);
|
|
@@ -1090,10 +1095,10 @@ async function refreshAllSparklines() {
|
|
|
1090
1095
|
async function getPreviewData(branchName) {
|
|
1091
1096
|
try {
|
|
1092
1097
|
// Get last 5 commits (try remote, fall back to local)
|
|
1093
|
-
const logResult = await
|
|
1098
|
+
const logResult = await execGitOptional(
|
|
1094
1099
|
['log', `origin/${branchName}`, '-5', '--oneline'],
|
|
1095
1100
|
{ cwd: PROJECT_ROOT }
|
|
1096
|
-
) || await
|
|
1101
|
+
) || await execGitOptional(
|
|
1097
1102
|
['log', branchName, '-5', '--oneline'],
|
|
1098
1103
|
{ cwd: PROJECT_ROOT }
|
|
1099
1104
|
);
|
|
@@ -1106,10 +1111,10 @@ async function getPreviewData(branchName) {
|
|
|
1106
1111
|
|
|
1107
1112
|
// Get files changed (comparing to current branch)
|
|
1108
1113
|
let filesChanged = [];
|
|
1109
|
-
const diffResult = await
|
|
1114
|
+
const diffResult = await execGitOptional(
|
|
1110
1115
|
['diff', '--stat', '--name-only', `HEAD...origin/${branchName}`],
|
|
1111
1116
|
{ cwd: PROJECT_ROOT }
|
|
1112
|
-
) || await
|
|
1117
|
+
) || await execGitOptional(
|
|
1113
1118
|
['diff', '--stat', '--name-only', `HEAD...${branchName}`],
|
|
1114
1119
|
{ cwd: PROJECT_ROOT }
|
|
1115
1120
|
);
|
|
@@ -1119,6 +1124,9 @@ async function getPreviewData(branchName) {
|
|
|
1119
1124
|
|
|
1120
1125
|
return { commits, filesChanged };
|
|
1121
1126
|
} catch (e) {
|
|
1127
|
+
// Preview pane is best-effort — branch may not exist on the remote yet,
|
|
1128
|
+
// refs may have been pruned mid-fetch. Empty pane is better than an
|
|
1129
|
+
// error toast for a background UI enrichment.
|
|
1122
1130
|
return { commits: [], filesChanged: [] };
|
|
1123
1131
|
}
|
|
1124
1132
|
}
|
|
@@ -1585,7 +1593,7 @@ async function pullCurrentBranch() {
|
|
|
1585
1593
|
|
|
1586
1594
|
// Capture HEAD before pull so we can diff against it when git pull
|
|
1587
1595
|
// doesn't put a clean "already up to date" message on stdout.
|
|
1588
|
-
const preHead = await
|
|
1596
|
+
const preHead = await execGitOptional(['rev-parse', 'HEAD'], { cwd: PROJECT_ROOT });
|
|
1589
1597
|
const oldCommit = preHead && preHead.stdout ? preHead.stdout.trim() : null;
|
|
1590
1598
|
|
|
1591
1599
|
const result = await execGit(['pull', REMOTE_NAME, branch], { cwd: PROJECT_ROOT, timeout: 60000 });
|
package/package.json
CHANGED
package/src/git/branch.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Provides branch management and parsing
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
const { execGit,
|
|
6
|
+
const { execGit, execGitOptional, fetch, hasUncommittedChanges, getCommitsByDay, log, deleteLocalBranch } = require('./commands');
|
|
7
7
|
const { GitError, ValidationError } = require('../utils/errors');
|
|
8
8
|
|
|
9
9
|
// Valid git branch name pattern (conservative)
|
|
@@ -73,6 +73,8 @@ async function getCurrentBranch(cwd) {
|
|
|
73
73
|
|
|
74
74
|
return { name: stdout, isDetached: false };
|
|
75
75
|
} catch (error) {
|
|
76
|
+
// Not in a git repo, or git is broken. name:null signals "no current
|
|
77
|
+
// branch" to the caller, which renders as "Not in a git repository".
|
|
76
78
|
return { name: null, isDetached: false };
|
|
77
79
|
}
|
|
78
80
|
}
|
|
@@ -100,7 +102,7 @@ async function getAllBranches(options = {}) {
|
|
|
100
102
|
// Get local branches
|
|
101
103
|
// Use \x1f (Unit Separator) as delimiter since | can appear in commit subjects
|
|
102
104
|
const delimiter = '\x1f';
|
|
103
|
-
const localResult = await
|
|
105
|
+
const localResult = await execGitOptional(
|
|
104
106
|
['for-each-ref', '--sort=-committerdate', `--format=%(refname:short)${delimiter}%(committerdate:iso8601)${delimiter}%(objectname:short)${delimiter}%(subject)`, 'refs/heads/'],
|
|
105
107
|
{ cwd }
|
|
106
108
|
);
|
|
@@ -125,7 +127,7 @@ async function getAllBranches(options = {}) {
|
|
|
125
127
|
}
|
|
126
128
|
|
|
127
129
|
// Get remote branches
|
|
128
|
-
const remoteResult = await
|
|
130
|
+
const remoteResult = await execGitOptional(
|
|
129
131
|
['for-each-ref', '--sort=-committerdate', `--format=%(refname:short)${delimiter}%(committerdate:iso8601)${delimiter}%(objectname:short)${delimiter}%(subject)`, `refs/remotes/${remoteName}/`],
|
|
130
132
|
{ cwd }
|
|
131
133
|
);
|
|
@@ -300,6 +302,9 @@ async function getPreviewData(branchName, options = {}) {
|
|
|
300
302
|
|
|
301
303
|
return { commits, files };
|
|
302
304
|
} catch (error) {
|
|
305
|
+
// Preview is best-effort UI enrichment — branch may not exist on the
|
|
306
|
+
// remote yet, log may be empty, etc. Returning empty lists renders the
|
|
307
|
+
// preview pane as "no commits to show" rather than crashing the TUI.
|
|
303
308
|
return { commits: [], files: [] };
|
|
304
309
|
}
|
|
305
310
|
}
|
|
@@ -345,6 +350,7 @@ async function getLocalBranches(cwd) {
|
|
|
345
350
|
.map((b) => b.trim().replace(/^\* /, ''))
|
|
346
351
|
.filter(Boolean);
|
|
347
352
|
} catch (error) {
|
|
353
|
+
// Not in a git repo or git unavailable — treat as "no local branches".
|
|
348
354
|
return [];
|
|
349
355
|
}
|
|
350
356
|
}
|
|
@@ -381,6 +387,8 @@ async function getGoneBranches(cwd) {
|
|
|
381
387
|
}
|
|
382
388
|
return gone;
|
|
383
389
|
} catch (error) {
|
|
390
|
+
// `branch -vv` fails on a repo with no commits yet, or when git is
|
|
391
|
+
// broken. Caller treats "no gone branches" as "nothing to clean up".
|
|
384
392
|
return [];
|
|
385
393
|
}
|
|
386
394
|
}
|
package/src/git/commands.js
CHANGED
|
@@ -62,12 +62,20 @@ async function execGit(args, options = {}) {
|
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
/**
|
|
65
|
-
* Execute git command
|
|
65
|
+
* Execute a git command, collapsing any failure into a null result.
|
|
66
|
+
*
|
|
67
|
+
* Callers use this when they want "the output, or nothing": the fallback
|
|
68
|
+
* pattern `execGitOptional(A) || execGitOptional(B)` relies on this, as does
|
|
69
|
+
* every caller that treats a missing result as "no data to show." This does
|
|
70
|
+
* conflate "branch has no commits" (empty stdout, non-null result) with
|
|
71
|
+
* "git errored" (null result) — if you need to distinguish those, use
|
|
72
|
+
* execGit() and handle the throw yourself.
|
|
73
|
+
*
|
|
66
74
|
* @param {string[]} command - Git arguments
|
|
67
75
|
* @param {Object} [options] - Execution options
|
|
68
|
-
* @returns {Promise<{stdout: string, stderr: string}|null>}
|
|
76
|
+
* @returns {Promise<{stdout: string, stderr: string}|null>} Result, or null if git failed
|
|
69
77
|
*/
|
|
70
|
-
async function
|
|
78
|
+
async function execGitOptional(command, options = {}) {
|
|
71
79
|
try {
|
|
72
80
|
return await execGit(command, options);
|
|
73
81
|
} catch (error) {
|
|
@@ -112,6 +120,7 @@ async function getRemotes(cwd) {
|
|
|
112
120
|
const { stdout } = await execGit(['remote'], { cwd, timeout: 5000 });
|
|
113
121
|
return stdout.split('\n').filter(Boolean);
|
|
114
122
|
} catch (error) {
|
|
123
|
+
// Not a git repo or `git remote` unavailable — treat as "no remotes".
|
|
115
124
|
return [];
|
|
116
125
|
}
|
|
117
126
|
}
|
|
@@ -241,7 +250,9 @@ async function getCommitsByDay(branchName, days = 7, cwd) {
|
|
|
241
250
|
}
|
|
242
251
|
}
|
|
243
252
|
} catch (error) {
|
|
244
|
-
//
|
|
253
|
+
// Sparkline is decorative — a git-log failure (missing branch, network
|
|
254
|
+
// hiccup) returns all-zeros and renders a flat bar rather than crashing
|
|
255
|
+
// the caller.
|
|
245
256
|
}
|
|
246
257
|
|
|
247
258
|
return counts;
|
|
@@ -331,6 +342,8 @@ async function getChangedFiles(branchName, baseBranch = 'HEAD', cwd) {
|
|
|
331
342
|
);
|
|
332
343
|
return stdout.split('\n').filter(Boolean);
|
|
333
344
|
} catch (error) {
|
|
345
|
+
// Diff fails when branches share no common ancestor, or when either
|
|
346
|
+
// ref doesn't exist. Caller renders "no changed files" either way.
|
|
334
347
|
return [];
|
|
335
348
|
}
|
|
336
349
|
}
|
|
@@ -370,6 +383,8 @@ async function getDiffStats(fromCommit, toCommit = 'HEAD', options = {}) {
|
|
|
370
383
|
const { stdout } = await execGit(['diff', '--stat', `${fromCommit}..${toCommit}`], options);
|
|
371
384
|
return parseDiffStats(stdout);
|
|
372
385
|
} catch (e) {
|
|
386
|
+
// Diff fails when a ref is gone or commits share no ancestor. Zero
|
|
387
|
+
// added/deleted renders as "no change summary" in the activity log.
|
|
373
388
|
return { added: 0, deleted: 0 };
|
|
374
389
|
}
|
|
375
390
|
}
|
|
@@ -418,6 +433,9 @@ async function getAheadBehind(branchRef, baseRef, options = {}) {
|
|
|
418
433
|
ahead: parseInt(parts[1], 10) || 0,
|
|
419
434
|
};
|
|
420
435
|
} catch (e) {
|
|
436
|
+
// rev-list fails when baseRef is missing (no remote yet) or when the
|
|
437
|
+
// branches share no common ancestor. 0/0 hides the ahead/behind column
|
|
438
|
+
// for that row rather than crashing the background refresher.
|
|
421
439
|
return { ahead: 0, behind: 0 };
|
|
422
440
|
}
|
|
423
441
|
}
|
|
@@ -438,13 +456,15 @@ async function getDiffShortstat(baseRef, branchRef, options = {}) {
|
|
|
438
456
|
);
|
|
439
457
|
return parseDiffStats(stdout);
|
|
440
458
|
} catch (e) {
|
|
459
|
+
// See getAheadBehind: same background-refresh path, same 0/0 fallback
|
|
460
|
+
// to hide the +/- column when a ref is missing.
|
|
441
461
|
return { added: 0, deleted: 0 };
|
|
442
462
|
}
|
|
443
463
|
}
|
|
444
464
|
|
|
445
465
|
module.exports = {
|
|
446
466
|
execGit,
|
|
447
|
-
|
|
467
|
+
execGitOptional,
|
|
448
468
|
isGitAvailable,
|
|
449
469
|
isGitRepository,
|
|
450
470
|
getRemotes,
|
package/src/index.js
CHANGED
|
@@ -103,7 +103,7 @@ module.exports = {
|
|
|
103
103
|
parseDiffStats: gitCommands.parseDiffStats,
|
|
104
104
|
getDiffStats: gitCommands.getDiffStats,
|
|
105
105
|
execGit: gitCommands.execGit,
|
|
106
|
-
|
|
106
|
+
execGitOptional: gitCommands.execGitOptional,
|
|
107
107
|
isGitAvailable: gitCommands.isGitAvailable,
|
|
108
108
|
isGitRepository: gitCommands.isGitRepository,
|
|
109
109
|
getRemotes: gitCommands.getRemotes,
|
|
@@ -83,6 +83,9 @@ function readLock() {
|
|
|
83
83
|
if (!data || !data.pid) return null;
|
|
84
84
|
return data;
|
|
85
85
|
} catch (e) {
|
|
86
|
+
// Lock file was unlinked between existsSync and readFileSync, or contains
|
|
87
|
+
// garbage (crashed mid-write). Treat as "no lock" so tryAcquireLock()
|
|
88
|
+
// can clean it up and retry.
|
|
86
89
|
return null;
|
|
87
90
|
}
|
|
88
91
|
}
|
package/src/server/process.js
CHANGED
|
@@ -352,16 +352,35 @@ class ProcessManager {
|
|
|
352
352
|
}
|
|
353
353
|
|
|
354
354
|
/**
|
|
355
|
-
* Restart the server process
|
|
355
|
+
* Restart the server process.
|
|
356
|
+
*
|
|
357
|
+
* Waits for the old process to fully exit (so it releases its port)
|
|
358
|
+
* rather than sleeping a static RESTART_DELAY that is shorter than
|
|
359
|
+
* the SIGKILL grace period. Bounded by KILL_GRACE_PERIOD + a small
|
|
360
|
+
* margin so we never hang indefinitely if 'close' doesn't fire.
|
|
361
|
+
*
|
|
356
362
|
* @returns {Promise<{success: boolean, error?: Error, pid?: number}>}
|
|
357
363
|
*/
|
|
358
364
|
async restart() {
|
|
359
365
|
return this._restartMutex.withLock(async () => {
|
|
360
366
|
const command = this.command;
|
|
367
|
+
// Capture before stop() nulls this.process.
|
|
368
|
+
const oldProc = this.process;
|
|
369
|
+
|
|
361
370
|
this.stop();
|
|
362
371
|
|
|
363
|
-
|
|
364
|
-
|
|
372
|
+
if (oldProc && oldProc.exitCode === null && oldProc.signalCode === null) {
|
|
373
|
+
// Old process hasn't exited yet — wait for 'close' with a bounded
|
|
374
|
+
// timeout so we don't hang if the process ignores all signals.
|
|
375
|
+
await new Promise((resolve) => {
|
|
376
|
+
const timeout = setTimeout(resolve, KILL_GRACE_PERIOD + RESTART_DELAY);
|
|
377
|
+
timeout.unref();
|
|
378
|
+
oldProc.once('close', () => {
|
|
379
|
+
clearTimeout(timeout);
|
|
380
|
+
resolve();
|
|
381
|
+
});
|
|
382
|
+
});
|
|
383
|
+
}
|
|
365
384
|
|
|
366
385
|
return this.start(command);
|
|
367
386
|
});
|
package/src/server/web-ui/js.js
CHANGED
|
@@ -65,7 +65,7 @@ function getDashboardJs() {
|
|
|
65
65
|
function loadPrefs() {
|
|
66
66
|
try {
|
|
67
67
|
return JSON.parse(localStorage.getItem(PREFS_KEY)) || {};
|
|
68
|
-
} catch (e) { return {}; }
|
|
68
|
+
} catch (e) { /* localStorage unavailable (private mode) or stored JSON got corrupted — fall back to defaults */ return {}; }
|
|
69
69
|
}
|
|
70
70
|
function savePrefs(updates) {
|
|
71
71
|
const prefs = loadPrefs();
|
package/src/utils/gitignore.js
CHANGED
|
@@ -64,6 +64,9 @@ function gitignorePatternToRegex(pattern) {
|
|
|
64
64
|
try {
|
|
65
65
|
return new RegExp(regexStr);
|
|
66
66
|
} catch (e) {
|
|
67
|
+
// A malformed pattern in .gitignore shouldn't disable the file watcher
|
|
68
|
+
// entirely — signal "skip this line" via null, caller drops it from
|
|
69
|
+
// the pattern list. Extremely rare in practice.
|
|
67
70
|
return null;
|
|
68
71
|
}
|
|
69
72
|
}
|
|
@@ -97,7 +100,10 @@ function parseGitignoreFile(gitignorePath) {
|
|
|
97
100
|
}
|
|
98
101
|
}
|
|
99
102
|
} catch (err) {
|
|
100
|
-
//
|
|
103
|
+
// .gitignore exists but we can't read it (permissions, mid-edit truncation,
|
|
104
|
+
// encoding). The file watcher still runs — it just won't honor gitignore
|
|
105
|
+
// rules this session. Surfacing the error would be noise; the user will
|
|
106
|
+
// notice when they see changes to ignored files trigger reloads.
|
|
101
107
|
}
|
|
102
108
|
|
|
103
109
|
return patterns;
|
|
@@ -69,6 +69,9 @@ function readLock(file) {
|
|
|
69
69
|
if (!data || typeof data.pid !== 'number') return null;
|
|
70
70
|
return data;
|
|
71
71
|
} catch (e) {
|
|
72
|
+
// File was unlinked between existsSync and readFileSync, or contains
|
|
73
|
+
// garbage (crashed mid-write, foreign content). Treat as "no lock" so
|
|
74
|
+
// acquire() can clean it up and retry.
|
|
72
75
|
return null;
|
|
73
76
|
}
|
|
74
77
|
}
|