navig-github 0.2.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.
- navig_github/__init__.py +3 -0
- navig_github/commands/__init__.py +0 -0
- navig_github/commands/github.py +612 -0
- navig_github/engine/__init__.py +18 -0
- navig_github/engine/__main__.py +11 -0
- navig_github/engine/analytics.py +567 -0
- navig_github/engine/attachments.py +519 -0
- navig_github/engine/cli.py +4969 -0
- navig_github/engine/config.py +285 -0
- navig_github/engine/diff.py +628 -0
- navig_github/engine/gists.py +469 -0
- navig_github/engine/git_utils.py +488 -0
- navig_github/engine/github_api.py +1634 -0
- navig_github/engine/incremental.py +367 -0
- navig_github/engine/mirror.py +293 -0
- navig_github/engine/models.py +555 -0
- navig_github/engine/notifications.py +679 -0
- navig_github/engine/restore.py +613 -0
- navig_github/engine/rich_utils.py +124 -0
- navig_github/engine/scheduler.py +366 -0
- navig_github/engine/templates.py +575 -0
- navig_github/engine/transfer.py +501 -0
- navig_github/engine/validation.py +219 -0
- navig_github/engine/verify.py +379 -0
- navig_github/plugin.py +52 -0
- navig_github/skills/github-ops/SKILL.md +25 -0
- navig_github-0.2.0.dist-info/METADATA +61 -0
- navig_github-0.2.0.dist-info/RECORD +32 -0
- navig_github-0.2.0.dist-info/WHEEL +5 -0
- navig_github-0.2.0.dist-info/entry_points.txt +5 -0
- navig_github-0.2.0.dist-info/licenses/LICENSE +173 -0
- navig_github-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,567 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The GitHub engine Analytics Module - Backup statistics, reporting, and insights.
|
|
3
|
+
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import subprocess
|
|
9
|
+
from collections import defaultdict
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class RepositoryStats:
|
|
18
|
+
"""Statistics for a single repository."""
|
|
19
|
+
|
|
20
|
+
name: str
|
|
21
|
+
path: Path
|
|
22
|
+
size_bytes: int = 0
|
|
23
|
+
file_count: int = 0
|
|
24
|
+
commit_count: int = 0
|
|
25
|
+
branch_count: int = 0
|
|
26
|
+
tag_count: int = 0
|
|
27
|
+
last_commit_date: str | None = None
|
|
28
|
+
last_backup_date: str | None = None
|
|
29
|
+
is_bare: bool = False
|
|
30
|
+
has_lfs: bool = False
|
|
31
|
+
languages: dict[str, int] = field(default_factory=dict)
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def size_mb(self) -> float:
|
|
35
|
+
"""Size in megabytes."""
|
|
36
|
+
return self.size_bytes / (1024 * 1024)
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def size_gb(self) -> float:
|
|
40
|
+
"""Size in gigabytes."""
|
|
41
|
+
return self.size_bytes / (1024 * 1024 * 1024)
|
|
42
|
+
|
|
43
|
+
def to_dict(self) -> dict:
|
|
44
|
+
"""Convert to dictionary."""
|
|
45
|
+
return {
|
|
46
|
+
"name": self.name,
|
|
47
|
+
"path": str(self.path),
|
|
48
|
+
"size_bytes": self.size_bytes,
|
|
49
|
+
"size_mb": round(self.size_mb, 2),
|
|
50
|
+
"file_count": self.file_count,
|
|
51
|
+
"commit_count": self.commit_count,
|
|
52
|
+
"branch_count": self.branch_count,
|
|
53
|
+
"tag_count": self.tag_count,
|
|
54
|
+
"last_commit_date": self.last_commit_date,
|
|
55
|
+
"last_backup_date": self.last_backup_date,
|
|
56
|
+
"is_bare": self.is_bare,
|
|
57
|
+
"has_lfs": self.has_lfs,
|
|
58
|
+
"languages": self.languages,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class BackupStats:
|
|
64
|
+
"""Aggregate statistics for a backup directory."""
|
|
65
|
+
|
|
66
|
+
path: Path
|
|
67
|
+
total_repositories: int = 0
|
|
68
|
+
total_size_bytes: int = 0
|
|
69
|
+
total_files: int = 0
|
|
70
|
+
total_commits: int = 0
|
|
71
|
+
repositories: list[RepositoryStats] = field(default_factory=list)
|
|
72
|
+
categories: dict[str, int] = field(default_factory=dict)
|
|
73
|
+
languages: dict[str, int] = field(default_factory=dict)
|
|
74
|
+
analyzed_at: str = field(default_factory=lambda: datetime.now().isoformat())
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def total_size_mb(self) -> float:
|
|
78
|
+
"""Total size in megabytes."""
|
|
79
|
+
return self.total_size_bytes / (1024 * 1024)
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def total_size_gb(self) -> float:
|
|
83
|
+
"""Total size in gigabytes."""
|
|
84
|
+
return self.total_size_bytes / (1024 * 1024 * 1024)
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def avg_repo_size_mb(self) -> float:
|
|
88
|
+
"""Average repository size in MB."""
|
|
89
|
+
if self.total_repositories == 0:
|
|
90
|
+
return 0
|
|
91
|
+
return self.total_size_mb / self.total_repositories
|
|
92
|
+
|
|
93
|
+
def to_dict(self) -> dict:
|
|
94
|
+
"""Convert to dictionary."""
|
|
95
|
+
return {
|
|
96
|
+
"path": str(self.path),
|
|
97
|
+
"total_repositories": self.total_repositories,
|
|
98
|
+
"total_size_bytes": self.total_size_bytes,
|
|
99
|
+
"total_size_mb": round(self.total_size_mb, 2),
|
|
100
|
+
"total_size_gb": round(self.total_size_gb, 3),
|
|
101
|
+
"total_files": self.total_files,
|
|
102
|
+
"total_commits": self.total_commits,
|
|
103
|
+
"avg_repo_size_mb": round(self.avg_repo_size_mb, 2),
|
|
104
|
+
"categories": self.categories,
|
|
105
|
+
"languages": self.languages,
|
|
106
|
+
"analyzed_at": self.analyzed_at,
|
|
107
|
+
"repositories": [r.to_dict() for r in self.repositories],
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass
|
|
112
|
+
class BackupHistory:
|
|
113
|
+
"""Historical record of backups."""
|
|
114
|
+
|
|
115
|
+
backup_id: str
|
|
116
|
+
started_at: str
|
|
117
|
+
completed_at: str | None = None
|
|
118
|
+
duration_seconds: float = 0
|
|
119
|
+
repos_cloned: int = 0
|
|
120
|
+
repos_updated: int = 0
|
|
121
|
+
repos_failed: int = 0
|
|
122
|
+
total_size_bytes: int = 0
|
|
123
|
+
success: bool = True
|
|
124
|
+
error_message: str | None = None
|
|
125
|
+
|
|
126
|
+
def to_dict(self) -> dict:
|
|
127
|
+
"""Convert to dictionary."""
|
|
128
|
+
return {
|
|
129
|
+
"backup_id": self.backup_id,
|
|
130
|
+
"started_at": self.started_at,
|
|
131
|
+
"completed_at": self.completed_at,
|
|
132
|
+
"duration_seconds": self.duration_seconds,
|
|
133
|
+
"repos_cloned": self.repos_cloned,
|
|
134
|
+
"repos_updated": self.repos_updated,
|
|
135
|
+
"repos_failed": self.repos_failed,
|
|
136
|
+
"total_size_bytes": self.total_size_bytes,
|
|
137
|
+
"success": self.success,
|
|
138
|
+
"error_message": self.error_message,
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class BackupAnalytics:
|
|
143
|
+
"""
|
|
144
|
+
Analyze backup directories and generate statistics.
|
|
145
|
+
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
HISTORY_FILE = ".navig-github-history.json"
|
|
149
|
+
|
|
150
|
+
def __init__(self, backup_dir: Path | None = None):
|
|
151
|
+
"""Initialize the analytics engine."""
|
|
152
|
+
self.backup_dir = backup_dir or Path("backups")
|
|
153
|
+
self._history: list[BackupHistory] = []
|
|
154
|
+
self._load_history()
|
|
155
|
+
|
|
156
|
+
def _load_history(self) -> None:
|
|
157
|
+
"""Load backup history from disk."""
|
|
158
|
+
history_path = self.backup_dir / self.HISTORY_FILE
|
|
159
|
+
if history_path.exists():
|
|
160
|
+
try:
|
|
161
|
+
data = json.loads(history_path.read_text())
|
|
162
|
+
self._history = [
|
|
163
|
+
BackupHistory(**h) for h in data.get("history", [])
|
|
164
|
+
]
|
|
165
|
+
except (json.JSONDecodeError, TypeError):
|
|
166
|
+
self._history = []
|
|
167
|
+
|
|
168
|
+
def _save_history(self) -> None:
|
|
169
|
+
"""Save backup history to disk."""
|
|
170
|
+
history_path = self.backup_dir / self.HISTORY_FILE
|
|
171
|
+
history_path.parent.mkdir(parents=True, exist_ok=True)
|
|
172
|
+
data = {
|
|
173
|
+
"version": "1.0",
|
|
174
|
+
"updated_at": datetime.now().isoformat(),
|
|
175
|
+
"history": [h.to_dict() for h in self._history],
|
|
176
|
+
}
|
|
177
|
+
history_path.write_text(json.dumps(data, indent=2))
|
|
178
|
+
|
|
179
|
+
def record_backup(
|
|
180
|
+
self,
|
|
181
|
+
repos_cloned: int,
|
|
182
|
+
repos_updated: int,
|
|
183
|
+
repos_failed: int,
|
|
184
|
+
duration_seconds: float,
|
|
185
|
+
total_size_bytes: int = 0,
|
|
186
|
+
error_message: str | None = None,
|
|
187
|
+
) -> BackupHistory:
|
|
188
|
+
"""Record a backup operation in history."""
|
|
189
|
+
import uuid
|
|
190
|
+
|
|
191
|
+
now = datetime.now()
|
|
192
|
+
history = BackupHistory(
|
|
193
|
+
backup_id=str(uuid.uuid4())[:8],
|
|
194
|
+
started_at=now.isoformat(),
|
|
195
|
+
completed_at=now.isoformat(),
|
|
196
|
+
duration_seconds=duration_seconds,
|
|
197
|
+
repos_cloned=repos_cloned,
|
|
198
|
+
repos_updated=repos_updated,
|
|
199
|
+
repos_failed=repos_failed,
|
|
200
|
+
total_size_bytes=total_size_bytes,
|
|
201
|
+
success=repos_failed == 0,
|
|
202
|
+
error_message=error_message,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
self._history.append(history)
|
|
206
|
+
|
|
207
|
+
# Keep last 100 entries
|
|
208
|
+
if len(self._history) > 100:
|
|
209
|
+
self._history = self._history[-100:]
|
|
210
|
+
|
|
211
|
+
self._save_history()
|
|
212
|
+
return history
|
|
213
|
+
|
|
214
|
+
def get_history(self, limit: int = 20) -> list[BackupHistory]:
|
|
215
|
+
"""Get recent backup history."""
|
|
216
|
+
return self._history[-limit:]
|
|
217
|
+
|
|
218
|
+
def analyze_repository(self, repo_path: Path) -> RepositoryStats:
|
|
219
|
+
"""Analyze a single repository and return statistics."""
|
|
220
|
+
stats = RepositoryStats(
|
|
221
|
+
name=repo_path.name,
|
|
222
|
+
path=repo_path,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
# Check if it's a bare repository
|
|
226
|
+
if (repo_path / "HEAD").exists() and not (repo_path / ".git").exists():
|
|
227
|
+
stats.is_bare = True
|
|
228
|
+
git_dir = repo_path
|
|
229
|
+
elif (repo_path / ".git").exists():
|
|
230
|
+
git_dir = repo_path / ".git"
|
|
231
|
+
else:
|
|
232
|
+
# Not a git repository
|
|
233
|
+
return stats
|
|
234
|
+
|
|
235
|
+
# Calculate size
|
|
236
|
+
stats.size_bytes = self._get_directory_size(repo_path)
|
|
237
|
+
|
|
238
|
+
# Count files (for non-bare repos)
|
|
239
|
+
if not stats.is_bare:
|
|
240
|
+
stats.file_count = self._count_files(repo_path)
|
|
241
|
+
|
|
242
|
+
# Get git statistics
|
|
243
|
+
try:
|
|
244
|
+
# Commit count
|
|
245
|
+
result = subprocess.run(
|
|
246
|
+
["git", "rev-list", "--count", "HEAD"],
|
|
247
|
+
cwd=repo_path,
|
|
248
|
+
capture_output=True,
|
|
249
|
+
text=True,
|
|
250
|
+
timeout=30,
|
|
251
|
+
)
|
|
252
|
+
if result.returncode == 0:
|
|
253
|
+
stats.commit_count = int(result.stdout.strip())
|
|
254
|
+
except (subprocess.TimeoutExpired, ValueError):
|
|
255
|
+
pass
|
|
256
|
+
|
|
257
|
+
try:
|
|
258
|
+
# Branch count
|
|
259
|
+
result = subprocess.run(
|
|
260
|
+
["git", "branch", "-a"],
|
|
261
|
+
cwd=repo_path,
|
|
262
|
+
capture_output=True,
|
|
263
|
+
text=True,
|
|
264
|
+
timeout=30,
|
|
265
|
+
)
|
|
266
|
+
if result.returncode == 0:
|
|
267
|
+
stats.branch_count = len([
|
|
268
|
+
b for b in result.stdout.strip().split("\n")
|
|
269
|
+
if b.strip() and not b.strip().startswith("->")
|
|
270
|
+
])
|
|
271
|
+
except subprocess.TimeoutExpired:
|
|
272
|
+
pass
|
|
273
|
+
|
|
274
|
+
try:
|
|
275
|
+
# Tag count
|
|
276
|
+
result = subprocess.run(
|
|
277
|
+
["git", "tag"],
|
|
278
|
+
cwd=repo_path,
|
|
279
|
+
capture_output=True,
|
|
280
|
+
text=True,
|
|
281
|
+
timeout=30,
|
|
282
|
+
)
|
|
283
|
+
if result.returncode == 0:
|
|
284
|
+
stats.tag_count = len([t for t in result.stdout.strip().split("\n") if t.strip()])
|
|
285
|
+
except subprocess.TimeoutExpired:
|
|
286
|
+
pass
|
|
287
|
+
|
|
288
|
+
try:
|
|
289
|
+
# Last commit date
|
|
290
|
+
result = subprocess.run(
|
|
291
|
+
["git", "log", "-1", "--format=%ci"],
|
|
292
|
+
cwd=repo_path,
|
|
293
|
+
capture_output=True,
|
|
294
|
+
text=True,
|
|
295
|
+
timeout=30,
|
|
296
|
+
)
|
|
297
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
298
|
+
stats.last_commit_date = result.stdout.strip()
|
|
299
|
+
except subprocess.TimeoutExpired:
|
|
300
|
+
pass
|
|
301
|
+
|
|
302
|
+
# Check for LFS
|
|
303
|
+
lfs_config = repo_path / ".lfsconfig" if not stats.is_bare else git_dir / "lfs"
|
|
304
|
+
if lfs_config.exists() or (repo_path / ".gitattributes").exists():
|
|
305
|
+
gitattributes = repo_path / ".gitattributes"
|
|
306
|
+
if gitattributes.exists():
|
|
307
|
+
content = gitattributes.read_text()
|
|
308
|
+
stats.has_lfs = "filter=lfs" in content
|
|
309
|
+
|
|
310
|
+
# Get language statistics (for non-bare repos)
|
|
311
|
+
if not stats.is_bare:
|
|
312
|
+
stats.languages = self._analyze_languages(repo_path)
|
|
313
|
+
|
|
314
|
+
# Last backup date (file modification time)
|
|
315
|
+
try:
|
|
316
|
+
mtime = repo_path.stat().st_mtime
|
|
317
|
+
stats.last_backup_date = datetime.fromtimestamp(mtime).isoformat()
|
|
318
|
+
except OSError:
|
|
319
|
+
pass
|
|
320
|
+
|
|
321
|
+
return stats
|
|
322
|
+
|
|
323
|
+
def analyze_directory(self, path: Path | None = None) -> BackupStats:
|
|
324
|
+
"""Analyze an entire backup directory."""
|
|
325
|
+
path = path or self.backup_dir
|
|
326
|
+
|
|
327
|
+
stats = BackupStats(path=path)
|
|
328
|
+
|
|
329
|
+
# Find all git repositories
|
|
330
|
+
repos = self._find_repositories(path)
|
|
331
|
+
|
|
332
|
+
for repo_path in repos:
|
|
333
|
+
repo_stats = self.analyze_repository(repo_path)
|
|
334
|
+
stats.repositories.append(repo_stats)
|
|
335
|
+
stats.total_size_bytes += repo_stats.size_bytes
|
|
336
|
+
stats.total_files += repo_stats.file_count
|
|
337
|
+
stats.total_commits += repo_stats.commit_count
|
|
338
|
+
|
|
339
|
+
# Aggregate languages
|
|
340
|
+
for lang, count in repo_stats.languages.items():
|
|
341
|
+
stats.languages[lang] = stats.languages.get(lang, 0) + count
|
|
342
|
+
|
|
343
|
+
stats.total_repositories = len(repos)
|
|
344
|
+
|
|
345
|
+
# Categorize repositories
|
|
346
|
+
stats.categories = self._categorize_repositories(repos)
|
|
347
|
+
|
|
348
|
+
return stats
|
|
349
|
+
|
|
350
|
+
def _find_repositories(self, path: Path) -> list[Path]:
|
|
351
|
+
"""Find all git repositories in a directory."""
|
|
352
|
+
repos = []
|
|
353
|
+
|
|
354
|
+
for root, dirs, files in os.walk(path):
|
|
355
|
+
root_path = Path(root)
|
|
356
|
+
|
|
357
|
+
# Check for regular git repo
|
|
358
|
+
if ".git" in dirs:
|
|
359
|
+
repos.append(root_path)
|
|
360
|
+
dirs.remove(".git") # Don't recurse into .git
|
|
361
|
+
dirs[:] = [] # Don't recurse into subdirectories of a repo
|
|
362
|
+
continue
|
|
363
|
+
|
|
364
|
+
# Check for bare repo
|
|
365
|
+
if "HEAD" in files and "objects" in dirs and "refs" in dirs:
|
|
366
|
+
repos.append(root_path)
|
|
367
|
+
dirs[:] = [] # Don't recurse into subdirectories
|
|
368
|
+
continue
|
|
369
|
+
|
|
370
|
+
return repos
|
|
371
|
+
|
|
372
|
+
def _get_directory_size(self, path: Path) -> int:
|
|
373
|
+
"""Calculate total size of a directory in bytes."""
|
|
374
|
+
total = 0
|
|
375
|
+
try:
|
|
376
|
+
for entry in path.rglob("*"):
|
|
377
|
+
if entry.is_file():
|
|
378
|
+
try:
|
|
379
|
+
total += entry.stat().st_size
|
|
380
|
+
except OSError:
|
|
381
|
+
pass
|
|
382
|
+
except OSError:
|
|
383
|
+
pass
|
|
384
|
+
return total
|
|
385
|
+
|
|
386
|
+
def _count_files(self, path: Path) -> int:
|
|
387
|
+
"""Count non-hidden files in a directory."""
|
|
388
|
+
count = 0
|
|
389
|
+
try:
|
|
390
|
+
for entry in path.rglob("*"):
|
|
391
|
+
if entry.is_file() and not any(p.startswith(".") for p in entry.parts):
|
|
392
|
+
count += 1
|
|
393
|
+
except OSError:
|
|
394
|
+
pass
|
|
395
|
+
return count
|
|
396
|
+
|
|
397
|
+
def _analyze_languages(self, path: Path) -> dict[str, int]:
|
|
398
|
+
"""Analyze language distribution in a repository."""
|
|
399
|
+
extensions: defaultdict[str, int] = defaultdict(int)
|
|
400
|
+
|
|
401
|
+
language_map = {
|
|
402
|
+
".py": "Python",
|
|
403
|
+
".js": "JavaScript",
|
|
404
|
+
".ts": "TypeScript",
|
|
405
|
+
".jsx": "React JSX",
|
|
406
|
+
".tsx": "React TSX",
|
|
407
|
+
".java": "Java",
|
|
408
|
+
".go": "Go",
|
|
409
|
+
".rs": "Rust",
|
|
410
|
+
".c": "C",
|
|
411
|
+
".cpp": "C++",
|
|
412
|
+
".h": "C/C++ Header",
|
|
413
|
+
".cs": "C#",
|
|
414
|
+
".rb": "Ruby",
|
|
415
|
+
".php": "PHP",
|
|
416
|
+
".swift": "Swift",
|
|
417
|
+
".kt": "Kotlin",
|
|
418
|
+
".scala": "Scala",
|
|
419
|
+
".r": "R",
|
|
420
|
+
".sql": "SQL",
|
|
421
|
+
".html": "HTML",
|
|
422
|
+
".css": "CSS",
|
|
423
|
+
".scss": "SCSS",
|
|
424
|
+
".sass": "Sass",
|
|
425
|
+
".less": "Less",
|
|
426
|
+
".json": "JSON",
|
|
427
|
+
".yaml": "YAML",
|
|
428
|
+
".yml": "YAML",
|
|
429
|
+
".xml": "XML",
|
|
430
|
+
".md": "Markdown",
|
|
431
|
+
".sh": "Shell",
|
|
432
|
+
".bash": "Bash",
|
|
433
|
+
".zsh": "Zsh",
|
|
434
|
+
".ps1": "PowerShell",
|
|
435
|
+
".dockerfile": "Dockerfile",
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
try:
|
|
439
|
+
for entry in path.rglob("*"):
|
|
440
|
+
if entry.is_file() and not any(p.startswith(".") for p in entry.parts):
|
|
441
|
+
ext = entry.suffix.lower()
|
|
442
|
+
if ext in language_map:
|
|
443
|
+
extensions[language_map[ext]] += 1
|
|
444
|
+
except OSError:
|
|
445
|
+
pass
|
|
446
|
+
|
|
447
|
+
return dict(extensions)
|
|
448
|
+
|
|
449
|
+
def _categorize_repositories(self, repos: list[Path]) -> dict[str, int]:
|
|
450
|
+
"""Categorize repositories by directory structure."""
|
|
451
|
+
categories: dict[str, int] = defaultdict(int)
|
|
452
|
+
|
|
453
|
+
for repo_path in repos:
|
|
454
|
+
# Look for category indicators in path
|
|
455
|
+
parts = repo_path.parts
|
|
456
|
+
|
|
457
|
+
if "private" in parts:
|
|
458
|
+
categories["private"] += 1
|
|
459
|
+
elif "public" in parts:
|
|
460
|
+
categories["public"] += 1
|
|
461
|
+
elif "starred" in parts:
|
|
462
|
+
categories["starred"] += 1
|
|
463
|
+
elif "watched" in parts:
|
|
464
|
+
categories["watched"] += 1
|
|
465
|
+
elif "forks" in parts:
|
|
466
|
+
categories["forks"] += 1
|
|
467
|
+
elif "organizations" in parts:
|
|
468
|
+
categories["organizations"] += 1
|
|
469
|
+
elif "gists" in parts:
|
|
470
|
+
categories["gists"] += 1
|
|
471
|
+
else:
|
|
472
|
+
categories["other"] += 1
|
|
473
|
+
|
|
474
|
+
return dict(categories)
|
|
475
|
+
|
|
476
|
+
def generate_report(
|
|
477
|
+
self,
|
|
478
|
+
path: Path | None = None,
|
|
479
|
+
format: str = "text",
|
|
480
|
+
) -> str:
|
|
481
|
+
"""Generate a backup report."""
|
|
482
|
+
stats = self.analyze_directory(path)
|
|
483
|
+
|
|
484
|
+
if format == "json":
|
|
485
|
+
return json.dumps(stats.to_dict(), indent=2)
|
|
486
|
+
|
|
487
|
+
if format == "yaml":
|
|
488
|
+
import yaml
|
|
489
|
+
return yaml.dump(stats.to_dict(), default_flow_style=False)
|
|
490
|
+
|
|
491
|
+
# Text format
|
|
492
|
+
lines = [
|
|
493
|
+
"=" * 60,
|
|
494
|
+
"NAVIG-GITHUB BACKUP REPORT",
|
|
495
|
+
"=" * 60,
|
|
496
|
+
"",
|
|
497
|
+
f"📁 Backup Directory: {stats.path}",
|
|
498
|
+
f"📊 Analyzed: {stats.analyzed_at}",
|
|
499
|
+
"",
|
|
500
|
+
"SUMMARY",
|
|
501
|
+
"-" * 40,
|
|
502
|
+
f"Total Repositories: {stats.total_repositories}",
|
|
503
|
+
f"Total Size: {stats.total_size_mb:.2f} MB ({stats.total_size_gb:.3f} GB)",
|
|
504
|
+
f"Total Files: {stats.total_files:,}",
|
|
505
|
+
f"Total Commits: {stats.total_commits:,}",
|
|
506
|
+
f"Average Repo Size: {stats.avg_repo_size_mb:.2f} MB",
|
|
507
|
+
"",
|
|
508
|
+
]
|
|
509
|
+
|
|
510
|
+
if stats.categories:
|
|
511
|
+
lines.extend([
|
|
512
|
+
"CATEGORIES",
|
|
513
|
+
"-" * 40,
|
|
514
|
+
])
|
|
515
|
+
for category, count in sorted(stats.categories.items(), key=lambda x: -x[1]):
|
|
516
|
+
lines.append(f" {category}: {count}")
|
|
517
|
+
lines.append("")
|
|
518
|
+
|
|
519
|
+
if stats.languages:
|
|
520
|
+
lines.extend([
|
|
521
|
+
"LANGUAGES (Top 10)",
|
|
522
|
+
"-" * 40,
|
|
523
|
+
])
|
|
524
|
+
top_languages = sorted(stats.languages.items(), key=lambda x: -x[1])[:10]
|
|
525
|
+
for lang, count in top_languages:
|
|
526
|
+
lines.append(f" {lang}: {count} files")
|
|
527
|
+
lines.append("")
|
|
528
|
+
|
|
529
|
+
if stats.repositories:
|
|
530
|
+
lines.extend([
|
|
531
|
+
"TOP 10 LARGEST REPOSITORIES",
|
|
532
|
+
"-" * 40,
|
|
533
|
+
])
|
|
534
|
+
top_repos = sorted(stats.repositories, key=lambda x: -x.size_bytes)[:10]
|
|
535
|
+
for repo in top_repos:
|
|
536
|
+
lines.append(f" {repo.name}: {repo.size_mb:.2f} MB")
|
|
537
|
+
|
|
538
|
+
lines.extend(["", "=" * 60])
|
|
539
|
+
|
|
540
|
+
return "\n".join(lines)
|
|
541
|
+
|
|
542
|
+
def get_growth_stats(self) -> dict[str, Any]:
|
|
543
|
+
"""Get backup growth statistics from history."""
|
|
544
|
+
if len(self._history) < 2:
|
|
545
|
+
return {
|
|
546
|
+
"has_data": False,
|
|
547
|
+
"message": "Not enough history for growth analysis",
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
first = self._history[0]
|
|
551
|
+
last = self._history[-1]
|
|
552
|
+
|
|
553
|
+
total_repos = sum(h.repos_cloned + h.repos_updated for h in self._history)
|
|
554
|
+
total_failures = sum(h.repos_failed for h in self._history)
|
|
555
|
+
total_duration = sum(h.duration_seconds for h in self._history)
|
|
556
|
+
|
|
557
|
+
return {
|
|
558
|
+
"has_data": True,
|
|
559
|
+
"backup_count": len(self._history),
|
|
560
|
+
"first_backup": first.started_at,
|
|
561
|
+
"last_backup": last.started_at,
|
|
562
|
+
"total_repos_processed": total_repos,
|
|
563
|
+
"total_failures": total_failures,
|
|
564
|
+
"success_rate": ((total_repos - total_failures) / total_repos * 100) if total_repos > 0 else 100,
|
|
565
|
+
"avg_duration_seconds": total_duration / len(self._history),
|
|
566
|
+
"total_duration_hours": total_duration / 3600,
|
|
567
|
+
}
|