gitroster 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.
gitroster/scanner.py ADDED
@@ -0,0 +1,454 @@
1
+ """Filesystem scan: git repos, virtual envs, git state, ports."""
2
+ import json
3
+ import os
4
+ import re
5
+ import shutil
6
+ import subprocess
7
+ import sys
8
+ import time
9
+ from concurrent.futures import ThreadPoolExecutor
10
+ from pathlib import Path
11
+
12
+ from . import deps as depmod
13
+ from . import procs
14
+
15
+ PRUNE = {
16
+ "node_modules", ".cache", ".git", "__pycache__", ".npm", "site-packages",
17
+ ".local", "snap", ".cargo", ".rustup", ".gradle", ".m2", "dist", "build",
18
+ "Library", "AppData", ".Trash", "target", "$RECYCLE.BIN", "Application Data",
19
+ }
20
+ MAX_DEPTH = 6
21
+ GENERIC_NAMES = {"project", "src", "app", "code", "repo", "main", "web", "backend", "frontend"}
22
+ VENV_NAMES = (".venv", "venv", "env")
23
+ WIN = sys.platform == "win32"
24
+ BIN = "Scripts" if WIN else "bin"
25
+ PY = "python" if WIN else "python3"
26
+ SEP = "\\" if WIN else "/"
27
+ ENTRY_FILES = ("main.py", "app.py", "run.py", "server.py")
28
+
29
+
30
+ def run(cmd, timeout=10, cwd=None):
31
+ try:
32
+ return subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace",
33
+ timeout=timeout, cwd=cwd).stdout
34
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
35
+ return ""
36
+
37
+
38
+ def read(path, limit=6000):
39
+ try:
40
+ with open(path, encoding="utf-8", errors="replace") as f:
41
+ return f.read(limit)
42
+ except OSError:
43
+ return ""
44
+
45
+
46
+ def walk_size(path, deadline):
47
+ total = 0
48
+ stack = [str(path)]
49
+ while stack:
50
+ if time.time() > deadline:
51
+ return None
52
+ try:
53
+ with os.scandir(stack.pop()) as it:
54
+ for e in it:
55
+ try:
56
+ if e.is_symlink():
57
+ continue
58
+ if e.is_dir():
59
+ stack.append(e.path)
60
+ else:
61
+ total += e.stat().st_size
62
+ except OSError:
63
+ pass
64
+ except OSError:
65
+ pass
66
+ return total
67
+
68
+
69
+ def dir_size(path, timeout=20):
70
+ if shutil.which("du"):
71
+ out = run(["du", "-sk", str(path)], timeout)
72
+ try:
73
+ return int(out.split()[0]) * 1024
74
+ except (IndexError, ValueError):
75
+ return None
76
+ return walk_size(path, time.time() + timeout) # Windows has no du
77
+
78
+
79
+ def walk(roots):
80
+ """Return (repos, envs). repos: [(Path, is_tool)], envs: [(Path, kind)]."""
81
+ repos, envs, seen = [], [], set()
82
+ for root in roots:
83
+ root = Path(root).expanduser().resolve()
84
+ base = len(root.parts)
85
+ for dirpath, dirs, files in os.walk(root):
86
+ p = Path(dirpath)
87
+ if len(p.parts) - base >= MAX_DEPTH:
88
+ dirs[:] = []
89
+ continue
90
+ kind = "venv" if "pyvenv.cfg" in files else "conda" if "conda-meta" in dirs else None
91
+ if kind:
92
+ if p not in seen:
93
+ seen.add(p)
94
+ envs.append((p, kind))
95
+ dirs[:] = []
96
+ continue
97
+ if (".git" in dirs or ".git" in files) and p not in seen:
98
+ seen.add(p)
99
+ rel = p.relative_to(root).parts
100
+ tool = bool(rel) and rel[0].startswith(".")
101
+ repos.append((p, tool))
102
+ if tool:
103
+ dirs[:] = []
104
+ continue
105
+ dirs[:] = [d for d in dirs if d not in PRUNE and not (d.startswith(".") and d not in VENV_NAMES)]
106
+ return repos, envs
107
+
108
+
109
+ def git_info(path):
110
+ info = {"branch": "", "dirty": False, "ahead": 0, "behind": 0, "remote": "", "last_commit": None}
111
+ out = run(["git", "-C", str(path), "status", "--porcelain=v1", "-b"], 8).splitlines()
112
+ if out:
113
+ m = re.match(r"## (?:No commits yet on |Initial commit on )?(.+?)(?:\.\.\.| \[|$)", out[0])
114
+ info["branch"] = m.group(1) if m else ""
115
+ a = re.search(r"ahead (\d+)", out[0])
116
+ b = re.search(r"behind (\d+)", out[0])
117
+ info["ahead"], info["behind"] = int(a.group(1)) if a else 0, int(b.group(1)) if b else 0
118
+ info["dirty"] = len(out) > 1
119
+ info["remote"] = run(["git", "-C", str(path), "remote", "get-url", "origin"], 5).strip()
120
+ last = run(["git", "-C", str(path), "log", "-1", "--format=%ct"], 5).strip()
121
+ info["last_commit"] = int(last) if last.isdigit() else None
122
+ info["head"] = run(["git", "-C", str(path), "rev-parse", "HEAD"], 5).strip()
123
+ info["remotes"] = {}
124
+ for line in run(["git", "-C", str(path), "remote", "-v"], 5).splitlines():
125
+ parts = line.split()
126
+ if len(parts) >= 2:
127
+ info["remotes"].setdefault(parts[0], parts[1])
128
+ info["kind"] = repo_kind(path, info["remote"])
129
+ return info
130
+
131
+
132
+ def repo_kind(path, origin):
133
+ """local (no remote) / fork (has an upstream remote) / mine (you authored commits) / clone."""
134
+ remotes = run(["git", "-C", str(path), "remote"], 5).split()
135
+ if not remotes:
136
+ return "local"
137
+ if "upstream" in remotes:
138
+ return "fork"
139
+ email = run(["git", "-C", str(path), "config", "user.email"], 5).strip()
140
+ if email and run(["git", "-C", str(path), "log", "-1", "--fixed-strings", f"--author={email}", "--format=%H"], 8).strip():
141
+ return "mine"
142
+ return "clone"
143
+
144
+
145
+ def readme(path):
146
+ for name in ("README.md", "readme.md", "README.rst", "README.txt", "README"):
147
+ text = read(Path(path) / name, 20000)
148
+ if text:
149
+ lines = [l.strip() for l in text.splitlines()]
150
+ title = next((l.lstrip("# ").strip() for l in lines if l.startswith("#")), "")
151
+ para = next((l for l in lines if len(l) > 30 and not l.startswith(("#", "!", "[", "<", "`", "|", "-", "=", "*"))), "")
152
+ cmds = []
153
+ for block in re.findall(r"```(?:bash|sh|shell|console|zsh)?\n(.*?)```", text, re.S):
154
+ for line in block.splitlines():
155
+ line = line.strip().lstrip("$ ").strip()
156
+ if re.match(r"(python3?|npm|npx|pnpm|yarn|docker|uv|pip3?|make|cargo|go|bash|\./)\b", line) and len(line) < 120:
157
+ cmds.append(line)
158
+ ports = sorted({int(x) for x in re.findall(r"(?:localhost|127\.0\.0\.1|0\.0\.0\.0):(\d{2,5})", text)})[:4]
159
+ return {"title": title, "summary": para[:220], "readme_file": name, "cmds": list(dict.fromkeys(cmds))[:5], "expected_ports": ports}
160
+ return {"title": "", "summary": "", "cmds": [], "expected_ports": []}
161
+
162
+
163
+ def detect(path):
164
+ """Deterministic stack + command detection. `{py}` marks the Python to use."""
165
+ p = Path(path)
166
+ stack, setup, runs = [], [], []
167
+ if (p / "package.json").exists():
168
+ stack.append("node")
169
+ pm = "pnpm" if (p / "pnpm-lock.yaml").exists() else "yarn" if (p / "yarn.lock").exists() else "npm"
170
+ if not (p / "node_modules").exists():
171
+ setup.append(f"{pm} install")
172
+ try:
173
+ scripts = json.loads(read(p / "package.json", 200000)).get("scripts", {})
174
+ except ValueError:
175
+ scripts = {}
176
+ for k in ("dev", "start", "serve", "preview"):
177
+ if k in scripts and re.fullmatch(r"[\w:.-]+", k):
178
+ runs.append(f"{pm} run {k}")
179
+ if any((p / f).exists() for f in ("pyproject.toml", "requirements.txt", "setup.py", "Pipfile")):
180
+ stack.append("python")
181
+ if (p / "manage.py").exists():
182
+ runs.append("{py} manage.py runserver")
183
+ for entry in ENTRY_FILES:
184
+ if (p / entry).exists():
185
+ runs.append("{py} " + entry)
186
+ break
187
+ if (p / "Cargo.toml").exists():
188
+ stack.append("rust")
189
+ runs.append("cargo run")
190
+ if (p / "go.mod").exists():
191
+ stack.append("go")
192
+ runs.append("go run .")
193
+ if (p / "pubspec.yaml").exists():
194
+ stack.append("flutter")
195
+ runs.append("flutter run")
196
+ if (p / "Makefile").exists():
197
+ stack.append("make")
198
+ for t in re.findall(r"^([a-zA-Z][\w-]*):", read(p / "Makefile"), re.M):
199
+ if t in ("run", "dev", "start", "serve"):
200
+ runs.append(f"make {t}")
201
+ if any((p / f).exists() for f in ("docker-compose.yml", "compose.yaml", "docker-compose.yaml", "compose.yml")):
202
+ stack.append("docker")
203
+ runs.append("docker compose up")
204
+ if any((p / f).exists() for f in ("wrangler.toml", "wrangler.jsonc", "wrangler.json")):
205
+ stack.append("cloudflare")
206
+ runs.append("npx wrangler dev")
207
+ return stack, setup, list(dict.fromkeys(runs))
208
+
209
+
210
+ SECRET_FILES = re.compile(r"(^|/)(\.env(\.(?!example|sample|template)[\w.-]+)?|id_rsa|id_ed25519|[^/]+\.(pem|key|p12|pfx))$")
211
+
212
+
213
+ def ignored(path, name):
214
+ return subprocess.run(["git", "-C", str(path), "check-ignore", "-q", name], capture_output=True).returncode == 0
215
+
216
+
217
+ def health(path):
218
+ """Cheap local checks, no network: secrets that git tracks, .env files nobody ignores."""
219
+ p, issues = Path(path), []
220
+ tracked = run(["git", "-C", str(p), "ls-files"], 10).splitlines()
221
+ leaked = [f for f in tracked if SECRET_FILES.search(f)]
222
+ if leaked:
223
+ issues.append({"kind": "tracked-secret", "text": "secret-looking files are tracked by git", "files": leaked[:5]})
224
+ loose = [f for f in (".env", ".env.local") if (p / f).is_file() and f not in tracked and not ignored(p, f)]
225
+ if loose:
226
+ issues.append({"kind": "unignored-env", "text": ".env exists but is not in .gitignore", "files": loose})
227
+ if tracked and not (p / ".gitignore").exists() and any(x in detect(p)[0] for x in ("node", "python")):
228
+ issues.append({"kind": "no-gitignore", "text": "no .gitignore", "files": []})
229
+ return issues
230
+
231
+
232
+ def default_branch(path):
233
+ ref = run(["git", "-C", str(path), "symbolic-ref", "--short", "refs/remotes/origin/HEAD"], 5).strip()
234
+ if "/" in ref:
235
+ return ref.split("/", 1)[1]
236
+ for b in ("main", "master"):
237
+ if run(["git", "-C", str(path), "rev-parse", "--verify", "-q", f"refs/heads/{b}"], 5).strip():
238
+ return b
239
+ return ""
240
+
241
+
242
+ def forgotten_work(path):
243
+ """Work that is easy to lose track of: stashes, branches not merged anywhere safe, extra worktrees."""
244
+ p = str(path)
245
+ stashes = []
246
+ for line in run(["git", "-C", p, "stash", "list", "--format=%gd\t%ct\t%gs"], 8).splitlines():
247
+ ref, _, rest = line.partition("\t")
248
+ ts, _, msg = rest.partition("\t")
249
+ stashes.append({"ref": ref, "time": int(ts) if ts.isdigit() else None, "message": msg})
250
+ base = default_branch(path)
251
+ heads = run(["git", "-C", p, "for-each-ref", "refs/heads", "--format=%(refname:short)"], 8).split()
252
+ bases = [b for b in dict.fromkeys([base, "develop", "dev"]) if b and b in heads] # git-flow repos merge into develop
253
+ unmerged = set(heads) - set(bases)
254
+ for b in bases:
255
+ unmerged &= set(run(["git", "-C", p, "for-each-ref", f"--no-merged={b}", "refs/heads", "--format=%(refname:short)"], 8).split())
256
+ branches, merged = [], 0
257
+ fmt = "%(refname:short)\t%(upstream:short)\t%(upstream:track)\t%(committerdate:unix)\t%(HEAD)"
258
+ for line in run(["git", "-C", p, "for-each-ref", "refs/heads", f"--format={fmt}"], 8).splitlines():
259
+ name, upstream, track, ts, head = (line.split("\t") + [""] * 5)[:5]
260
+ if name in bases:
261
+ continue
262
+ if bases and name not in unmerged:
263
+ merged += 1
264
+ continue
265
+ ahead = re.search(r"ahead (\d+)", track)
266
+ gone = "gone" in track
267
+ state = "local-only" if not upstream or gone or ahead else "unmerged"
268
+ branches.append({"name": name, "state": state, "upstream": upstream, "gone": gone,
269
+ "ahead": int(ahead.group(1)) if ahead else 0,
270
+ "time": int(ts) if ts.isdigit() else None, "current": head.strip() == "*"})
271
+ worktrees, block = [], {}
272
+ for line in run(["git", "-C", p, "worktree", "list", "--porcelain"], 8).splitlines() + [""]:
273
+ if line:
274
+ k, _, v = line.partition(" ")
275
+ block[k] = v
276
+ elif block:
277
+ worktrees.append(block)
278
+ block = {}
279
+ extra = [{"path": w.get("worktree", ""), "branch": w.get("branch", "").replace("refs/heads/", ""),
280
+ "missing": "prunable" in w or not os.path.isdir(w.get("worktree", ""))} for w in worktrees[1:]]
281
+ branches.sort(key=lambda b: (b["state"] != "local-only", -(b["time"] or 0)))
282
+ return {"default": base, "stashes": stashes, "branches": branches, "merged_branches": merged, "worktrees": extra}
283
+
284
+
285
+ def graph(path, limit=300):
286
+ """Recent commits of all branches, tags and remotes, newest first, for drawing a commit graph."""
287
+ fmt = "%H%x1f%P%x1f%an%x1f%ct%x1f%D%x1f%s"
288
+ out = run(["git", "-C", str(path), "log", "--topo-order", f"-n{int(limit)}", "--branches", "--tags", "--remotes",
289
+ f"--format={fmt}"], 20)
290
+ commits = []
291
+ for line in out.splitlines():
292
+ h, parents, author, ts, refs, subject = (line.split("\x1f") + [""] * 6)[:6]
293
+ commits.append({"hash": h, "parents": parents.split(), "author": author, "time": int(ts) if ts.isdigit() else None,
294
+ "refs": [r.strip() for r in refs.split(",") if r.strip()], "subject": subject[:200]})
295
+ return commits
296
+
297
+
298
+ def subprojects(path, limit=8):
299
+ """Projects living in subfolders of a repo (monorepos, nested apps)."""
300
+ found = []
301
+ try:
302
+ dirs = sorted(d for d in Path(path).iterdir() if d.is_dir())
303
+ except OSError:
304
+ return found
305
+ for d in dirs:
306
+ if d.name in PRUNE or d.name.startswith("."):
307
+ continue
308
+ try:
309
+ stack, _, runs = detect(d)
310
+ except OSError: # unreadable folder
311
+ continue
312
+ if not stack:
313
+ continue
314
+ if runs:
315
+ found.append({"path": str(d), "name": d.name, "stack": stack, "run": runs})
316
+ return found[:limit]
317
+
318
+
319
+ def env_python_version(v):
320
+ m = re.search(r"^version(?:_info)?\s*=\s*(\S+)", read(Path(v) / "pyvenv.cfg"), re.M)
321
+ return m.group(1) if m else ""
322
+
323
+
324
+ def display_name(p):
325
+ return f"{p.parent.name}/{p.name}" if p.name.lower() in GENERIC_NAMES else p.name
326
+
327
+
328
+ def analyze_repo(p, tool):
329
+ p = Path(p)
330
+ stack, setup, runs = detect(p)
331
+ r = {
332
+ "name": display_name(p), "path": str(p), "tool": tool,
333
+ "stack": stack, "setup": setup, "run": runs, "envs": [], "ports": [],
334
+ }
335
+ r.update(git_info(p))
336
+ r.update(readme(p))
337
+ d = depmod.collect(p)
338
+ r["pkg"], r["deps"] = d["names"], d["deps"]
339
+ r["work"] = forgotten_work(p)
340
+ r["health"] = health(p)
341
+ r["subprojects"] = subprojects(p)
342
+ r["size"] = dir_size(p)
343
+ return r
344
+
345
+
346
+ def safe_analyze(p, tool):
347
+ """One broken repo must not take the whole scan down."""
348
+ try:
349
+ return analyze_repo(p, tool)
350
+ except Exception as e:
351
+ print(f"gitroster: skipped {p}: {e}", file=sys.stderr)
352
+ return None
353
+
354
+
355
+ def owner(path, items):
356
+ """Item with the longest `path` that contains `path`."""
357
+ best = None
358
+ for c in items:
359
+ cp = c["path"]
360
+ if path == cp or path.startswith(cp + os.sep):
361
+ if best is None or len(cp) > len(best["path"]):
362
+ best = c
363
+ return best
364
+
365
+
366
+ def finalize(repo):
367
+ """Fill in `{py}` and Python setup steps once envs are linked."""
368
+ py = PY
369
+ venvs = [e for e in repo["envs"] if e["kind"] == "venv"]
370
+ if venvs:
371
+ v = venvs[0]["path"]
372
+ inside = v.startswith(repo["path"] + os.sep)
373
+ v = os.path.relpath(v, repo["path"]) if inside else v
374
+ py = f"{v}{SEP}{BIN}{SEP}python"
375
+ repo["activate"] = f"{v}{SEP}{BIN}{SEP}activate" if WIN else f"source {v}/bin/activate"
376
+ elif "python" in repo["stack"]:
377
+ repo["setup"].insert(0, f"{PY} -m venv .venv")
378
+ pip = f".venv{SEP}{BIN}{SEP}pip"
379
+ p = Path(repo["path"])
380
+ if (p / "requirements.txt").exists():
381
+ repo["setup"].append(f"{pip} install -r requirements.txt")
382
+ elif (p / "pyproject.toml").exists():
383
+ repo["setup"].append(f"{pip} install -e .")
384
+ repo["run"] = [c.replace("{py}", py) for c in repo["run"]]
385
+
386
+
387
+ def norm_remote(url):
388
+ """git@github.com:a/b.git, ssh://git@github.com:22/a/b and https://github.com/a/b.git -> github.com/a/b"""
389
+ u = (url or "").strip().lower()
390
+ m = re.match(r"^(?:[a-z+]+://)?(?:[^@/]+@)?([^/:]+)(?::\d+)?[:/](.+)$", u)
391
+ if not m:
392
+ return u
393
+ path = re.sub(r"\.git$", "", m.group(2).strip("/"))
394
+ return f"{m.group(1)}/{path}"
395
+
396
+
397
+ def main_remote(r):
398
+ remotes = r.get("remotes", {})
399
+ return remotes.get("origin") or next(iter(remotes.values()), "")
400
+
401
+
402
+ GENERIC_PKGS = {"app", "main", "test", "tests", "src", "lib", "utils", "common", "core", "api", "web", "server", "client"}
403
+
404
+
405
+ def relate(repos):
406
+ """Fill same_remote (copies of one repo), uses / used_by (local packages that depend on each other)."""
407
+ by_remote, by_pkg = {}, {}
408
+ for r in repos:
409
+ key = norm_remote(main_remote(r))
410
+ r["same_remote"] = []
411
+ r["uses"], r["used_by"] = [], []
412
+ if key:
413
+ by_remote.setdefault(key, []).append(r["path"])
414
+ for n in r.get("pkg", []):
415
+ if len(n["name"]) >= 3 and n["name"].lower() not in GENERIC_PKGS:
416
+ by_pkg.setdefault((n["eco"], n["name"].lower()), set()).add(r["path"])
417
+ for r in repos:
418
+ key = norm_remote(main_remote(r))
419
+ r["same_remote"] = [x for x in by_remote.get(key, []) if x != r["path"]] if key else []
420
+ for d in r.get("deps", []):
421
+ for other in by_pkg.get((d["eco"], d["name"].lower()), ()):
422
+ if other != r["path"] and other not in r["uses"]:
423
+ r["uses"].append(other)
424
+ index = {r["path"]: r for r in repos}
425
+ for r in repos:
426
+ for other in r["uses"]:
427
+ index[other]["used_by"].append(r["path"])
428
+
429
+
430
+ def scan(roots, progress=None):
431
+ roots = [str(Path(r).expanduser()) for r in roots]
432
+ repo_paths, env_paths = walk(roots)
433
+ with ThreadPoolExecutor(8) as ex:
434
+ repos = [r for r in ex.map(lambda rp: safe_analyze(*rp), repo_paths) if r]
435
+ envs = [{"path": str(p), "name": p.name, "kind": k, "python": env_python_version(p) if k == "venv" else "", "repo": None}
436
+ for p, k in env_paths]
437
+ for e, s in zip(envs, ex.map(lambda e: dir_size(e["path"]), envs)):
438
+ e["size"] = s
439
+ for e in envs:
440
+ o = owner(e["path"], repos)
441
+ if o:
442
+ e["repo"] = o["path"]
443
+ o["envs"].append(e)
444
+ for r in repos:
445
+ finalize(r)
446
+ relate(repos)
447
+ ports = procs.listening()
448
+ containers = procs.docker_containers()
449
+ procs.link(ports, containers, repos, envs)
450
+ for r in repos:
451
+ r["ports"] = sorted({p["port"] for p in ports if p["repo"] == r["path"]})
452
+ repos.sort(key=lambda r: (r["tool"], -(r["last_commit"] or 0)))
453
+ return {"roots": roots, "scanned": time.time(), "repos": repos, "envs": envs,
454
+ "ports": ports, "containers": containers}