git-cleanup-merged 1.0.1
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 +473 -0
- package/package.json +84 -0
- package/src/bin.js +7 -0
- package/src/index.js +605 -0
- package/src/utils/index.js +22 -0
- package/src/utils/spinner.js +75 -0
package/README.md
ADDED
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
# Git Cleanup Merged
|
|
2
|
+
|
|
3
|
+
[](https://github.com/ondrovic/git-cleanup-merged/actions/workflows/ci.yml)
|
|
4
|
+
[](https://codecov.io/gh/ondrovic/git-cleanup-merged)
|
|
5
|
+
|
|
6
|
+
A Node.js command-line tool that automatically identifies and deletes local Git branches that have been merged via GitHub Pull Requests.
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## ๐งช Testing & Quality Assurance
|
|
11
|
+
|
|
12
|
+
- [](https://github.com/ondrovic/git-cleanup-merged/actions/workflows/ci.yml) **CI runs on all pull requests and on push to `main`/`master` only** via GitHub Actions (tests Node.js 18.x, 20.x)
|
|
13
|
+
- This avoids duplicate runs for feature branches and is the recommended best practice for open source projects.
|
|
14
|
+
- [](https://codecov.io/gh/ondrovic/git-cleanup-merged) **Live coverage tracking** via Codecov
|
|
15
|
+
- ๐ฆ **Branch coverage threshold:** CI will fail if branch coverage drops below 75%
|
|
16
|
+
- ๐ **JUnit test results and coverage are uploaded to Codecov for every CI run**
|
|
17
|
+
- ๐งช **Run tests locally:**
|
|
18
|
+
```bash
|
|
19
|
+
npm test
|
|
20
|
+
npm run test:coverage
|
|
21
|
+
```
|
|
22
|
+
- ๐ **Check coverage report:**
|
|
23
|
+
After running `npm run test:coverage`, open `coverage/lcov-report/index.html` in your browser for a detailed report.
|
|
24
|
+
- ๐ **Code quality:** ESLint and Prettier configured for consistent code style
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## โจ Features
|
|
29
|
+
|
|
30
|
+
- ๐ **Smart Detection**: Automatically checks GitHub PR status for tracked branches
|
|
31
|
+
- ๐ท๏ธ **Untracked Branch Support**: Clean up local-only branches with `--untracked-only` mode
|
|
32
|
+
- โ
**Safe Deletion**: Only deletes branches with merged or closed PRs, or untracked branches
|
|
33
|
+
- ๐ **Protection**: Never deletes `main`, `master`, or your current branch
|
|
34
|
+
- ๐ **Preview Mode**: Dry-run option to see what would be deleted
|
|
35
|
+
- ๐ **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
|
|
37
|
+
- ๐ **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
|
|
40
|
+
- ๐ฏ **Code Quality**: ESLint and Prettier for consistent code style
|
|
41
|
+
- ๐ง **Smart UX**: Focused modes - main mode for PR cleanup, untracked mode for local cleanup
|
|
42
|
+
|
|
43
|
+
## Prerequisites
|
|
44
|
+
|
|
45
|
+
Before installing, make sure you have:
|
|
46
|
+
|
|
47
|
+
- **Node.js** (version 18 or higher - tested on 18.x and 20.x)
|
|
48
|
+
- **Git** installed and configured
|
|
49
|
+
- **GitHub CLI** (`gh`) installed and authenticated (only required for main mode, not for `--untracked-only`)
|
|
50
|
+
- Active internet connection for GitHub API calls (only required for main mode)
|
|
51
|
+
|
|
52
|
+
### Installing GitHub CLI
|
|
53
|
+
|
|
54
|
+
If you don't have GitHub CLI installed:
|
|
55
|
+
|
|
56
|
+
**macOS (Homebrew):**
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
brew install gh
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
**Windows (Chocolatey):**
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
choco install gh
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
**Linux (Ubuntu/Debian):**
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg
|
|
72
|
+
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null
|
|
73
|
+
sudo apt update
|
|
74
|
+
sudo apt install gh
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
**Authenticate GitHub CLI:**
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
gh auth login
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Installation
|
|
84
|
+
|
|
85
|
+
### Option 1: Global Installation via NPM
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
# Install globally from npm (if published)
|
|
89
|
+
npm install -g git-cleanup-merged
|
|
90
|
+
|
|
91
|
+
# Or install globally from GitHub
|
|
92
|
+
npm install -g https://github.com/ondro/git-cleanup-merged.git
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Option 2: Local Installation
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
# Clone the repository
|
|
99
|
+
git clone https://github.com/ondro/git-cleanup-merged.git
|
|
100
|
+
cd git-cleanup-merged
|
|
101
|
+
|
|
102
|
+
# Install dependencies
|
|
103
|
+
npm install
|
|
104
|
+
|
|
105
|
+
# Make executable (Unix/macOS/Linux)
|
|
106
|
+
chmod +x index.js
|
|
107
|
+
|
|
108
|
+
# Create symlink for global access (optional)
|
|
109
|
+
npm link
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### Option 3: Direct Download
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
# Download the script directly
|
|
116
|
+
curl -o git-cleanup-merged https://raw.githubusercontent.com/ondro/git-cleanup-merged/main/index.js
|
|
117
|
+
chmod +x git-cleanup-merged
|
|
118
|
+
|
|
119
|
+
# Move to your PATH
|
|
120
|
+
sudo mv git-cleanup-merged /usr/local/bin/
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### Option 4: Using npx (No Installation)
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
# Run directly without installing
|
|
127
|
+
npx git-cleanup-merged
|
|
128
|
+
|
|
129
|
+
# Or from GitHub
|
|
130
|
+
npx https://github.com/ondro/git-cleanup-merged.git
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## Usage
|
|
134
|
+
|
|
135
|
+
### Basic Usage
|
|
136
|
+
|
|
137
|
+
> **Note:** At the start of every run, the tool will display the name of the repository directory being scanned (e.g. 'git-local-branch-cleanup' or 'ollama-git-commit'), so you always know which directory is being operated on.
|
|
138
|
+
|
|
139
|
+
#### Main Mode (Default) - Clean up branches with merged PRs
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
# Clean up merged branches (with confirmation)
|
|
143
|
+
git-cleanup-merged
|
|
144
|
+
|
|
145
|
+
# Clean up merged branches in a different directory
|
|
146
|
+
git-cleanup-merged ../path/to/repo
|
|
147
|
+
|
|
148
|
+
# Preview what would be deleted (dry run)
|
|
149
|
+
git-cleanup-merged --dry-run
|
|
150
|
+
|
|
151
|
+
# Show detailed processing information
|
|
152
|
+
git-cleanup-merged --verbose
|
|
153
|
+
|
|
154
|
+
# Combine options
|
|
155
|
+
git-cleanup-merged ../path/to/repo --dry-run --verbose
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
#### Untracked Mode - Clean up local-only branches
|
|
159
|
+
|
|
160
|
+
```bash
|
|
161
|
+
# Clean up untracked branches (local branches without remote tracking)
|
|
162
|
+
git-cleanup-merged --untracked-only
|
|
163
|
+
|
|
164
|
+
# Same as above using shorthand
|
|
165
|
+
git-cleanup-merged -u
|
|
166
|
+
|
|
167
|
+
# Preview untracked branches (dry run)
|
|
168
|
+
git-cleanup-merged --untracked-only --dry-run
|
|
169
|
+
|
|
170
|
+
# Same as above using shorthand
|
|
171
|
+
git-cleanup-merged -u -n
|
|
172
|
+
|
|
173
|
+
# Show detailed processing for untracked branches
|
|
174
|
+
git-cleanup-merged --untracked-only --verbose
|
|
175
|
+
|
|
176
|
+
# Same as above using shorthand
|
|
177
|
+
git-cleanup-merged -u -v
|
|
178
|
+
|
|
179
|
+
# Clean up untracked branches in a different directory
|
|
180
|
+
git-cleanup-merged ../path/to/repo --untracked-only
|
|
181
|
+
|
|
182
|
+
# Same as above using shorthand
|
|
183
|
+
git-cleanup-merged ../path/to/repo -u
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
### Command Line Options
|
|
187
|
+
|
|
188
|
+
| Option | Short | Description |
|
|
189
|
+
| ------------------ | ----- | ------------------------------------------------------------------------------------ |
|
|
190
|
+
| `[DIRECTORY]` | | Path to a git repository to operate on. Defaults to the current directory if omitted |
|
|
191
|
+
| `--dry-run` | `-n` | Show what would be deleted without actually deleting |
|
|
192
|
+
| `--verbose` | `-v` | Show detailed information during processing |
|
|
193
|
+
| `--untracked-only` | `-u` | Only process untracked local branches (no remote tracking branch) |
|
|
194
|
+
| `--help` | `-h` | Show help message |
|
|
195
|
+
|
|
196
|
+
### Example Output
|
|
197
|
+
|
|
198
|
+
#### Main Mode (Default) - Branches with merged PRs
|
|
199
|
+
|
|
200
|
+
```
|
|
201
|
+
๐ Scanning repository: my-project
|
|
202
|
+
โ
Dependencies checked
|
|
203
|
+
โ
Current branch: main
|
|
204
|
+
โ
Finished checking 3 tracked branches
|
|
205
|
+
|
|
206
|
+
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
207
|
+
Branch Icon Status
|
|
208
|
+
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
209
|
+
feature/user-authentication โ
Merged
|
|
210
|
+
bugfix/header-layout โ
Merged
|
|
211
|
+
feature/experimental ๐ Closed
|
|
212
|
+
feature/dark-mode โณ Open
|
|
213
|
+
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
214
|
+
|
|
215
|
+
โ The following branches have merged or closed PRs and will be deleted:
|
|
216
|
+
feature/user-authentication
|
|
217
|
+
bugfix/header-layout
|
|
218
|
+
feature/experimental
|
|
219
|
+
|
|
220
|
+
Proceed with deletion? (y/N): y
|
|
221
|
+
|
|
222
|
+
โ
Deleted branch feature/user-authentication
|
|
223
|
+
โ
Deleted branch bugfix/header-layout
|
|
224
|
+
โ
Deleted branch feature/experimental
|
|
225
|
+
โ
Successfully deleted 3 branches
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
#### Untracked Mode - Local-only branches
|
|
229
|
+
|
|
230
|
+
```
|
|
231
|
+
๐ Scanning repository: my-project
|
|
232
|
+
โ
Dependencies checked
|
|
233
|
+
โ
Current branch: main
|
|
234
|
+
โ
Finished processing 2 untracked branches
|
|
235
|
+
|
|
236
|
+
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
237
|
+
Branch Icon Status
|
|
238
|
+
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
239
|
+
debug/test-branch ๐ท๏ธ Untracked
|
|
240
|
+
temp/experiment ๐ท๏ธ Untracked
|
|
241
|
+
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
242
|
+
|
|
243
|
+
โ The following untracked local branches will be deleted:
|
|
244
|
+
debug/test-branch
|
|
245
|
+
temp/experiment
|
|
246
|
+
|
|
247
|
+
Proceed with deletion of untracked branches? (y/N): y
|
|
248
|
+
|
|
249
|
+
โ
Deleted branch debug/test-branch
|
|
250
|
+
โ
Deleted branch temp/experiment
|
|
251
|
+
โ
Successfully deleted 2 branches
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
## How It Works
|
|
255
|
+
|
|
256
|
+
### Main Mode (Default)
|
|
257
|
+
|
|
258
|
+
1. **Dependency Check**: Verifies you're in a Git repository and GitHub CLI is installed/authenticated
|
|
259
|
+
2. **Current Branch Detection**: Identifies and protects your current working branch
|
|
260
|
+
3. **Tracked Branch Discovery**: Lists only branches that have remote tracking (excluding `main`, `master`, current branch)
|
|
261
|
+
- Uses Git's upstream tracking information to accurately detect tracked branches
|
|
262
|
+
- Works with any remote name (origin, upstream, etc.) - not hard-coded to "origin"
|
|
263
|
+
- Robust parsing handles multiple consecutive spaces in Git output
|
|
264
|
+
4. **PR Status Check**: Queries GitHub API for each branch's PR status with progress indication
|
|
265
|
+
5. **Results Display**: Shows a comprehensive status table with clear visual indicators
|
|
266
|
+
6. **Safe Deletion**: Only deletes branches with merged or closed PRs (with user confirmation)
|
|
267
|
+
|
|
268
|
+
### Untracked Mode (`--untracked-only`)
|
|
269
|
+
|
|
270
|
+
1. **Dependency Check**: Verifies you're in a Git repository (GitHub CLI not required)
|
|
271
|
+
2. **Current Branch Detection**: Identifies and protects your current working branch
|
|
272
|
+
3. **Untracked Branch Discovery**: Lists only local branches without remote tracking (excluding `main`, `master`, current branch)
|
|
273
|
+
- Uses Git's upstream tracking information to accurately detect untracked branches
|
|
274
|
+
- Works with any remote name (origin, upstream, etc.) - not hard-coded to "origin"
|
|
275
|
+
- Robust parsing handles multiple consecutive spaces in Git output
|
|
276
|
+
4. **Results Display**: Shows untracked branches with ๐ท๏ธ icon
|
|
277
|
+
5. **Safe Deletion**: Deletes untracked branches (with user confirmation)
|
|
278
|
+
|
|
279
|
+
## Branch Status Indicators
|
|
280
|
+
|
|
281
|
+
### Main Mode
|
|
282
|
+
|
|
283
|
+
| Icon | Status | Description |
|
|
284
|
+
| ---- | ------ | ------------------------------------------------------ |
|
|
285
|
+
| โ
| Merged | PR has been merged - branch is safe to delete |
|
|
286
|
+
| ๐ | Closed | PR has been closed without merging - branch is safe to delete |
|
|
287
|
+
| โณ | Open | PR is still open - branch will be preserved |
|
|
288
|
+
| โ | No PR | No PR found for this branch - branch will be preserved |
|
|
289
|
+
|
|
290
|
+
### Untracked Mode
|
|
291
|
+
|
|
292
|
+
| Icon | Status | Description |
|
|
293
|
+
| ---- | --------- | ----------------------------------------------------- |
|
|
294
|
+
| ๐ท๏ธ | Untracked | Local branch without remote tracking - safe to delete |
|
|
295
|
+
|
|
296
|
+
## Safety Features
|
|
297
|
+
|
|
298
|
+
- **Protected Branches**: Never touches `main`, `master`, or your current branch
|
|
299
|
+
- **Confirmation Required**: Always asks before deleting (unless in dry-run mode)
|
|
300
|
+
- **GitHub Verification**: Only deletes branches with confirmed merged or closed PRs (main mode)
|
|
301
|
+
- **Untracked Detection**: Only deletes local branches without remote tracking (untracked mode)
|
|
302
|
+
- **Robust Parsing**: Handles various Git output formats including multiple consecutive spaces
|
|
303
|
+
- **Error Handling**: Graceful failure handling with informative messages
|
|
304
|
+
- **Progress Feedback**: Real-time spinner shows current operation status
|
|
305
|
+
- **Smart UX**: Main mode focuses on PR cleanup, untracked mode focuses on local cleanup
|
|
306
|
+
|
|
307
|
+
## Troubleshooting
|
|
308
|
+
|
|
309
|
+
### Common Issues
|
|
310
|
+
|
|
311
|
+
**"Not in a git repository"**
|
|
312
|
+
|
|
313
|
+
- Make sure you're running the command from within a Git repository
|
|
314
|
+
|
|
315
|
+
**"GitHub CLI (gh) is not installed"**
|
|
316
|
+
|
|
317
|
+
- Install GitHub CLI following the prerequisites section above
|
|
318
|
+
|
|
319
|
+
**"GitHub CLI is not authenticated"**
|
|
320
|
+
|
|
321
|
+
- Run `gh auth login` and follow the authentication process
|
|
322
|
+
|
|
323
|
+
**"Failed to get current branch"**
|
|
324
|
+
|
|
325
|
+
- Ensure you're in a valid Git repository with at least one commit
|
|
326
|
+
|
|
327
|
+
**Branch names appearing garbled in terminal**
|
|
328
|
+
|
|
329
|
+
- This was a known issue with the spinner display that has been fixed in recent versions
|
|
330
|
+
|
|
331
|
+
### Debug Mode
|
|
332
|
+
|
|
333
|
+
For troubleshooting, use verbose mode to see detailed processing:
|
|
334
|
+
|
|
335
|
+
```bash
|
|
336
|
+
# Main mode with verbose output
|
|
337
|
+
git-cleanup-merged --verbose --dry-run
|
|
338
|
+
|
|
339
|
+
# Untracked mode with verbose output
|
|
340
|
+
git-cleanup-merged --untracked-only --verbose --dry-run
|
|
341
|
+
|
|
342
|
+
# Same as above using shorthand
|
|
343
|
+
git-cleanup-merged -u -v -n
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
## Development
|
|
347
|
+
|
|
348
|
+
### Project Structure
|
|
349
|
+
|
|
350
|
+
```
|
|
351
|
+
git-cleanup-merged/
|
|
352
|
+
โโโ __tests__/ # Test files
|
|
353
|
+
โ โโโ index.test.js # Main functionality tests
|
|
354
|
+
โ โโโ spinner.test.js # Spinner component tests
|
|
355
|
+
โ โโโ utils.test.js # Utility function tests
|
|
356
|
+
โโโ coverage/ # Coverage reports (generated)
|
|
357
|
+
|
|
358
|
+
โโโ src/
|
|
359
|
+
โ โโโ bin.js # CLI entry point
|
|
360
|
+
โ โโโ index.js # Main GitCleanupTool class
|
|
361
|
+
โ โโโ utils/
|
|
362
|
+
โ โโโ index.js # Utility functions
|
|
363
|
+
โ โโโ spinner.js # Spinner component
|
|
364
|
+
โโโ package.json
|
|
365
|
+
โโโ package-lock.json
|
|
366
|
+
โโโ eslint.config.mjs # ESLint configuration
|
|
367
|
+
โโโ .prettierrc # Prettier configuration
|
|
368
|
+
โโโ README.md
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
### Key Components
|
|
372
|
+
|
|
373
|
+
- **GitCleanupTool**: Main class that orchestrates the cleanup process
|
|
374
|
+
- **Spinner**: Enhanced spinner class with proper terminal handling and testability
|
|
375
|
+
- **CLI Entry Point**: Separate `bin.js` file for clean CLI execution
|
|
376
|
+
- **Test Suite**: Comprehensive tests covering all functionality and edge cases
|
|
377
|
+
|
|
378
|
+
### Contributing
|
|
379
|
+
|
|
380
|
+
1. Fork the repository
|
|
381
|
+
2. Create a feature branch
|
|
382
|
+
3. Make your changes
|
|
383
|
+
4. Test thoroughly with both `--dry-run` and actual deletion
|
|
384
|
+
5. Submit a pull request
|
|
385
|
+
|
|
386
|
+
### Running Tests
|
|
387
|
+
|
|
388
|
+
```bash
|
|
389
|
+
# Run all tests
|
|
390
|
+
npm test
|
|
391
|
+
|
|
392
|
+
# Run tests with coverage
|
|
393
|
+
npm run test:coverage
|
|
394
|
+
|
|
395
|
+
# Run linting
|
|
396
|
+
npm run lint
|
|
397
|
+
|
|
398
|
+
# Run linting with auto-fix
|
|
399
|
+
npm run lint -- --fix
|
|
400
|
+
|
|
401
|
+
# Format code
|
|
402
|
+
npm run format
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
## License
|
|
406
|
+
|
|
407
|
+
MIT License - see LICENSE file for details.
|
|
408
|
+
|
|
409
|
+
## ๐ Changelog
|
|
410
|
+
|
|
411
|
+
### v1.3.1
|
|
412
|
+
|
|
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
|
|
419
|
+
|
|
420
|
+
### v1.3.0
|
|
421
|
+
|
|
422
|
+
- ๐ท๏ธ **New Feature**: Added `--untracked-only` mode to clean up local branches without remote tracking
|
|
423
|
+
- ๐ง **Improved UX**: Main mode now only shows tracked branches with PRs, untracked mode handles local-only branches
|
|
424
|
+
- ๐ง **Smart Dependencies**: GitHub CLI only required for main mode, not for untracked mode
|
|
425
|
+
- ๐ก **Helpful Guidance**: Suggests `--untracked-only` when no tracked branches found in main mode
|
|
426
|
+
- ๐ฏ **100% Test Coverage**: Achieved complete test coverage with 97 comprehensive test cases
|
|
427
|
+
- ๐ **Bug Fixes**: Fixed branch tracking detection logic and improved deletion feedback
|
|
428
|
+
- ๐ **Enhanced Testing**: Added tests for all new functionality and edge cases
|
|
429
|
+
- ๐ง **Critical Fix**: Fixed branch tracking detection to use proper Git upstream relationships instead of hard-coded remote names
|
|
430
|
+
- ๐ ๏ธ **Robust Parsing**: Fixed whitespace parsing bug that could misclassify tracked branches as untracked
|
|
431
|
+
|
|
432
|
+
### v1.2.1
|
|
433
|
+
|
|
434
|
+
- ๐ง **Node.js Compatibility**: Updated to require Node.js 18+ for ESLint 9.x compatibility
|
|
435
|
+
- ๐งช **CI Updates**: Removed Node.js 16.x from CI matrix (reached end-of-life)
|
|
436
|
+
- ๐ฆ **Dependencies**: Updated to use modern ESLint flat config format
|
|
437
|
+
- ๐ฆ **Workflow Optimization**: CI now only runs on pull requests and on push to `main`/`master` to avoid duplicate runs for feature branches
|
|
438
|
+
|
|
439
|
+
### v1.2.0
|
|
440
|
+
|
|
441
|
+
- ๐ฏ **100% Test Coverage**: Achieved complete test coverage across all code paths
|
|
442
|
+
- ๐งช **Enhanced Test Suite**: Added 76 comprehensive test cases covering all functionality
|
|
443
|
+
- ๐ง **Code Quality**: Added ESLint and Prettier for consistent code style
|
|
444
|
+
- ๐๏ธ **Architecture Improvements**: Separated CLI entry point for better testability
|
|
445
|
+
- ๐ **Bug Fixes**: Fixed spinner component and improved error handling
|
|
446
|
+
- ๐ **Coverage Thresholds**: Set minimum 75% branch coverage requirement
|
|
447
|
+
|
|
448
|
+
### v1.1.0
|
|
449
|
+
|
|
450
|
+
- Fixed spinner display issue where branch names would merge together
|
|
451
|
+
- Improved terminal output clearing with proper ANSI escape sequences
|
|
452
|
+
- Enhanced progress indicators during branch checking and deletion
|
|
453
|
+
- Added directory argument support for operating on different repositories
|
|
454
|
+
|
|
455
|
+
### v1.0.0
|
|
456
|
+
|
|
457
|
+
- Initial release
|
|
458
|
+
- Basic branch cleanup functionality
|
|
459
|
+
- GitHub PR integration
|
|
460
|
+
- Dry-run mode
|
|
461
|
+
- Verbose logging
|
|
462
|
+
- Interactive spinner with progress feedback
|
|
463
|
+
|
|
464
|
+
## ๐ค Support
|
|
465
|
+
|
|
466
|
+
- ๐ **Bug Reports**: [GitHub Issues](https://github.com/ondro/git-cleanup-merged/issues)
|
|
467
|
+
- ๐ก **Feature Requests**: [GitHub Discussions](https://github.com/ondro/git-cleanup-merged/discussions)
|
|
468
|
+
- ๐ง **Contact**: ondrovic@gmail.com
|
|
469
|
+
- ๐ **Documentation**: This README contains comprehensive usage examples and troubleshooting
|
|
470
|
+
|
|
471
|
+
---
|
|
472
|
+
|
|
473
|
+
**โ ๏ธ Important**: Always run with `--dry-run` first to preview changes before actual deletion. This tool is designed to be safe, but you should always verify the branches it wants to delete.
|
package/package.json
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "git-cleanup-merged",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "Clean up local Git branches that have merged PRs on GitHub",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"git-cleanup-merged": "./src/bin.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"test": "jest",
|
|
11
|
+
"test:coverage": "jest --coverage",
|
|
12
|
+
"test:coverage:ci": "jest --coverage && node scripts/fix-junit.js",
|
|
13
|
+
"lint": "eslint .",
|
|
14
|
+
"format": "prettier --write ."
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"git",
|
|
18
|
+
"github",
|
|
19
|
+
"cli",
|
|
20
|
+
"cleanup",
|
|
21
|
+
"branches",
|
|
22
|
+
"merge"
|
|
23
|
+
],
|
|
24
|
+
"author": "Chris Ondrovic",
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=18.0.0"
|
|
28
|
+
},
|
|
29
|
+
"preferGlobal": true,
|
|
30
|
+
"files": [
|
|
31
|
+
"src/**/*.js",
|
|
32
|
+
"index.js",
|
|
33
|
+
"README.md",
|
|
34
|
+
"LICENSE"
|
|
35
|
+
],
|
|
36
|
+
"repository": {
|
|
37
|
+
"type": "git",
|
|
38
|
+
"url": "https://github.com/ondro/git-cleanup-merged.git"
|
|
39
|
+
},
|
|
40
|
+
"bugs": {
|
|
41
|
+
"url": "https://github.com/ondro/git-cleanup-merged/issues"
|
|
42
|
+
},
|
|
43
|
+
"homepage": "https://github.com/ondro/git-cleanup-merged#readme",
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@eslint/js": "^9.30.1",
|
|
46
|
+
"eslint": "^9.30.1",
|
|
47
|
+
"globals": "^16.3.0",
|
|
48
|
+
"jest": "^30.0.3",
|
|
49
|
+
"jest-junit": "^16.0.0",
|
|
50
|
+
"prettier": "^3.6.2"
|
|
51
|
+
},
|
|
52
|
+
"jest": {
|
|
53
|
+
"coverageThreshold": {
|
|
54
|
+
"global": {
|
|
55
|
+
"branches": 75
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
"collectCoverageFrom": [
|
|
59
|
+
"src/**/*.js",
|
|
60
|
+
"!src/bin.js"
|
|
61
|
+
],
|
|
62
|
+
"coverageReporters": [
|
|
63
|
+
"text",
|
|
64
|
+
"lcov",
|
|
65
|
+
"html"
|
|
66
|
+
],
|
|
67
|
+
"reporters": [
|
|
68
|
+
"default",
|
|
69
|
+
[
|
|
70
|
+
"jest-junit",
|
|
71
|
+
{
|
|
72
|
+
"outputDirectory": "coverage",
|
|
73
|
+
"outputName": "junit.xml",
|
|
74
|
+
"classNameTemplate": "{filepath}",
|
|
75
|
+
"titleTemplate": "{title}",
|
|
76
|
+
"ancestorSeparator": " โบ ",
|
|
77
|
+
"usePathForSuiteName": true,
|
|
78
|
+
"useFullFilePath": true,
|
|
79
|
+
"classNameTemplate": "{filepath}"
|
|
80
|
+
}
|
|
81
|
+
]
|
|
82
|
+
]
|
|
83
|
+
}
|
|
84
|
+
}
|
package/src/bin.js
ADDED
package/src/index.js
ADDED
|
@@ -0,0 +1,605 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const { execSync } = require("child_process");
|
|
4
|
+
const readline = require("readline");
|
|
5
|
+
const clearTerminal = require("./utils");
|
|
6
|
+
const Spinner = require("./utils/spinner");
|
|
7
|
+
const path = require("path");
|
|
8
|
+
|
|
9
|
+
// Colors for terminal output
|
|
10
|
+
const colors = {
|
|
11
|
+
green: "\x1b[32m",
|
|
12
|
+
red: "\x1b[31m",
|
|
13
|
+
yellow: "\x1b[33m",
|
|
14
|
+
blue: "\x1b[34m",
|
|
15
|
+
reset: "\x1b[0m",
|
|
16
|
+
bold: "\x1b[1m",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
class GitCleanupTool {
|
|
20
|
+
constructor() {
|
|
21
|
+
this.dryRun = false;
|
|
22
|
+
this.verbose = false;
|
|
23
|
+
this.untrackedOnly = false;
|
|
24
|
+
this.countOnly = false;
|
|
25
|
+
this.branchesToDelete = [];
|
|
26
|
+
this.prResults = [];
|
|
27
|
+
this.currentBranch = "";
|
|
28
|
+
this.spinner = new Spinner();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Helper method for minimum spinner visibility
|
|
32
|
+
async sleep(ms) {
|
|
33
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async execCommand(command, options = {}) {
|
|
37
|
+
try {
|
|
38
|
+
const result = execSync(command, {
|
|
39
|
+
encoding: "utf8",
|
|
40
|
+
stdio: options.silent ? "pipe" : "inherit",
|
|
41
|
+
...options,
|
|
42
|
+
});
|
|
43
|
+
return result.trim();
|
|
44
|
+
} catch (error) {
|
|
45
|
+
if (!options.silent) {
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async checkDependencies() {
|
|
53
|
+
this.spinner.updateMessage("Checking dependencies...");
|
|
54
|
+
this.spinner.start();
|
|
55
|
+
|
|
56
|
+
// 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");
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
64
|
+
await this.sleep(300); // Minimum spinner time
|
|
65
|
+
|
|
66
|
+
// Only check GitHub CLI dependencies if not in untracked-only or count-only mode
|
|
67
|
+
if (!this.untrackedOnly && !this.countOnly) {
|
|
68
|
+
this.spinner.updateMessage("Checking GitHub CLI...");
|
|
69
|
+
// 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
|
+
);
|
|
74
|
+
process.exit(1);
|
|
75
|
+
}
|
|
76
|
+
await this.sleep(200);
|
|
77
|
+
|
|
78
|
+
this.spinner.updateMessage("Verifying GitHub authentication...");
|
|
79
|
+
// 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
|
+
);
|
|
86
|
+
process.exit(1);
|
|
87
|
+
}
|
|
88
|
+
await this.sleep(200);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
this.spinner.success("Dependencies checked");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async getCurrentBranch() {
|
|
95
|
+
this.spinner.updateMessage("Getting current branch...");
|
|
96
|
+
this.spinner.start();
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
this.currentBranch = await this.execCommand("git branch --show-current", {
|
|
100
|
+
silent: true,
|
|
101
|
+
});
|
|
102
|
+
await this.sleep(200); // Let the spinner show
|
|
103
|
+
this.spinner.success(`Current branch: ${this.currentBranch}`);
|
|
104
|
+
} catch {
|
|
105
|
+
this.spinner.error("Failed to get current branch");
|
|
106
|
+
process.exit(1);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
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() {
|
|
132
|
+
try {
|
|
133
|
+
// Get all local branches with their upstream tracking information
|
|
134
|
+
const branches = await this.execCommand(
|
|
135
|
+
'git for-each-ref --format="%(refname:short) %(upstream:short)" refs/heads/',
|
|
136
|
+
{ silent: true },
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
const trackedBranches = [];
|
|
140
|
+
|
|
141
|
+
const branchLines = branches
|
|
142
|
+
.split("\n")
|
|
143
|
+
.filter((line) => line.trim() !== "")
|
|
144
|
+
.map((line) => line.trim());
|
|
145
|
+
|
|
146
|
+
for (const line of branchLines) {
|
|
147
|
+
// Use regex to split on one or more whitespace characters
|
|
148
|
+
const parts = line.split(/\s+/);
|
|
149
|
+
const branchName = parts[0];
|
|
150
|
+
const upstream = parts.slice(1).join(" "); // Join remaining parts in case upstream contains spaces
|
|
151
|
+
|
|
152
|
+
// Skip main, master, and current branch
|
|
153
|
+
if (["main", "master", this.currentBranch].includes(branchName)) {
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// If upstream is not empty, the branch is tracking a remote branch
|
|
158
|
+
if (upstream && upstream.trim() !== "") {
|
|
159
|
+
trackedBranches.push(branchName);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return trackedBranches;
|
|
164
|
+
} catch {
|
|
165
|
+
this.spinner.error("Failed to get tracked branches");
|
|
166
|
+
return [];
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
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
|
+
}
|
|
195
|
+
|
|
196
|
+
// If upstream is empty, the branch is not tracking any remote branch
|
|
197
|
+
if (!upstream || upstream.trim() === "") {
|
|
198
|
+
untrackedBranches.push(branchName);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return untrackedBranches;
|
|
203
|
+
} catch {
|
|
204
|
+
this.spinner.error("Failed to get untracked branches");
|
|
205
|
+
return [];
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async countBranches() {
|
|
210
|
+
this.spinner.updateMessage("Counting branches...");
|
|
211
|
+
this.spinner.start();
|
|
212
|
+
|
|
213
|
+
const trackedBranches = await this.getTrackedBranches();
|
|
214
|
+
const untrackedBranches = await this.getUntrackedBranches();
|
|
215
|
+
const total = trackedBranches.length + untrackedBranches.length;
|
|
216
|
+
|
|
217
|
+
await this.sleep(300); // Minimum spinner time
|
|
218
|
+
this.spinner.success("Branch count complete");
|
|
219
|
+
|
|
220
|
+
console.log("");
|
|
221
|
+
console.log(`${colors.bold}๐ Branch Count Summary${colors.reset}`);
|
|
222
|
+
console.log(` Total branches: ${total}`);
|
|
223
|
+
console.log(` Tracked: ${trackedBranches.length}`);
|
|
224
|
+
console.log(` Untracked: ${untrackedBranches.length}`);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async getPRStatus(branch) {
|
|
228
|
+
try {
|
|
229
|
+
const result = await this.execCommand(
|
|
230
|
+
`gh pr view "${branch}" --json state --jq .state`,
|
|
231
|
+
{ silent: true },
|
|
232
|
+
);
|
|
233
|
+
return result;
|
|
234
|
+
} catch {
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async checkBranches() {
|
|
240
|
+
this.spinner.updateMessage("Fetching tracked branches...");
|
|
241
|
+
this.spinner.start();
|
|
242
|
+
|
|
243
|
+
const branches = await this.getTrackedBranches();
|
|
244
|
+
|
|
245
|
+
if (branches.length === 0) {
|
|
246
|
+
this.spinner.warning("No tracked branches found to check.");
|
|
247
|
+
this.spinner.info(
|
|
248
|
+
"You might want to use --untracked-only to see local-only branches.",
|
|
249
|
+
);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
this.spinner.updateMessage(
|
|
254
|
+
`Checking ${branches.length} tracked branches against GitHub...`,
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
// Process each branch
|
|
258
|
+
for (let i = 0; i < branches.length; i++) {
|
|
259
|
+
const branch = branches[i];
|
|
260
|
+
this.spinner.updateMessage(
|
|
261
|
+
`Checking branch ${i + 1}/${branches.length}: ${branch}`,
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
const prStatus = await this.getPRStatus(branch);
|
|
265
|
+
|
|
266
|
+
// Only call debug if verbose is enabled to avoid stopping spinner
|
|
267
|
+
if (this.verbose) {
|
|
268
|
+
this.spinner.debug(
|
|
269
|
+
`Checking branch ${branch} -> PR state: ${prStatus || "unknown"}`,
|
|
270
|
+
this.verbose,
|
|
271
|
+
);
|
|
272
|
+
// Restart spinner after debug output
|
|
273
|
+
this.spinner.updateMessage(
|
|
274
|
+
`Checking branch ${i + 1}/${branches.length}: ${branch}`,
|
|
275
|
+
);
|
|
276
|
+
this.spinner.start();
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
let icon, label;
|
|
280
|
+
switch (prStatus) {
|
|
281
|
+
case "MERGED":
|
|
282
|
+
icon = "โ
";
|
|
283
|
+
label = "Merged";
|
|
284
|
+
this.branchesToDelete.push(branch);
|
|
285
|
+
break;
|
|
286
|
+
case "CLOSED":
|
|
287
|
+
icon = "๐";
|
|
288
|
+
label = "Closed";
|
|
289
|
+
this.branchesToDelete.push(branch);
|
|
290
|
+
break;
|
|
291
|
+
case "OPEN":
|
|
292
|
+
icon = "โณ";
|
|
293
|
+
label = "Open";
|
|
294
|
+
break;
|
|
295
|
+
default:
|
|
296
|
+
icon = "โ";
|
|
297
|
+
label = "No PR";
|
|
298
|
+
break;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
this.prResults.push({ branch, icon, label });
|
|
302
|
+
|
|
303
|
+
// Add a small delay so we can see the spinner working
|
|
304
|
+
await this.sleep(300);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
this.spinner.success(
|
|
308
|
+
`Finished checking ${branches.length} tracked branches`,
|
|
309
|
+
);
|
|
310
|
+
console.log(""); // Empty line for spacing
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async checkUntrackedBranches() {
|
|
314
|
+
this.spinner.updateMessage("Fetching untracked local branches...");
|
|
315
|
+
this.spinner.start();
|
|
316
|
+
|
|
317
|
+
const untrackedBranches = await this.getUntrackedBranches();
|
|
318
|
+
|
|
319
|
+
if (untrackedBranches.length === 0) {
|
|
320
|
+
this.spinner.warning("No untracked local branches found.");
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
this.spinner.updateMessage(
|
|
325
|
+
`Found ${untrackedBranches.length} untracked local branches...`,
|
|
326
|
+
);
|
|
327
|
+
|
|
328
|
+
// Process each untracked branch
|
|
329
|
+
for (let i = 0; i < untrackedBranches.length; i++) {
|
|
330
|
+
const branch = untrackedBranches[i];
|
|
331
|
+
this.spinner.updateMessage(
|
|
332
|
+
`Processing untracked branch ${i + 1}/${untrackedBranches.length}: ${branch}`,
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
// Only call debug if verbose is enabled to avoid stopping spinner
|
|
336
|
+
if (this.verbose) {
|
|
337
|
+
this.spinner.debug(
|
|
338
|
+
`Processing untracked branch ${branch}`,
|
|
339
|
+
this.verbose,
|
|
340
|
+
);
|
|
341
|
+
// Restart spinner after debug output
|
|
342
|
+
this.spinner.updateMessage(
|
|
343
|
+
`Processing untracked branch ${i + 1}/${untrackedBranches.length}: ${branch}`,
|
|
344
|
+
);
|
|
345
|
+
this.spinner.start();
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const icon = "๐ท๏ธ";
|
|
349
|
+
const label = "Untracked";
|
|
350
|
+
this.branchesToDelete.push(branch);
|
|
351
|
+
|
|
352
|
+
this.prResults.push({ branch, icon, label });
|
|
353
|
+
|
|
354
|
+
// Add a small delay so we can see the spinner working
|
|
355
|
+
await this.sleep(300);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
this.spinner.success(
|
|
359
|
+
`Finished processing ${untrackedBranches.length} untracked branches`,
|
|
360
|
+
);
|
|
361
|
+
console.log(""); // Empty line for spacing
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
displayResults() {
|
|
365
|
+
this.spinner.log("โ".repeat(60));
|
|
366
|
+
this.spinner.log(
|
|
367
|
+
`${"Branch".padEnd(40)} ${"Icon".padEnd(6)} ${"Status".padEnd(10)}`,
|
|
368
|
+
);
|
|
369
|
+
this.spinner.log("โ".repeat(60));
|
|
370
|
+
|
|
371
|
+
this.prResults.forEach(({ branch, icon, label }) => {
|
|
372
|
+
this.spinner.log(
|
|
373
|
+
`${branch.padEnd(40)} ${icon.padEnd(6)} ${label.padEnd(10)}`,
|
|
374
|
+
);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
this.spinner.log("โ".repeat(60));
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async deleteBranches() {
|
|
381
|
+
if (this.branchesToDelete.length === 0) {
|
|
382
|
+
if (this.untrackedOnly) {
|
|
383
|
+
this.spinner.warning("No untracked local branches found.");
|
|
384
|
+
} else {
|
|
385
|
+
this.spinner.warning("No branches with merged or closed PRs found.");
|
|
386
|
+
}
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
console.log(""); // Empty line for spacing
|
|
391
|
+
if (this.dryRun) {
|
|
392
|
+
this.spinner.warning("DRY RUN โ branches eligible for deletion:");
|
|
393
|
+
} else {
|
|
394
|
+
if (this.untrackedOnly) {
|
|
395
|
+
this.spinner.error(
|
|
396
|
+
"The following untracked local branches will be deleted:",
|
|
397
|
+
);
|
|
398
|
+
} else {
|
|
399
|
+
this.spinner.error(
|
|
400
|
+
"The following branches have merged or closed PRs and will be deleted:",
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// List branches without icons since the spinner methods handle them
|
|
406
|
+
this.branchesToDelete.forEach((branch) => {
|
|
407
|
+
this.spinner.log(` ${branch}`, colors.red);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
if (this.dryRun) {
|
|
411
|
+
if (this.untrackedOnly) {
|
|
412
|
+
this.spinner.info(
|
|
413
|
+
"Run without --dry-run to actually delete untracked branches.",
|
|
414
|
+
);
|
|
415
|
+
} else {
|
|
416
|
+
this.spinner.info("Run without --dry-run to actually delete them.");
|
|
417
|
+
}
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const confirmationMessage = this.untrackedOnly
|
|
422
|
+
? "Proceed with deletion of untracked branches? (y/N): "
|
|
423
|
+
: "Proceed with deletion? (y/N): ";
|
|
424
|
+
|
|
425
|
+
const confirmed = await this.askConfirmation(confirmationMessage);
|
|
426
|
+
|
|
427
|
+
if (confirmed) {
|
|
428
|
+
console.log(""); // Empty line for spacing
|
|
429
|
+
|
|
430
|
+
let deletedCount = 0;
|
|
431
|
+
let failedBranches = [];
|
|
432
|
+
|
|
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();
|
|
439
|
+
|
|
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
|
+
}
|
|
456
|
+
|
|
457
|
+
// Final status
|
|
458
|
+
if (failedBranches.length === 0) {
|
|
459
|
+
this.spinner.success(`Successfully deleted ${deletedCount} branches`);
|
|
460
|
+
} else {
|
|
461
|
+
this.spinner.warning(
|
|
462
|
+
`Deleted ${deletedCount} branches, ${failedBranches.length} failed`,
|
|
463
|
+
);
|
|
464
|
+
failedBranches.forEach((branch) => {
|
|
465
|
+
this.spinner.log(` Failed: ${branch}`, colors.red);
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
} else {
|
|
469
|
+
this.spinner.info("Cancelled.");
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
async askConfirmation(question) {
|
|
474
|
+
const rl = readline.createInterface({
|
|
475
|
+
input: process.stdin,
|
|
476
|
+
output: process.stdout,
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
return new Promise((resolve) => {
|
|
480
|
+
rl.question(question, (answer) => {
|
|
481
|
+
rl.close();
|
|
482
|
+
resolve(answer.toLowerCase() === "y" || answer.toLowerCase() === "yes");
|
|
483
|
+
});
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
showHelp() {
|
|
488
|
+
console.log(`
|
|
489
|
+
${colors.bold}git-cleanup-merged${colors.reset} - Clean up merged Git branches
|
|
490
|
+
|
|
491
|
+
${colors.bold}USAGE:${colors.reset}
|
|
492
|
+
git-cleanup-merged [DIRECTORY] [OPTIONS]
|
|
493
|
+
|
|
494
|
+
${colors.bold}DIRECTORY (optional):${colors.reset}
|
|
495
|
+
Path to a git repository to operate on. Defaults to the current directory if omitted.
|
|
496
|
+
|
|
497
|
+
${colors.bold}OPTIONS:${colors.reset}
|
|
498
|
+
-n, --dry-run Show what would be deleted without actually deleting
|
|
499
|
+
-v, --verbose Show detailed information during processing
|
|
500
|
+
-u, --untracked-only Only process untracked local branches (no remote tracking branch)
|
|
501
|
+
-c, --count Display branch count summary and exit (no deletion)
|
|
502
|
+
-h, --help Show this help message
|
|
503
|
+
|
|
504
|
+
${colors.bold}DESCRIPTION:${colors.reset}
|
|
505
|
+
This tool checks your local Git branches against GitHub PRs to find
|
|
506
|
+
branches that have been merged and are safe to delete locally.
|
|
507
|
+
|
|
508
|
+
When using --untracked-only, it will only process local branches that
|
|
509
|
+
don't have a corresponding remote tracking branch.
|
|
510
|
+
|
|
511
|
+
${colors.bold}REQUIREMENTS:${colors.reset}
|
|
512
|
+
- Git repository
|
|
513
|
+
- GitHub CLI (gh) installed and authenticated (only for normal mode)
|
|
514
|
+
- Internet connection to check GitHub PR status (only for normal mode)
|
|
515
|
+
|
|
516
|
+
${colors.bold}EXAMPLES:${colors.reset}
|
|
517
|
+
git-cleanup-merged # Clean up merged branches in current directory
|
|
518
|
+
git-cleanup-merged ../my/repo # Clean up merged branches in another repo
|
|
519
|
+
git-cleanup-merged --dry-run # Preview what would be deleted
|
|
520
|
+
git-cleanup-merged --verbose # Show detailed processing info
|
|
521
|
+
git-cleanup-merged --untracked-only # Clean up untracked local branches only
|
|
522
|
+
git-cleanup-merged -u # Same as --untracked-only
|
|
523
|
+
git-cleanup-merged --untracked-only --dry-run # Preview untracked branches
|
|
524
|
+
git-cleanup-merged -u -n # Same as above with shorthand
|
|
525
|
+
git-cleanup-merged --count # Display branch count summary
|
|
526
|
+
git-cleanup-merged -c # Same as --count
|
|
527
|
+
`);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
parseArguments() {
|
|
531
|
+
const args = process.argv.slice(2);
|
|
532
|
+
// Check for a directory as the first positional argument (not a flag)
|
|
533
|
+
if (args[0] && !args[0].startsWith("-")) {
|
|
534
|
+
try {
|
|
535
|
+
process.chdir(args[0]);
|
|
536
|
+
} catch {
|
|
537
|
+
this.spinner.error(`Failed to change directory to: ${args[0]}`);
|
|
538
|
+
process.exit(1);
|
|
539
|
+
}
|
|
540
|
+
args.shift();
|
|
541
|
+
}
|
|
542
|
+
for (const arg of args) {
|
|
543
|
+
switch (arg) {
|
|
544
|
+
case "--dry-run":
|
|
545
|
+
case "-n":
|
|
546
|
+
this.dryRun = true;
|
|
547
|
+
break;
|
|
548
|
+
case "--verbose":
|
|
549
|
+
case "-v":
|
|
550
|
+
this.verbose = true;
|
|
551
|
+
break;
|
|
552
|
+
case "--untracked-only":
|
|
553
|
+
case "-u":
|
|
554
|
+
this.untrackedOnly = true;
|
|
555
|
+
break;
|
|
556
|
+
case "--count":
|
|
557
|
+
case "-c":
|
|
558
|
+
this.countOnly = true;
|
|
559
|
+
break;
|
|
560
|
+
case "--help":
|
|
561
|
+
case "-h":
|
|
562
|
+
this.showHelp();
|
|
563
|
+
return 0;
|
|
564
|
+
default:
|
|
565
|
+
this.spinner.error(`Unknown option: ${arg}`);
|
|
566
|
+
this.spinner.info("Use --help for usage information.");
|
|
567
|
+
process.exit(1);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
async run() {
|
|
573
|
+
clearTerminal();
|
|
574
|
+
// Display just the directory name being scanned
|
|
575
|
+
this.spinner.log(
|
|
576
|
+
`๐ Scanning repository: ${path.basename(process.cwd())}`,
|
|
577
|
+
"\x1b[34m",
|
|
578
|
+
);
|
|
579
|
+
try {
|
|
580
|
+
this.parseArguments();
|
|
581
|
+
await this.checkDependencies();
|
|
582
|
+
await this.getCurrentBranch();
|
|
583
|
+
|
|
584
|
+
// Handle count-only mode and exit early
|
|
585
|
+
if (this.countOnly) {
|
|
586
|
+
await this.countBranches();
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
if (this.untrackedOnly) {
|
|
591
|
+
await this.checkUntrackedBranches();
|
|
592
|
+
} else {
|
|
593
|
+
await this.checkBranches();
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
this.displayResults();
|
|
597
|
+
await this.deleteBranches();
|
|
598
|
+
} catch (error) {
|
|
599
|
+
this.spinner.error(`An error occurred: ${error.message}`);
|
|
600
|
+
process.exit(1);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
module.exports = GitCleanupTool;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
const { execSync } = require("child_process");
|
|
2
|
+
|
|
3
|
+
function clearTerminal() {
|
|
4
|
+
// Clear screen and scroll-back buffer on all platforms
|
|
5
|
+
if (process.platform === "win32") {
|
|
6
|
+
// Windows: Use cls command and clear scroll-back
|
|
7
|
+
try {
|
|
8
|
+
execSync("cls", { stdio: "inherit" });
|
|
9
|
+
} catch {
|
|
10
|
+
// Fallback for Windows
|
|
11
|
+
process.stdout.write("\x1B[2J\x1B[0f\x1B[3J");
|
|
12
|
+
}
|
|
13
|
+
} else {
|
|
14
|
+
// Unix-like systems (macOS, Linux): Clear screen and scroll-back
|
|
15
|
+
process.stdout.write("\x1B[2J\x1B[0f\x1B[3J");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Additional reset for cursor position
|
|
19
|
+
process.stdout.write("\x1B[H");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
module.exports = clearTerminal;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
const colors = {
|
|
2
|
+
green: "\x1b[32m",
|
|
3
|
+
red: "\x1b[31m",
|
|
4
|
+
yellow: "\x1b[33m",
|
|
5
|
+
blue: "\x1b[34m",
|
|
6
|
+
reset: "\x1b[0m",
|
|
7
|
+
bold: "\x1b[1m",
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
class Spinner {
|
|
11
|
+
constructor(message = "Loading...") {
|
|
12
|
+
this.message = message;
|
|
13
|
+
this.frames = ["โ ", "โ ", "โ น", "โ ธ", "โ ผ", "โ ด", "โ ฆ", "โ ง", "โ ", "โ "];
|
|
14
|
+
this.current = 0;
|
|
15
|
+
this.interval = null;
|
|
16
|
+
this.isSpinning = false;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
start() {
|
|
20
|
+
if (this.isSpinning) return;
|
|
21
|
+
this.isSpinning = true;
|
|
22
|
+
process.stdout.write("\x1b[?25l"); // Hide cursor
|
|
23
|
+
this.interval = setInterval(() => {
|
|
24
|
+
process.stdout.write(
|
|
25
|
+
`\r\x1b[K${colors.blue}${this.frames[this.current]} ${this.message}${colors.reset}`,
|
|
26
|
+
);
|
|
27
|
+
this.current = (this.current + 1) % this.frames.length;
|
|
28
|
+
}, 100);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
stop() {
|
|
32
|
+
if (!this.isSpinning) return;
|
|
33
|
+
this.isSpinning = false;
|
|
34
|
+
clearInterval(this.interval);
|
|
35
|
+
this.interval = null;
|
|
36
|
+
process.stdout.write("\r\x1b[K"); // Clear line
|
|
37
|
+
process.stdout.write("\x1b[?25h"); // Show cursor
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
updateMessage(message) {
|
|
41
|
+
this.message = message;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
success(message) {
|
|
45
|
+
this.stop();
|
|
46
|
+
console.log(`${colors.green}โ
${message}${colors.reset}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
error(message) {
|
|
50
|
+
this.stop();
|
|
51
|
+
console.log(`${colors.red}โ ${message}${colors.reset}`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
warning(message) {
|
|
55
|
+
this.stop();
|
|
56
|
+
console.log(`${colors.yellow}โ ๏ธ ${message}${colors.reset}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
info(message) {
|
|
60
|
+
this.stop();
|
|
61
|
+
console.log(`${colors.blue}โน๏ธ ${message}${colors.reset}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
debug(message, verbose = false) {
|
|
65
|
+
if (!verbose) return;
|
|
66
|
+
this.stop();
|
|
67
|
+
console.log(`${colors.blue}๐ DEBUG: ${message}${colors.reset}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
log(message, color = "") {
|
|
71
|
+
console.log(`${color}${message}${colors.reset}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
module.exports = Spinner;
|