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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ git-unneeded = git_unneeded:main
@@ -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())