branchtidy 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 branchtidy contributors
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,162 @@
1
+ Metadata-Version: 2.4
2
+ Name: branchtidy
3
+ Version: 0.1.0
4
+ Summary: Find and delete merged & stale git branches, safely. Dry-run by default, never touches main/master/develop/current, handles local + remote. Zero dependencies.
5
+ Author: yyfjj
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/jjdoor/branchtidy-py
8
+ Project-URL: Repository, https://github.com/jjdoor/branchtidy-py
9
+ Project-URL: Issues, https://github.com/jjdoor/branchtidy-py/issues
10
+ Keywords: git,branch,branches,cleanup,prune,stale,merged,cli,devtools,git-branch
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Topic :: Software Development :: Version Control :: Git
18
+ Classifier: Topic :: Utilities
19
+ Requires-Python: >=3.8
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Dynamic: license-file
23
+
24
+ # branchtidy
25
+
26
+ **Delete merged & stale git branches — safely.** branchtidy finds the local (and
27
+ optionally remote) branches that are already merged, or haven't seen a commit in
28
+ N days, previews them, and deletes them in one batch. It is **dry-run by
29
+ default**, refuses to touch `main` / `master` / `develop` / your current branch,
30
+ and won't nuke unmerged work unless you explicitly ask.
31
+
32
+ Zero dependencies (pure Python stdlib). Zero config. No daemon, no account.
33
+
34
+ ```bash
35
+ pipx run branchtidy
36
+ ```
37
+
38
+ ```
39
+ branchtidy local branches · default main · stale > 90d
40
+
41
+ BRANCH LAST COMMIT MERGED ACTION
42
+ feature/login 12d ago yes delete (merged)
43
+ feature/old-poc 210d ago no delete (stale 210d)
44
+ main 2d ago no keep (protected)
45
+ feature/wip 3d ago no keep (active)
46
+
47
+ Dry run. 2 branch(es) WOULD be deleted. Re-run with --delete to apply.
48
+ ```
49
+
50
+ Nothing was deleted. That's the point — you read the table, *then* decide.
51
+
52
+ ## Why another branch cleaner?
53
+
54
+ Everyone reinvents this as a throwaway `git branch --merged | grep -v ... | xargs`
55
+ one-liner, and those one-liners are exactly how people delete branches they
56
+ wanted. branchtidy's whole pitch is **safety + zero config**:
57
+
58
+ - **Dry-run is the default.** No flags → it only *prints* what it would do.
59
+ - **Real deletion is gated twice:** `--delete` *and* an interactive confirm
60
+ (skip the prompt only with `--yes`).
61
+ - **Protected branches are never candidates:** `main`, `master`, `develop`, the
62
+ current `HEAD`, plus anything you pass to `--protect`.
63
+ - **Merged vs unmerged is respected.** Merged branches use the safe
64
+ `git branch -d`. Unmerged branches are only deletable with an explicit
65
+ `--force` (which maps to `git branch -D`).
66
+ - **Remote deletion is double-gated:** it requires `--remote --delete` *and* its
67
+ own confirmation, and uses `git push <remote> --delete`.
68
+
69
+ When in doubt, branchtidy **keeps** the branch.
70
+
71
+ ## Install
72
+
73
+ ```bash
74
+ pipx run branchtidy # no install, run on demand
75
+ pip install branchtidy # or install the `branchtidy` command
76
+ ```
77
+
78
+ There's an identical Node build too: `npx branchtidy` / `npm i -g branchtidy`
79
+ (see [branchtidy](https://github.com/jjdoor/branchtidy)). Both ports share one
80
+ selection-vector table, so they make byte-for-byte identical decisions.
81
+
82
+ ## Usage
83
+
84
+ ```bash
85
+ branchtidy [options] # dry-run preview (default — deletes nothing)
86
+ branchtidy --delete # actually delete, after a confirm
87
+ ```
88
+
89
+ | Option | Description |
90
+ | --- | --- |
91
+ | `--delete` | Perform deletion. Without it, branchtidy only previews. |
92
+ | `--yes` | Skip the interactive confirm (use with `--delete`, e.g. in scripts). |
93
+ | `--stale <dur>` | Staleness threshold. Default `90d`. Accepts `30d`, `2w`, `12h`, `45m`, `30s`, or a bare number (days). |
94
+ | `--merged-only` | Only delete *merged* branches; never delete on age alone. |
95
+ | `--remote [name]` | Operate on remote-tracking branches (default remote: `origin`). |
96
+ | `--protect <a,b>` | Extra branch names to never delete (comma-separated). |
97
+ | `--force` | Allow deleting **unmerged** branches (maps to `git branch -D`). |
98
+ | `--json` | Machine-readable output; never prompts, never deletes (preview only). |
99
+ | `--no-color` | Disable ANSI colors. |
100
+ | `-h, --help` | Show help. |
101
+ | `-v, --version` | Print version. |
102
+
103
+ Exit codes: `0` success/clean, `1` one or more deletions failed, `2` usage or
104
+ environment error (e.g. not a git repo).
105
+
106
+ ### Examples
107
+
108
+ ```bash
109
+ # what WOULD be cleaned up, right now?
110
+ branchtidy
111
+
112
+ # stricter window, only merged branches, do it (with a confirm)
113
+ branchtidy --stale 30d --merged-only --delete
114
+
115
+ # clean up gone-stale remote branches on origin (double-gated + confirm)
116
+ branchtidy --remote origin --delete
117
+
118
+ # delete unmerged stale branches too — you have to ask for it
119
+ branchtidy --stale 180d --delete --force
120
+
121
+ # protect a couple of long-lived branches by name
122
+ branchtidy --protect release/v1,staging --delete
123
+
124
+ # pipe the plan somewhere
125
+ branchtidy --json | jq '.toDelete'
126
+ ```
127
+
128
+ ## How it decides
129
+
130
+ For each branch branchtidy looks at: is it the current `HEAD`? is it protected?
131
+ is it merged into the default branch? how old is its last commit? Then:
132
+
133
+ 1. current branch → **keep** (`current`)
134
+ 2. protected (default set or `--protect`) → **keep** (`protected`)
135
+ 3. merged → **delete** (`merged`)
136
+ 4. otherwise, if older than `--stale` → **delete** (`stale <N>d`)
137
+ 5. otherwise → **keep** (`active`)
138
+
139
+ In `--merged-only` mode, step 4 is skipped entirely — age never causes a
140
+ deletion.
141
+
142
+ The default branch is resolved from `origin/HEAD` when available, otherwise it
143
+ falls back to `main`, then `master`.
144
+
145
+ ## Design notes
146
+
147
+ - **One pure function at the core.** `select_branches(branches, policy, now_ms)`
148
+ has no git, no fs, no clock — it's a pure data→data transform that returns
149
+ `{toDelete, toKeep}` with a reason on every branch. The CLI is a thin git
150
+ wrapper around it. That's what makes the Node and Python ports verifiably
151
+ identical: they run the same vector table.
152
+ - **Time is integer math.** Ages are computed from `committerdate:unix` against
153
+ a single captured `now` in milliseconds — no `datetime` parity to worry about
154
+ between languages.
155
+ - **Safe by construction.** Protected and current branches are filtered out
156
+ *before* any staleness logic runs, the staleness test is a strict `>` (a
157
+ branch exactly at the threshold is kept), and deletion always passes through
158
+ the safe `git branch -d` unless you opt into `-D` with `--force`.
159
+
160
+ ## License
161
+
162
+ MIT
@@ -0,0 +1,139 @@
1
+ # branchtidy
2
+
3
+ **Delete merged & stale git branches — safely.** branchtidy finds the local (and
4
+ optionally remote) branches that are already merged, or haven't seen a commit in
5
+ N days, previews them, and deletes them in one batch. It is **dry-run by
6
+ default**, refuses to touch `main` / `master` / `develop` / your current branch,
7
+ and won't nuke unmerged work unless you explicitly ask.
8
+
9
+ Zero dependencies (pure Python stdlib). Zero config. No daemon, no account.
10
+
11
+ ```bash
12
+ pipx run branchtidy
13
+ ```
14
+
15
+ ```
16
+ branchtidy local branches · default main · stale > 90d
17
+
18
+ BRANCH LAST COMMIT MERGED ACTION
19
+ feature/login 12d ago yes delete (merged)
20
+ feature/old-poc 210d ago no delete (stale 210d)
21
+ main 2d ago no keep (protected)
22
+ feature/wip 3d ago no keep (active)
23
+
24
+ Dry run. 2 branch(es) WOULD be deleted. Re-run with --delete to apply.
25
+ ```
26
+
27
+ Nothing was deleted. That's the point — you read the table, *then* decide.
28
+
29
+ ## Why another branch cleaner?
30
+
31
+ Everyone reinvents this as a throwaway `git branch --merged | grep -v ... | xargs`
32
+ one-liner, and those one-liners are exactly how people delete branches they
33
+ wanted. branchtidy's whole pitch is **safety + zero config**:
34
+
35
+ - **Dry-run is the default.** No flags → it only *prints* what it would do.
36
+ - **Real deletion is gated twice:** `--delete` *and* an interactive confirm
37
+ (skip the prompt only with `--yes`).
38
+ - **Protected branches are never candidates:** `main`, `master`, `develop`, the
39
+ current `HEAD`, plus anything you pass to `--protect`.
40
+ - **Merged vs unmerged is respected.** Merged branches use the safe
41
+ `git branch -d`. Unmerged branches are only deletable with an explicit
42
+ `--force` (which maps to `git branch -D`).
43
+ - **Remote deletion is double-gated:** it requires `--remote --delete` *and* its
44
+ own confirmation, and uses `git push <remote> --delete`.
45
+
46
+ When in doubt, branchtidy **keeps** the branch.
47
+
48
+ ## Install
49
+
50
+ ```bash
51
+ pipx run branchtidy # no install, run on demand
52
+ pip install branchtidy # or install the `branchtidy` command
53
+ ```
54
+
55
+ There's an identical Node build too: `npx branchtidy` / `npm i -g branchtidy`
56
+ (see [branchtidy](https://github.com/jjdoor/branchtidy)). Both ports share one
57
+ selection-vector table, so they make byte-for-byte identical decisions.
58
+
59
+ ## Usage
60
+
61
+ ```bash
62
+ branchtidy [options] # dry-run preview (default — deletes nothing)
63
+ branchtidy --delete # actually delete, after a confirm
64
+ ```
65
+
66
+ | Option | Description |
67
+ | --- | --- |
68
+ | `--delete` | Perform deletion. Without it, branchtidy only previews. |
69
+ | `--yes` | Skip the interactive confirm (use with `--delete`, e.g. in scripts). |
70
+ | `--stale <dur>` | Staleness threshold. Default `90d`. Accepts `30d`, `2w`, `12h`, `45m`, `30s`, or a bare number (days). |
71
+ | `--merged-only` | Only delete *merged* branches; never delete on age alone. |
72
+ | `--remote [name]` | Operate on remote-tracking branches (default remote: `origin`). |
73
+ | `--protect <a,b>` | Extra branch names to never delete (comma-separated). |
74
+ | `--force` | Allow deleting **unmerged** branches (maps to `git branch -D`). |
75
+ | `--json` | Machine-readable output; never prompts, never deletes (preview only). |
76
+ | `--no-color` | Disable ANSI colors. |
77
+ | `-h, --help` | Show help. |
78
+ | `-v, --version` | Print version. |
79
+
80
+ Exit codes: `0` success/clean, `1` one or more deletions failed, `2` usage or
81
+ environment error (e.g. not a git repo).
82
+
83
+ ### Examples
84
+
85
+ ```bash
86
+ # what WOULD be cleaned up, right now?
87
+ branchtidy
88
+
89
+ # stricter window, only merged branches, do it (with a confirm)
90
+ branchtidy --stale 30d --merged-only --delete
91
+
92
+ # clean up gone-stale remote branches on origin (double-gated + confirm)
93
+ branchtidy --remote origin --delete
94
+
95
+ # delete unmerged stale branches too — you have to ask for it
96
+ branchtidy --stale 180d --delete --force
97
+
98
+ # protect a couple of long-lived branches by name
99
+ branchtidy --protect release/v1,staging --delete
100
+
101
+ # pipe the plan somewhere
102
+ branchtidy --json | jq '.toDelete'
103
+ ```
104
+
105
+ ## How it decides
106
+
107
+ For each branch branchtidy looks at: is it the current `HEAD`? is it protected?
108
+ is it merged into the default branch? how old is its last commit? Then:
109
+
110
+ 1. current branch → **keep** (`current`)
111
+ 2. protected (default set or `--protect`) → **keep** (`protected`)
112
+ 3. merged → **delete** (`merged`)
113
+ 4. otherwise, if older than `--stale` → **delete** (`stale <N>d`)
114
+ 5. otherwise → **keep** (`active`)
115
+
116
+ In `--merged-only` mode, step 4 is skipped entirely — age never causes a
117
+ deletion.
118
+
119
+ The default branch is resolved from `origin/HEAD` when available, otherwise it
120
+ falls back to `main`, then `master`.
121
+
122
+ ## Design notes
123
+
124
+ - **One pure function at the core.** `select_branches(branches, policy, now_ms)`
125
+ has no git, no fs, no clock — it's a pure data→data transform that returns
126
+ `{toDelete, toKeep}` with a reason on every branch. The CLI is a thin git
127
+ wrapper around it. That's what makes the Node and Python ports verifiably
128
+ identical: they run the same vector table.
129
+ - **Time is integer math.** Ages are computed from `committerdate:unix` against
130
+ a single captured `now` in milliseconds — no `datetime` parity to worry about
131
+ between languages.
132
+ - **Safe by construction.** Protected and current branches are filtered out
133
+ *before* any staleness logic runs, the staleness test is a strict `>` (a
134
+ branch exactly at the threshold is kept), and deletion always passes through
135
+ the safe `git branch -d` unless you opt into `-D` with `--force`.
136
+
137
+ ## License
138
+
139
+ MIT
@@ -0,0 +1,38 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "branchtidy"
7
+ version = "0.1.0"
8
+ description = "Find and delete merged & stale git branches, safely. Dry-run by default, never touches main/master/develop/current, handles local + remote. Zero dependencies."
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "yyfjj" }]
13
+ keywords = ["git", "branch", "branches", "cleanup", "prune", "stale", "merged", "cli", "devtools", "git-branch"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Environment :: Console",
17
+ "Intended Audience :: Developers",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Operating System :: OS Independent",
20
+ "Programming Language :: Python :: 3",
21
+ "Topic :: Software Development :: Version Control :: Git",
22
+ "Topic :: Utilities",
23
+ ]
24
+ dependencies = []
25
+
26
+ [project.urls]
27
+ Homepage = "https://github.com/jjdoor/branchtidy-py"
28
+ Repository = "https://github.com/jjdoor/branchtidy-py"
29
+ Issues = "https://github.com/jjdoor/branchtidy-py/issues"
30
+
31
+ [project.scripts]
32
+ branchtidy = "branchtidy.cli:main"
33
+
34
+ [tool.setuptools]
35
+ package-dir = { "" = "src" }
36
+
37
+ [tool.setuptools.packages.find]
38
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,6 @@
1
+ """branchtidy — delete merged & stale git branches, safely."""
2
+
3
+ from .core import select_branches, parse_duration, age_days
4
+
5
+ __version__ = "0.1.0"
6
+ __all__ = ["select_branches", "parse_duration", "age_days"]
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
@@ -0,0 +1,426 @@
1
+ """branchtidy command-line interface — a thin git wrapper around the pure core.
2
+
3
+ The only impure layer lives here: subprocess calls to git, prompts, and stdout.
4
+ All selection decisions go through ``core.select_branches``.
5
+ """
6
+
7
+ import json
8
+ import os
9
+ import subprocess
10
+ import sys
11
+ import time
12
+
13
+ from . import core
14
+
15
+ VERSION = "0.1.0"
16
+
17
+ _COLOR = sys.stdout.isatty() and not os.environ.get("NO_COLOR")
18
+
19
+
20
+ def _c(code, s):
21
+ return f"\x1b[{code}m{s}\x1b[0m" if _COLOR else s
22
+
23
+
24
+ def bold(s): return _c("1", s)
25
+ def dim(s): return _c("2", s)
26
+ def red(s): return _c("31", s)
27
+ def green(s): return _c("32", s)
28
+ def yellow(s): return _c("33", s)
29
+
30
+
31
+ PROTECTED_DEFAULTS = ["main", "master", "develop"]
32
+
33
+
34
+ def _help():
35
+ return f"""{bold('branchtidy')} — delete merged & stale git branches, safely.
36
+
37
+ {bold('Usage')}
38
+ branchtidy [options] {dim('# dry-run preview (default — deletes nothing)')}
39
+ branchtidy --delete {dim('# actually delete, after a confirm')}
40
+
41
+ {bold('Options')}
42
+ --delete perform deletion (otherwise: preview only)
43
+ --yes skip the interactive confirm (use with --delete)
44
+ --stale <dur> staleness threshold, default 90d (e.g. 30d, 2w, 12h)
45
+ --merged-only only delete merged branches; never delete on age alone
46
+ --remote [name] operate on remote branches (default remote: origin)
47
+ --protect <a,b> extra branch names to never delete (comma-separated)
48
+ --force allow deleting UNMERGED branches (git branch -D)
49
+ --json machine-readable output (no prompts/colors)
50
+ --no-color disable ANSI colors
51
+ -h, --help show this help
52
+ -v, --version print version
53
+
54
+ {bold('Safety')}
55
+ - Dry-run is the default. Nothing is deleted without {bold('--delete')}.
56
+ - {bold('main')}, {bold('master')}, {bold('develop')}, current HEAD and --protect names are never touched.
57
+ - Unmerged branches need {bold('--force')}; merged ones use a safe {dim('git branch -d')}.
58
+ - Remote deletion is double-gated: needs {bold('--remote --delete')} and its own confirm.
59
+
60
+ {bold('Examples')}
61
+ branchtidy {dim('# what WOULD be cleaned up?')}
62
+ branchtidy --stale 30d {dim('# stricter staleness window')}
63
+ branchtidy --merged-only --delete
64
+ branchtidy --remote origin --delete --force
65
+ """
66
+
67
+
68
+ def die(msg, code=2):
69
+ sys.stderr.write(f"branchtidy: {msg}\n")
70
+ sys.exit(code)
71
+
72
+
73
+ def _value(argv, i):
74
+ if i + 1 >= len(argv):
75
+ die(f"{argv[i]} needs a value")
76
+ return argv[i + 1]
77
+
78
+
79
+ def parse_args(argv):
80
+ global _COLOR
81
+ opts = {
82
+ "delete": False,
83
+ "yes": False,
84
+ "staleSpec": "90d",
85
+ "mergedOnly": False,
86
+ "remote": False,
87
+ "remoteName": "origin",
88
+ "protect": [],
89
+ "force": False,
90
+ "json": False,
91
+ }
92
+ i = 0
93
+ while i < len(argv):
94
+ a = argv[i]
95
+ if a == "--delete":
96
+ opts["delete"] = True; i += 1; continue
97
+ if a in ("--yes", "-y"):
98
+ opts["yes"] = True; i += 1; continue
99
+ if a == "--stale":
100
+ opts["staleSpec"] = _value(argv, i); i += 2; continue
101
+ if a == "--merged-only":
102
+ opts["mergedOnly"] = True; i += 1; continue
103
+ if a == "--remote":
104
+ opts["remote"] = True
105
+ nxt = argv[i + 1] if i + 1 < len(argv) else None
106
+ if nxt is not None and not nxt.startswith("-"):
107
+ opts["remoteName"] = nxt; i += 2
108
+ else:
109
+ i += 1
110
+ continue
111
+ if a == "--protect":
112
+ opts["protect"] = [s.strip() for s in _value(argv, i).split(",") if s.strip()]
113
+ i += 2; continue
114
+ if a == "--force":
115
+ opts["force"] = True; i += 1; continue
116
+ if a == "--json":
117
+ opts["json"] = True; i += 1; continue
118
+ if a == "--no-color":
119
+ _COLOR = False; i += 1; continue
120
+ if a == "--color":
121
+ _COLOR = True; i += 1; continue
122
+ die(f'unknown option "{a}" (try --help)')
123
+ return opts
124
+
125
+
126
+ # ---------------------------------------------------------------------------
127
+ # git IO adapter — the only impure layer. Everything routes through `_git`.
128
+ # ---------------------------------------------------------------------------
129
+
130
+ def _git(args, allow_fail=False):
131
+ try:
132
+ out = subprocess.run(
133
+ ["git"] + args,
134
+ stdout=subprocess.PIPE,
135
+ stderr=subprocess.PIPE,
136
+ check=True,
137
+ )
138
+ return out.stdout.decode("utf-8", "replace")
139
+ except FileNotFoundError:
140
+ die("git is not installed or not on PATH", 2)
141
+ except subprocess.CalledProcessError as e:
142
+ if allow_fail:
143
+ return None
144
+ msg = e.stderr.decode("utf-8", "replace").strip() if e.stderr else str(e)
145
+ raise RuntimeError(msg)
146
+
147
+
148
+ def ensure_git_repo():
149
+ out = _git(["rev-parse", "--is-inside-work-tree"], allow_fail=True)
150
+ if not out or out.strip() != "true":
151
+ die("not inside a git repository", 2)
152
+
153
+
154
+ def current_branch():
155
+ out = _git(["rev-parse", "--abbrev-ref", "HEAD"], allow_fail=True)
156
+ return out.strip() if out else None # "HEAD" when detached
157
+
158
+
159
+ def default_branch():
160
+ sym = _git(["symbolic-ref", "refs/remotes/origin/HEAD"], allow_fail=True)
161
+ if sym:
162
+ ref = sym.strip() # refs/remotes/origin/main
163
+ idx = ref.rfind("/")
164
+ if idx != -1 and "refs/remotes/" in ref:
165
+ # strip refs/remotes/<remote>/
166
+ parts = ref.split("/", 3)
167
+ if len(parts) == 4:
168
+ return parts[3]
169
+ for cand in ("main", "master"):
170
+ v = _git(["rev-parse", "--verify", "--quiet", f"refs/heads/{cand}"], allow_fail=True)
171
+ if v:
172
+ return cand
173
+ return "main"
174
+
175
+
176
+ def _parse_ref_list(out):
177
+ rows = []
178
+ for line in (out or "").split("\n"):
179
+ if not line.strip():
180
+ continue
181
+ sp = line.rfind(" ")
182
+ if sp < 0:
183
+ continue
184
+ name = line[:sp]
185
+ try:
186
+ unix = int(line[sp + 1:])
187
+ except ValueError:
188
+ continue
189
+ if not name:
190
+ continue
191
+ rows.append({"name": name, "lastCommitMs": unix * 1000})
192
+ return rows
193
+
194
+
195
+ def list_local_branches():
196
+ out = _git(["for-each-ref", "--format=%(refname:short) %(committerdate:unix)", "refs/heads"])
197
+ return _parse_ref_list(out)
198
+
199
+
200
+ def list_remote_branches(remote):
201
+ out = _git(["for-each-ref", "--format=%(refname:short) %(committerdate:unix)", f"refs/remotes/{remote}"])
202
+ prefix = f"{remote}/"
203
+ rows = []
204
+ for b in _parse_ref_list(out):
205
+ if b["name"] == f"{remote}/HEAD" or b["name"].endswith("/HEAD"):
206
+ continue
207
+ short = b["name"][len(prefix):] if b["name"].startswith(prefix) else b["name"]
208
+ rows.append({**b, "short": short})
209
+ return rows
210
+
211
+
212
+ def merged_set(base):
213
+ out = _git(["branch", "--merged", base], allow_fail=True)
214
+ if not out:
215
+ return set()
216
+ names = set()
217
+ for line in out.split("\n"):
218
+ name = line.lstrip("*+ ").strip()
219
+ if name:
220
+ names.add(name)
221
+ return names
222
+
223
+
224
+ def merged_set_remote(remote, base):
225
+ out = _git(["branch", "-r", "--merged", f"{remote}/{base}"], allow_fail=True)
226
+ if not out:
227
+ return set()
228
+ names = set()
229
+ for line in out.split("\n"):
230
+ name = line.strip()
231
+ if name and "->" not in name:
232
+ names.add(name)
233
+ return names
234
+
235
+
236
+ # ---------------------------------------------------------------------------
237
+ # preview + delete
238
+ # ---------------------------------------------------------------------------
239
+
240
+ def _fmt_age(last_commit_ms, now_ms):
241
+ d = core.age_days(last_commit_ms, now_ms)
242
+ if d == 0:
243
+ return "today"
244
+ if d == 1:
245
+ return "1d ago"
246
+ return f"{d}d ago"
247
+
248
+
249
+ def _pad(s, w):
250
+ s = str(s)
251
+ return s if len(s) >= w else s + " " * (w - len(s))
252
+
253
+
254
+ def print_preview(rows, now_ms):
255
+ name_w = max([6] + [len(r["name"]) for r in rows])
256
+ sys.stdout.write(
257
+ " " + _pad("BRANCH", name_w) + " " + _pad("LAST COMMIT", 12) + " " + _pad("MERGED", 7) + " ACTION\n"
258
+ )
259
+ for r in rows:
260
+ merged = "yes" if r["merged"] else "no"
261
+ if r["action"] == "delete":
262
+ action = red(f"delete ({r['reason']})")
263
+ else:
264
+ action = dim(f"keep ({r['reason']})")
265
+ sys.stdout.write(
266
+ " " + _pad(r["name"], name_w) + " " + _pad(_fmt_age(r["lastCommitMs"], now_ms), 12)
267
+ + " " + _pad(merged, 7) + " " + action + "\n"
268
+ )
269
+
270
+
271
+ def confirm(question):
272
+ sys.stdout.write(question)
273
+ sys.stdout.flush()
274
+ try:
275
+ answer = sys.stdin.readline()
276
+ except KeyboardInterrupt:
277
+ return False
278
+ return answer.strip().lower() in ("y", "yes")
279
+
280
+
281
+ def main(argv=None):
282
+ argv = sys.argv[1:] if argv is None else argv
283
+ if "-h" in argv or "--help" in argv:
284
+ sys.stdout.write(_help())
285
+ return 0
286
+ if "-v" in argv or "--version" in argv:
287
+ sys.stdout.write(VERSION + "\n")
288
+ return 0
289
+
290
+ opts = parse_args(argv)
291
+ global _COLOR
292
+ if opts["json"]:
293
+ _COLOR = False
294
+
295
+ try:
296
+ stale_ms = core.parse_duration(opts["staleSpec"])
297
+ except ValueError as e:
298
+ die(str(e))
299
+
300
+ ensure_git_repo()
301
+
302
+ now_ms = int(time.time() * 1000)
303
+ cur = current_branch()
304
+ base = default_branch()
305
+ protected_names = PROTECTED_DEFAULTS + opts["protect"] + ([base] if base else [])
306
+
307
+ if opts["remote"]:
308
+ remote = opts["remoteName"]
309
+ merged = merged_set_remote(remote, base)
310
+ branches = []
311
+ for b in list_remote_branches(remote):
312
+ branches.append({
313
+ "name": b["name"], # e.g. origin/feature/x
314
+ "short": b["short"], # e.g. feature/x (used for push --delete)
315
+ "lastCommitMs": b["lastCommitMs"],
316
+ "merged": b["name"] in merged,
317
+ "isProtected": b["short"] in protected_names or b["short"] == f"{remote}/HEAD",
318
+ "isCurrent": False, # remote refs are never the local HEAD
319
+ })
320
+ else:
321
+ merged = merged_set(base)
322
+ branches = []
323
+ for b in list_local_branches():
324
+ branches.append({
325
+ "name": b["name"],
326
+ "lastCommitMs": b["lastCommitMs"],
327
+ "merged": b["name"] in merged,
328
+ "isProtected": b["name"] in protected_names,
329
+ "isCurrent": b["name"] == cur,
330
+ })
331
+
332
+ policy = {"staleMs": stale_ms, "mergedOnly": opts["mergedOnly"], "protected": protected_names}
333
+ result = core.select_branches(branches, policy, now_ms)
334
+ to_delete = result["toDelete"]
335
+ to_keep = result["toKeep"]
336
+
337
+ by_name = {b["name"]: b for b in branches}
338
+ delete_rows = [{**by_name[d["name"]], "action": "delete", "reason": d["reason"]} for d in to_delete]
339
+ keep_rows = [{**by_name[k["name"]], "action": "keep", "reason": k["reason"]} for k in to_keep]
340
+
341
+ if opts["json"]:
342
+ out = {
343
+ "mode": "remote" if opts["remote"] else "local",
344
+ "remote": opts["remoteName"] if opts["remote"] else None,
345
+ "defaultBranch": base,
346
+ "current": cur,
347
+ "stale": opts["staleSpec"],
348
+ "mergedOnly": opts["mergedOnly"],
349
+ "dryRun": not opts["delete"],
350
+ "toDelete": [{"name": d["name"], "reason": d["reason"]} for d in to_delete],
351
+ "toKeep": [{"name": k["name"], "reason": k["reason"]} for k in to_keep],
352
+ }
353
+ sys.stdout.write(json.dumps(out, separators=(",", ":"), ensure_ascii=False) + "\n")
354
+ # In JSON mode we never delete (no prompt surface). Treat as preview only.
355
+ return 0
356
+
357
+ scope = f"remote {bold(opts['remoteName'])}" if opts["remote"] else "local"
358
+ sys.stdout.write(
359
+ f"{bold('branchtidy')} {scope} branches · default {green(base)} · stale > {yellow(opts['staleSpec'])}\n\n"
360
+ )
361
+
362
+ print_preview(delete_rows + keep_rows, now_ms)
363
+ sys.stdout.write("\n")
364
+
365
+ if not to_delete:
366
+ sys.stdout.write(green("Nothing to clean up — all branches kept.\n"))
367
+ return 0
368
+
369
+ unmerged = [r for r in delete_rows if not r["merged"]]
370
+ blocked_unmerged = [] if opts["force"] else unmerged
371
+ actually_deletable = delete_rows if opts["force"] else [r for r in delete_rows if r["merged"]]
372
+
373
+ if not opts["delete"]:
374
+ sys.stdout.write(
375
+ dim(f"Dry run. {len(to_delete)} branch(es) WOULD be deleted. Re-run with ")
376
+ + bold("--delete") + dim(" to apply.\n")
377
+ )
378
+ if blocked_unmerged:
379
+ sys.stdout.write(
380
+ yellow(f" ({len(blocked_unmerged)} are unmerged — they need ")
381
+ + bold("--force") + yellow(" to delete.)\n")
382
+ )
383
+ return 0
384
+
385
+ # --- real deletion path ---
386
+ if not actually_deletable:
387
+ sys.stdout.write(
388
+ yellow("All deletable branches are unmerged; pass ") + bold("--force") + yellow(" to delete them.\n")
389
+ )
390
+ return 0
391
+
392
+ if not opts["yes"]:
393
+ where = f"from remote '{opts['remoteName']}'" if opts["remote"] else "locally"
394
+ names = ", ".join(r["name"] for r in actually_deletable)
395
+ verb = red("PERMANENTLY delete") if opts["remote"] else "delete"
396
+ ok = confirm(
397
+ f"About to {verb} {bold(str(len(actually_deletable)))} branch(es) {where}:\n {names}\nProceed? [y/N] "
398
+ )
399
+ if not ok:
400
+ sys.stdout.write("Aborted. Nothing deleted.\n")
401
+ return 0
402
+
403
+ failures = 0
404
+ for r in actually_deletable:
405
+ try:
406
+ if opts["remote"]:
407
+ _git(["push", opts["remoteName"], "--delete", r["short"]])
408
+ elif r["merged"]:
409
+ _git(["branch", "-d", r["name"]])
410
+ else:
411
+ _git(["branch", "-D", r["name"]]) # unmerged + --force
412
+ sys.stdout.write(green(f" deleted {r['name']}\n"))
413
+ except RuntimeError as e:
414
+ failures += 1
415
+ sys.stderr.write(red(f" failed {r['name']}: {e}\n"))
416
+
417
+ if blocked_unmerged:
418
+ sys.stdout.write(
419
+ yellow(f" skipped {len(blocked_unmerged)} unmerged branch(es) (no --force)\n")
420
+ )
421
+
422
+ return 1 if failures else 0
423
+
424
+
425
+ if __name__ == "__main__":
426
+ sys.exit(main())
@@ -0,0 +1,98 @@
1
+ """branchtidy core — pure branch-selection logic. No git, no fs, no clock, no
2
+ network. Everything here is a pure function of its arguments so this port and
3
+ the Node one are checked against the exact same input -> output vectors.
4
+
5
+ The one idea: given a snapshot of branches and a policy, decide which are safe
6
+ to delete and which to keep -- and *why*. The reasons are the product. A branch
7
+ cleaner that deletes work you still wanted is worse than useless, so the rule is
8
+ "when unsure, KEEP", and protected / current branches are removed from the
9
+ candidate set before any staleness math runs.
10
+
11
+ A branch is a dict:
12
+ {"name", "lastCommitMs", "merged", "isProtected", "isCurrent"}
13
+ A policy is a dict:
14
+ {"staleMs", "mergedOnly", "protected": [names]}
15
+ """
16
+
17
+ import math
18
+ import re
19
+
20
+ DAY_MS = 86400000 # 24 * 60 * 60 * 1000
21
+
22
+ _DURATION_RE = re.compile(r"^(\d+(?:\.\d+)?)\s*([smhdw]?)$")
23
+ _UNIT_MS = {"s": 1000, "m": 60000, "h": 3600000, "d": DAY_MS, "w": 7 * DAY_MS}
24
+
25
+
26
+ def parse_duration(spec):
27
+ """Parse a human duration like "90d" / "2w" / "12h" into milliseconds.
28
+ Supported units: s, m, h, d, w (case-insensitive). A bare number is days.
29
+ Raises ValueError on garbage so the CLI can surface a clean error."""
30
+ if isinstance(spec, bool):
31
+ raise ValueError(f'invalid duration "{spec}"')
32
+ if isinstance(spec, (int, float)):
33
+ if not math.isfinite(spec) or spec < 0:
34
+ raise ValueError(f'invalid duration "{spec}"')
35
+ return int(math.floor(spec * DAY_MS))
36
+ s = str(spec).strip().lower()
37
+ m = _DURATION_RE.match(s)
38
+ if not m:
39
+ raise ValueError(f'invalid duration "{spec}" (use e.g. 90d, 2w, 12h)')
40
+ n = float(m.group(1))
41
+ unit = m.group(2) or "d"
42
+ return int(math.floor(n * _UNIT_MS[unit]))
43
+
44
+
45
+ def age_days(last_commit_ms, now_ms):
46
+ """Whole-day age of a branch's last commit relative to now_ms, floored.
47
+ Negative (future-dated) ages clamp to 0. Pure integer math -- no datetime."""
48
+ diff = now_ms - last_commit_ms
49
+ if diff <= 0:
50
+ return 0
51
+ return diff // DAY_MS
52
+
53
+
54
+ def select_branches(branches, policy, now_ms):
55
+ """Split a list of branches into the ones safe to delete and the ones to
56
+ keep, each tagged with a human-readable reason.
57
+
58
+ Rules, in priority order (first match wins per branch):
59
+ 1. current branch (HEAD) -> KEEP, reason "current"
60
+ 2. protected (flag or policy list) -> KEEP, reason "protected"
61
+ 3. mergedOnly mode:
62
+ - merged -> DELETE, reason "merged"
63
+ - else -> KEEP, reason "active"
64
+ 4. default mode:
65
+ - merged -> DELETE, reason "merged"
66
+ - age > staleMs -> DELETE, reason "stale <N>d"
67
+ - else -> KEEP, reason "active"
68
+
69
+ The staleness test is strict ">": a branch exactly at the threshold is kept.
70
+ Conservative on purpose.
71
+ """
72
+ stale_ms = policy["staleMs"]
73
+ merged_only = bool(policy.get("mergedOnly"))
74
+ protected_set = set(policy.get("protected") or [])
75
+
76
+ to_delete = []
77
+ to_keep = []
78
+
79
+ for b in branches:
80
+ if b.get("isCurrent"):
81
+ to_keep.append({"name": b["name"], "reason": "current"})
82
+ continue
83
+ if b.get("isProtected") or b["name"] in protected_set:
84
+ to_keep.append({"name": b["name"], "reason": "protected"})
85
+ continue
86
+ if b.get("merged"):
87
+ to_delete.append({"name": b["name"], "reason": "merged"})
88
+ continue
89
+ if merged_only:
90
+ to_keep.append({"name": b["name"], "reason": "active"})
91
+ continue
92
+ age = age_days(b["lastCommitMs"], now_ms)
93
+ if now_ms - b["lastCommitMs"] > stale_ms:
94
+ to_delete.append({"name": b["name"], "reason": f"stale {age}d"})
95
+ continue
96
+ to_keep.append({"name": b["name"], "reason": "active"})
97
+
98
+ return {"toDelete": to_delete, "toKeep": to_keep}
@@ -0,0 +1,162 @@
1
+ Metadata-Version: 2.4
2
+ Name: branchtidy
3
+ Version: 0.1.0
4
+ Summary: Find and delete merged & stale git branches, safely. Dry-run by default, never touches main/master/develop/current, handles local + remote. Zero dependencies.
5
+ Author: yyfjj
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/jjdoor/branchtidy-py
8
+ Project-URL: Repository, https://github.com/jjdoor/branchtidy-py
9
+ Project-URL: Issues, https://github.com/jjdoor/branchtidy-py/issues
10
+ Keywords: git,branch,branches,cleanup,prune,stale,merged,cli,devtools,git-branch
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Topic :: Software Development :: Version Control :: Git
18
+ Classifier: Topic :: Utilities
19
+ Requires-Python: >=3.8
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Dynamic: license-file
23
+
24
+ # branchtidy
25
+
26
+ **Delete merged & stale git branches — safely.** branchtidy finds the local (and
27
+ optionally remote) branches that are already merged, or haven't seen a commit in
28
+ N days, previews them, and deletes them in one batch. It is **dry-run by
29
+ default**, refuses to touch `main` / `master` / `develop` / your current branch,
30
+ and won't nuke unmerged work unless you explicitly ask.
31
+
32
+ Zero dependencies (pure Python stdlib). Zero config. No daemon, no account.
33
+
34
+ ```bash
35
+ pipx run branchtidy
36
+ ```
37
+
38
+ ```
39
+ branchtidy local branches · default main · stale > 90d
40
+
41
+ BRANCH LAST COMMIT MERGED ACTION
42
+ feature/login 12d ago yes delete (merged)
43
+ feature/old-poc 210d ago no delete (stale 210d)
44
+ main 2d ago no keep (protected)
45
+ feature/wip 3d ago no keep (active)
46
+
47
+ Dry run. 2 branch(es) WOULD be deleted. Re-run with --delete to apply.
48
+ ```
49
+
50
+ Nothing was deleted. That's the point — you read the table, *then* decide.
51
+
52
+ ## Why another branch cleaner?
53
+
54
+ Everyone reinvents this as a throwaway `git branch --merged | grep -v ... | xargs`
55
+ one-liner, and those one-liners are exactly how people delete branches they
56
+ wanted. branchtidy's whole pitch is **safety + zero config**:
57
+
58
+ - **Dry-run is the default.** No flags → it only *prints* what it would do.
59
+ - **Real deletion is gated twice:** `--delete` *and* an interactive confirm
60
+ (skip the prompt only with `--yes`).
61
+ - **Protected branches are never candidates:** `main`, `master`, `develop`, the
62
+ current `HEAD`, plus anything you pass to `--protect`.
63
+ - **Merged vs unmerged is respected.** Merged branches use the safe
64
+ `git branch -d`. Unmerged branches are only deletable with an explicit
65
+ `--force` (which maps to `git branch -D`).
66
+ - **Remote deletion is double-gated:** it requires `--remote --delete` *and* its
67
+ own confirmation, and uses `git push <remote> --delete`.
68
+
69
+ When in doubt, branchtidy **keeps** the branch.
70
+
71
+ ## Install
72
+
73
+ ```bash
74
+ pipx run branchtidy # no install, run on demand
75
+ pip install branchtidy # or install the `branchtidy` command
76
+ ```
77
+
78
+ There's an identical Node build too: `npx branchtidy` / `npm i -g branchtidy`
79
+ (see [branchtidy](https://github.com/jjdoor/branchtidy)). Both ports share one
80
+ selection-vector table, so they make byte-for-byte identical decisions.
81
+
82
+ ## Usage
83
+
84
+ ```bash
85
+ branchtidy [options] # dry-run preview (default — deletes nothing)
86
+ branchtidy --delete # actually delete, after a confirm
87
+ ```
88
+
89
+ | Option | Description |
90
+ | --- | --- |
91
+ | `--delete` | Perform deletion. Without it, branchtidy only previews. |
92
+ | `--yes` | Skip the interactive confirm (use with `--delete`, e.g. in scripts). |
93
+ | `--stale <dur>` | Staleness threshold. Default `90d`. Accepts `30d`, `2w`, `12h`, `45m`, `30s`, or a bare number (days). |
94
+ | `--merged-only` | Only delete *merged* branches; never delete on age alone. |
95
+ | `--remote [name]` | Operate on remote-tracking branches (default remote: `origin`). |
96
+ | `--protect <a,b>` | Extra branch names to never delete (comma-separated). |
97
+ | `--force` | Allow deleting **unmerged** branches (maps to `git branch -D`). |
98
+ | `--json` | Machine-readable output; never prompts, never deletes (preview only). |
99
+ | `--no-color` | Disable ANSI colors. |
100
+ | `-h, --help` | Show help. |
101
+ | `-v, --version` | Print version. |
102
+
103
+ Exit codes: `0` success/clean, `1` one or more deletions failed, `2` usage or
104
+ environment error (e.g. not a git repo).
105
+
106
+ ### Examples
107
+
108
+ ```bash
109
+ # what WOULD be cleaned up, right now?
110
+ branchtidy
111
+
112
+ # stricter window, only merged branches, do it (with a confirm)
113
+ branchtidy --stale 30d --merged-only --delete
114
+
115
+ # clean up gone-stale remote branches on origin (double-gated + confirm)
116
+ branchtidy --remote origin --delete
117
+
118
+ # delete unmerged stale branches too — you have to ask for it
119
+ branchtidy --stale 180d --delete --force
120
+
121
+ # protect a couple of long-lived branches by name
122
+ branchtidy --protect release/v1,staging --delete
123
+
124
+ # pipe the plan somewhere
125
+ branchtidy --json | jq '.toDelete'
126
+ ```
127
+
128
+ ## How it decides
129
+
130
+ For each branch branchtidy looks at: is it the current `HEAD`? is it protected?
131
+ is it merged into the default branch? how old is its last commit? Then:
132
+
133
+ 1. current branch → **keep** (`current`)
134
+ 2. protected (default set or `--protect`) → **keep** (`protected`)
135
+ 3. merged → **delete** (`merged`)
136
+ 4. otherwise, if older than `--stale` → **delete** (`stale <N>d`)
137
+ 5. otherwise → **keep** (`active`)
138
+
139
+ In `--merged-only` mode, step 4 is skipped entirely — age never causes a
140
+ deletion.
141
+
142
+ The default branch is resolved from `origin/HEAD` when available, otherwise it
143
+ falls back to `main`, then `master`.
144
+
145
+ ## Design notes
146
+
147
+ - **One pure function at the core.** `select_branches(branches, policy, now_ms)`
148
+ has no git, no fs, no clock — it's a pure data→data transform that returns
149
+ `{toDelete, toKeep}` with a reason on every branch. The CLI is a thin git
150
+ wrapper around it. That's what makes the Node and Python ports verifiably
151
+ identical: they run the same vector table.
152
+ - **Time is integer math.** Ages are computed from `committerdate:unix` against
153
+ a single captured `now` in milliseconds — no `datetime` parity to worry about
154
+ between languages.
155
+ - **Safe by construction.** Protected and current branches are filtered out
156
+ *before* any staleness logic runs, the staleness test is a strict `>` (a
157
+ branch exactly at the threshold is kept), and deletion always passes through
158
+ the safe `git branch -d` unless you opt into `-D` with `--force`.
159
+
160
+ ## License
161
+
162
+ MIT
@@ -0,0 +1,13 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/branchtidy/__init__.py
5
+ src/branchtidy/__main__.py
6
+ src/branchtidy/cli.py
7
+ src/branchtidy/core.py
8
+ src/branchtidy.egg-info/PKG-INFO
9
+ src/branchtidy.egg-info/SOURCES.txt
10
+ src/branchtidy.egg-info/dependency_links.txt
11
+ src/branchtidy.egg-info/entry_points.txt
12
+ src/branchtidy.egg-info/top_level.txt
13
+ tests/test_core.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ branchtidy = branchtidy.cli:main
@@ -0,0 +1 @@
1
+ branchtidy
@@ -0,0 +1,162 @@
1
+ import pytest
2
+
3
+ from branchtidy.core import select_branches, parse_duration, age_days
4
+
5
+ # A fixed "now" so the vectors are deterministic. 2024-06-10T00:00:00Z in ms.
6
+ NOW = 1717977600000
7
+ DAY = 86400000
8
+
9
+
10
+ def days_ago(n):
11
+ return NOW - n * DAY
12
+
13
+
14
+ STALE_90D = 90 * DAY
15
+
16
+ # Shared selection vectors. The Node port (test/core.test.js) runs this exact
17
+ # same table, so both languages are proven to agree on every selection decision.
18
+ #
19
+ # Each vector: (name, branches, policy, to_delete, to_keep)
20
+ # branch = {name, lastCommitMs, merged, isProtected, isCurrent}
21
+ # policy = {staleMs, mergedOnly, protected}
22
+ VECTORS = [
23
+ (
24
+ "protected branch is always kept",
25
+ [{"name": "main", "lastCommitMs": days_ago(400), "merged": False, "isProtected": True, "isCurrent": False}],
26
+ {"staleMs": STALE_90D, "mergedOnly": False, "protected": []},
27
+ [],
28
+ [{"name": "main", "reason": "protected"}],
29
+ ),
30
+ (
31
+ "current branch is always kept (even if stale + merged)",
32
+ [{"name": "feature/now", "lastCommitMs": days_ago(400), "merged": True, "isProtected": False, "isCurrent": True}],
33
+ {"staleMs": STALE_90D, "mergedOnly": False, "protected": []},
34
+ [],
35
+ [{"name": "feature/now", "reason": "current"}],
36
+ ),
37
+ (
38
+ "current takes priority over protected reason",
39
+ [{"name": "develop", "lastCommitMs": days_ago(10), "merged": False, "isProtected": True, "isCurrent": True}],
40
+ {"staleMs": STALE_90D, "mergedOnly": False, "protected": ["develop"]},
41
+ [],
42
+ [{"name": "develop", "reason": "current"}],
43
+ ),
44
+ (
45
+ "policy.protected list shields a branch by name",
46
+ [{"name": "release/v2", "lastCommitMs": days_ago(300), "merged": True, "isProtected": False, "isCurrent": False}],
47
+ {"staleMs": STALE_90D, "mergedOnly": False, "protected": ["release/v2"]},
48
+ [],
49
+ [{"name": "release/v2", "reason": "protected"}],
50
+ ),
51
+ (
52
+ "merged branch is deleted with reason merged",
53
+ [{"name": "feature/login", "lastCommitMs": days_ago(2), "merged": True, "isProtected": False, "isCurrent": False}],
54
+ {"staleMs": STALE_90D, "mergedOnly": False, "protected": []},
55
+ [{"name": "feature/login", "reason": "merged"}],
56
+ [],
57
+ ),
58
+ (
59
+ "unmerged but stale branch is deleted with day count",
60
+ [{"name": "feature/old", "lastCommitMs": days_ago(200), "merged": False, "isProtected": False, "isCurrent": False}],
61
+ {"staleMs": STALE_90D, "mergedOnly": False, "protected": []},
62
+ [{"name": "feature/old", "reason": "stale 200d"}],
63
+ [],
64
+ ),
65
+ (
66
+ "unmerged recent branch is kept as active",
67
+ [{"name": "feature/wip", "lastCommitMs": days_ago(5), "merged": False, "isProtected": False, "isCurrent": False}],
68
+ {"staleMs": STALE_90D, "mergedOnly": False, "protected": []},
69
+ [],
70
+ [{"name": "feature/wip", "reason": "active"}],
71
+ ),
72
+ (
73
+ "boundary: exactly at staleMs is KEPT (strict greater-than)",
74
+ [{"name": "feature/edge", "lastCommitMs": NOW - STALE_90D, "merged": False, "isProtected": False, "isCurrent": False}],
75
+ {"staleMs": STALE_90D, "mergedOnly": False, "protected": []},
76
+ [],
77
+ [{"name": "feature/edge", "reason": "active"}],
78
+ ),
79
+ (
80
+ "boundary: one ms past staleMs is deleted (stale 90d)",
81
+ [{"name": "feature/edge2", "lastCommitMs": NOW - STALE_90D - 1, "merged": False, "isProtected": False, "isCurrent": False}],
82
+ {"staleMs": STALE_90D, "mergedOnly": False, "protected": []},
83
+ [{"name": "feature/edge2", "reason": "stale 90d"}],
84
+ [],
85
+ ),
86
+ (
87
+ "mergedOnly: stale-but-unmerged is KEPT (active)",
88
+ [{"name": "feature/stale", "lastCommitMs": days_ago(300), "merged": False, "isProtected": False, "isCurrent": False}],
89
+ {"staleMs": STALE_90D, "mergedOnly": True, "protected": []},
90
+ [],
91
+ [{"name": "feature/stale", "reason": "active"}],
92
+ ),
93
+ (
94
+ "mergedOnly: merged branch still deleted",
95
+ [{"name": "feature/done", "lastCommitMs": days_ago(1), "merged": True, "isProtected": False, "isCurrent": False}],
96
+ {"staleMs": STALE_90D, "mergedOnly": True, "protected": []},
97
+ [{"name": "feature/done", "reason": "merged"}],
98
+ [],
99
+ ),
100
+ (
101
+ "future-dated commit clamps age to 0 and is kept active",
102
+ [{"name": "feature/clock-skew", "lastCommitMs": NOW + 10 * DAY, "merged": False, "isProtected": False, "isCurrent": False}],
103
+ {"staleMs": STALE_90D, "mergedOnly": False, "protected": []},
104
+ [],
105
+ [{"name": "feature/clock-skew", "reason": "active"}],
106
+ ),
107
+ (
108
+ "mixed repo: protected, current, merged, stale, active together",
109
+ [
110
+ {"name": "main", "lastCommitMs": days_ago(1), "merged": False, "isProtected": True, "isCurrent": False},
111
+ {"name": "feature/current", "lastCommitMs": days_ago(1), "merged": False, "isProtected": False, "isCurrent": True},
112
+ {"name": "feature/merged", "lastCommitMs": days_ago(3), "merged": True, "isProtected": False, "isCurrent": False},
113
+ {"name": "feature/ancient", "lastCommitMs": days_ago(365), "merged": False, "isProtected": False, "isCurrent": False},
114
+ {"name": "feature/fresh", "lastCommitMs": days_ago(2), "merged": False, "isProtected": False, "isCurrent": False},
115
+ ],
116
+ {"staleMs": STALE_90D, "mergedOnly": False, "protected": []},
117
+ [
118
+ {"name": "feature/merged", "reason": "merged"},
119
+ {"name": "feature/ancient", "reason": "stale 365d"},
120
+ ],
121
+ [
122
+ {"name": "main", "reason": "protected"},
123
+ {"name": "feature/current", "reason": "current"},
124
+ {"name": "feature/fresh", "reason": "active"},
125
+ ],
126
+ ),
127
+ ]
128
+
129
+
130
+ @pytest.mark.parametrize(
131
+ "name,branches,policy,to_delete,to_keep", VECTORS, ids=[v[0] for v in VECTORS]
132
+ )
133
+ def test_select_branches(name, branches, policy, to_delete, to_keep):
134
+ got = select_branches(branches, policy, NOW)
135
+ assert got["toDelete"] == to_delete
136
+ assert got["toKeep"] == to_keep
137
+
138
+
139
+ def test_parse_duration_units_and_default():
140
+ assert parse_duration("90d") == 90 * DAY
141
+ assert parse_duration("2w") == 14 * DAY
142
+ assert parse_duration("12h") == 12 * 3600000
143
+ assert parse_duration("30m") == 30 * 60000
144
+ assert parse_duration("45s") == 45 * 1000
145
+ assert parse_duration("7") == 7 * DAY # bare number = days
146
+ assert parse_duration("1.5d") == int(1.5 * DAY)
147
+ assert parse_duration(3) == 3 * DAY # numeric input = days
148
+
149
+
150
+ def test_parse_duration_rejects_garbage():
151
+ for bad in ("soon", "10y", ""):
152
+ with pytest.raises(ValueError):
153
+ parse_duration(bad)
154
+ with pytest.raises(ValueError):
155
+ parse_duration(-5)
156
+
157
+
158
+ def test_age_days():
159
+ assert age_days(days_ago(10), NOW) == 10
160
+ assert age_days(NOW - (5 * DAY + 1000), NOW) == 5 # floors partial day
161
+ assert age_days(NOW, NOW) == 0
162
+ assert age_days(NOW + 5 * DAY, NOW) == 0 # future -> 0