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/__init__.py +1 -0
- gitroster/__main__.py +4 -0
- gitroster/agent.py +157 -0
- gitroster/cli.py +43 -0
- gitroster/demo.py +140 -0
- gitroster/deps.py +207 -0
- gitroster/export.py +40 -0
- gitroster/opener.py +218 -0
- gitroster/pathscan.py +34 -0
- gitroster/procs.py +179 -0
- gitroster/repoinfo.py +228 -0
- gitroster/scanner.py +454 -0
- gitroster/server.py +234 -0
- gitroster/static/icon.svg +12 -0
- gitroster/static/index.html +833 -0
- gitroster/store.py +80 -0
- gitroster-0.2.0.dist-info/METADATA +138 -0
- gitroster-0.2.0.dist-info/RECORD +22 -0
- gitroster-0.2.0.dist-info/WHEEL +5 -0
- gitroster-0.2.0.dist-info/entry_points.txt +2 -0
- gitroster-0.2.0.dist-info/licenses/LICENSE +9 -0
- gitroster-0.2.0.dist-info/top_level.txt +1 -0
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}
|