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.
git_reclaim/__init__.py
ADDED
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,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.
|