git-unneeded 1.0.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: git-unneeded
|
|
3
|
+
Version: 1.0.2
|
|
4
|
+
Summary: Determine whether we don't need this clone (or some of its branches) anymore.
|
|
5
|
+
Requires-Python: >=3.12
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: gitpython>=3.1.46
|
|
8
|
+
|
|
9
|
+
# git unneeded
|
|
10
|
+
|
|
11
|
+
Determine whether we don't need this clone (or some of its branches) anymore.
|
|
12
|
+
|
|
13
|
+
If a branch has been pushed/merged, we don't need to keep it.
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
With uv: `uv tool install git-unneeded`
|
|
19
|
+
|
|
20
|
+
With pipx: `pipx install git-unneeded`
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
git unneeded -h
|
|
27
|
+
```
|
|
28
|
+
<!-- [[[cog
|
|
29
|
+
import os
|
|
30
|
+
os.environ["COLUMNS"] = "80"
|
|
31
|
+
from argparse_help_markdown import run
|
|
32
|
+
run(filename="git_unneeded.py", include_usage=True, writer=None)
|
|
33
|
+
]]] -->
|
|
34
|
+
```
|
|
35
|
+
usage: git_unneeded.py [-h] [--color {never,always,auto}] [--debug] [--quiet]
|
|
36
|
+
[--oneline] [--skip-unknown-directories] [--no-fetch]
|
|
37
|
+
[--no-search-parent]
|
|
38
|
+
[directory ...]
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
| Options | Values | Help |
|
|
42
|
+
| ------- | ------- | ---- |
|
|
43
|
+
| *positional arguments* | |
|
|
44
|
+
| <pre>directory</pre> | Default: `.` | GIT\_DIR. Default current directory. |
|
|
45
|
+
| *options* | |
|
|
46
|
+
| <pre>-h --help</pre> | Flag. | show this help message and exit |
|
|
47
|
+
| <pre>--color</pre> | Choice: `never`, `always`, `auto`<br/>Default: `auto` | |
|
|
48
|
+
| <pre>--debug</pre> | Flag. | Exact git commands and decisions. |
|
|
49
|
+
| <pre>--quiet -q -s</pre> | Flag. | Just safe/not, no justification. |
|
|
50
|
+
| <pre>--oneline</pre> | Flag. | Just output directory\\0True. Implies --quiet. |
|
|
51
|
+
| <pre>--skip-unknown-directories</pre> | Flag. | If a passed directory isn\'t a git repo, skip it. Not considered a failure. |
|
|
52
|
+
| <pre>--no-fetch</pre> | Flag. | Don\'t connect to any configured remotes. Local cache might be old. |
|
|
53
|
+
| <pre>--no-search-parent</pre> | Flag. | Don\'t search up parent directories for .git. |
|
|
54
|
+
<!-- [[[end]]] -->
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
git_unneeded.py,sha256=qlZRT57mD6WwNJo_ee4pBNiPerxHFSMYdI6eNEweGII,11616
|
|
2
|
+
git_unneeded-1.0.2.dist-info/METADATA,sha256=g4NohqnADsFzNwPO6M-G7IOoemkssDstu1wiTbRYBfY,1849
|
|
3
|
+
git_unneeded-1.0.2.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
4
|
+
git_unneeded-1.0.2.dist-info/entry_points.txt,sha256=z9EAQ_nOT-RrNrDMCSLke_HzZj3z0A5MaJ8qRvPwzcM,51
|
|
5
|
+
git_unneeded-1.0.2.dist-info/top_level.txt,sha256=X8PiP7nOs9KFKTshNz6BZdHmpWKmRBTlpKSn31M-TnU,13
|
|
6
|
+
git_unneeded-1.0.2.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
git_unneeded
|
git_unneeded.py
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
#!/usr/bin/env -S uv run --script
|
|
2
|
+
# /// script
|
|
3
|
+
# requires-python = ">=3.12"
|
|
4
|
+
# dependencies = [
|
|
5
|
+
# "gitpython>=3.1.46",
|
|
6
|
+
# ]
|
|
7
|
+
# ///
|
|
8
|
+
|
|
9
|
+
"""
|
|
10
|
+
Determine whether we don't need this clone (or some of its branches) anymore.
|
|
11
|
+
|
|
12
|
+
If a branch has been pushed/merged, we don't need to keep it.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
from argparse import ArgumentParser, Namespace
|
|
19
|
+
from collections.abc import Generator, Sequence
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from datetime import UTC, datetime, timedelta
|
|
22
|
+
from functools import partial
|
|
23
|
+
from textwrap import indent
|
|
24
|
+
from typing import IO
|
|
25
|
+
|
|
26
|
+
import git
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Colors:
|
|
32
|
+
"""Minimal piece of stdlib _colorize"""
|
|
33
|
+
|
|
34
|
+
RESET = "\x1b[0m"
|
|
35
|
+
BOLD_RED = "\x1b[1;31m"
|
|
36
|
+
BOLD_GREEN = "\x1b[1;32m"
|
|
37
|
+
BOLD_BLUE = "\x1b[1;34m"
|
|
38
|
+
BOLD = "\x1b[1m"
|
|
39
|
+
|
|
40
|
+
INTENSE_GREEN = "\x1b[92m"
|
|
41
|
+
GREEN = "\x1b[32m"
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def disable(cls) -> None:
|
|
45
|
+
for attr in cls.__dict__.keys():
|
|
46
|
+
if not attr.startswith("__"):
|
|
47
|
+
setattr(cls, attr, "")
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def can(cls, file: IO[str] | IO[bytes]) -> bool:
|
|
51
|
+
# overrides
|
|
52
|
+
if os.environ.get("NO_COLOR", None):
|
|
53
|
+
return False
|
|
54
|
+
if os.environ.get("FORCE_COLOR", None):
|
|
55
|
+
return True
|
|
56
|
+
if os.environ.get("TERM", None) == "dumb":
|
|
57
|
+
return False
|
|
58
|
+
|
|
59
|
+
if not hasattr(file, "fileno"):
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
return os.isatty(file.fileno())
|
|
64
|
+
except OSError:
|
|
65
|
+
return hasattr(file, "isatty") and file.isatty()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class Safe:
|
|
70
|
+
repo_path: git.PathLike = field(repr=False, hash=False, compare=False)
|
|
71
|
+
reason: str
|
|
72
|
+
suggestions: Sequence[str]
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def _major_color(self) -> str:
|
|
76
|
+
return Colors.GREEN
|
|
77
|
+
|
|
78
|
+
def __init__(self, repo: git.Repo, reason: str, suggestions: Sequence[str] = ()) -> None:
|
|
79
|
+
if repo.working_dir:
|
|
80
|
+
self.repo_path = repo.working_dir
|
|
81
|
+
else:
|
|
82
|
+
self.repo_path = repo.git_dir
|
|
83
|
+
self.reason = reason
|
|
84
|
+
self.suggestions = suggestions
|
|
85
|
+
|
|
86
|
+
def format(self, with_repo: bool = False) -> str:
|
|
87
|
+
repo_chunk = f" - {self.repo_path}" if with_repo else ""
|
|
88
|
+
return "\n".join(
|
|
89
|
+
[f"{self._major_color}{self.__class__.__name__}{Colors.RESET}{Colors.BOLD}:{Colors.RESET} {self.reason}{repo_chunk}"]
|
|
90
|
+
+ [f" => {suggestion}" for suggestion in self.suggestions]
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
def __str__(self) -> str:
|
|
94
|
+
return self.format()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class Unsafe(Safe):
|
|
98
|
+
@property
|
|
99
|
+
def _major_color(self) -> str:
|
|
100
|
+
return Colors.GREEN
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def prune_probability_key(branch: git.Head) -> int:
|
|
104
|
+
if is_main_branch(branch):
|
|
105
|
+
return 2
|
|
106
|
+
if branch.tracking_branch():
|
|
107
|
+
return 1
|
|
108
|
+
return 0
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def is_main_branch(branch: git.Head) -> bool:
|
|
112
|
+
return branch.path in ("refs/heads/main", "refs/heads/master")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def describe_commit_one_line(c: git.Commit) -> str:
|
|
116
|
+
return f"{c.hexsha} {c.committed_datetime} {c.committer}: {str(c.summary)}"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def repository_safe_to_delete(repo: git.Repo, fetch: bool = True) -> Generator[Safe | Unsafe, None, None]:
|
|
120
|
+
repo_logger = logger.getChild(str(repo.git_dir))
|
|
121
|
+
|
|
122
|
+
repo_logger.debug("starting vvvv")
|
|
123
|
+
if repo.is_dirty(untracked_files=True, working_tree=True, index=True):
|
|
124
|
+
if repo.untracked_files:
|
|
125
|
+
yield Unsafe(repo, "Untracked files in working directory", repo.untracked_files[:10])
|
|
126
|
+
else:
|
|
127
|
+
yield Unsafe(repo, "Repo is dirty.", ("Run git status for details.",))
|
|
128
|
+
|
|
129
|
+
if fetch:
|
|
130
|
+
for r in repo.remotes:
|
|
131
|
+
# fetch up to date information for each remote.
|
|
132
|
+
# this might download data. Oh well.
|
|
133
|
+
repo_logger.info(f"Fetching remote {r=}")
|
|
134
|
+
r.fetch(verbose=True) # needs network access to update
|
|
135
|
+
|
|
136
|
+
pretend_deleted_local_branches: list[git.Head] = []
|
|
137
|
+
|
|
138
|
+
for subject_branch in sorted(repo.branches, key=prune_probability_key): # sort main last
|
|
139
|
+
repo_logger.debug(f"Considering {subject_branch=} for pretend-deletion")
|
|
140
|
+
if is_main_branch(subject_branch):
|
|
141
|
+
# special case two branches we probably never want to consider "branched"
|
|
142
|
+
continue
|
|
143
|
+
|
|
144
|
+
for possibly_merged_into_branch in sorted(repo.branches, key=prune_probability_key, reverse=True): # sort main first
|
|
145
|
+
if subject_branch == possibly_merged_into_branch:
|
|
146
|
+
continue # that's us, skip
|
|
147
|
+
|
|
148
|
+
if possibly_merged_into_branch in pretend_deleted_local_branches:
|
|
149
|
+
continue # already pretend-deleted, so it's not a safe place to keep commits
|
|
150
|
+
|
|
151
|
+
subject_branch_commits = list(repo.iter_commits(f"{possibly_merged_into_branch.path}..{subject_branch.path}"))
|
|
152
|
+
|
|
153
|
+
if subject_branch_commits == []:
|
|
154
|
+
yield Safe(
|
|
155
|
+
repo,
|
|
156
|
+
f"delete branch {subject_branch.path} @ {subject_branch.commit} - also on {possibly_merged_into_branch.path}",
|
|
157
|
+
[
|
|
158
|
+
f"git branch --points-at {possibly_merged_into_branch.name}",
|
|
159
|
+
f"git branch --verbose -d {subject_branch.name}",
|
|
160
|
+
],
|
|
161
|
+
)
|
|
162
|
+
pretend_deleted_local_branches.append(subject_branch)
|
|
163
|
+
break
|
|
164
|
+
|
|
165
|
+
if pretend_deleted_local_branches:
|
|
166
|
+
repo_logger.debug(f"{pretend_deleted_local_branches=}")
|
|
167
|
+
|
|
168
|
+
for b in repo.branches:
|
|
169
|
+
bl = repo_logger.getChild(f"branch={b.name}")
|
|
170
|
+
|
|
171
|
+
tracking_branch = b.tracking_branch()
|
|
172
|
+
if not tracking_branch:
|
|
173
|
+
# is this branch a simple rename of or fully contained in some other branch?
|
|
174
|
+
if b not in pretend_deleted_local_branches:
|
|
175
|
+
yield Unsafe(
|
|
176
|
+
repo,
|
|
177
|
+
f"Local branch {b.name} is not known to remotes, and has commits.",
|
|
178
|
+
[r.url for r in repo.remotes] + [describe_commit_one_line(b.commit)],
|
|
179
|
+
)
|
|
180
|
+
continue
|
|
181
|
+
|
|
182
|
+
# branch b tracks remote branch
|
|
183
|
+
if tracking_branch.commit == b.commit:
|
|
184
|
+
# common & fast path
|
|
185
|
+
bl.info(f"Branch {b.path} and remote {tracking_branch.path} point to same commit {b.commit}")
|
|
186
|
+
continue
|
|
187
|
+
|
|
188
|
+
bl.info(f"Local branch {b.path} points to commit {b.commit}")
|
|
189
|
+
bl.info(f"Remote branch {tracking_branch.path} points to commit {tracking_branch.commit}")
|
|
190
|
+
|
|
191
|
+
# refs point to different commits.
|
|
192
|
+
# is one ahead of the other? Or did they diverge?
|
|
193
|
+
|
|
194
|
+
remote_has_commits_we_dont_have = list(repo.iter_commits(f"{b.path}..{tracking_branch.path}"))
|
|
195
|
+
if remote_has_commits_we_dont_have:
|
|
196
|
+
bl.info(f"{tracking_branch.path=} has commits we don't have on {b.path}: {remote_has_commits_we_dont_have}")
|
|
197
|
+
|
|
198
|
+
we_have_commits_remote_doesnt_have = list(repo.iter_commits(f"{tracking_branch.path}..{b.path}"))
|
|
199
|
+
if we_have_commits_remote_doesnt_have:
|
|
200
|
+
yield Unsafe(
|
|
201
|
+
repo,
|
|
202
|
+
f"Local branch {b.path} has commits that {tracking_branch.path} lacks.",
|
|
203
|
+
[f"git log {tracking_branch.path}..{b.path}"] + [describe_commit_one_line(c) for c in we_have_commits_remote_doesnt_have],
|
|
204
|
+
)
|
|
205
|
+
elif not is_main_branch(b):
|
|
206
|
+
# don't bother saying that main could be deleted
|
|
207
|
+
yield Safe(repo, f"Local branch {b.path} is behind {tracking_branch.path}.", [f"git branch --all --contains {b.commit}"])
|
|
208
|
+
try:
|
|
209
|
+
latest_commits = list(repo.iter_commits(rev=b, since="7.days.ago", date_order=True, max_count=5))
|
|
210
|
+
except ValueError: # pragma: no cover
|
|
211
|
+
# does not have any commits yet
|
|
212
|
+
latest_commits = []
|
|
213
|
+
|
|
214
|
+
if latest_commits:
|
|
215
|
+
lc = latest_commits[0]
|
|
216
|
+
age_ago: timedelta = datetime.now(tz=UTC) - lc.committed_datetime
|
|
217
|
+
hours_ago = int(age_ago.total_seconds() / 3600)
|
|
218
|
+
if age_ago < timedelta(days=2):
|
|
219
|
+
yield Unsafe(
|
|
220
|
+
repo, f"Branch {b} might be active - last commit was {hours_ago} hours ago.", [describe_commit_one_line(c) for c in latest_commits]
|
|
221
|
+
)
|
|
222
|
+
else:
|
|
223
|
+
yield Safe(
|
|
224
|
+
repo, f"Branch {b} might be inactive - last commit was {hours_ago} hours ago.", [describe_commit_one_line(c) for c in latest_commits]
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
repo_logger.debug("finished ^^^^")
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def print_if_not_quiet(value: str, quiet: bool) -> None:
|
|
231
|
+
if not quiet:
|
|
232
|
+
return print(value)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def main() -> int:
|
|
236
|
+
parser = ArgumentParser(description=__doc__)
|
|
237
|
+
parser.add_argument("directory", nargs="*", default=["."], help="GIT_DIR. Default current directory.")
|
|
238
|
+
parser.add_argument("--color", choices=("never", "always", "auto"), default="auto")
|
|
239
|
+
parser.add_argument("--debug", action="store_true", help="Exact git commands and decisions.")
|
|
240
|
+
parser.add_argument("--quiet", "-q", "-s", action="store_true", help="Just safe/not, no justification.")
|
|
241
|
+
parser.add_argument("--oneline", action="store_true", help=f"Just output directory\\0{True}. Implies --quiet.")
|
|
242
|
+
parser.add_argument("--skip-unknown-directories", action="store_true", help="If a passed directory isn't a git repo, skip it. Not considered a failure.")
|
|
243
|
+
parser.add_argument("--no-fetch", dest="fetch_remotes", action="store_false", help="Don't connect to any configured remotes. Local cache might be old.")
|
|
244
|
+
parser.add_argument("--no-search-parent", dest="search_parent_directories", action="store_false", help="Don't search up parent directories for .git.")
|
|
245
|
+
|
|
246
|
+
args: Namespace = parser.parse_args()
|
|
247
|
+
|
|
248
|
+
if args.color == "auto":
|
|
249
|
+
args.color = "always" if Colors.can(sys.stdout) else "never"
|
|
250
|
+
if args.color == "never":
|
|
251
|
+
Colors.disable()
|
|
252
|
+
|
|
253
|
+
logging.basicConfig(level=logging.DEBUG if args.debug else logging.WARNING)
|
|
254
|
+
logger.root.name = parser.prog
|
|
255
|
+
|
|
256
|
+
exit_code = 0
|
|
257
|
+
|
|
258
|
+
if args.oneline:
|
|
259
|
+
args.quiet = True
|
|
260
|
+
|
|
261
|
+
p = partial(print_if_not_quiet, quiet=args.quiet)
|
|
262
|
+
|
|
263
|
+
repo_objects: list[git.Repo] = []
|
|
264
|
+
repo_directory: os.PathLike[str]
|
|
265
|
+
|
|
266
|
+
for repo_directory in args.directory:
|
|
267
|
+
try:
|
|
268
|
+
repo_objects.append(git.Repo(repo_directory, search_parent_directories=args.search_parent_directories))
|
|
269
|
+
except git.InvalidGitRepositoryError:
|
|
270
|
+
if args.skip_unknown_directories:
|
|
271
|
+
logger.warning(f"{repo_directory}: .git not found. Skipping.")
|
|
272
|
+
continue
|
|
273
|
+
parser.error(f"{repo_directory}: .git not found. Run from inside a cloned repository or pass on command-line.")
|
|
274
|
+
|
|
275
|
+
need_newline_before_heading = False
|
|
276
|
+
|
|
277
|
+
for repo in repo_objects:
|
|
278
|
+
safe_to_delete_repo = True
|
|
279
|
+
simple_repo_pathname: git.PathLike = repo.working_dir or repo.git_dir
|
|
280
|
+
|
|
281
|
+
if not args.oneline:
|
|
282
|
+
print(f"{'\n' if need_newline_before_heading else ''}{Colors.BOLD}{simple_repo_pathname}{Colors.RESET}")
|
|
283
|
+
need_newline_before_heading = True
|
|
284
|
+
|
|
285
|
+
with repo: # cleanup open files
|
|
286
|
+
reasons = repository_safe_to_delete(repo, fetch=args.fetch_remotes)
|
|
287
|
+
|
|
288
|
+
for reason in reasons:
|
|
289
|
+
if isinstance(reason, Unsafe):
|
|
290
|
+
safe_to_delete_repo = False
|
|
291
|
+
exit_code = 1
|
|
292
|
+
|
|
293
|
+
p(indent(str(reason), prefix=f" {Colors.BOLD_BLUE}|{Colors.RESET} "))
|
|
294
|
+
|
|
295
|
+
p("") # add a newline if not quiet
|
|
296
|
+
|
|
297
|
+
if args.oneline:
|
|
298
|
+
print(f"{simple_repo_pathname}\0{safe_to_delete_repo}")
|
|
299
|
+
else:
|
|
300
|
+
print(f"{"It's" if safe_to_delete_repo else 'Not'} safe to delete repo directory: {simple_repo_pathname}")
|
|
301
|
+
|
|
302
|
+
return exit_code
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
if __name__ == "__main__": # pragma: no cover
|
|
306
|
+
raise SystemExit(main())
|