git-reclaim 0.1.0__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,3 @@
1
+ """Reclaim disk space across a tree of git repositories."""
2
+
3
+ __version__ = "0.1.0"
git_reclaim/cli.py ADDED
@@ -0,0 +1,353 @@
1
+ """Command-line entry point for git-reclaim.
2
+
3
+ The tool is installed as ``git-reclaim``, so git runs it as ``git reclaim``.
4
+ It has three independent jobs:
5
+
6
+ * report and optionally purge regenerable cache directories
7
+ * compact every repository's ``.git`` with ``git gc``
8
+ * list clones that are safe to delete because the remote has everything
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import os
15
+ import shutil
16
+ import subprocess
17
+ import sys
18
+ from dataclasses import dataclass
19
+ from pathlib import Path
20
+
21
+ from git_reclaim import __version__
22
+
23
+ # Directory names that are regenerable and never source. dist/ and build/ are
24
+ # deliberately excluded because they are occasionally committed.
25
+ CACHE_NAMES = frozenset(
26
+ {
27
+ "node_modules",
28
+ ".venv",
29
+ "venv",
30
+ "__pycache__",
31
+ ".pytest_cache",
32
+ ".ruff_cache",
33
+ ".mypy_cache",
34
+ ".next",
35
+ "target",
36
+ }
37
+ )
38
+
39
+ # Directories not worth descending into while looking for repos. A directory
40
+ # that is itself a repo is detected before this list is applied.
41
+ PRUNE_NAMES = CACHE_NAMES | {".tox", "vendor"}
42
+
43
+ GIT_TIMEOUT = 60
44
+ GC_TIMEOUT = 600
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class Style:
49
+ """ANSI colours, disabled when output is not a terminal."""
50
+
51
+ red: str = ""
52
+ green: str = ""
53
+ yellow: str = ""
54
+ grey: str = ""
55
+ bold: str = ""
56
+ reset: str = ""
57
+
58
+ @classmethod
59
+ def auto(cls) -> Style:
60
+ if not sys.stdout.isatty() or os.environ.get("NO_COLOR"):
61
+ return cls()
62
+ return cls(
63
+ red="\033[31m",
64
+ green="\033[32m",
65
+ yellow="\033[33m",
66
+ grey="\033[90m",
67
+ bold="\033[1m",
68
+ reset="\033[0m",
69
+ )
70
+
71
+
72
+ def human(kb: int) -> str:
73
+ """Format a size in KiB the way the old shell version did."""
74
+ if kb >= 1024 * 1024:
75
+ return f"{kb / 1024 / 1024:.1f}G"
76
+ if kb >= 1024:
77
+ return f"{kb // 1024}M"
78
+ return f"{kb}K"
79
+
80
+
81
+ def du_kb(path: Path) -> int:
82
+ """Return disk usage for a path in KiB, without following symlinks."""
83
+ total = 0
84
+ for root, dirs, files in os.walk(path, followlinks=False):
85
+ for name in [*dirs, *files]:
86
+ entry = Path(root) / name
87
+ if entry.is_symlink():
88
+ continue
89
+ try:
90
+ stat = entry.lstat()
91
+ except OSError:
92
+ continue
93
+ blocks = getattr(stat, "st_blocks", 0)
94
+ total += blocks * 512 if blocks else stat.st_size
95
+ return total // 1024
96
+
97
+
98
+ def find_cache_dirs(root: Path) -> list[Path]:
99
+ """Find cache directories under root, without descending into them."""
100
+ found: list[Path] = []
101
+ for dirpath, dirnames, _ in os.walk(root, topdown=True, followlinks=False):
102
+ current = Path(dirpath)
103
+ keep: list[str] = []
104
+ for name in dirnames:
105
+ entry = current / name
106
+ if name == ".git" or entry.is_symlink():
107
+ continue
108
+ if name in CACHE_NAMES:
109
+ found.append(entry)
110
+ continue
111
+ keep.append(name)
112
+ dirnames[:] = keep
113
+ return found
114
+
115
+
116
+ def find_repos(root: Path) -> list[Path]:
117
+ """Find git repositories under root, stopping at each repo boundary."""
118
+ repos: list[Path] = []
119
+ for dirpath, dirnames, _ in os.walk(root, topdown=True, followlinks=False):
120
+ current = Path(dirpath)
121
+ if (current / ".git").exists():
122
+ repos.append(current)
123
+ dirnames[:] = []
124
+ continue
125
+
126
+ keep: list[str] = []
127
+ for name in dirnames:
128
+ entry = current / name
129
+ if name in PRUNE_NAMES or entry.is_symlink():
130
+ continue
131
+ keep.append(name)
132
+ dirnames[:] = keep
133
+ return repos
134
+
135
+
136
+ def git(
137
+ repo: Path, *args: str, timeout: int = GIT_TIMEOUT
138
+ ) -> subprocess.CompletedProcess[str]:
139
+ """Run git in a repository, capturing output."""
140
+ return subprocess.run(
141
+ ["git", "-C", str(repo), *args],
142
+ capture_output=True,
143
+ text=True,
144
+ timeout=timeout,
145
+ check=False,
146
+ )
147
+
148
+
149
+ def git_tracks_files(path: Path) -> bool:
150
+ """True when git tracks at least one file inside path."""
151
+ result = git(path, "ls-files")
152
+ return result.returncode == 0 and bool(result.stdout.strip())
153
+
154
+
155
+ def report_caches(root: Path, style: Style, purge: bool) -> None:
156
+ """Report caches and optionally delete them."""
157
+ caches = find_cache_dirs(root)
158
+ sizes = sorted(((du_kb(path), path) for path in caches), reverse=True)
159
+
160
+ print(f"{style.bold}Regenerable caches under {root}{style.reset}")
161
+ if not sizes:
162
+ print(f" {style.grey}none found{style.reset}")
163
+ return
164
+
165
+ for kb, path in sizes[:12]:
166
+ print(f" {human(kb):<8} {style.grey}{path.relative_to(root)}{style.reset}")
167
+ if len(sizes) > 12:
168
+ print(f" {style.grey}... and {len(sizes) - 12} more{style.reset}")
169
+
170
+ total = sum(kb for kb, _ in sizes)
171
+ print(
172
+ f" {style.bold}{len(sizes)} cache dirs, {human(total)} reclaimable{style.reset}"
173
+ )
174
+
175
+ if not purge:
176
+ print(f" {style.yellow}(dry run, pass -p to delete these){style.reset}")
177
+ return
178
+
179
+ before = shutil.disk_usage(root).free // 1024
180
+ purged = skipped = failed = 0
181
+ for _, path in sizes:
182
+ if git_tracks_files(path):
183
+ print(
184
+ f" {style.yellow}skipped {path.relative_to(root)} "
185
+ f"(git tracks files inside it){style.reset}"
186
+ )
187
+ skipped += 1
188
+ continue
189
+ try:
190
+ shutil.rmtree(path)
191
+ except OSError:
192
+ failed += 1
193
+ else:
194
+ purged += 1
195
+
196
+ after = shutil.disk_usage(root).free // 1024
197
+ freed = max(0, after - before)
198
+ print(
199
+ f" {style.green}-> purged {purged} of {len(sizes)} dirs, "
200
+ f"freed {human(freed)} (free: {human(before)} -> {human(after)}){style.reset}"
201
+ )
202
+ if failed:
203
+ print(f" {style.red}-> {failed} dir(s) could not be removed{style.reset}")
204
+
205
+
206
+ def report_gc(root: Path, style: Style) -> None:
207
+ """Run git gc, and git lfs prune when available, in every repo."""
208
+ print()
209
+ print(f"{style.bold}Compacting repo history (git gc){style.reset}")
210
+
211
+ have_lfs = shutil.which("git-lfs") is not None
212
+ count = before = after = 0
213
+
214
+ for repo in find_repos(root):
215
+ rev = git(repo, "rev-parse", "--absolute-git-dir")
216
+ if rev.returncode != 0:
217
+ continue
218
+ gitdir = Path(rev.stdout.strip())
219
+ size_before = du_kb(gitdir)
220
+ git(repo, "gc", "--quiet", timeout=GC_TIMEOUT)
221
+ if have_lfs:
222
+ git(repo, "lfs", "prune", timeout=GC_TIMEOUT)
223
+ size_after = du_kb(gitdir)
224
+
225
+ count += 1
226
+ before += size_before
227
+ after += size_after
228
+ if size_before > size_after:
229
+ print(
230
+ f" -{human(size_before - size_after):<7} "
231
+ f"{style.grey}{repo.relative_to(root)}{style.reset}"
232
+ )
233
+
234
+ saved = max(0, before - after)
235
+ print(
236
+ f" {style.bold}gc'd {count} repo(s): .git {human(before)} -> "
237
+ f"{human(after)} (saved {human(saved)}){style.reset}"
238
+ )
239
+
240
+
241
+ def report_safe_repos(root: Path, style: Style) -> None:
242
+ """List clones where the remote already has everything."""
243
+ print()
244
+ print(
245
+ f"{style.bold}Repos safe to delete (remote has everything), "
246
+ f"biggest first{style.reset}"
247
+ )
248
+
249
+ rows: list[tuple[int, str, Path, str]] = []
250
+ keep = 0
251
+
252
+ for repo in find_repos(root):
253
+ remotes = git(repo, "remote").stdout.strip()
254
+ dirty = git(repo, "status", "--porcelain").stdout.strip()
255
+ unpushed = git(
256
+ repo, "log", "--branches", "--not", "--remotes", "--oneline"
257
+ ).stdout.strip()
258
+ stash = git(repo, "stash", "list").stdout.strip()
259
+ tags = git(repo, "tag").stdout.strip()
260
+
261
+ if not remotes or dirty or unpushed or stash or tags:
262
+ keep += 1
263
+ continue
264
+
265
+ size = du_kb(repo)
266
+ last = git(repo, "log", "-1", "--format=%cs").stdout.strip()
267
+ url_result = git(repo, "remote", "get-url", "origin")
268
+ url = url_result.stdout.strip() if url_result.returncode == 0 else "?"
269
+ rows.append((size, last, repo, url))
270
+
271
+ rows.sort(key=lambda row: row[0], reverse=True)
272
+ for size, last, repo, url in rows:
273
+ print(
274
+ f" {human(size):<8} last:{last:<10} "
275
+ f"{str(repo.relative_to(root)):<44} {style.grey}{url}{style.reset}"
276
+ )
277
+
278
+ total = sum(size for size, _, _, _ in rows)
279
+ print(
280
+ f"{style.bold}{len(rows)} safe ({human(total)}) / {keep} keep{style.reset} "
281
+ f"{style.grey}(keep = dirty, unpushed, stashed, tagged, or no remote)"
282
+ f"{style.reset}"
283
+ )
284
+
285
+
286
+ def build_parser() -> argparse.ArgumentParser:
287
+ parser = argparse.ArgumentParser(
288
+ prog="git reclaim",
289
+ description="Reclaim disk space across a tree of git repositories.",
290
+ formatter_class=argparse.RawDescriptionHelpFormatter,
291
+ epilog=(
292
+ "(default) dry run: report reclaimable cache space; deletes nothing\n"
293
+ "-p purge the cache directories (git-tracked dirs are skipped)\n"
294
+ "-g run git gc (and git lfs prune) in every repo\n"
295
+ "-r list repos that are safe to delete (size, last commit, URL)\n"
296
+ "-d DIR directory to scan (default: current directory)"
297
+ ),
298
+ )
299
+ parser.add_argument(
300
+ "-p",
301
+ "--purge",
302
+ action="store_true",
303
+ help="delete the cache directories",
304
+ )
305
+ parser.add_argument(
306
+ "-g",
307
+ "--gc",
308
+ action="store_true",
309
+ help="compact every repository with git gc",
310
+ )
311
+ parser.add_argument(
312
+ "-r",
313
+ "--repos",
314
+ action="store_true",
315
+ help="list repositories that are safe to delete",
316
+ )
317
+ parser.add_argument(
318
+ "-d",
319
+ "--dir",
320
+ default=".",
321
+ metavar="DIR",
322
+ help="directory to scan (default: current directory)",
323
+ )
324
+ parser.add_argument(
325
+ "-V",
326
+ "--version",
327
+ action="version",
328
+ version=f"%(prog)s {__version__}",
329
+ )
330
+ return parser
331
+
332
+
333
+ def main(argv: list[str] | None = None) -> int:
334
+ args = build_parser().parse_args(argv)
335
+ root = Path(args.dir).expanduser()
336
+
337
+ if not root.is_dir():
338
+ print(f"Cannot cd into {root}", file=sys.stderr)
339
+ return 1
340
+
341
+ root = root.resolve()
342
+ style = Style.auto()
343
+
344
+ report_caches(root, style, args.purge)
345
+ if args.gc:
346
+ report_gc(root, style)
347
+ if args.repos:
348
+ report_safe_repos(root, style)
349
+ return 0
350
+
351
+
352
+ if __name__ == "__main__":
353
+ raise SystemExit(main())
@@ -0,0 +1,163 @@
1
+ Metadata-Version: 2.5
2
+ Name: git-reclaim
3
+ Version: 0.1.0
4
+ Summary: Reclaim disk space across a tree of git repositories
5
+ Project-URL: Homepage, https://github.com/michael-borck/git-reclaim
6
+ Project-URL: Repository, https://github.com/michael-borck/git-reclaim
7
+ Project-URL: Issues, https://github.com/michael-borck/git-reclaim/issues
8
+ Author-email: Michael Borck <michael@borck.dev>
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: cleanup,cli,disk-space,git,repository-management
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: MacOS
17
+ Classifier: Operating System :: POSIX :: Linux
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Topic :: Software Development :: Version Control :: Git
24
+ Classifier: Topic :: System :: Filesystems
25
+ Classifier: Topic :: Utilities
26
+ Requires-Python: >=3.10
27
+ Description-Content-Type: text/markdown
28
+
29
+ # git-reclaim
30
+
31
+ A small, dependency-free **disk-reclaim** helper for people who keep many git
32
+ clones side by side. It finds space you can safely get back across a whole tree
33
+ of repos, and flags clones that are safe to delete because the remote already
34
+ has everything.
35
+
36
+ `git-reclaim` is a [git custom subcommand](https://git-scm.com/book/en/v2/Git-Internals-Git-Internals):
37
+ an executable named `git-reclaim` on your `PATH` is runnable as `git reclaim`.
38
+
39
+ > **Scope:** `git-reclaim` does one thing: reclaim disk space across local
40
+ > clones. For multi-repo **status and sync** (branch, ahead/behind, batch
41
+ > `git` commands), use [gita](https://github.com/nosarthur/gita). For anything
42
+ > needing the **GitHub API** (bulk listing, health checks, topics, portfolio
43
+ > sites), use [gh-toolkit](https://github.com/michael-borck/gh-toolkit) or
44
+ > [`gh`](https://cli.github.com/). If you want one verb set across all of them,
45
+ > use [gx](https://github.com/michael-borck/gx).
46
+
47
+ ## Install
48
+
49
+ ```bash
50
+ uv tool install git-reclaim
51
+ # or
52
+ pipx install git-reclaim
53
+ # or
54
+ pip install git-reclaim
55
+ ```
56
+
57
+ No dependencies beyond Python 3.10+ and `git`.
58
+
59
+ ## `git reclaim`
60
+
61
+ ```
62
+ git reclaim [-p] [-g] [-r] [-d DIR]
63
+
64
+ (default) dry run: report reclaimable cache space; deletes nothing
65
+ -p purge the cache directories (any dir git tracks files inside is
66
+ skipped, never deleted)
67
+ -g compact history: run `git gc` (and `git lfs prune` if installed)
68
+ in every repo and report the .git space reclaimed
69
+ -r also list repos that are safe to delete (size + last commit + URL)
70
+ -d DIR directory to scan (default: current directory)
71
+ ```
72
+
73
+ Three ways to free space across a tree of clones:
74
+
75
+ - **Purge regenerable caches.** It finds `node_modules`, `.venv`/`venv`,
76
+ `__pycache__`, `.pytest_cache`, `.ruff_cache`, `.mypy_cache`, `.next`, and
77
+ `target`. These rebuild on demand. So `git reclaim -p` recovers space without
78
+ touching your source, history, or uncommitted work. If a directory with one
79
+ of these names turns out to be git-tracked (a committed `target/`, say), the
80
+ purge detects that and skips it. `dist/` and `build/` are deliberately
81
+ excluded because they are occasionally committed. Re-run `npm install` or
82
+ `uv sync` next time you open a project.
83
+
84
+ - **Compact `.git` itself.** `git reclaim -g` runs `git gc` (plus
85
+ `git lfs prune` when git-lfs is installed) in every repo and reports the
86
+ space recovered per repo. This is standard git maintenance: nothing reachable
87
+ is deleted, and lfs prune always retains unpushed objects.
88
+
89
+ - **Find repos that are safe to delete.** `git reclaim -r` lists repos where
90
+ the remote already has everything: clean working tree, has a remote, no
91
+ unpushed commits on any local branch, no stashes, and no tags. Rows are sorted
92
+ by size, with each repo's last-commit date and re-clone URL. Anything not
93
+ fully synced is counted as "keep" and never listed as safe. Tags are treated
94
+ conservatively because a local tag ref can be lost even when its commit is on
95
+ the remote.
96
+
97
+ ### How discovery works
98
+
99
+ It walks the tree but **prunes at repo boundaries**. The moment a directory
100
+ contains a `.git` entry it is recorded as a repo and not descended into. So
101
+ workspace folders are descended into to find their child repos, while a repo's
102
+ own working tree (and its `node_modules`, `vendor`, submodules, etc.) is not
103
+ re-scanned. `.git` is detected as a **file or directory**, so submodule and
104
+ worktree clones are recognised too.
105
+
106
+ ### Examples
107
+
108
+ ```bash
109
+ git reclaim # what could I free from caches? (dry run)
110
+ git reclaim -p # actually purge the caches
111
+ git reclaim -g # repack every repo's .git (git gc)
112
+ git reclaim -r # caches report + list of repos safe to delete
113
+ git reclaim -r -d ~/work # scan a different tree
114
+ ```
115
+
116
+ > `git reclaim` only ever deletes **caches**, and only with `-p`; `-g` just
117
+ > repacks history in place. It never deletes a repo. It just reports which are
118
+ > safe, so you stay in control of removing whole clones. Pair it with the
119
+ > re-clone URLs it prints.
120
+
121
+ ## Using it from `gita`
122
+
123
+ If you manage your clones with `gita`, you can make `git-reclaim` feel native.
124
+ gita loads a user command file from `~/.config/gita/cmds.json` and lets custom
125
+ commands shadow the defaults. Add:
126
+
127
+ ```json
128
+ {
129
+ "reclaim": {
130
+ "cmd": "git-reclaim -d .",
131
+ "disable_async": true,
132
+ "help": "reclaim caches / flag safe-to-delete clones"
133
+ }
134
+ }
135
+ ```
136
+
137
+ Now `gita reclaim` (or `gita reclaim <group>`) runs `git-reclaim` in each
138
+ registered repo. No-config alternative:
139
+
140
+ ```bash
141
+ gita shell 'git-reclaim -d .'
142
+ ```
143
+
144
+ Note this runs it **per repo**, which suits the cache purge (`-p`) and
145
+ `git gc` (`-g`) modes. For the cross-tree "what's safe to delete, biggest
146
+ first" view (`-r`), run `git-reclaim` standalone against the parent folder
147
+ (`git reclaim -r -d ~/projects`) so you get the single aggregate, size-sorted
148
+ report.
149
+
150
+ ## Development
151
+
152
+ ```bash
153
+ uv sync --group dev
154
+ uv run pytest
155
+ uv run ruff check src tests
156
+ uv run ruff format --check src tests
157
+ ```
158
+
159
+ CI runs the same checks on every push and pull request.
160
+
161
+ ## License
162
+
163
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,7 @@
1
+ git_reclaim/__init__.py,sha256=1qFGzD8CMX6-z99zZbqjVZmzC5RdsybxS-HE7iElAN8,83
2
+ git_reclaim/cli.py,sha256=0iT6BBE2mzEchPAudt6Hex1ONmMWEtDiMS00QQsJpaU,10680
3
+ git_reclaim-0.1.0.dist-info/METADATA,sha256=qVCymBLngjlLuz_PnBXDdNVuYfmDjFEZyUDJ74psv6U,6328
4
+ git_reclaim-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
5
+ git_reclaim-0.1.0.dist-info/entry_points.txt,sha256=wKeZHp8QvfLR8zbSRUCeUfqcOrCfWN-qb37TfwFCiXc,53
6
+ git_reclaim-0.1.0.dist-info/licenses/LICENSE,sha256=tP28nznO05HKBQN8uZ9NSvmcpzCWMj4P8e6F7IDj24Q,1070
7
+ git_reclaim-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ git-reclaim = git_reclaim.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Michael Borck
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.