git-cleanup-merged 1.0.4 โ†’ 1.0.5

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.
Files changed (3) hide show
  1. package/README.md +44 -14
  2. package/package.json +2 -4
  3. package/src/index.js +277 -134
package/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  [![CI](https://github.com/ondrovic/git-cleanup-merged/actions/workflows/ci.yml/badge.svg)](https://github.com/ondrovic/git-cleanup-merged/actions/workflows/ci.yml)
4
4
  [![codecov](https://codecov.io/gh/ondrovic/git-cleanup-merged/graph/badge.svg?token=x3cYga3d2E)](https://codecov.io/gh/ondrovic/git-cleanup-merged)
5
+ [![Publish to NPM](https://github.com/ondrovic/git-cleanup-merged/actions/workflows/publish.yml/badge.svg)](https://github.com/ondrovic/git-cleanup-merged/actions/workflows/publish.yml)
6
+ [![npm version](https://img.shields.io/npm/v/git-cleanup-merged.svg)](https://www.npmjs.com/package/git-cleanup-merged)
5
7
 
6
8
  A Node.js command-line tool that automatically identifies and deletes local Git branches that have been merged via GitHub Pull Requests.
7
9
 
@@ -33,10 +35,11 @@ A Node.js command-line tool that automatically identifies and deletes local Git
33
35
  - ๐Ÿ”’ **Protection**: Never deletes `main`, `master`, or your current branch
34
36
  - ๐Ÿ‘€ **Preview Mode**: Dry-run option to see what would be deleted
35
37
  - ๐Ÿ“‚ **Directory Support**: Operate on any git repo by passing a directory as the first argument
36
- - ๐ŸŽจ **Colorful Output**: Clear visual indicators with icons and colors
38
+ - โšก **Performance**: Parallel PR status checking and branch deletion for faster processing
39
+ - ๐ŸŽจ **Colorful Output**: Clear visual indicators with status icons (โœ… Merged, ๐Ÿ”’ Closed, โณ Open)
37
40
  - ๐Ÿ“Š **Status Overview**: Shows comprehensive branch status table
38
- - โšก **Interactive Spinner**: Real-time progress updates with animated spinner
39
- - ๐Ÿ›ก๏ธ **Comprehensive Testing**: 100% test coverage with 97 test cases and live coverage tracking
41
+ - โšก **Interactive Spinner**: Real-time progress updates with an animated spinner
42
+ - ๐Ÿ›ก๏ธ **Comprehensive Testing**: 100% test coverage for statements, branches, functions, and lines with 168 test cases
40
43
  - ๐ŸŽฏ **Code Quality**: ESLint and Prettier for consistent code style
41
44
  - ๐Ÿง  **Smart UX**: Focused modes - main mode for PR cleanup, untracked mode for local cleanup
42
45
 
@@ -191,6 +194,8 @@ git-cleanup-merged ../path/to/repo -u
191
194
  | `--dry-run` | `-n` | Show what would be deleted without actually deleting |
192
195
  | `--verbose` | `-v` | Show detailed information during processing |
193
196
  | `--untracked-only` | `-u` | Only process untracked local branches (no remote tracking branch) |
197
+ | `--count` | `-c` | Display branch count summary and exit (no deletion) |
198
+ | `--version` | `-V` | Show version information |
194
199
  | `--help` | `-h` | Show help message |
195
200
 
196
201
  ### Example Output
@@ -408,16 +413,16 @@ MIT License - see LICENSE file for details.
408
413
 
409
414
  ## ๐Ÿ“‹ Changelog
410
415
 
411
- ### v1.3.1
416
+ ### v1.0.5
412
417
 
413
- - ๐Ÿ› **Critical Bug Fix**: Fixed whitespace parsing issue in branch tracking detection
414
- - The `line.split(" ")` logic was not robust and could misclassify tracked branches as untracked when `git for-each-ref` output contained multiple consecutive spaces
415
- - Replaced with `line.split(/\s+/)` and proper array handling to correctly parse branch names and upstream information
416
- - Added comprehensive tests to verify the fix works with various whitespace scenarios
417
- - ๐Ÿงช **Enhanced Testing**: Added 2 new test cases specifically for whitespace parsing edge cases
418
- - โœ… **Maintained Quality**: 100% test coverage preserved with 97 test cases
418
+ - Implemented major refactoring of branch handling: unified `getBranches` with mode support; added parallel PR status checks and deletion with concurrency limits; updated README to reflect new performance and icon details; bumped package version to 1.0.2. Enhanced error messaging, streamlined spinner usage, and improved test coverage for new logic.
419
+ - feat: Add new `--version` flag, improve error handling, and enhance timeouts
420
+ - chore: Bump the version to 1.0.5 and update Jest configuration
421
+ - fix: Update test case count and enhance spinner messaging
422
+ - chore: Remove unused `useFullFilePath` option in Jest configuration
423
+ - docs: Add NPM publishing badges and update changelog in README, Clean up and consolidate changelog in README
419
424
 
420
- ### v1.3.0
425
+ ### v1.0.4
421
426
 
422
427
  - ๐Ÿท๏ธ **New Feature**: Added `--untracked-only` mode to clean up local branches without remote tracking
423
428
  - ๐Ÿง  **Improved UX**: Main mode now only shows tracked branches with PRs, untracked mode handles local-only branches
@@ -428,15 +433,30 @@ MIT License - see LICENSE file for details.
428
433
  - ๐Ÿ“Š **Enhanced Testing**: Added tests for all new functionality and edge cases
429
434
  - ๐Ÿ”ง **Critical Fix**: Fixed branch tracking detection to use proper Git upstream relationships instead of hard-coded remote names
430
435
  - ๐Ÿ› ๏ธ **Robust Parsing**: Fixed whitespace parsing bug that could misclassify tracked branches as untracked
436
+ - ๐Ÿ› **Critical Bug Fix**: Fixed whitespace parsing issue in branch tracking detection
437
+ - The `line.split(" ")` logic was not robust and could misclassify tracked branches as untracked when `git for-each-ref` output contained multiple consecutive spaces
438
+ - Replaced with `line.split(/\s+/)` and proper array handling to correctly parse branch names and upstream information
439
+ - Added comprehensive tests to verify the fix works with various whitespace scenarios
440
+ - ๐Ÿงช **Enhanced Testing**: Added 2 new test cases specifically for whitespace parsing edge cases
441
+ - โœ… **Maintained Quality**: 100% test coverage preserved with 168 test cases
431
442
 
432
- ### v1.2.1
443
+ ### v1.0.3
433
444
 
434
445
  - ๐Ÿ”ง **Node.js Compatibility**: Updated to require Node.js 18+ for ESLint 9.x compatibility
435
446
  - ๐Ÿงช **CI Updates**: Removed Node.js 16.x from CI matrix (reached end-of-life)
436
447
  - ๐Ÿ“ฆ **Dependencies**: Updated to use modern ESLint flat config format
437
448
  - ๐Ÿšฆ **Workflow Optimization**: CI now only runs on pull requests and on push to `main`/`master` to avoid duplicate runs for feature branches
449
+ - ๐Ÿท๏ธ **New Feature**: Added `--untracked-only` mode to clean up local branches without remote tracking
450
+ - ๐Ÿง  **Improved UX**: Main mode now only shows tracked branches with PRs, untracked mode handles local-only branches
451
+ - ๐Ÿ”ง **Smart Dependencies**: GitHub CLI only required for main mode, not for untracked mode
452
+ - ๐Ÿ’ก **Helpful Guidance**: Suggests `--untracked-only` when no tracked branches found in main mode
453
+ - ๐ŸŽฏ **100% Test Coverage**: Achieved complete test coverage with 97 comprehensive test cases
454
+ - ๐Ÿ› **Bug Fixes**: Fixed branch tracking detection logic and improved deletion feedback
455
+ - ๐Ÿ“Š **Enhanced Testing**: Added tests for all new functionality and edge cases
456
+ - ๐Ÿ”ง **Critical Fix**: Fixed branch tracking detection to use proper Git upstream relationships instead of hard-coded remote names
457
+ - ๐Ÿ› ๏ธ **Robust Parsing**: Fixed whitespace parsing bug that could misclassify tracked branches as untracked
438
458
 
439
- ### v1.2.0
459
+ ### v1.0.2
440
460
 
441
461
  - ๐ŸŽฏ **100% Test Coverage**: Achieved complete test coverage across all code paths
442
462
  - ๐Ÿงช **Enhanced Test Suite**: Added 76 comprehensive test cases covering all functionality
@@ -444,13 +464,23 @@ MIT License - see LICENSE file for details.
444
464
  - ๐Ÿ—๏ธ **Architecture Improvements**: Separated CLI entry point for better testability
445
465
  - ๐Ÿ› **Bug Fixes**: Fixed spinner component and improved error handling
446
466
  - ๐Ÿ“Š **Coverage Thresholds**: Set minimum 75% branch coverage requirement
467
+ - ๐Ÿ”ง **Node.js Compatibility**: Updated to require Node.js 18+ for ESLint 9.x compatibility
468
+ - ๐Ÿงช **CI Updates**: Removed Node.js 16.x from CI matrix (reached end-of-life)
469
+ - ๐Ÿ“ฆ **Dependencies**: Updated to use modern ESLint flat config format
470
+ - ๐Ÿšฆ **Workflow Optimization**: CI now only runs on pull requests and on push to `main`/`master` to avoid duplicate runs for feature branches
447
471
 
448
- ### v1.1.0
472
+ ### v1.0.1
449
473
 
450
474
  - Fixed spinner display issue where branch names would merge together
451
475
  - Improved terminal output clearing with proper ANSI escape sequences
452
476
  - Enhanced progress indicators during branch checking and deletion
453
477
  - Added directory argument support for operating on different repositories
478
+ - ๐ŸŽฏ **100% Test Coverage**: Achieved complete test coverage across all code paths
479
+ - ๐Ÿงช **Enhanced Test Suite**: Added 76 comprehensive test cases covering all functionality
480
+ - ๐Ÿ”ง **Code Quality**: Added ESLint and Prettier for consistent code style
481
+ - ๐Ÿ—๏ธ **Architecture Improvements**: Separated CLI entry point for better testability
482
+ - ๐Ÿ› **Bug Fixes**: Fixed spinner component and improved error handling
483
+ - ๐Ÿ“Š **Coverage Thresholds**: Set minimum 75% branch coverage requirement
454
484
 
455
485
  ### v1.0.0
456
486
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-cleanup-merged",
3
- "version": "1.0.4",
3
+ "version": "1.0.5",
4
4
  "description": "Clean up local Git branches that have merged PRs on GitHub",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -74,9 +74,7 @@
74
74
  "classNameTemplate": "{filepath}",
75
75
  "titleTemplate": "{title}",
76
76
  "ancestorSeparator": " โ€บ ",
77
- "usePathForSuiteName": true,
78
- "useFullFilePath": true,
79
- "classNameTemplate": "{filepath}"
77
+ "usePathForSuiteName": true
80
78
  }
81
79
  ]
82
80
  ]
package/src/index.js CHANGED
@@ -26,6 +26,7 @@ class GitCleanupTool {
26
26
  this.prResults = [];
27
27
  this.currentBranch = "";
28
28
  this.spinner = new Spinner();
29
+ this.version = require("../package.json").version;
29
30
  }
30
31
 
31
32
  // Helper method for minimum spinner visibility
@@ -35,9 +36,11 @@ class GitCleanupTool {
35
36
 
36
37
  async execCommand(command, options = {}) {
37
38
  try {
39
+ const timeout = options.timeout || 30000; // Default 30s timeout
38
40
  const result = execSync(command, {
39
41
  encoding: "utf8",
40
42
  stdio: options.silent ? "pipe" : "inherit",
43
+ timeout: timeout,
41
44
  ...options,
42
45
  });
43
46
  return result.trim();
@@ -45,6 +48,10 @@ class GitCleanupTool {
45
48
  if (!options.silent) {
46
49
  throw error;
47
50
  }
51
+ // If it's a timeout, return a specific error or indicator
52
+ if (error.code === "ETIMEDOUT" || error.signal === "SIGTERM") {
53
+ return "__TIMEOUT__";
54
+ }
48
55
  return null;
49
56
  }
50
57
  }
@@ -54,11 +61,15 @@ class GitCleanupTool {
54
61
  this.spinner.start();
55
62
 
56
63
  // Check if we're in a git repository
57
- if (
58
- (await this.execCommand("git rev-parse --git-dir", { silent: true })) ===
59
- null
60
- ) {
61
- this.spinner.error("Not in a git repository");
64
+ const gitDirResult = await this.execCommand("git rev-parse --git-dir", {
65
+ silent: true,
66
+ });
67
+ if (gitDirResult === null || gitDirResult === "__TIMEOUT__") {
68
+ if (gitDirResult === "__TIMEOUT__") {
69
+ this.spinner.error("Git repository check timed out");
70
+ } else {
71
+ this.spinner.error("Not in a git repository");
72
+ }
62
73
  process.exit(1);
63
74
  }
64
75
  await this.sleep(300); // Minimum spinner time
@@ -67,22 +78,38 @@ class GitCleanupTool {
67
78
  if (!this.untrackedOnly && !this.countOnly) {
68
79
  this.spinner.updateMessage("Checking GitHub CLI...");
69
80
  // Check for GitHub CLI
70
- if ((await this.execCommand("gh --version", { silent: true })) === null) {
71
- this.spinner.error(
72
- "GitHub CLI (gh) is not installed. Please install it from https://cli.github.com/",
73
- );
81
+ const ghVersionResult = await this.execCommand("gh --version", {
82
+ silent: true,
83
+ });
84
+ if (ghVersionResult === null || ghVersionResult === "__TIMEOUT__") {
85
+ if (ghVersionResult === "__TIMEOUT__") {
86
+ this.spinner.error(
87
+ "GitHub CLI check timed out. Please check your connection.",
88
+ );
89
+ } else {
90
+ this.spinner.error(
91
+ "GitHub CLI (gh) is not installed. Please install it from https://cli.github.com/",
92
+ );
93
+ }
74
94
  process.exit(1);
75
95
  }
76
96
  await this.sleep(200);
77
97
 
78
98
  this.spinner.updateMessage("Verifying GitHub authentication...");
79
99
  // Check GitHub CLI authentication
80
- if (
81
- (await this.execCommand("gh auth status", { silent: true })) === null
82
- ) {
83
- this.spinner.error(
84
- "GitHub CLI is not authenticated. Run: gh auth login",
85
- );
100
+ const authStatusResult = await this.execCommand("gh auth status", {
101
+ silent: true,
102
+ });
103
+ if (authStatusResult === null || authStatusResult === "__TIMEOUT__") {
104
+ if (authStatusResult === "__TIMEOUT__") {
105
+ this.spinner.error(
106
+ "GitHub authentication check timed out. Please check your connection.",
107
+ );
108
+ } else {
109
+ this.spinner.error(
110
+ "GitHub CLI is not authenticated. Run: gh auth login",
111
+ );
112
+ }
86
113
  process.exit(1);
87
114
  }
88
115
  await this.sleep(200);
@@ -96,9 +123,19 @@ class GitCleanupTool {
96
123
  this.spinner.start();
97
124
 
98
125
  try {
99
- this.currentBranch = await this.execCommand("git branch --show-current", {
126
+ const branchResult = await this.execCommand("git branch --show-current", {
100
127
  silent: true,
101
128
  });
129
+ // Check if command failed or timed out
130
+ if (branchResult === null || branchResult === "__TIMEOUT__") {
131
+ if (branchResult === "__TIMEOUT__") {
132
+ this.spinner.error("Failed to get current branch (timeout)");
133
+ } else {
134
+ this.spinner.error("Failed to get current branch");
135
+ }
136
+ process.exit(1);
137
+ }
138
+ this.currentBranch = branchResult;
102
139
  await this.sleep(200); // Let the spinner show
103
140
  this.spinner.success(`Current branch: ${this.currentBranch}`);
104
141
  } catch {
@@ -107,28 +144,7 @@ class GitCleanupTool {
107
144
  }
108
145
  }
109
146
 
110
- async getLocalBranches() {
111
- try {
112
- const branches = await this.execCommand(
113
- 'git for-each-ref --format="%(refname:short)" refs/heads/',
114
- { silent: true },
115
- );
116
-
117
- return branches
118
- .split("\n")
119
- .filter((branch) => branch.trim() !== "")
120
- .filter(
121
- (branch) =>
122
- !["main", "master", this.currentBranch].includes(branch.trim()),
123
- )
124
- .map((branch) => branch.trim());
125
- } catch {
126
- this.spinner.error("Failed to get local branches");
127
- return [];
128
- }
129
- }
130
-
131
- async getTrackedBranches() {
147
+ async getBranches(mode = "all") {
132
148
  try {
133
149
  // Get all local branches with their upstream tracking information
134
150
  const branches = await this.execCommand(
@@ -136,74 +152,59 @@ class GitCleanupTool {
136
152
  { silent: true },
137
153
  );
138
154
 
139
- const trackedBranches = [];
155
+ // Check if execCommand returned null or timed out (command failed)
156
+ if (branches === null || branches === "__TIMEOUT__") {
157
+ if (branches === "__TIMEOUT__") {
158
+ this.spinner.error(`Failed to get ${mode} branches (timeout)`);
159
+ } else {
160
+ this.spinner.error(`Failed to get ${mode} branches`);
161
+ }
162
+ return [];
163
+ }
140
164
 
165
+ const result = [];
141
166
  const branchLines = branches
142
167
  .split("\n")
143
168
  .filter((line) => line.trim() !== "")
144
169
  .map((line) => line.trim());
145
170
 
146
171
  for (const line of branchLines) {
147
- // Use regex to split on one or more whitespace characters
148
172
  const parts = line.split(/\s+/);
149
173
  const branchName = parts[0];
150
- const upstream = parts.slice(1).join(" "); // Join remaining parts in case upstream contains spaces
174
+ const upstream = parts.slice(1).join(" ");
151
175
 
152
- // Skip main, master, and current branch
153
176
  if (["main", "master", this.currentBranch].includes(branchName)) {
154
177
  continue;
155
178
  }
156
179
 
157
- // If upstream is not empty, the branch is tracking a remote branch
158
- if (upstream && upstream.trim() !== "") {
159
- trackedBranches.push(branchName);
180
+ const isTracked = upstream && upstream.trim() !== "";
181
+
182
+ if (mode === "tracked" && isTracked) {
183
+ result.push(branchName);
184
+ } else if (mode === "untracked" && !isTracked) {
185
+ result.push(branchName);
186
+ } else if (mode === "all") {
187
+ result.push(branchName);
160
188
  }
161
189
  }
162
190
 
163
- return trackedBranches;
191
+ return result;
164
192
  } catch {
165
- this.spinner.error("Failed to get tracked branches");
193
+ this.spinner.error(`Failed to get ${mode} branches`);
166
194
  return [];
167
195
  }
168
196
  }
169
197
 
170
- async getUntrackedBranches() {
171
- try {
172
- // Get all local branches with their upstream tracking information
173
- const branches = await this.execCommand(
174
- 'git for-each-ref --format="%(refname:short) %(upstream:short)" refs/heads/',
175
- { silent: true },
176
- );
177
-
178
- const untrackedBranches = [];
179
-
180
- const branchLines = branches
181
- .split("\n")
182
- .filter((line) => line.trim() !== "")
183
- .map((line) => line.trim());
184
-
185
- for (const line of branchLines) {
186
- // Use regex to split on one or more whitespace characters
187
- const parts = line.split(/\s+/);
188
- const branchName = parts[0];
189
- const upstream = parts.slice(1).join(" "); // Join remaining parts in case upstream contains spaces
190
-
191
- // Skip main, master, and current branch
192
- if (["main", "master", this.currentBranch].includes(branchName)) {
193
- continue;
194
- }
198
+ async getLocalBranches() {
199
+ return this.getBranches("all");
200
+ }
195
201
 
196
- // If upstream is empty, the branch is not tracking any remote branch
197
- if (!upstream || upstream.trim() === "") {
198
- untrackedBranches.push(branchName);
199
- }
200
- }
202
+ async getTrackedBranches() {
203
+ return this.getBranches("tracked");
204
+ }
201
205
 
202
- return untrackedBranches;
203
- } catch {
204
- this.spinner.error("Failed to get untracked branches");
205
- return [];
206
- }
206
+ async getUntrackedBranches() {
207
+ return this.getBranches("untracked");
207
208
  }
208
209
 
209
210
  async countBranches() {
@@ -226,11 +227,10 @@ class GitCleanupTool {
226
227
 
227
228
  async getPRStatus(branch) {
228
229
  try {
229
- const result = await this.execCommand(
230
- `gh pr view "${branch}" --json state --jq .state`,
231
- { silent: true },
230
+ return await this.execCommand(
231
+ `gh pr view "${branch}" --json state --jq .state`,
232
+ {silent: true, timeout: 10000}, // 10s timeout for PR status
232
233
  );
233
- return result;
234
234
  } catch {
235
235
  return null;
236
236
  }
@@ -253,59 +253,127 @@ class GitCleanupTool {
253
253
  this.spinner.updateMessage(
254
254
  `Checking ${branches.length} tracked branches against GitHub...`,
255
255
  );
256
-
257
- // Process each branch
258
- for (let i = 0; i < branches.length; i++) {
259
- const branch = branches[i];
256
+ this.spinner.start(); // Ensure spinner is running before workers start
257
+ await this.sleep(150); // Give spinner time to display initial message
258
+
259
+ // Limit concurrency to avoid hitting GitHub API rate limits
260
+ const CONCURRENCY_LIMIT = 5;
261
+ // Use Map to store results by branch name to preserve input order
262
+ const resultsMap = new Map();
263
+ const totalBranches = branches.length;
264
+
265
+ // Atomic counter for branch index tracking
266
+ let nextIndex = 0;
267
+
268
+ // Function to get next branch index atomically
269
+ const getNextBranchIndex = () => {
270
+ if (nextIndex >= totalBranches) {
271
+ return null;
272
+ }
273
+ return nextIndex++;
274
+ };
275
+
276
+ // Synchronized array for branches to delete to avoid race conditions
277
+ const branchesToDeleteSync = [];
278
+ const addBranchToDelete = (branch) => {
279
+ branchesToDeleteSync.push(branch);
280
+ };
281
+
282
+ const processBranch = async (branchIndex) => {
283
+ const branch = branches[branchIndex];
284
+
285
+ // Update the spinner message as we start checking this branch
260
286
  this.spinner.updateMessage(
261
- `Checking branch ${i + 1}/${branches.length}: ${branch}`,
287
+ `Checking branch ${branchIndex + 1}/${branches.length}: ${branch}`,
262
288
  );
263
-
289
+ this.spinner.start();
290
+
264
291
  const prStatus = await this.getPRStatus(branch);
265
-
266
- // Only call debug if verbose is enabled to avoid stopping spinner
292
+
267
293
  if (this.verbose) {
268
294
  this.spinner.debug(
269
- `Checking branch ${branch} -> PR state: ${prStatus || "unknown"}`,
295
+ `Checking branch ${branchIndex + 1}/${branches.length}: ${branch} -> PR state: ${prStatus || "unknown"}`,
270
296
  this.verbose,
271
297
  );
272
- // Restart spinner after debug output
298
+ // Restart spinner after debug output stops it
273
299
  this.spinner.updateMessage(
274
- `Checking branch ${i + 1}/${branches.length}: ${branch}`,
300
+ `Checking branch ${branchIndex + 1}/${branches.length}: ${branch}`,
275
301
  );
276
302
  this.spinner.start();
277
303
  }
278
-
304
+
279
305
  let icon, label;
280
306
  switch (prStatus) {
281
307
  case "MERGED":
282
308
  icon = "โœ…";
283
309
  label = "Merged";
284
- this.branchesToDelete.push(branch);
310
+ addBranchToDelete(branch);
285
311
  break;
286
312
  case "CLOSED":
287
313
  icon = "๐Ÿ”’";
288
314
  label = "Closed";
289
- this.branchesToDelete.push(branch);
315
+ addBranchToDelete(branch);
290
316
  break;
291
317
  case "OPEN":
292
318
  icon = "โณ";
293
319
  label = "Open";
294
320
  break;
321
+ case "__TIMEOUT__":
322
+ icon = "โ“";
323
+ label = "Timeout";
324
+ break;
295
325
  default:
296
326
  icon = "โŒ";
297
327
  label = "No PR";
298
328
  break;
299
329
  }
330
+
331
+ resultsMap.set(branch, { branch, icon, label });
332
+ };
333
+
334
+ const fluidWorker = async () => {
335
+ while (true) {
336
+ const branchIndex = getNextBranchIndex();
337
+ if (branchIndex === null) {
338
+ break; // No more branches to process
339
+ }
340
+ try {
341
+ await processBranch(branchIndex);
342
+ } catch (error) {
343
+ // Handle errors gracefully - one failed branch shouldn't stop the entire operation
344
+ const branch = branches[branchIndex];
345
+ // Store error result for this branch
346
+ resultsMap.set(branch, {
347
+ branch,
348
+ icon: "โš ๏ธ",
349
+ label: "Error",
350
+ });
351
+ // Log error if verbose mode is enabled
352
+ if (this.verbose) {
353
+ this.spinner.debug(
354
+ `Error checking '${branch}': ${error.message}`,
355
+ this.verbose,
356
+ );
357
+ }
358
+ }
359
+ }
360
+ };
300
361
 
301
- this.prResults.push({ branch, icon, label });
362
+ const workers = Array(Math.min(CONCURRENCY_LIMIT, totalBranches))
363
+ .fill(null)
364
+ .map(() => fluidWorker());
302
365
 
303
- // Add a small delay so we can see the spinner working
304
- await this.sleep(300);
305
- }
366
+ // Wait for all concurrent workers to finish
367
+ await Promise.all(workers);
368
+
369
+ // Reconstruct results array in the original input order
370
+ this.prResults = branches.map((branch) => resultsMap.get(branch));
371
+
372
+ // Copy synchronized branches to delete array after all workers complete
373
+ this.branchesToDelete = branchesToDeleteSync;
306
374
 
307
375
  this.spinner.success(
308
- `Finished checking ${branches.length} tracked branches`,
376
+ `Finished checking ${totalBranches} tracked branches`,
309
377
  );
310
378
  console.log(""); // Empty line for spacing
311
379
  }
@@ -402,9 +470,11 @@ class GitCleanupTool {
402
470
  }
403
471
  }
404
472
 
405
- // List branches without icons since the spinner methods handle them
473
+ // List branches with icons for better clarity
406
474
  this.branchesToDelete.forEach((branch) => {
407
- this.spinner.log(` ${branch}`, colors.red);
475
+ const result = this.prResults.find((r) => r.branch === branch);
476
+ const icon = result ? result.icon : "๐Ÿ—‘๏ธ";
477
+ this.spinner.log(` ${icon} ${branch}`, colors.red);
408
478
  });
409
479
 
410
480
  if (this.dryRun) {
@@ -427,41 +497,104 @@ class GitCleanupTool {
427
497
  if (confirmed) {
428
498
  console.log(""); // Empty line for spacing
429
499
 
500
+ // Concurrency limit for deletion as well
501
+ const DELETE_CONCURRENCY = 3;
430
502
  let deletedCount = 0;
431
- let failedBranches = [];
503
+ const failedBranchesSync = [];
504
+ const branchesToDeleteCopy = [...this.branchesToDelete];
505
+ const totalToDelete = this.branchesToDelete.length;
506
+ // Use atomic counter to avoid race conditions with concurrent workers
507
+ // The counter object ensures the increment operation is atomic
508
+ const deletionCounter = { value: 0 };
509
+ const getNextDeletionProgress = () => {
510
+ // Increment and return in a single synchronous operation
511
+ return ++deletionCounter.value;
512
+ };
513
+
514
+ // Atomic function to get next branch index to avoid race conditions
515
+ let nextDeleteIndex = 0;
516
+ const getNextDeleteBranch = () => {
517
+ if (nextDeleteIndex >= branchesToDeleteCopy.length) {
518
+ return null;
519
+ }
520
+ return branchesToDeleteCopy[nextDeleteIndex++];
521
+ };
522
+
523
+ // Synchronized function to add failed branch
524
+ const addFailedBranch = (branch) => {
525
+ failedBranchesSync.push(branch);
526
+ };
527
+
528
+ // Synchronized function to increment deleted count
529
+ const incrementDeletedCount = () => {
530
+ deletedCount++;
531
+ };
532
+
533
+ const deletionWorker = async () => {
534
+ while (true) {
535
+ const branch = getNextDeleteBranch();
536
+ if (branch === null) {
537
+ break; // No more branches to process
538
+ }
539
+ const currentProgress = getNextDeletionProgress();
540
+
541
+ this.spinner.updateMessage(
542
+ `Deleting branch ${currentProgress}/${totalToDelete}: ${branch}`,
543
+ );
544
+ this.spinner.start();
545
+
546
+ try {
547
+ const result = await this.execCommand(`git branch -d "${branch}"`, {
548
+ silent: true,
549
+ });
550
+ // Check if the command failed (returns null or "__TIMEOUT__" instead of throwing)
551
+ if (result === "__TIMEOUT__") {
552
+ addFailedBranch(branch);
553
+ this.spinner.stop();
554
+ this.spinner.log(
555
+ `โŒ Failed to delete branch ${branch} (timeout)`,
556
+ colors.red,
557
+ );
558
+ } else if (result === null) {
559
+ // Command failed (non-timeout error)
560
+ addFailedBranch(branch);
561
+ this.spinner.stop();
562
+ this.spinner.log(
563
+ `โŒ Failed to delete branch ${branch}`,
564
+ colors.red,
565
+ );
566
+ } else {
567
+ // Success - result is a non-empty string
568
+ incrementDeletedCount();
569
+ this.spinner.stop();
570
+ this.spinner.log(`โœ… Deleted branch ${branch}`, colors.green);
571
+ }
572
+ } catch {
573
+ addFailedBranch(branch);
574
+ this.spinner.stop();
575
+ this.spinner.log(
576
+ `โŒ Failed to delete branch ${branch}`,
577
+ colors.red,
578
+ );
579
+ }
580
+ await this.sleep(50);
581
+ }
582
+ };
432
583
 
433
- for (let i = 0; i < this.branchesToDelete.length; i++) {
434
- const branch = this.branchesToDelete[i];
435
- this.spinner.updateMessage(
436
- `Deleting branch ${i + 1}/${this.branchesToDelete.length}: ${branch}`,
437
- );
438
- this.spinner.start();
584
+ const workers = Array(Math.min(DELETE_CONCURRENCY, totalToDelete))
585
+ .fill(null)
586
+ .map(() => deletionWorker());
439
587
 
440
- try {
441
- await this.execCommand(`git branch -d "${branch}"`, { silent: true });
442
- deletedCount++;
443
- // Stop spinner before printing confirmation to avoid overlap
444
- this.spinner.stop();
445
- this.spinner.log(`โœ… Deleted branch ${branch}`, colors.green);
446
- // Brief pause to show progress
447
- await this.sleep(200);
448
- } catch {
449
- failedBranches.push(branch);
450
- // Stop spinner before printing error to avoid overlap
451
- this.spinner.stop();
452
- this.spinner.log(`โŒ Failed to delete branch ${branch}`, colors.red);
453
- await this.sleep(200);
454
- }
455
- }
588
+ await Promise.all(workers);
456
589
 
457
590
  // Final status
458
- if (failedBranches.length === 0) {
591
+ if (failedBranchesSync.length === 0) {
459
592
  this.spinner.success(`Successfully deleted ${deletedCount} branches`);
460
593
  } else {
461
594
  this.spinner.warning(
462
- `Deleted ${deletedCount} branches, ${failedBranches.length} failed`,
595
+ `Deleted ${deletedCount} branches, ${failedBranchesSync.length} failed`,
463
596
  );
464
- failedBranches.forEach((branch) => {
597
+ failedBranchesSync.forEach((branch) => {
465
598
  this.spinner.log(` Failed: ${branch}`, colors.red);
466
599
  });
467
600
  }
@@ -499,6 +632,7 @@ ${colors.bold}OPTIONS:${colors.reset}
499
632
  -v, --verbose Show detailed information during processing
500
633
  -u, --untracked-only Only process untracked local branches (no remote tracking branch)
501
634
  -c, --count Display branch count summary and exit (no deletion)
635
+ -V, --version Show version information
502
636
  -h, --help Show this help message
503
637
 
504
638
  ${colors.bold}DESCRIPTION:${colors.reset}
@@ -557,6 +691,10 @@ ${colors.bold}EXAMPLES:${colors.reset}
557
691
  case "-c":
558
692
  this.countOnly = true;
559
693
  break;
694
+ case "--version":
695
+ case "-V":
696
+ console.log(this.version);
697
+ return 0;
560
698
  case "--help":
561
699
  case "-h":
562
700
  this.showHelp();
@@ -577,7 +715,12 @@ ${colors.bold}EXAMPLES:${colors.reset}
577
715
  "\x1b[34m",
578
716
  );
579
717
  try {
580
- this.parseArguments();
718
+ const parseResult = this.parseArguments();
719
+ // If parseArguments returns 0, it means --version or --help was requested
720
+ // Exit early without executing the main cleanup logic
721
+ if (parseResult === 0) {
722
+ return;
723
+ }
581
724
  await this.checkDependencies();
582
725
  await this.getCurrentBranch();
583
726