git-branch-cleanup-cli 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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,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
+ [![CI](https://github.com/haiderstudio/git-branch-cleanup-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/haiderstudio/git-branch-cleanup-cli/actions/workflows/ci.yml) [![Licence: MIT](https://img.shields.io/badge/Licence-MIT-yellow.svg)](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,91 @@
1
+ # git-branch-cleanup-cli
2
+
3
+ Delete stale local git branches — gone on the remote, or fully merged — with a dry run first, always.
4
+
5
+ [![CI](https://github.com/haiderstudio/git-branch-cleanup-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/haiderstudio/git-branch-cleanup-cli/actions/workflows/ci.yml) [![Licence: MIT](https://img.shields.io/badge/Licence-MIT-yellow.svg)](LICENSE)
6
+
7
+ ## What it does
8
+
9
+ 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:
10
+
11
+ - **Upstream gone** — the remote branch no longer exists (shows as `: gone]` in `git branch -vv`).
12
+ - **Fully merged into HEAD** — `git branch --merged HEAD` says the work is already in your current branch.
13
+
14
+ ## What it does NOT do
15
+
16
+ - It never deletes the current branch.
17
+ - It never deletes an unmerged branch unless you pass **both** `--include-unmerged` and `--yes` — a single flag is refused on purpose.
18
+ - 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.
19
+ - It never deletes remote branches, tags, or stashes.
20
+ - It sends nothing anywhere. Everything runs locally, Python standard library only.
21
+
22
+ ## Install
23
+
24
+ No install step and no dependencies — Python 3.8+ and git are enough:
25
+
26
+ ```sh
27
+ git clone https://github.com/haiderstudio/git-branch-cleanup-cli.git
28
+ cd git-branch-cleanup-cli
29
+ python3 src/git_branch_cleanup.py --help
30
+ ```
31
+
32
+ ## Usage
33
+
34
+ Dry run first — this is the default, and it deletes nothing:
35
+
36
+ ```sh
37
+ python3 src/git_branch_cleanup.py
38
+ ```
39
+
40
+ Real output on a sample repo (see `examples/`):
41
+
42
+ ```
43
+ Current branch: main
44
+ Local branches: 3
45
+ Would delete:
46
+ - done (merged)
47
+ Kept:
48
+ - main (current branch)
49
+ - wip (not merged and upstream still exists)
50
+ Dry run: nothing deleted. Re-run with --apply to delete.
51
+ ```
52
+
53
+ Delete the safe branches:
54
+
55
+ ```sh
56
+ python3 src/git_branch_cleanup.py --apply
57
+ ```
58
+
59
+ Work on a different repo without changing directory:
60
+
61
+ ```sh
62
+ python3 src/git_branch_cleanup.py --repo /path/to/your/repo --apply
63
+ ```
64
+
65
+ Delete unmerged branches too — only with both flags, deliberately:
66
+
67
+ ```sh
68
+ python3 src/git_branch_cleanup.py --apply --include-unmerged --yes
69
+ ```
70
+
71
+ `--include-unmerged` alone is refused (exit code 2) and deletes nothing.
72
+
73
+ ## Example
74
+
75
+ 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.
76
+
77
+ ## How it is tested
78
+
79
+ ```sh
80
+ python3 tests/test_git_branch_cleanup.py
81
+ ```
82
+
83
+ 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.
84
+
85
+ ## Contributing
86
+
87
+ 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`.
88
+
89
+ ## Licence
90
+
91
+ 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,31 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "git-branch-cleanup-cli"
7
+ version = "0.1.0"
8
+ description = "Delete stale local git branches — gone on the remote, or fully merged — with a dry run first, always."
9
+ readme = { file = "README.md", content-type = "text/markdown" }
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Haider Ali" }]
13
+ keywords = ["git", "branches", "cleanup", "cli", "developer-tools"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Environment :: Console",
17
+ "Intended Audience :: Developers",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Programming Language :: Python :: 3",
20
+ "Topic :: Software Development :: Version Control :: Git",
21
+ ]
22
+ urls = { Homepage = "https://github.com/haiderstudio/git-branch-cleanup-cli", Repository = "https://github.com/haiderstudio/git-branch-cleanup-cli", Issues = "https://github.com/haiderstudio/git-branch-cleanup-cli/issues" }
23
+
24
+ [project.scripts]
25
+ git-branch-cleanup = "git_branch_cleanup:main"
26
+
27
+ [tool.setuptools.package-dir]
28
+ "" = "src"
29
+
30
+ [tool.setuptools]
31
+ py-modules = ["git_branch_cleanup"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+ [![CI](https://github.com/haiderstudio/git-branch-cleanup-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/haiderstudio/git-branch-cleanup-cli/actions/workflows/ci.yml) [![Licence: MIT](https://img.shields.io/badge/Licence-MIT-yellow.svg)](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,10 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/git_branch_cleanup.py
5
+ src/git_branch_cleanup_cli.egg-info/PKG-INFO
6
+ src/git_branch_cleanup_cli.egg-info/SOURCES.txt
7
+ src/git_branch_cleanup_cli.egg-info/dependency_links.txt
8
+ src/git_branch_cleanup_cli.egg-info/entry_points.txt
9
+ src/git_branch_cleanup_cli.egg-info/top_level.txt
10
+ tests/test_git_branch_cleanup.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ git-branch-cleanup = git_branch_cleanup:main
@@ -0,0 +1,99 @@
1
+ """Tests for git-branch-cleanup.
2
+
3
+ Run: python3 tests/test_git_branch_cleanup.py
4
+ No third-party dependencies. The integration test builds a real throwaway
5
+ git repo in a temp dir and never touches the caller's repo.
6
+ """
7
+
8
+ import os
9
+ import pathlib
10
+ import subprocess
11
+ import sys
12
+ import tempfile
13
+
14
+ sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "src"))
15
+
16
+ from git_branch_cleanup import classify, gone_branches, main # noqa: E402
17
+
18
+
19
+ def test_classify_never_deletes_current():
20
+ delete, keep = classify(["main", "done", "wip"], "main",
21
+ merged={"done"}, gone=set())
22
+ assert delete == ["done"], delete
23
+ assert keep["main"] == "current branch"
24
+ assert keep["wip"] == "not merged and upstream still exists"
25
+
26
+
27
+ def test_classify_gone_beats_unmerged():
28
+ delete, keep = classify(["old"], "main", merged=set(), gone={"old"})
29
+ assert delete == ["old"]
30
+ assert "old" not in keep
31
+
32
+
33
+ def test_gone_parsing():
34
+ # gone_branches parses `git branch -vv` output; simulate by monkeypatch.
35
+ import git_branch_cleanup as mod
36
+ sample = ("* main abc1234 [origin/main] first\n"
37
+ " old abc1234 [origin/old: gone] last\n")
38
+ orig = mod.run_git
39
+ mod.run_git = lambda repo, args: (0, sample)
40
+ try:
41
+ assert gone_branches(".") == {"old"}
42
+ finally:
43
+ mod.run_git = orig
44
+
45
+
46
+ def git(repo, *args, check=True):
47
+ return subprocess.run(["git", "-C", repo] + list(args),
48
+ capture_output=True, text=True, check=check)
49
+
50
+
51
+ def test_integration_dry_run_then_apply():
52
+ with tempfile.TemporaryDirectory() as tmp:
53
+ git(tmp, "init", "-b", "main")
54
+ git(tmp, "config", "user.email", "test@example.com")
55
+ git(tmp, "config", "user.name", "Test")
56
+ pathlib.Path(tmp, "a.txt").write_text("a\n")
57
+ git(tmp, "add", ".")
58
+ git(tmp, "commit", "-m", "base")
59
+ git(tmp, "branch", "done")
60
+ git(tmp, "checkout", "-b", "wip")
61
+ pathlib.Path(tmp, "b.txt").write_text("b\n")
62
+ git(tmp, "add", ".")
63
+ git(tmp, "commit", "-m", "wip work")
64
+ git(tmp, "checkout", "main")
65
+ git(tmp, "merge", "done", "--no-ff", "-m", "merge done")
66
+ # Dry run must not delete anything.
67
+ assert main(["--repo", tmp]) == 0
68
+ names = git(tmp, "branch", "--format=%(refname:short)").stdout.split()
69
+ assert set(names) == {"main", "done", "wip"}, names
70
+ # Apply deletes the merged branch only; wip survives.
71
+ assert main(["--repo", tmp, "--apply"]) == 0
72
+ names = git(tmp, "branch", "--format=%(refname:short)").stdout.split()
73
+ assert set(names) == {"main", "wip"}, names
74
+ # Unmerged needs both flags, otherwise refused.
75
+ assert main(["--repo", tmp, "--apply", "--include-unmerged"]) == 2
76
+ names = git(tmp, "branch", "--format=%(refname:short)").stdout.split()
77
+ assert "wip" in names
78
+
79
+
80
+ def test_refuses_non_repo():
81
+ with tempfile.TemporaryDirectory() as tmp:
82
+ assert main(["--repo", tmp]) == 2
83
+
84
+
85
+ def main_test():
86
+ tests = [test_classify_never_deletes_current,
87
+ test_classify_gone_beats_unmerged,
88
+ test_gone_parsing,
89
+ test_integration_dry_run_then_apply,
90
+ test_refuses_non_repo]
91
+ for test in tests:
92
+ test()
93
+ print(f"PASS {test.__name__}")
94
+ print(f"All {len(tests)} tests passed.")
95
+ return 0
96
+
97
+
98
+ if __name__ == "__main__":
99
+ sys.exit(main_test())