git-watchtower 1.6.0 → 1.7.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 +89 -9
- package/package.json +6 -1
- package/sounds/README.md +34 -0
- package/src/casino/index.js +721 -0
- package/src/casino/sounds.js +245 -0
- package/src/cli/args.js +239 -0
- package/src/config/loader.js +329 -0
- package/src/config/schema.js +305 -0
- package/src/git/branch.js +428 -0
- package/src/git/commands.js +416 -0
- package/src/git/pr.js +111 -0
- package/src/git/remote.js +127 -0
- package/src/index.js +179 -0
- package/src/polling/engine.js +157 -0
- package/src/server/process.js +329 -0
- package/src/server/static.js +95 -0
- package/src/state/store.js +527 -0
- package/src/telemetry/analytics.js +142 -0
- package/src/telemetry/config.js +123 -0
- package/src/telemetry/index.js +93 -0
- package/src/ui/actions.js +425 -0
- package/src/ui/ansi.js +498 -0
- package/src/ui/keybindings.js +198 -0
- package/src/ui/renderer.js +1326 -0
- package/src/utils/async.js +219 -0
- package/src/utils/browser.js +40 -0
- package/src/utils/errors.js +490 -0
- package/src/utils/gitignore.js +174 -0
- package/src/utils/sound.js +33 -0
- package/src/utils/time.js +27 -0
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git branch operations module
|
|
3
|
+
* Provides branch management and parsing
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const { execGit, execGitSilent, fetch, hasUncommittedChanges, getCommitsByDay, log, deleteLocalBranch } = require('./commands');
|
|
7
|
+
const { GitError, ValidationError } = require('../utils/errors');
|
|
8
|
+
|
|
9
|
+
// Valid git branch name pattern (conservative)
|
|
10
|
+
const VALID_BRANCH_PATTERN = /^[a-zA-Z0-9_\-./]+$/;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @typedef {Object} Branch
|
|
14
|
+
* @property {string} name - Branch name
|
|
15
|
+
* @property {string} commit - Short commit hash
|
|
16
|
+
* @property {string} subject - Commit subject
|
|
17
|
+
* @property {Date} date - Commit date
|
|
18
|
+
* @property {boolean} isLocal - Is a local branch
|
|
19
|
+
* @property {boolean} hasRemote - Has a remote tracking branch
|
|
20
|
+
* @property {boolean} hasUpdates - Has updates available from remote
|
|
21
|
+
* @property {string} [remoteCommit] - Remote commit hash
|
|
22
|
+
* @property {Date} [remoteDate] - Remote commit date
|
|
23
|
+
* @property {string} [remoteSubject] - Remote commit subject
|
|
24
|
+
* @property {boolean} [isNew] - Newly discovered branch
|
|
25
|
+
* @property {boolean} [isDeleted] - Branch was deleted
|
|
26
|
+
* @property {boolean} [justUpdated] - Was just updated
|
|
27
|
+
* @property {string} [sparkline] - Activity sparkline
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Validate a branch name for safety
|
|
32
|
+
* @param {string} name - Branch name to validate
|
|
33
|
+
* @returns {boolean}
|
|
34
|
+
*/
|
|
35
|
+
function isValidBranchName(name) {
|
|
36
|
+
if (!name || typeof name !== 'string') return false;
|
|
37
|
+
if (name.length > 255) return false;
|
|
38
|
+
if (!VALID_BRANCH_PATTERN.test(name)) return false;
|
|
39
|
+
// Reject dangerous patterns
|
|
40
|
+
if (name.includes('..')) return false;
|
|
41
|
+
if (name.startsWith('-')) return false;
|
|
42
|
+
if (name.startsWith('/') || name.endsWith('/')) return false;
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Sanitize and validate a branch name
|
|
48
|
+
* @param {string} name - Branch name to sanitize
|
|
49
|
+
* @returns {string}
|
|
50
|
+
* @throws {ValidationError}
|
|
51
|
+
*/
|
|
52
|
+
function sanitizeBranchName(name) {
|
|
53
|
+
if (!isValidBranchName(name)) {
|
|
54
|
+
throw ValidationError.invalidBranchName(name);
|
|
55
|
+
}
|
|
56
|
+
return name;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Get the current branch name
|
|
61
|
+
* @param {string} [cwd] - Working directory
|
|
62
|
+
* @returns {Promise<{name: string|null, isDetached: boolean}>}
|
|
63
|
+
*/
|
|
64
|
+
async function getCurrentBranch(cwd) {
|
|
65
|
+
try {
|
|
66
|
+
const { stdout } = await execGit(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd });
|
|
67
|
+
|
|
68
|
+
if (stdout === 'HEAD') {
|
|
69
|
+
// Detached HEAD state - get short commit hash
|
|
70
|
+
const { stdout: commitHash } = await execGit(['rev-parse', '--short', 'HEAD'], { cwd });
|
|
71
|
+
return { name: `HEAD@${commitHash}`, isDetached: true };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return { name: stdout, isDetached: false };
|
|
75
|
+
} catch (error) {
|
|
76
|
+
return { name: null, isDetached: false };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Get all branches (local and remote)
|
|
82
|
+
* @param {Object} [options] - Options
|
|
83
|
+
* @param {string} [options.remoteName='origin'] - Remote name
|
|
84
|
+
* @param {boolean} [options.fetch=true] - Fetch before listing
|
|
85
|
+
* @param {string} [options.cwd] - Working directory
|
|
86
|
+
* @returns {Promise<Branch[]>}
|
|
87
|
+
*/
|
|
88
|
+
async function getAllBranches(options = {}) {
|
|
89
|
+
const { remoteName = 'origin', fetch: shouldFetch = true, cwd } = options;
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
// Optionally fetch first
|
|
93
|
+
if (shouldFetch) {
|
|
94
|
+
await fetch(remoteName, { prune: true, all: true, cwd });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const branchList = [];
|
|
98
|
+
const seenBranches = new Set();
|
|
99
|
+
|
|
100
|
+
// Get local branches
|
|
101
|
+
const localResult = await execGitSilent(
|
|
102
|
+
['for-each-ref', '--sort=-committerdate', '--format=%(refname:short)|%(committerdate:iso8601)|%(objectname:short)|%(subject)', 'refs/heads/'],
|
|
103
|
+
{ cwd }
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
if (localResult) {
|
|
107
|
+
for (const line of localResult.stdout.split('\n').filter(Boolean)) {
|
|
108
|
+
const [name, dateStr, commit, subject] = line.split('|');
|
|
109
|
+
if (!seenBranches.has(name) && isValidBranchName(name)) {
|
|
110
|
+
seenBranches.add(name);
|
|
111
|
+
branchList.push({
|
|
112
|
+
name,
|
|
113
|
+
commit,
|
|
114
|
+
subject: subject || '',
|
|
115
|
+
date: new Date(dateStr),
|
|
116
|
+
isLocal: true,
|
|
117
|
+
hasRemote: false,
|
|
118
|
+
hasUpdates: false,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Get remote branches
|
|
125
|
+
const remoteResult = await execGitSilent(
|
|
126
|
+
['for-each-ref', '--sort=-committerdate', '--format=%(refname:short)|%(committerdate:iso8601)|%(objectname:short)|%(subject)', `refs/remotes/${remoteName}/`],
|
|
127
|
+
{ cwd }
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
if (remoteResult) {
|
|
131
|
+
const remotePrefix = `${remoteName}/`;
|
|
132
|
+
for (const line of remoteResult.stdout.split('\n').filter(Boolean)) {
|
|
133
|
+
const [fullName, dateStr, commit, subject] = line.split('|');
|
|
134
|
+
const name = fullName.replace(remotePrefix, '');
|
|
135
|
+
|
|
136
|
+
if (name === 'HEAD') continue;
|
|
137
|
+
if (!isValidBranchName(name)) continue;
|
|
138
|
+
|
|
139
|
+
const existing = /** @type {Branch|undefined} */ (branchList.find((b) => b.name === name));
|
|
140
|
+
if (existing) {
|
|
141
|
+
existing.hasRemote = true;
|
|
142
|
+
existing.remoteCommit = commit;
|
|
143
|
+
existing.remoteDate = new Date(dateStr);
|
|
144
|
+
existing.remoteSubject = subject || '';
|
|
145
|
+
if (commit !== existing.commit) {
|
|
146
|
+
existing.hasUpdates = true;
|
|
147
|
+
// Use remote's date when it has updates (so it sorts to top)
|
|
148
|
+
existing.date = new Date(dateStr);
|
|
149
|
+
existing.subject = subject || existing.subject;
|
|
150
|
+
}
|
|
151
|
+
} else if (!seenBranches.has(name)) {
|
|
152
|
+
seenBranches.add(name);
|
|
153
|
+
branchList.push({
|
|
154
|
+
name,
|
|
155
|
+
commit,
|
|
156
|
+
subject: subject || '',
|
|
157
|
+
date: new Date(dateStr),
|
|
158
|
+
isLocal: false,
|
|
159
|
+
hasRemote: true,
|
|
160
|
+
hasUpdates: false,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Sort by date (most recent first)
|
|
167
|
+
branchList.sort((a, b) => b.date.getTime() - a.date.getTime());
|
|
168
|
+
|
|
169
|
+
return branchList;
|
|
170
|
+
} catch (error) {
|
|
171
|
+
throw new GitError(`Failed to get branches: ${error.message}`, 'GIT_BRANCH_LIST_FAILED', {
|
|
172
|
+
originalError: error,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Detect changes between two branch lists
|
|
179
|
+
* @param {Branch[]} oldBranches - Previous branch list
|
|
180
|
+
* @param {Branch[]} newBranches - Current branch list
|
|
181
|
+
* @returns {{added: Branch[], removed: Branch[], updated: Branch[]}}
|
|
182
|
+
*/
|
|
183
|
+
function detectBranchChanges(oldBranches, newBranches) {
|
|
184
|
+
const oldNames = new Map(oldBranches.map((b) => [b.name, b]));
|
|
185
|
+
const newNames = new Map(newBranches.map((b) => [b.name, b]));
|
|
186
|
+
|
|
187
|
+
const added = newBranches.filter((b) => !oldNames.has(b.name));
|
|
188
|
+
const removed = oldBranches.filter((b) => !newNames.has(b.name));
|
|
189
|
+
const updated = newBranches.filter((b) => {
|
|
190
|
+
const old = oldNames.get(b.name);
|
|
191
|
+
return old && old.commit !== b.commit;
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
return { added, removed, updated };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Check out a branch
|
|
199
|
+
* @param {string} branchName - Branch to check out
|
|
200
|
+
* @param {Object} [options] - Options
|
|
201
|
+
* @param {string} [options.remoteName='origin'] - Remote name
|
|
202
|
+
* @param {boolean} [options.force=false] - Force checkout (discard changes)
|
|
203
|
+
* @param {string} [options.cwd] - Working directory
|
|
204
|
+
* @returns {Promise<{success: boolean, error?: GitError}>}
|
|
205
|
+
*/
|
|
206
|
+
async function checkout(branchName, options = {}) {
|
|
207
|
+
const { remoteName = 'origin', force = false, cwd } = options;
|
|
208
|
+
|
|
209
|
+
try {
|
|
210
|
+
// Validate branch name
|
|
211
|
+
const safeName = sanitizeBranchName(branchName);
|
|
212
|
+
|
|
213
|
+
// Check for uncommitted changes (unless force)
|
|
214
|
+
if (!force && (await hasUncommittedChanges(cwd))) {
|
|
215
|
+
return {
|
|
216
|
+
success: false,
|
|
217
|
+
error: new GitError(
|
|
218
|
+
'Cannot switch: uncommitted changes in working directory',
|
|
219
|
+
'GIT_DIRTY_WORKDIR'
|
|
220
|
+
),
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Check if local branch exists
|
|
225
|
+
const { stdout: localBranches } = await execGit(['branch', '--list'], { cwd });
|
|
226
|
+
const hasLocal = localBranches
|
|
227
|
+
.split('\n')
|
|
228
|
+
.some((b) => b.trim().replace('* ', '') === safeName);
|
|
229
|
+
|
|
230
|
+
if (hasLocal) {
|
|
231
|
+
// Local branch exists - just check out
|
|
232
|
+
if (force) {
|
|
233
|
+
await execGit(['checkout', '--', '.'], { cwd });
|
|
234
|
+
await execGit(['checkout', safeName], { cwd });
|
|
235
|
+
} else {
|
|
236
|
+
await execGit(['checkout', safeName], { cwd });
|
|
237
|
+
}
|
|
238
|
+
} else {
|
|
239
|
+
// Create local branch from remote
|
|
240
|
+
await execGit(['checkout', '-b', safeName, `${remoteName}/${safeName}`], { cwd });
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return { success: true };
|
|
244
|
+
} catch (error) {
|
|
245
|
+
return {
|
|
246
|
+
success: false,
|
|
247
|
+
error: error instanceof GitError ? error : GitError.fromExecError(error, 'checkout'),
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Get preview data for a branch
|
|
254
|
+
* @param {string} branchName - Branch name
|
|
255
|
+
* @param {Object} [options] - Options
|
|
256
|
+
* @param {number} [options.commitCount=5] - Number of commits to show
|
|
257
|
+
* @param {number} [options.fileCount=10] - Number of files to show
|
|
258
|
+
* @param {string} [options.cwd] - Working directory
|
|
259
|
+
* @returns {Promise<{commits: Array, files: string[]}>}
|
|
260
|
+
*/
|
|
261
|
+
async function getPreviewData(branchName, options = {}) {
|
|
262
|
+
const { commitCount = 5, fileCount = 10, cwd } = options;
|
|
263
|
+
|
|
264
|
+
try {
|
|
265
|
+
const safeName = sanitizeBranchName(branchName);
|
|
266
|
+
|
|
267
|
+
// Get recent commits
|
|
268
|
+
const commitLog = await log(safeName, {
|
|
269
|
+
count: commitCount,
|
|
270
|
+
format: '%h|%s|%cr',
|
|
271
|
+
cwd,
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
const commits = commitLog
|
|
275
|
+
.split('\n')
|
|
276
|
+
.filter(Boolean)
|
|
277
|
+
.map((line) => {
|
|
278
|
+
const [hash, subject, time] = line.split('|');
|
|
279
|
+
return { hash, subject, time };
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
// Get changed files compared to current branch
|
|
283
|
+
let files = [];
|
|
284
|
+
try {
|
|
285
|
+
const { stdout: diffFiles } = await execGit(
|
|
286
|
+
['diff', '--name-only', `HEAD...${safeName}`],
|
|
287
|
+
{ cwd }
|
|
288
|
+
);
|
|
289
|
+
files = diffFiles.split('\n').filter(Boolean).slice(0, fileCount);
|
|
290
|
+
} catch (e) {
|
|
291
|
+
// May fail if branches have no common ancestor
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return { commits, files };
|
|
295
|
+
} catch (error) {
|
|
296
|
+
return { commits: [], files: [] };
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Generate sparkline for branch activity
|
|
302
|
+
* @param {string} branchName - Branch name
|
|
303
|
+
* @param {Object} [options] - Options
|
|
304
|
+
* @param {number} [options.days=7] - Days to include
|
|
305
|
+
* @param {string} [options.cwd] - Working directory
|
|
306
|
+
* @returns {Promise<string>}
|
|
307
|
+
*/
|
|
308
|
+
async function generateSparkline(branchName, options = {}) {
|
|
309
|
+
const { days = 7, cwd } = options;
|
|
310
|
+
|
|
311
|
+
const sparkChars = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
|
|
312
|
+
const counts = await getCommitsByDay(branchName, days, cwd);
|
|
313
|
+
|
|
314
|
+
if (counts.every((c) => c === 0)) {
|
|
315
|
+
return ' '.repeat(days);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const max = Math.max(...counts);
|
|
319
|
+
return counts
|
|
320
|
+
.map((count) => {
|
|
321
|
+
if (count === 0) return ' ';
|
|
322
|
+
const level = Math.floor((count / max) * 7);
|
|
323
|
+
return sparkChars[Math.min(level, 7)];
|
|
324
|
+
})
|
|
325
|
+
.join('');
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Get list of local branches
|
|
330
|
+
* @param {string} [cwd] - Working directory
|
|
331
|
+
* @returns {Promise<string[]>}
|
|
332
|
+
*/
|
|
333
|
+
async function getLocalBranches(cwd) {
|
|
334
|
+
try {
|
|
335
|
+
const { stdout } = await execGit(['branch', '--list'], { cwd });
|
|
336
|
+
return stdout
|
|
337
|
+
.split('\n')
|
|
338
|
+
.map((b) => b.trim().replace('* ', ''))
|
|
339
|
+
.filter(Boolean);
|
|
340
|
+
} catch (error) {
|
|
341
|
+
return [];
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Check if a branch exists locally
|
|
347
|
+
* @param {string} branchName - Branch name
|
|
348
|
+
* @param {string} [cwd] - Working directory
|
|
349
|
+
* @returns {Promise<boolean>}
|
|
350
|
+
*/
|
|
351
|
+
async function localBranchExists(branchName, cwd) {
|
|
352
|
+
const branches = await getLocalBranches(cwd);
|
|
353
|
+
return branches.includes(branchName);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Get local branches whose remote tracking branch is gone.
|
|
358
|
+
* These are branches where the upstream was deleted on the remote
|
|
359
|
+
* (shown as [origin/...: gone] in git branch -vv output).
|
|
360
|
+
* @param {string} [cwd] - Working directory
|
|
361
|
+
* @returns {Promise<string[]>} Array of branch names whose remotes are gone
|
|
362
|
+
*/
|
|
363
|
+
async function getGoneBranches(cwd) {
|
|
364
|
+
try {
|
|
365
|
+
const { stdout } = await execGit(['branch', '-vv'], { cwd });
|
|
366
|
+
const gone = [];
|
|
367
|
+
for (const line of stdout.split('\n')) {
|
|
368
|
+
// Match lines like " branch-name abc1234 [origin/branch-name: gone] commit msg"
|
|
369
|
+
// Skip current branch (starts with *)
|
|
370
|
+
const match = line.match(/^[ *]+(\S+)\s+\S+\s+\[.*: gone\]/);
|
|
371
|
+
if (match && isValidBranchName(match[1])) {
|
|
372
|
+
gone.push(match[1]);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return gone;
|
|
376
|
+
} catch (error) {
|
|
377
|
+
return [];
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Delete multiple local branches whose remotes are gone.
|
|
383
|
+
* Skips the current branch and branches that fail to delete.
|
|
384
|
+
* @param {string[]} branchNames - Branch names to delete
|
|
385
|
+
* @param {Object} [options] - Options
|
|
386
|
+
* @param {boolean} [options.force=false] - Force delete unmerged branches
|
|
387
|
+
* @param {string} [options.cwd] - Working directory
|
|
388
|
+
* @returns {Promise<{deleted: string[], failed: Array<{name: string, error: string}>}>}
|
|
389
|
+
*/
|
|
390
|
+
async function deleteGoneBranches(branchNames, options = {}) {
|
|
391
|
+
const { force = false, cwd } = options;
|
|
392
|
+
const deleted = [];
|
|
393
|
+
const failed = [];
|
|
394
|
+
|
|
395
|
+
// Get current branch to avoid deleting it
|
|
396
|
+
const current = await getCurrentBranch(cwd);
|
|
397
|
+
|
|
398
|
+
for (const name of branchNames) {
|
|
399
|
+
if (current.name === name) {
|
|
400
|
+
failed.push({ name, error: 'Cannot delete the currently checked out branch' });
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
const result = await deleteLocalBranch(name, { force, cwd });
|
|
404
|
+
if (result.success) {
|
|
405
|
+
deleted.push(name);
|
|
406
|
+
} else {
|
|
407
|
+
failed.push({ name, error: result.error ? result.error.message : 'Unknown error' });
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
return { deleted, failed };
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
module.exports = {
|
|
415
|
+
isValidBranchName,
|
|
416
|
+
sanitizeBranchName,
|
|
417
|
+
getCurrentBranch,
|
|
418
|
+
getAllBranches,
|
|
419
|
+
detectBranchChanges,
|
|
420
|
+
checkout,
|
|
421
|
+
getPreviewData,
|
|
422
|
+
generateSparkline,
|
|
423
|
+
getLocalBranches,
|
|
424
|
+
localBranchExists,
|
|
425
|
+
getGoneBranches,
|
|
426
|
+
deleteGoneBranches,
|
|
427
|
+
VALID_BRANCH_PATTERN,
|
|
428
|
+
};
|