kryptorious-gitsweep 1.0.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.
@@ -0,0 +1,17 @@
1
+ Metadata-Version: 2.4
2
+ Name: kryptorious-gitsweep
3
+ Version: 1.0.0
4
+ Summary: Git repository cleanup CLI — find stale branches, large files, and bloated history.
5
+ Author: Kryptorious Quantum Biosciences, Inc.
6
+ License: MIT
7
+ Project-URL: Homepage, https://devflow.sh
8
+ Project-URL: Repository, https://github.com/kryptorious/gitsweep
9
+ Requires-Python: >=3.9
10
+ Description-Content-Type: text/markdown
11
+ Requires-Dist: gitpython>=3.1
12
+ Requires-Dist: rich>=13.0
13
+ Requires-Dist: click>=8.0
14
+ Provides-Extra: dev
15
+ Requires-Dist: pytest>=7.0; extra == "dev"
16
+ Requires-Dist: black>=23.0; extra == "dev"
17
+ Requires-Dist: ruff>=0.1; extra == "dev"
@@ -0,0 +1,26 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "kryptorious-gitsweep"
7
+ version = "1.0.0"
8
+ description = "Git repository cleanup CLI — find stale branches, large files, and bloated history."
9
+ readme = "README.md"
10
+ license = {text = "MIT"}
11
+ requires-python = ">=3.9"
12
+ authors = [{name = "Kryptorious Quantum Biosciences, Inc."}]
13
+ dependencies = ["gitpython>=3.1", "rich>=13.0", "click>=8.0"]
14
+
15
+ [project.optional-dependencies]
16
+ dev = ["pytest>=7.0", "black>=23.0", "ruff>=0.1"]
17
+
18
+ [project.scripts]
19
+ gitsweep = "gitsweep.cli:main"
20
+
21
+ [project.urls]
22
+ Homepage = "https://devflow.sh"
23
+ Repository = "https://github.com/kryptorious/gitsweep"
24
+
25
+ [tool.setuptools.packages.find]
26
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,2 @@
1
+ """GitSweep — Git repository cleanup CLI."""
2
+ __version__ = "1.0.0"
@@ -0,0 +1,67 @@
1
+ """GitSweep CLI — main entry point."""
2
+
3
+ import click
4
+ from rich.console import Console
5
+
6
+ console = Console()
7
+
8
+
9
+ @click.group()
10
+ @click.version_option(version="1.0.0", prog_name="gitsweep")
11
+ def main():
12
+ """GitSweep — Clean up your git repositories.
13
+
14
+ Find stale branches, large files, and bloated history.
15
+ Sweep it clean.
16
+ """
17
+ pass
18
+
19
+
20
+ @main.command()
21
+ @click.option("--repo", "-r", default=".", help="Path to git repository")
22
+ @click.option("--merged/--all", default=True, help="Show only merged branches")
23
+ @click.option("--stale", "-s", default=90, help="Days since last commit to consider stale")
24
+ @click.option("--delete/--dry-run", default=False, help="Delete branches (premium feature)")
25
+ def branches(repo, merged, stale, delete):
26
+ """Find stale and merged branches."""
27
+ from .commands import sweep_branches
28
+ sweep_branches(repo=repo, merged_only=merged, stale_days=stale, delete=delete)
29
+
30
+
31
+ @main.command()
32
+ @click.option("--repo", "-r", default=".", help="Path to git repository")
33
+ @click.option("--size", "-s", default=1.0, help="Minimum file size in MB")
34
+ @click.option("--count", "-n", default=20, help="Number of largest files to show")
35
+ def large(repo, size, count):
36
+ """Find large files bloating your repository."""
37
+ from .commands import sweep_large
38
+ sweep_large(repo=repo, min_size_mb=size, top_n=count)
39
+
40
+
41
+ @main.command()
42
+ @click.option("--repo", "-r", default=".", help="Path to git repository")
43
+ @click.option("--days", "-d", default=365, help="Days of history to analyze")
44
+ @click.option("--author", "-a", default=None, help="Filter by author email")
45
+ def history(repo, days, author):
46
+ """Analyze commit history for bloat and patterns."""
47
+ from .commands import sweep_history
48
+ sweep_history(repo=repo, days=days, author=author)
49
+
50
+
51
+ @main.command()
52
+ @click.option("--repo", "-r", default=".", help="Path to git repository")
53
+ @click.option("--aggressive/--safe", default=False, help="Aggressive cleanup (premium)")
54
+ def clean(repo, aggressive):
55
+ """Run all sweeps and clean everything (premium)."""
56
+ console.print("[yellow]GitSweep clean is a premium feature.[/yellow]")
57
+ console.print("Upgrade at https://kryptorious.gumroad.com/l/jbvet")
58
+ console.print()
59
+ console.print("Running free sweeps instead...")
60
+ from .commands import sweep_branches, sweep_large, sweep_history
61
+ sweep_branches(repo=repo, merged_only=True, stale_days=90, delete=False)
62
+ sweep_large(repo=repo, min_size_mb=1, top_n=10)
63
+ sweep_history(repo=repo, days=90, author=None)
64
+
65
+
66
+ if __name__ == "__main__":
67
+ main()
@@ -0,0 +1,283 @@
1
+ """GitSweep commands."""
2
+
3
+ import os
4
+ import re
5
+ from datetime import datetime, timedelta, timezone
6
+ from pathlib import Path
7
+
8
+ import git
9
+ from rich.console import Console
10
+ from rich.panel import Panel
11
+ from rich.table import Table
12
+ from rich.text import Text
13
+
14
+ console = Console()
15
+
16
+
17
+ def _get_repo(repo_path: str) -> git.Repo:
18
+ """Get git repo object, searching upward if needed."""
19
+ path = Path(repo_path).resolve()
20
+ try:
21
+ return git.Repo(path, search_parent_directories=True)
22
+ except git.InvalidGitRepositoryError:
23
+ console.print(f"[red]Error:[/red] Not a git repository: {path}")
24
+ raise SystemExit(1)
25
+
26
+
27
+ def sweep_branches(repo: str = ".", merged_only: bool = True,
28
+ stale_days: int = 90, delete: bool = False):
29
+ """Find stale and merged branches."""
30
+ r = _get_repo(repo)
31
+ console.print()
32
+ console.print(Panel(f"[bold]GitSweep — Branches[/bold] in [cyan]{Path(r.working_dir).name}[/cyan]",
33
+ border_style="blue"))
34
+
35
+ try:
36
+ main_branch = r.active_branch.name
37
+ except (TypeError, ValueError):
38
+ # Detached HEAD — try to find main
39
+ for candidate in ["main", "master"]:
40
+ if candidate in r.heads:
41
+ main_branch = candidate
42
+ break
43
+ else:
44
+ main_branch = list(r.heads)[0].name if r.heads else "main"
45
+
46
+ console.print(f"Main branch: [bold]{main_branch}[/bold]")
47
+
48
+ stale_date = datetime.now(timezone.utc) - timedelta(days=stale_days)
49
+ candidates = []
50
+
51
+ for branch in r.heads:
52
+ if branch.name == main_branch:
53
+ continue
54
+
55
+ commit = branch.commit
56
+ commit_date = commit.committed_datetime
57
+
58
+ # Check if merged
59
+ is_merged = False
60
+ try:
61
+ r.git.merge_base("--is-ancestor", branch.name, main_branch)
62
+ is_merged = True
63
+ except git.GitCommandError:
64
+ is_merged = False
65
+
66
+ # Check if stale
67
+ is_stale = commit_date.replace(tzinfo=timezone.utc) < stale_date
68
+
69
+ if merged_only and not is_merged:
70
+ continue
71
+
72
+ candidates.append({
73
+ "name": branch.name,
74
+ "last_commit": commit_date.strftime("%Y-%m-%d"),
75
+ "author": commit.author.name,
76
+ "message": commit.message.split("\n")[0][:60],
77
+ "merged": is_merged,
78
+ "stale": is_stale,
79
+ })
80
+
81
+ # Sort: merged first, then by date
82
+ candidates.sort(key=lambda b: (not b["merged"], b["last_commit"]))
83
+
84
+ if not candidates:
85
+ console.print("[green]No stale or merged branches found. Repo is clean![/green]")
86
+ return
87
+
88
+ table = Table(title=f"Found {len(candidates)} branches to sweep")
89
+ table.add_column("Branch", style="cyan")
90
+ table.add_column("Last Commit", style="dim")
91
+ table.add_column("Author")
92
+ table.add_column("Status")
93
+ table.add_column("Message")
94
+
95
+ for b in candidates:
96
+ status = []
97
+ if b["merged"]:
98
+ status.append("[green]merged[/green]")
99
+ if b["stale"]:
100
+ status.append("[yellow]stale[/yellow]")
101
+ table.add_row(
102
+ b["name"],
103
+ b["last_commit"],
104
+ b["author"],
105
+ " ".join(status),
106
+ b["message"][:50]
107
+ )
108
+
109
+ console.print(table)
110
+ console.print()
111
+
112
+ if delete:
113
+ console.print("[red]Branch deletion is a premium feature.[/red]")
114
+ console.print("Upgrade at https://kryptorious.gumroad.com/l/jbvet")
115
+ else:
116
+ console.print("[yellow]Run with --delete to remove these branches (premium feature)[/yellow]")
117
+ console.print("Free version: copy these commands to delete manually:")
118
+ console.print()
119
+ for b in candidates:
120
+ console.print(f" git branch -d {b['name']}")
121
+
122
+
123
+ def sweep_large(repo: str = ".", min_size_mb: float = 1.0, top_n: int = 20):
124
+ """Find large files in git history."""
125
+ r = _get_repo(repo)
126
+ console.print()
127
+ console.print(Panel(f"[bold]GitSweep — Large Files[/bold] in [cyan]{Path(r.working_dir).name}[/cyan]",
128
+ border_style="blue"))
129
+
130
+ # Use git rev-list to find large objects
131
+ try:
132
+ # Find large files in HEAD
133
+ result = r.git.rev_list("--objects", "--all")
134
+ objects = {}
135
+ for line in result.split("\n"):
136
+ parts = line.split()
137
+ if len(parts) >= 2:
138
+ try:
139
+ size = r.git.cat_file("-s", parts[0])
140
+ size_bytes = int(size.strip())
141
+ if size_bytes >= min_size_mb * 1024 * 1024:
142
+ objects[parts[1] if len(parts) > 1 else parts[0]] = size_bytes
143
+ except (git.GitCommandError, ValueError):
144
+ pass
145
+ except git.GitCommandError:
146
+ console.print("[yellow]Could not analyze git objects. Trying alternative method...[/yellow]")
147
+ objects = {}
148
+
149
+ # Also find large tracked files in working tree
150
+ for root, dirs, files in os.walk(r.working_dir):
151
+ if ".git" in root:
152
+ continue
153
+ for f in files:
154
+ fp = os.path.join(root, f)
155
+ try:
156
+ size = os.path.getsize(fp)
157
+ if size >= min_size_mb * 1024 * 1024:
158
+ rel_path = os.path.relpath(fp, r.working_dir)
159
+ objects[rel_path] = size
160
+ except OSError:
161
+ pass
162
+
163
+ if not objects:
164
+ console.print(f"[green]No files larger than {min_size_mb}MB found![/green]")
165
+ return
166
+
167
+ # Sort by size descending
168
+ sorted_files = sorted(objects.items(), key=lambda x: x[1], reverse=True)[:top_n]
169
+
170
+ table = Table(title=f"Largest Files (>{min_size_mb}MB)")
171
+ table.add_column("File", style="cyan")
172
+ table.add_column("Size", justify="right")
173
+
174
+ for path, size_bytes in sorted_files:
175
+ if size_bytes >= 1024 * 1024:
176
+ size_str = f"{size_bytes / (1024 * 1024):.1f} MB"
177
+ else:
178
+ size_str = f"{size_bytes / 1024:.1f} KB"
179
+ table.add_row(path, size_str)
180
+
181
+ console.print(table)
182
+
183
+ total_size = sum(s for _, s in sorted_files)
184
+ console.print(f"\nTotal: [bold]{total_size / (1024 * 1024):.1f} MB[/bold] in {len(sorted_files)} files")
185
+
186
+ # Show git-filter-repo command for cleanup
187
+ console.print()
188
+ console.print("[yellow]To clean large files from history (premium feature):[/yellow]")
189
+ console.print("Upgrade at https://kryptorious.gumroad.com/l/jbvet")
190
+ console.print()
191
+ console.print("Free tip: Use [bold]git-filter-repo[/bold] or [bold]BFG Repo-Cleaner[/bold]")
192
+
193
+
194
+ def sweep_history(repo: str = ".", days: int = 365, author: str = None):
195
+ """Analyze commit history."""
196
+ r = _get_repo(repo)
197
+ console.print()
198
+ console.print(Panel(f"[bold]GitSweep — History[/bold] in [cyan]{Path(r.working_dir).name}[/cyan]",
199
+ border_style="blue"))
200
+
201
+ since = datetime.now(timezone.utc) - timedelta(days=days)
202
+
203
+ # Collect stats
204
+ try:
205
+ commits = list(r.iter_commits(since=since.isoformat()))
206
+ except (ValueError, git.GitCommandError):
207
+ console.print("No commits found in this repository.")
208
+ return
209
+ if author:
210
+ commits = [c for c in commits if c.author.email == author]
211
+
212
+ if not commits:
213
+ console.print(f"No commits in the last {days} days.")
214
+ return
215
+
216
+ # Author stats
217
+ author_stats = {}
218
+ for c in commits:
219
+ email = c.author.email
220
+ if email not in author_stats:
221
+ author_stats[email] = {"name": c.author.name, "commits": 0, "insertions": 0, "deletions": 0}
222
+ author_stats[email]["commits"] += 1
223
+ try:
224
+ stats = c.stats
225
+ author_stats[email]["insertions"] += stats.total.get("insertions", 0)
226
+ author_stats[email]["deletions"] += stats.total.get("deletions", 0)
227
+ except Exception:
228
+ pass
229
+
230
+ console.print(f"[bold]Last {days} days:[/bold] {len(commits)} commits by {len(author_stats)} authors")
231
+ console.print()
232
+
233
+ table = Table(title="Author Breakdown")
234
+ table.add_column("Author")
235
+ table.add_column("Commits", justify="right")
236
+ table.add_column("Insertions", justify="right")
237
+ table.add_column("Deletions", justify="right")
238
+ table.add_column("Net", justify="right")
239
+
240
+ for email, stats in sorted(author_stats.items(), key=lambda x: x[1]["commits"], reverse=True):
241
+ net = stats["insertions"] - stats["deletions"]
242
+ net_style = f"[green]+{net:,}[/green]" if net >= 0 else f"[red]{net:,}[/red]"
243
+ table.add_row(
244
+ stats["name"],
245
+ str(stats["commits"]),
246
+ f"[green]{stats['insertions']:,}[/green]",
247
+ f"[red]{stats['deletions']:,}[/red]",
248
+ net_style
249
+ )
250
+
251
+ console.print(table)
252
+
253
+ # Weekday patterns
254
+ weekday_counts = {i: 0 for i in range(7)}
255
+ for c in commits:
256
+ weekday_counts[c.committed_datetime.weekday()] += 1
257
+
258
+ days_names = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
259
+ console.print()
260
+ console.print("[bold]Commit activity by day:[/bold]")
261
+ max_count = max(weekday_counts.values()) if weekday_counts else 1
262
+ for i in range(7):
263
+ bar_len = int(weekday_counts[i] / max_count * 30) if max_count > 0 else 0
264
+ bar = "█" * bar_len
265
+ console.print(f" {days_names[i]}: {bar} {weekday_counts[i]}")
266
+
267
+ # File churn
268
+ file_changes = {}
269
+ for c in commits:
270
+ try:
271
+ for fpath, change in c.stats.files.items():
272
+ if fpath not in file_changes:
273
+ file_changes[fpath] = 0
274
+ file_changes[fpath] += change.get("lines", 0) or (change.get("insertions", 0) + change.get("deletions", 0))
275
+ except Exception:
276
+ pass
277
+
278
+ if file_changes:
279
+ top_files = sorted(file_changes.items(), key=lambda x: x[1], reverse=True)[:10]
280
+ console.print()
281
+ console.print("[bold]Most changed files:[/bold]")
282
+ for fpath, changes in top_files:
283
+ console.print(f" {changes:>6,} {fpath}")
@@ -0,0 +1,17 @@
1
+ Metadata-Version: 2.4
2
+ Name: kryptorious-gitsweep
3
+ Version: 1.0.0
4
+ Summary: Git repository cleanup CLI — find stale branches, large files, and bloated history.
5
+ Author: Kryptorious Quantum Biosciences, Inc.
6
+ License: MIT
7
+ Project-URL: Homepage, https://devflow.sh
8
+ Project-URL: Repository, https://github.com/kryptorious/gitsweep
9
+ Requires-Python: >=3.9
10
+ Description-Content-Type: text/markdown
11
+ Requires-Dist: gitpython>=3.1
12
+ Requires-Dist: rich>=13.0
13
+ Requires-Dist: click>=8.0
14
+ Provides-Extra: dev
15
+ Requires-Dist: pytest>=7.0; extra == "dev"
16
+ Requires-Dist: black>=23.0; extra == "dev"
17
+ Requires-Dist: ruff>=0.1; extra == "dev"
@@ -0,0 +1,10 @@
1
+ pyproject.toml
2
+ src/gitsweep/__init__.py
3
+ src/gitsweep/cli.py
4
+ src/gitsweep/commands.py
5
+ src/kryptorious_gitsweep.egg-info/PKG-INFO
6
+ src/kryptorious_gitsweep.egg-info/SOURCES.txt
7
+ src/kryptorious_gitsweep.egg-info/dependency_links.txt
8
+ src/kryptorious_gitsweep.egg-info/entry_points.txt
9
+ src/kryptorious_gitsweep.egg-info/requires.txt
10
+ src/kryptorious_gitsweep.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ gitsweep = gitsweep.cli:main
@@ -0,0 +1,8 @@
1
+ gitpython>=3.1
2
+ rich>=13.0
3
+ click>=8.0
4
+
5
+ [dev]
6
+ pytest>=7.0
7
+ black>=23.0
8
+ ruff>=0.1