git-branch-cleanup-cli 0.1.0__py3-none-any.whl
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.
- git_branch_cleanup.py +184 -0
- git_branch_cleanup_cli-0.1.0.dist-info/METADATA +112 -0
- git_branch_cleanup_cli-0.1.0.dist-info/RECORD +7 -0
- git_branch_cleanup_cli-0.1.0.dist-info/WHEEL +5 -0
- git_branch_cleanup_cli-0.1.0.dist-info/entry_points.txt +2 -0
- git_branch_cleanup_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- git_branch_cleanup_cli-0.1.0.dist-info/top_level.txt +1 -0
git_branch_cleanup.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""git-branch-cleanup: delete stale local git branches, safely by default.
|
|
3
|
+
|
|
4
|
+
Default run is a dry run: it lists what WOULD be deleted and deletes
|
|
5
|
+
nothing. Pass --apply to actually delete, and only then.
|
|
6
|
+
|
|
7
|
+
Safety rules (non-negotiable):
|
|
8
|
+
- The current branch is never deleted.
|
|
9
|
+
- A branch is only auto-deleted if its upstream is gone, or it is fully
|
|
10
|
+
merged into HEAD. Both are checked with git itself, not guessed.
|
|
11
|
+
- Unmerged branches are listed as "kept" and are never deleted unless the
|
|
12
|
+
user passes BOTH --include-unmerged and --yes (which uses `git branch -D`).
|
|
13
|
+
- Nothing is fetched, pushed, or sent anywhere. This tool only reads the
|
|
14
|
+
local repo and deletes local branches the user approved.
|
|
15
|
+
|
|
16
|
+
Usage:
|
|
17
|
+
python3 src/git_branch_cleanup.py # dry run, list only
|
|
18
|
+
python3 src/git_branch_cleanup.py --apply # delete gone + merged
|
|
19
|
+
python3 src/git_branch_cleanup.py --apply --include-unmerged --yes
|
|
20
|
+
|
|
21
|
+
Exit codes: 0 = ok (dry run or deletions done), 1 = usage/git error,
|
|
22
|
+
2 = refused (e.g. not a git repo, or --include-unmerged without --yes).
|
|
23
|
+
Python standard library only. No third-party dependencies.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import argparse
|
|
27
|
+
import subprocess
|
|
28
|
+
import sys
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def run_git(repo, args):
|
|
32
|
+
"""Run git in repo; return (returncode, stdout). Raise on git missing."""
|
|
33
|
+
result = subprocess.run(
|
|
34
|
+
["git", "-C", repo] + args,
|
|
35
|
+
capture_output=True, text=True, check=False,
|
|
36
|
+
)
|
|
37
|
+
return result.returncode, result.stdout.strip()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def is_git_repo(repo):
|
|
41
|
+
code, _ = run_git(repo, ["rev-parse", "--git-dir"])
|
|
42
|
+
return code == 0
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def current_branch(repo):
|
|
46
|
+
code, out = run_git(repo, ["branch", "--show-current"])
|
|
47
|
+
if code != 0:
|
|
48
|
+
raise RuntimeError("could not determine the current branch")
|
|
49
|
+
return out
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def local_branches(repo):
|
|
53
|
+
"""All local branch names, in git's order."""
|
|
54
|
+
code, out = run_git(repo, ["branch", "--format=%(refname:short)"])
|
|
55
|
+
if code != 0:
|
|
56
|
+
raise RuntimeError("could not list local branches")
|
|
57
|
+
return [line.strip() for line in out.splitlines() if line.strip()]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def merged_branches(repo):
|
|
61
|
+
"""Branches fully merged into HEAD."""
|
|
62
|
+
code, out = run_git(repo, ["branch", "--merged", "HEAD",
|
|
63
|
+
"--format=%(refname:short)"])
|
|
64
|
+
if code != 0:
|
|
65
|
+
raise RuntimeError("could not list merged branches")
|
|
66
|
+
return set(line.strip() for line in out.splitlines() if line.strip())
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def gone_branches(repo):
|
|
70
|
+
"""Branches whose upstream is gone.
|
|
71
|
+
|
|
72
|
+
Parses `git branch -vv`, where a gone upstream shows as ': gone]'.
|
|
73
|
+
Note: run `git fetch --prune` first if you want this list to reflect
|
|
74
|
+
the remote's current state; this tool deliberately does not fetch.
|
|
75
|
+
"""
|
|
76
|
+
code, out = run_git(repo, ["branch", "-vv"])
|
|
77
|
+
if code != 0:
|
|
78
|
+
raise RuntimeError("could not list branches with upstream info")
|
|
79
|
+
gone = set()
|
|
80
|
+
for line in out.splitlines():
|
|
81
|
+
if ": gone]" in line:
|
|
82
|
+
# Line form: "* name abc1234 [origin/name: gone] message"
|
|
83
|
+
# or: " name abc1234 [origin/name: gone] message"
|
|
84
|
+
parts = line.replace("*", " ").split()
|
|
85
|
+
if parts:
|
|
86
|
+
gone.add(parts[0])
|
|
87
|
+
return gone
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def classify(branches, current, merged, gone):
|
|
91
|
+
"""Sort branches into (delete, keep) with a reason for each kept one.
|
|
92
|
+
|
|
93
|
+
A branch is deletable only if it is not current AND (gone or merged).
|
|
94
|
+
Everything else is kept, with the reason stated plainly.
|
|
95
|
+
"""
|
|
96
|
+
delete = []
|
|
97
|
+
keep = {}
|
|
98
|
+
for branch in branches:
|
|
99
|
+
if branch == current:
|
|
100
|
+
keep[branch] = "current branch"
|
|
101
|
+
elif branch in gone:
|
|
102
|
+
delete.append(branch)
|
|
103
|
+
elif branch in merged:
|
|
104
|
+
delete.append(branch)
|
|
105
|
+
else:
|
|
106
|
+
keep[branch] = "not merged and upstream still exists"
|
|
107
|
+
return delete, keep
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def main(argv=None):
|
|
111
|
+
parser = argparse.ArgumentParser(
|
|
112
|
+
description="List (default) or delete stale local git branches.")
|
|
113
|
+
parser.add_argument("--repo", default=".",
|
|
114
|
+
help="path to the git repo (default: current dir)")
|
|
115
|
+
parser.add_argument("--apply", action="store_true",
|
|
116
|
+
help="actually delete; without this, dry run only")
|
|
117
|
+
parser.add_argument("--include-unmerged", action="store_true",
|
|
118
|
+
help="also delete unmerged branches (needs --yes)")
|
|
119
|
+
parser.add_argument("--yes", action="store_true",
|
|
120
|
+
help="confirm unmerged deletion (needs --include-unmerged)")
|
|
121
|
+
args = parser.parse_args(argv)
|
|
122
|
+
|
|
123
|
+
if args.include_unmerged and not args.yes:
|
|
124
|
+
print("Refused: --include-unmerged requires --yes. "
|
|
125
|
+
"Unmerged branches are never deleted on a single flag.",
|
|
126
|
+
file=sys.stderr)
|
|
127
|
+
return 2
|
|
128
|
+
if not is_git_repo(args.repo):
|
|
129
|
+
print(f"Refused: {args.repo} is not a git repository.",
|
|
130
|
+
file=sys.stderr)
|
|
131
|
+
return 2
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
current = current_branch(args.repo)
|
|
135
|
+
branches = local_branches(args.repo)
|
|
136
|
+
merged = merged_branches(args.repo)
|
|
137
|
+
gone = gone_branches(args.repo)
|
|
138
|
+
except RuntimeError as exc:
|
|
139
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
140
|
+
return 1
|
|
141
|
+
|
|
142
|
+
delete, keep = classify(branches, current, merged, gone)
|
|
143
|
+
unmerged = [b for b in keep if keep[b].startswith("not merged")]
|
|
144
|
+
if args.include_unmerged and args.yes:
|
|
145
|
+
delete = delete + unmerged
|
|
146
|
+
keep = {b: r for b, r in keep.items() if b not in unmerged}
|
|
147
|
+
|
|
148
|
+
print(f"Current branch: {current}")
|
|
149
|
+
print(f"Local branches: {len(branches)}")
|
|
150
|
+
if delete:
|
|
151
|
+
print("Would delete:" if not args.apply else "Deleting:")
|
|
152
|
+
for branch in delete:
|
|
153
|
+
reason = "upstream gone" if branch in gone else "merged"
|
|
154
|
+
print(f" - {branch} ({reason})")
|
|
155
|
+
else:
|
|
156
|
+
print("Nothing to delete.")
|
|
157
|
+
if keep:
|
|
158
|
+
print("Kept:")
|
|
159
|
+
for branch, reason in keep.items():
|
|
160
|
+
print(f" - {branch} ({reason})")
|
|
161
|
+
|
|
162
|
+
if not args.apply:
|
|
163
|
+
print("Dry run: nothing deleted. Re-run with --apply to delete.")
|
|
164
|
+
return 0
|
|
165
|
+
|
|
166
|
+
force = args.include_unmerged and args.yes
|
|
167
|
+
# Forced (-D) only for the explicitly confirmed unmerged set.
|
|
168
|
+
failures = []
|
|
169
|
+
for branch in delete:
|
|
170
|
+
is_unmerged = branch in unmerged
|
|
171
|
+
code, out = run_git(args.repo,
|
|
172
|
+
["branch", "-D" if is_unmerged else "-d", branch])
|
|
173
|
+
if code != 0:
|
|
174
|
+
failures.append((branch, out))
|
|
175
|
+
if failures:
|
|
176
|
+
for branch, out in failures:
|
|
177
|
+
print(f"Failed to delete {branch}: {out}", file=sys.stderr)
|
|
178
|
+
return 1
|
|
179
|
+
print(f"Deleted {len(delete)} branch(es).")
|
|
180
|
+
return 0
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
if __name__ == "__main__":
|
|
184
|
+
sys.exit(main())
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: git-branch-cleanup-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Delete stale local git branches — gone on the remote, or fully merged — with a dry run first, always.
|
|
5
|
+
Author: Haider Ali
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/haiderstudio/git-branch-cleanup-cli
|
|
8
|
+
Project-URL: Repository, https://github.com/haiderstudio/git-branch-cleanup-cli
|
|
9
|
+
Project-URL: Issues, https://github.com/haiderstudio/git-branch-cleanup-cli/issues
|
|
10
|
+
Keywords: git,branches,cleanup,cli,developer-tools
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Topic :: Software Development :: Version Control :: Git
|
|
17
|
+
Requires-Python: >=3.9
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# git-branch-cleanup-cli
|
|
23
|
+
|
|
24
|
+
Delete stale local git branches — gone on the remote, or fully merged — with a dry run first, always.
|
|
25
|
+
|
|
26
|
+
[](https://github.com/haiderstudio/git-branch-cleanup-cli/actions/workflows/ci.yml) [](LICENSE)
|
|
27
|
+
|
|
28
|
+
## What it does
|
|
29
|
+
|
|
30
|
+
Over a project's life, local branches pile up: feature branches merged weeks ago, branches whose remote branch was deleted, experiment branches never finished. `git-branch-cleanup` lists them and, only when you say `--apply`, deletes the safe ones:
|
|
31
|
+
|
|
32
|
+
- **Upstream gone** — the remote branch no longer exists (shows as `: gone]` in `git branch -vv`).
|
|
33
|
+
- **Fully merged into HEAD** — `git branch --merged HEAD` says the work is already in your current branch.
|
|
34
|
+
|
|
35
|
+
## What it does NOT do
|
|
36
|
+
|
|
37
|
+
- It never deletes the current branch.
|
|
38
|
+
- It never deletes an unmerged branch unless you pass **both** `--include-unmerged` and `--yes` — a single flag is refused on purpose.
|
|
39
|
+
- It never fetches, pushes, or talks to a remote. If you want the "gone" list to reflect the remote's current state, run `git fetch --prune` first yourself.
|
|
40
|
+
- It never deletes remote branches, tags, or stashes.
|
|
41
|
+
- It sends nothing anywhere. Everything runs locally, Python standard library only.
|
|
42
|
+
|
|
43
|
+
## Install
|
|
44
|
+
|
|
45
|
+
No install step and no dependencies — Python 3.8+ and git are enough:
|
|
46
|
+
|
|
47
|
+
```sh
|
|
48
|
+
git clone https://github.com/haiderstudio/git-branch-cleanup-cli.git
|
|
49
|
+
cd git-branch-cleanup-cli
|
|
50
|
+
python3 src/git_branch_cleanup.py --help
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Usage
|
|
54
|
+
|
|
55
|
+
Dry run first — this is the default, and it deletes nothing:
|
|
56
|
+
|
|
57
|
+
```sh
|
|
58
|
+
python3 src/git_branch_cleanup.py
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Real output on a sample repo (see `examples/`):
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
Current branch: main
|
|
65
|
+
Local branches: 3
|
|
66
|
+
Would delete:
|
|
67
|
+
- done (merged)
|
|
68
|
+
Kept:
|
|
69
|
+
- main (current branch)
|
|
70
|
+
- wip (not merged and upstream still exists)
|
|
71
|
+
Dry run: nothing deleted. Re-run with --apply to delete.
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Delete the safe branches:
|
|
75
|
+
|
|
76
|
+
```sh
|
|
77
|
+
python3 src/git_branch_cleanup.py --apply
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Work on a different repo without changing directory:
|
|
81
|
+
|
|
82
|
+
```sh
|
|
83
|
+
python3 src/git_branch_cleanup.py --repo /path/to/your/repo --apply
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Delete unmerged branches too — only with both flags, deliberately:
|
|
87
|
+
|
|
88
|
+
```sh
|
|
89
|
+
python3 src/git_branch_cleanup.py --apply --include-unmerged --yes
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
`--include-unmerged` alone is refused (exit code 2) and deletes nothing.
|
|
93
|
+
|
|
94
|
+
## Example
|
|
95
|
+
|
|
96
|
+
See `examples/` for a runnable walkthrough: it creates a throwaway repo with a merged branch and an unmerged branch, runs the dry run, then `--apply`, and shows that only the merged branch disappears. The same scenario is covered by the integration test, so the example and the tests cannot drift apart.
|
|
97
|
+
|
|
98
|
+
## How it is tested
|
|
99
|
+
|
|
100
|
+
```sh
|
|
101
|
+
python3 tests/test_git_branch_cleanup.py
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Expected output: `All 5 tests passed.` The suite covers the classification rules (current branch never deleted, gone branches deletable, unmerged kept), the `: gone]` parsing, a real-repo dry-run-then-apply run, and refusal outside a git repo. Tests run on the exact files in this repo, with no network and no third-party packages.
|
|
105
|
+
|
|
106
|
+
## Contributing
|
|
107
|
+
|
|
108
|
+
Small, focused improvements are welcome. Open an issue first for anything larger than a typo, keep the safety rules above intact, and run the test suite before opening a pull request. See `CONTRIBUTING.md`.
|
|
109
|
+
|
|
110
|
+
## Licence
|
|
111
|
+
|
|
112
|
+
MIT — see `LICENSE`. Original work, written for this repo; it builds on git's own `--merged` and `-vv` output, not on any other cleanup tool's code.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
git_branch_cleanup.py,sha256=O7Q5x4jMT3cQKUoan8Hw8H62ZKp1_UK0Px1vYiwKv_E,6686
|
|
2
|
+
git_branch_cleanup_cli-0.1.0.dist-info/licenses/LICENSE,sha256=mTjYlQM7htiV7thgBkBwT0woURqRpTJG1eWK1Logfl8,1067
|
|
3
|
+
git_branch_cleanup_cli-0.1.0.dist-info/METADATA,sha256=UnBG1Dr9rMPhGzv-mfky1Owh7OJhEnmA65xerK0ZXMQ,4432
|
|
4
|
+
git_branch_cleanup_cli-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
5
|
+
git_branch_cleanup_cli-0.1.0.dist-info/entry_points.txt,sha256=Q1GdBnFub4YDpC3hWdPScc1XRB3bkDr2ZAKbp9hnq14,63
|
|
6
|
+
git_branch_cleanup_cli-0.1.0.dist-info/top_level.txt,sha256=FnNmgY-NHrRSw6A0M1VwV0EtpymyISzjHrKXAp_Irfc,19
|
|
7
|
+
git_branch_cleanup_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Haider Ali
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
git_branch_cleanup
|