gitghost-scanner 1.2.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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 CyberM
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.
@@ -0,0 +1,99 @@
1
+ Metadata-Version: 2.2
2
+ Name: gitghost-scanner
3
+ Version: 1.2.0
4
+ Summary: Find secrets in a GitHub account's public repos — including the ones still hiding in git history after being 'deleted'.
5
+ Author: cy3erm
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/cy3erm/gitghost
8
+ Project-URL: Repository, https://github.com/cy3erm/gitghost
9
+ Project-URL: Issues, https://github.com/cy3erm/gitghost/issues
10
+ Keywords: security,secrets-detection,git,osint,devsecops,bug-bounty
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3 :: Only
16
+ Classifier: Topic :: Security
17
+ Requires-Python: >=3.10
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+
21
+ # gitghost
22
+ ![gitghost exposure dossier](preview.png)
23
+ Finds secrets in a GitHub account's public repos — including the ones that got committed, then "deleted," but are still sitting in the git history where anyone can read them.
24
+
25
+ I built this after noticing how often the real leak isn't in someone's current code — it's in a commit from eight months ago that they thought they'd cleaned up. You paste an API key, catch it, delete the line, and move on. The latest version looks fine. But the old commit still has the key, and `git log` hands it to anyone who clones the repo. Most scanners only look at your current files and miss this entirely. gitghost goes digging through history for exactly those, and then rolls everything up into a single exposure score so you can actually tell how bad things are at a glance.
26
+
27
+ ```
28
+ $ gitghost cy3erm
29
+
30
+ EXPOSURE SCORE: 96/100 [CRITICAL] grade F
31
+ · 7 live secrets in current code
32
+ · 3 "deleted" secrets still recoverable from history
33
+ · author email exposed in commit metadata
34
+ · timezone inferable from commit times (UTC+0530)
35
+
36
+ dossier written to gitghost-dossier.html
37
+ ```
38
+
39
+ You get an HTML report you can open in a browser, not just a wall of terminal text. Every finding links straight to the spot — the exact file and line for live secrets, or the commit for the ones recovered from history — so you're not hunting for where the key actually is. And the report ends with a short how-to-fix guide, because the instinct when you see a leaked key (delete the file, nuke the repo) doesn't actually fix anything: the key's already been cloned. The real fix is to rotate it, then purge it from history, and the report walks you through both.
40
+
41
+ ## Running it
42
+
43
+ You'll need Python 3.10+ and git. There are no runtime dependencies — it's all standard library.
44
+
45
+ Install it as a command:
46
+
47
+ ```bash
48
+ pipx install git+https://github.com/cy3erm/gitghost
49
+ gitghost <username>
50
+ ```
51
+
52
+ Or just clone and run it without installing:
53
+
54
+ ```bash
55
+ git clone https://github.com/cy3erm/gitghost
56
+ cd gitghost
57
+ python3 -m gitghost <username>
58
+ ```
59
+
60
+ If you'd rather not point it at a real person first, there's a demo. It builds a little repo with fake credentials — including one that gets "deleted" a few commits in — so you can watch the history recovery dig it back out:
61
+
62
+ ```bash
63
+ bash demo/make_demo_repo.sh /tmp/demo
64
+ python3 -m gitghost --local /tmp/demo --name demo
65
+ ```
66
+
67
+ A few other flags:
68
+
69
+ ```bash
70
+ python3 -m gitghost <username> --limit 50 # cap how many repos
71
+ python3 -m gitghost <username> --out report.html # where to write the report
72
+ python3 -m gitghost --local ./some-repo # scan a checkout you have locally
73
+ python3 -m gitghost --repo owner/name # scan just one repo (URL or owner/name)
74
+ ```
75
+
76
+ Scanning more than a couple of accounts? Set `GITHUB_TOKEN` (any token works, it doesn't need any scopes) so you don't run into GitHub's 60-requests-an-hour limit for anonymous calls.
77
+
78
+ ## What it looks for
79
+
80
+ - Cloud and service keys — AWS, GCP, Stripe, GitHub, OpenAI, Slack, private keys, database URLs
81
+ - The same keys pulled back out of history after they were removed from the current code
82
+ - Metadata you might not realize you're sharing — the email in your commits, and a rough guess at your timezone and working hours from commit timestamps
83
+ - Committed `.env` files, internal hostnames, hardcoded private IPs
84
+
85
+ The score is deliberately one number so you can watch it move — run it, clean things up, run it again. A single live cloud key is enough to put an account in the red by itself; a pile of smaller stuff pushes it up from there.
86
+
87
+ ## Where it draws the line
88
+
89
+ It only ever reads public repositories — things the account already chose to publish — and it's detection-only. It'll tell you a string *looks like* a credential and leave it at that. It won't try the key against the actual service to see if it still works, because quietly logging into someone else's account isn't the tool's job, and honestly it's not yours either. Findings in the report are shown as fingerprints, not the raw values, so you can share a report without leaking anything.
90
+
91
+ Point it at yourself first. Most people turn up at least one thing they'd completely forgotten about — I did. (The first time I pushed this repo, GitHub's own secret scanner blocked me because the demo's fake keys looked real enough to trip it. Which is about the best proof of the premise I could ask for.)
92
+
93
+ ## Adding your own detections
94
+
95
+ The patterns live in `gitghost/rules.py`. Each one is just a name, a regex, a severity, and a line of advice for fixing it — easy to add. If you write a good one, send a PR.
96
+
97
+ ## License
98
+
99
+ MIT
@@ -0,0 +1,79 @@
1
+ # gitghost
2
+ ![gitghost exposure dossier](preview.png)
3
+ Finds secrets in a GitHub account's public repos — including the ones that got committed, then "deleted," but are still sitting in the git history where anyone can read them.
4
+
5
+ I built this after noticing how often the real leak isn't in someone's current code — it's in a commit from eight months ago that they thought they'd cleaned up. You paste an API key, catch it, delete the line, and move on. The latest version looks fine. But the old commit still has the key, and `git log` hands it to anyone who clones the repo. Most scanners only look at your current files and miss this entirely. gitghost goes digging through history for exactly those, and then rolls everything up into a single exposure score so you can actually tell how bad things are at a glance.
6
+
7
+ ```
8
+ $ gitghost cy3erm
9
+
10
+ EXPOSURE SCORE: 96/100 [CRITICAL] grade F
11
+ · 7 live secrets in current code
12
+ · 3 "deleted" secrets still recoverable from history
13
+ · author email exposed in commit metadata
14
+ · timezone inferable from commit times (UTC+0530)
15
+
16
+ dossier written to gitghost-dossier.html
17
+ ```
18
+
19
+ You get an HTML report you can open in a browser, not just a wall of terminal text. Every finding links straight to the spot — the exact file and line for live secrets, or the commit for the ones recovered from history — so you're not hunting for where the key actually is. And the report ends with a short how-to-fix guide, because the instinct when you see a leaked key (delete the file, nuke the repo) doesn't actually fix anything: the key's already been cloned. The real fix is to rotate it, then purge it from history, and the report walks you through both.
20
+
21
+ ## Running it
22
+
23
+ You'll need Python 3.10+ and git. There are no runtime dependencies — it's all standard library.
24
+
25
+ Install it as a command:
26
+
27
+ ```bash
28
+ pipx install git+https://github.com/cy3erm/gitghost
29
+ gitghost <username>
30
+ ```
31
+
32
+ Or just clone and run it without installing:
33
+
34
+ ```bash
35
+ git clone https://github.com/cy3erm/gitghost
36
+ cd gitghost
37
+ python3 -m gitghost <username>
38
+ ```
39
+
40
+ If you'd rather not point it at a real person first, there's a demo. It builds a little repo with fake credentials — including one that gets "deleted" a few commits in — so you can watch the history recovery dig it back out:
41
+
42
+ ```bash
43
+ bash demo/make_demo_repo.sh /tmp/demo
44
+ python3 -m gitghost --local /tmp/demo --name demo
45
+ ```
46
+
47
+ A few other flags:
48
+
49
+ ```bash
50
+ python3 -m gitghost <username> --limit 50 # cap how many repos
51
+ python3 -m gitghost <username> --out report.html # where to write the report
52
+ python3 -m gitghost --local ./some-repo # scan a checkout you have locally
53
+ python3 -m gitghost --repo owner/name # scan just one repo (URL or owner/name)
54
+ ```
55
+
56
+ Scanning more than a couple of accounts? Set `GITHUB_TOKEN` (any token works, it doesn't need any scopes) so you don't run into GitHub's 60-requests-an-hour limit for anonymous calls.
57
+
58
+ ## What it looks for
59
+
60
+ - Cloud and service keys — AWS, GCP, Stripe, GitHub, OpenAI, Slack, private keys, database URLs
61
+ - The same keys pulled back out of history after they were removed from the current code
62
+ - Metadata you might not realize you're sharing — the email in your commits, and a rough guess at your timezone and working hours from commit timestamps
63
+ - Committed `.env` files, internal hostnames, hardcoded private IPs
64
+
65
+ The score is deliberately one number so you can watch it move — run it, clean things up, run it again. A single live cloud key is enough to put an account in the red by itself; a pile of smaller stuff pushes it up from there.
66
+
67
+ ## Where it draws the line
68
+
69
+ It only ever reads public repositories — things the account already chose to publish — and it's detection-only. It'll tell you a string *looks like* a credential and leave it at that. It won't try the key against the actual service to see if it still works, because quietly logging into someone else's account isn't the tool's job, and honestly it's not yours either. Findings in the report are shown as fingerprints, not the raw values, so you can share a report without leaking anything.
70
+
71
+ Point it at yourself first. Most people turn up at least one thing they'd completely forgotten about — I did. (The first time I pushed this repo, GitHub's own secret scanner blocked me because the demo's fake keys looked real enough to trip it. Which is about the best proof of the premise I could ask for.)
72
+
73
+ ## Adding your own detections
74
+
75
+ The patterns live in `gitghost/rules.py`. Each one is just a name, a regex, a severity, and a line of advice for fixing it — easy to add. If you write a good one, send a PR.
76
+
77
+ ## License
78
+
79
+ MIT
@@ -0,0 +1 @@
1
+ __version__ = "1.2.0"
@@ -0,0 +1,2 @@
1
+ from .cli import main
2
+ main()
@@ -0,0 +1,38 @@
1
+ import sys
2
+
3
+ from . import __version__
4
+
5
+ _ART = r"""
6
+ ____ _(_) /_____ _/ /_ ____ _____/ /_
7
+ / __ `/ / __/ __ `/ __ \/ __ \/ ___/ __/
8
+ / /_/ / / /_/ /_/ / / / / /_/ (__ ) /_
9
+ \__, /_/\__/\__, /_/ /_/\____/____/\__/
10
+ /____/ /____/
11
+ """
12
+
13
+ _GHOST = r""" .-.
14
+ (o o)
15
+ | O |
16
+ \_/
17
+ """
18
+
19
+
20
+ def banner():
21
+ if not sys.stdout.isatty():
22
+ return ""
23
+ orange, dim, ghost_c, reset = "\033[38;5;208m", "\033[2m", "\033[38;5;250m", "\033[0m"
24
+ art = _ART.strip("\n").splitlines()
25
+ ghost = _GHOST.strip("\n").splitlines()
26
+ width = max(len(l) for l in art) + 4
27
+ out = []
28
+ for i, line in enumerate(art):
29
+ g = ghost[i] if i < len(ghost) else ""
30
+ out.append(orange + line.ljust(width) + reset + ghost_c + g + reset)
31
+ tag = f"{dim} the secrets you deleted are still in git history{reset} {orange}v{__version__}{reset}"
32
+ return "\n".join(out) + "\n" + tag + "\n"
33
+
34
+
35
+ def print_banner():
36
+ b = banner()
37
+ if b:
38
+ print(b)
@@ -0,0 +1,123 @@
1
+ import argparse
2
+ import sys
3
+ import tempfile
4
+
5
+ from . import github
6
+ from .ghost import recover_ghosts
7
+ from .metadata import analyze_metadata
8
+ from .report import render_report
9
+ from .rules import Finding
10
+ from .scanner import scan_repo
11
+ from .score import compute_score
12
+
13
+
14
+ def _scan_one(root: str, name: str) -> tuple[list[Finding], object]:
15
+ findings = list(scan_repo(root, name).findings)
16
+ findings += recover_ghosts(root, name)
17
+ meta = analyze_metadata(root)
18
+ return findings, meta
19
+
20
+
21
+ def run_local(path: str, name: str, out: str) -> None:
22
+ print(f"[*] scanning local repo: {name}")
23
+ findings, meta = _scan_one(path, name)
24
+ card = compute_score(findings, meta)
25
+ _emit(name, card, findings, meta, 1, out)
26
+
27
+
28
+ def run_repo(url: str, out: str) -> None:
29
+ try:
30
+ repo = github.repo_from_url(url)
31
+ except ValueError as e:
32
+ sys.exit(f"[!] {e}")
33
+ print(f"[*] scanning single repo: {repo.full_name}")
34
+ with tempfile.TemporaryDirectory() as tmp:
35
+ dest = github.clone(repo, tmp)
36
+ if not dest:
37
+ sys.exit(f"[!] could not clone {repo.clone_url} (private, renamed, or network issue)")
38
+ findings, meta = _scan_one(dest, repo.name)
39
+ for finding in findings:
40
+ finding.repo_url = repo.html_url
41
+ card = compute_score(findings, meta)
42
+ _emit(repo.full_name, card, findings, meta, 1, out)
43
+
44
+
45
+ def run_identity(identity: str, limit: int, out: str) -> None:
46
+ print(f"[*] enumerating public repos for @{identity} ...")
47
+ try:
48
+ repos = github.list_public_repos(identity, limit=limit)
49
+ except Exception as e:
50
+ sys.exit(f"[!] could not reach GitHub API: {e}\n (set GITHUB_TOKEN to raise rate limits)")
51
+ if not repos:
52
+ sys.exit(f"[!] no public repos found for @{identity}")
53
+ print(f"[*] {len(repos)} repos. cloning + scanning (history included for ghost recovery)...")
54
+
55
+ all_findings: list[Finding] = []
56
+ merged_meta = None
57
+ with tempfile.TemporaryDirectory() as tmp:
58
+ for r in repos:
59
+ dest = github.clone(r, tmp)
60
+ if not dest:
61
+ print(f" - skip {r.name} (clone failed)")
62
+ continue
63
+ f, m = _scan_one(dest, r.name)
64
+ for finding in f:
65
+ finding.repo_url = r.html_url
66
+ all_findings += f
67
+ merged_meta = _merge_meta(merged_meta, m)
68
+ tag = f"{len([x for x in f if x.kind=='secret' and not x.is_ghost])} live / {len([x for x in f if x.is_ghost])} ghost"
69
+ print(f" - {r.name:<32} {tag}")
70
+
71
+ card = compute_score(all_findings, merged_meta)
72
+ _emit(identity, card, all_findings, merged_meta, len(repos), out)
73
+
74
+
75
+ def _merge_meta(a, b):
76
+ if a is None:
77
+ return b
78
+ for e in b.emails:
79
+ if e not in a.emails:
80
+ a.emails.append(e)
81
+ a.commit_count += b.commit_count
82
+ a.dominant_utc_offset = a.dominant_utc_offset or b.dominant_utc_offset
83
+ a.likely_active_hours = a.likely_active_hours or b.likely_active_hours
84
+ return a
85
+
86
+
87
+ def _emit(identity, card, findings, meta, repos_scanned, out):
88
+ html = render_report(identity, card, findings, meta, repos_scanned)
89
+ with open(out, "w", encoding="utf-8") as f:
90
+ f.write(html)
91
+ print(f"\n[=] EXPOSURE SCORE: {card.score}/100 [{card.band}] grade {card.grade}")
92
+ for d in card.drivers:
93
+ print(f" · {d}")
94
+ print(f"[=] dossier written to: {out}")
95
+
96
+
97
+ def main() -> None:
98
+ p = argparse.ArgumentParser(prog="gitghost", description="GitHub exposure dossier (detection-only).")
99
+ from . import __version__
100
+ p.add_argument("--version", action="version", version=f"gitghost {__version__}")
101
+ p.add_argument("identity", nargs="?", help="GitHub username or org")
102
+ p.add_argument("--repo", help="scan a single repo by URL or owner/name")
103
+ p.add_argument("--local", help="scan a repo already on disk instead of GitHub")
104
+ p.add_argument("--name", default="local-repo", help="label for --local scans")
105
+ p.add_argument("--limit", type=int, default=30, help="max repos to scan")
106
+ p.add_argument("--out", default="gitghost-dossier.html", help="output HTML path")
107
+ args = p.parse_args()
108
+
109
+ from .banner import print_banner
110
+ print_banner()
111
+
112
+ if args.local:
113
+ run_local(args.local, args.name, args.out)
114
+ elif args.repo:
115
+ run_repo(args.repo, args.out)
116
+ elif args.identity:
117
+ run_identity(args.identity, args.limit, args.out)
118
+ else:
119
+ p.error("provide a GitHub username, --repo <url>, or --local <path>")
120
+
121
+
122
+ if __name__ == "__main__":
123
+ main()
@@ -0,0 +1,81 @@
1
+ import re
2
+ import subprocess
3
+
4
+
5
+ from .rules import Finding, scan_text
6
+
7
+
8
+ def _git(root: str, *args: str) -> str:
9
+ return subprocess.run(
10
+ ["git", "-C", root, *args],
11
+ capture_output=True, text=True, errors="ignore",
12
+ ).stdout
13
+
14
+
15
+ def _head_blobs(root: str) -> set[str]:
16
+ out = _git(root, "ls-tree", "-r", "HEAD")
17
+ blobs = set()
18
+ for line in out.splitlines():
19
+ parts = line.split()
20
+ if len(parts) >= 3 and parts[1] == "blob":
21
+ blobs.add(parts[2])
22
+ return blobs
23
+
24
+
25
+ def _all_historical_blobs(root: str) -> list[tuple[str, str]]:
26
+ seen: dict[str, str] = {}
27
+ for line in _git(root, "rev-list", "--all", "--objects").splitlines():
28
+ parts = line.split(maxsplit=1)
29
+ if len(parts) == 2 and len(parts[0]) == 40:
30
+ seen.setdefault(parts[0], parts[1])
31
+
32
+ for line in _git(root, "fsck", "--unreachable", "--no-reflogs").splitlines():
33
+ parts = line.split()
34
+ if len(parts) == 3 and parts[1] == "blob":
35
+ seen.setdefault(parts[2], "")
36
+ return list(seen.items())
37
+
38
+
39
+ _VENDOR = re.compile(r"(^|/)(node_modules|vendor|dist|build|\.next|bower_components|"
40
+ r"third_party|site-packages|\.venv|venv)/|"
41
+ r"(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|"
42
+ r"\.min\.js|\.min\.css|\.map)$", re.I)
43
+
44
+
45
+ def _introducing_commit(root: str, blob: str) -> tuple[str, str]:
46
+ out = _git(root, "log", "--all", "--format=%H|%ci", "--find-object", blob, "--reverse")
47
+ for line in out.splitlines():
48
+ if "|" in line:
49
+ h, date = line.split("|", 1)
50
+ return h[:10], date.strip()[:10]
51
+ return "dangling", "unknown"
52
+
53
+
54
+ def recover_ghosts(root: str, repo_name: str, max_blobs: int = 4000) -> list[Finding]:
55
+ head = _head_blobs(root)
56
+ ghosts: list[Finding] = []
57
+ seen_fp: set[tuple] = set()
58
+ checked = 0
59
+ for blob, path in _all_historical_blobs(root):
60
+ if blob in head:
61
+ continue
62
+ if path and _VENDOR.search(path):
63
+ continue
64
+ if checked >= max_blobs:
65
+ break
66
+ checked += 1
67
+ content = _git(root, "cat-file", "-p", blob)
68
+ if not content:
69
+ continue
70
+ for f in scan_text(content):
71
+ key = (f.rule_id, f.redacted)
72
+ if key in seen_fp:
73
+ continue
74
+ seen_fp.add(key)
75
+ commit, date = _introducing_commit(root, blob)
76
+ f.repo = repo_name
77
+ f.is_ghost = True
78
+ f.commit = commit
79
+ f.path = f"(history) entered {date}"
80
+ ghosts.append(f)
81
+ return ghosts
@@ -0,0 +1,80 @@
1
+ import json
2
+ import os
3
+ import subprocess
4
+
5
+ import urllib.request
6
+ from dataclasses import dataclass
7
+
8
+
9
+ API = "https://api.github.com"
10
+
11
+
12
+ @dataclass
13
+ class Repo:
14
+ name: str
15
+ full_name: str
16
+ clone_url: str
17
+ pushed_at: str
18
+ html_url: str = ""
19
+
20
+
21
+ def _get(url: str) -> list | dict:
22
+ req = urllib.request.Request(url, headers={
23
+ "Accept": "application/vnd.github+json",
24
+ "User-Agent": "gitghost",
25
+ })
26
+ token = os.environ.get("GITHUB_TOKEN")
27
+ if token:
28
+ req.add_header("Authorization", f"Bearer {token}")
29
+ with urllib.request.urlopen(req, timeout=30) as r:
30
+ return json.loads(r.read().decode())
31
+
32
+
33
+ def list_public_repos(identity: str, limit: int = 30) -> list[Repo]:
34
+ repos: list[Repo] = []
35
+ page = 1
36
+ while len(repos) < limit:
37
+
38
+ data = _get(f"{API}/users/{identity}/repos?per_page=100&page={page}&sort=pushed")
39
+ if not isinstance(data, list) or not data:
40
+ break
41
+ for d in data:
42
+ if d.get("fork"):
43
+ continue
44
+ repos.append(Repo(
45
+ name=d["name"], full_name=d["full_name"],
46
+ clone_url=d["clone_url"], pushed_at=d.get("pushed_at", ""),
47
+ html_url=d.get("html_url", ""),
48
+ ))
49
+ if len(repos) >= limit:
50
+ break
51
+ page += 1
52
+ return repos
53
+
54
+
55
+ def repo_from_url(url: str) -> Repo:
56
+ u = url.strip().rstrip("/")
57
+ if u.endswith(".git"):
58
+ u = u[:-4]
59
+
60
+ for prefix in ("https://github.com/", "http://github.com/", "git@github.com:", "github.com/"):
61
+ if u.startswith(prefix):
62
+ u = u[len(prefix):]
63
+ break
64
+ parts = u.split("/")
65
+ if len(parts) < 2:
66
+ raise ValueError(f"not a valid repo reference: {url!r} (expected owner/name or a GitHub URL)")
67
+ owner, name = parts[0], parts[1]
68
+ full = f"{owner}/{name}"
69
+ return Repo(name=name, full_name=full,
70
+ clone_url=f"https://github.com/{full}.git",
71
+ pushed_at="", html_url=f"https://github.com/{full}")
72
+
73
+
74
+ def clone(repo: Repo, dest_parent: str) -> str | None:
75
+ dest = os.path.join(dest_parent, repo.name)
76
+ r = subprocess.run(
77
+ ["git", "clone", "--quiet", repo.clone_url, dest],
78
+ capture_output=True, text=True,
79
+ )
80
+ return dest if r.returncode == 0 else None
@@ -0,0 +1,52 @@
1
+ import re
2
+ import subprocess
3
+ from collections import Counter
4
+ from dataclasses import dataclass, field
5
+
6
+
7
+ @dataclass
8
+ class MetadataReport:
9
+ emails: list[str] = field(default_factory=list)
10
+ noreply_emails: list[str] = field(default_factory=list)
11
+ dominant_utc_offset: str | None = None
12
+ likely_active_hours: str | None = None
13
+ commit_count: int = 0
14
+
15
+
16
+ _NOREPLY = re.compile(r"noreply|users\.noreply\.github\.com", re.I)
17
+
18
+
19
+ def analyze_metadata(root: str) -> MetadataReport:
20
+ out = subprocess.run(
21
+ ["git", "-C", root, "log", "--all", "--format=%ae|%ai"],
22
+ capture_output=True, text=True, errors="ignore",
23
+ ).stdout
24
+
25
+ emails: Counter[str] = Counter()
26
+ offsets: Counter[str] = Counter()
27
+ hours: Counter[int] = Counter()
28
+ count = 0
29
+
30
+ for line in out.splitlines():
31
+ if "|" not in line:
32
+ continue
33
+ email, ts = line.split("|", 1)
34
+ count += 1
35
+ emails[email.strip()] += 1
36
+
37
+ m = re.search(r"(\d{2}):\d{2}:\d{2}\s([+-]\d{4})", ts)
38
+ if m:
39
+ hours[int(m.group(1))] += 1
40
+ offsets[m.group(2)] += 1
41
+
42
+ report = MetadataReport(commit_count=count)
43
+ for email, _ in emails.most_common():
44
+ (report.noreply_emails if _NOREPLY.search(email) else report.emails).append(email)
45
+
46
+ if offsets:
47
+ report.dominant_utc_offset = offsets.most_common(1)[0][0]
48
+ if hours:
49
+ top = [h for h, _ in hours.most_common(6)]
50
+ lo, hi = min(top), max(top)
51
+ report.likely_active_hours = f"{lo:02d}:00–{hi:02d}:00 (local)"
52
+ return report