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.
- package/README.md +44 -14
- package/package.json +2 -4
- package/src/index.js +277 -134
package/README.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://github.com/ondrovic/git-cleanup-merged/actions/workflows/ci.yml)
|
|
4
4
|
[](https://codecov.io/gh/ondrovic/git-cleanup-merged)
|
|
5
|
+
[](https://github.com/ondrovic/git-cleanup-merged/actions/workflows/publish.yml)
|
|
6
|
+
[](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
|
-
-
|
|
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
|
|
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.
|
|
416
|
+
### v1.0.5
|
|
412
417
|
|
|
413
|
-
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
-
|
|
418
|
-
-
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
) {
|
|
61
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
)
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(" ");
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
|
191
|
+
return result;
|
|
164
192
|
} catch {
|
|
165
|
-
this.spinner.error(
|
|
193
|
+
this.spinner.error(`Failed to get ${mode} branches`);
|
|
166
194
|
return [];
|
|
167
195
|
}
|
|
168
196
|
}
|
|
169
197
|
|
|
170
|
-
async
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
}
|
|
200
|
-
}
|
|
202
|
+
async getTrackedBranches() {
|
|
203
|
+
return this.getBranches("tracked");
|
|
204
|
+
}
|
|
201
205
|
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
//
|
|
258
|
-
|
|
259
|
-
|
|
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 ${
|
|
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 ${
|
|
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
|
-
|
|
310
|
+
addBranchToDelete(branch);
|
|
285
311
|
break;
|
|
286
312
|
case "CLOSED":
|
|
287
313
|
icon = "๐";
|
|
288
314
|
label = "Closed";
|
|
289
|
-
|
|
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
|
-
|
|
362
|
+
const workers = Array(Math.min(CONCURRENCY_LIMIT, totalBranches))
|
|
363
|
+
.fill(null)
|
|
364
|
+
.map(() => fluidWorker());
|
|
302
365
|
|
|
303
|
-
|
|
304
|
-
|
|
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 ${
|
|
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
|
|
473
|
+
// List branches with icons for better clarity
|
|
406
474
|
this.branchesToDelete.forEach((branch) => {
|
|
407
|
-
this.
|
|
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
|
-
|
|
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
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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
|
-
|
|
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 (
|
|
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, ${
|
|
595
|
+
`Deleted ${deletedCount} branches, ${failedBranchesSync.length} failed`,
|
|
463
596
|
);
|
|
464
|
-
|
|
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
|
|