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.
@@ -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
+ };