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.
- branchtidy-0.1.0/LICENSE +21 -0
- branchtidy-0.1.0/PKG-INFO +162 -0
- branchtidy-0.1.0/README.md +139 -0
- branchtidy-0.1.0/pyproject.toml +38 -0
- branchtidy-0.1.0/setup.cfg +4 -0
- branchtidy-0.1.0/src/branchtidy/__init__.py +6 -0
- branchtidy-0.1.0/src/branchtidy/__main__.py +6 -0
- branchtidy-0.1.0/src/branchtidy/cli.py +426 -0
- branchtidy-0.1.0/src/branchtidy/core.py +98 -0
- branchtidy-0.1.0/src/branchtidy.egg-info/PKG-INFO +162 -0
- branchtidy-0.1.0/src/branchtidy.egg-info/SOURCES.txt +13 -0
- branchtidy-0.1.0/src/branchtidy.egg-info/dependency_links.txt +1 -0
- branchtidy-0.1.0/src/branchtidy.egg-info/entry_points.txt +2 -0
- branchtidy-0.1.0/src/branchtidy.egg-info/top_level.txt +1 -0
- branchtidy-0.1.0/tests/test_core.py +162 -0
branchtidy-0.1.0/LICENSE
ADDED
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -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
|