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/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.0"
|
gitroster/__main__.py
ADDED
gitroster/agent.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Optional: ask a coding agent CLI you already have to summarize a repo.
|
|
2
|
+
|
|
3
|
+
Nothing here talks to a network service itself and no API keys are handled. gitroster only runs the
|
|
4
|
+
agent CLI you chose, feeds it a prompt on stdin and asks it not to use tools. The answer is stored
|
|
5
|
+
as "AI-generated, unverified".
|
|
6
|
+
"""
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import shlex
|
|
11
|
+
import subprocess
|
|
12
|
+
import time
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from . import scanner
|
|
16
|
+
from .pathscan import which
|
|
17
|
+
|
|
18
|
+
# id, label, program, arguments, how to install. The prompt is written to stdin. Every entry must keep
|
|
19
|
+
# the agent away from tools: the summary needs only text, and nothing should be run or edited.
|
|
20
|
+
AGENTS = [
|
|
21
|
+
("claude", "Claude Code", "claude", ["-p", "--disallowedTools", "Bash,Edit,Write,NotebookEdit"],
|
|
22
|
+
"npm install -g @anthropic-ai/claude-code"),
|
|
23
|
+
("codex", "Codex CLI", "codex", ["exec", "--sandbox", "read-only", "--skip-git-repo-check", "-"],
|
|
24
|
+
"npm install -g @openai/codex"),
|
|
25
|
+
("gemini", "Gemini CLI", "gemini", ["-p", "Follow the instructions in the input exactly. Do not use tools."],
|
|
26
|
+
"npm install -g @google/gemini-cli"),
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
PROMPT = """You are helping a developer remember a project they wrote or cloned long ago.
|
|
30
|
+
Use ONLY the facts below. If something is not in the facts, say "unknown". Do not run commands or use tools.
|
|
31
|
+
|
|
32
|
+
Answer with ONLY a JSON object (no markdown fence) with these string keys:
|
|
33
|
+
"what": one sentence on what this project is and does
|
|
34
|
+
"stack": the main languages, frameworks and services, short
|
|
35
|
+
"run": how to set it up and start it, 1-3 short steps, or "unknown"
|
|
36
|
+
"status": what the recent commits, unmerged branches and stashes suggest was being worked on, 1-2 sentences
|
|
37
|
+
"watch": gotchas such as API keys, GPUs, databases, large downloads; "none" if nothing is apparent
|
|
38
|
+
|
|
39
|
+
--- FACTS ---
|
|
40
|
+
Name: {name}
|
|
41
|
+
Path: {path}
|
|
42
|
+
Kind: {kind} Branch: {branch} Uncommitted changes: {dirty} Unpushed commits: {ahead}
|
|
43
|
+
Detected stack: {stack}
|
|
44
|
+
Setup commands: {setup}
|
|
45
|
+
Run commands: {run}
|
|
46
|
+
Dependencies: {deps}
|
|
47
|
+
Local-only or unmerged branches: {branches}
|
|
48
|
+
Stashes: {stashes}
|
|
49
|
+
|
|
50
|
+
Recent commits (newest first):
|
|
51
|
+
{commits}
|
|
52
|
+
|
|
53
|
+
File tree (depth 2):
|
|
54
|
+
{tree}
|
|
55
|
+
|
|
56
|
+
README (truncated):
|
|
57
|
+
{readme}
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def detect():
|
|
62
|
+
"""Agent CLIs installed on this machine."""
|
|
63
|
+
return [{"id": e[0], "label": e[1]} for e in AGENTS if which(e[2])]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def catalog():
|
|
67
|
+
"""Every agent gitroster knows how to drive, with whether it is installed here."""
|
|
68
|
+
return [{"id": i, "label": label, "installed": bool(which(prog)), "install": hint} for i, label, prog, _, hint in AGENTS]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _resolve(argv):
|
|
72
|
+
return [which(argv[0]) or argv[0], *argv[1:]]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def command(settings=None, override=None):
|
|
76
|
+
"""-> (argv, label). Precedence: --agent-cmd / $GITROSTER_AGENT, a custom command from Settings,
|
|
77
|
+
the agent picked in Settings, then the first one found."""
|
|
78
|
+
explicit = override or os.environ.get("GITROSTER_AGENT")
|
|
79
|
+
if explicit:
|
|
80
|
+
return _resolve(shlex.split(explicit)), "custom command"
|
|
81
|
+
settings = settings or {}
|
|
82
|
+
if settings.get("agent") == "custom" and settings.get("agent_custom"):
|
|
83
|
+
return _resolve(shlex.split(settings["agent_custom"])), "custom command"
|
|
84
|
+
entries = [e for e in AGENTS if which(e[2])]
|
|
85
|
+
chosen = next((e for e in entries if e[0] == settings.get("agent")), entries[0] if entries else None)
|
|
86
|
+
if not chosen:
|
|
87
|
+
return None, ""
|
|
88
|
+
return [which(chosen[2]), *chosen[3]], chosen[1]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def tree(path, depth=2, limit=80):
|
|
92
|
+
root = Path(path)
|
|
93
|
+
out = []
|
|
94
|
+
for dirpath, dirs, files in os.walk(root):
|
|
95
|
+
rel = Path(dirpath).relative_to(root)
|
|
96
|
+
if len(rel.parts) >= depth:
|
|
97
|
+
dirs[:] = []
|
|
98
|
+
dirs[:] = sorted(d for d in dirs if d not in (".git", "node_modules", "__pycache__") and not d.startswith(".venv"))
|
|
99
|
+
for name in sorted(files)[:15]:
|
|
100
|
+
shown = str(rel / name) if rel.parts else name
|
|
101
|
+
if not scanner.SECRET_FILES.search(shown): # never even list secret-looking files
|
|
102
|
+
out.append(shown)
|
|
103
|
+
if len(out) >= limit:
|
|
104
|
+
break
|
|
105
|
+
return "\n".join(out[:limit])
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def build_prompt(repo):
|
|
109
|
+
p = Path(repo["path"])
|
|
110
|
+
text = ""
|
|
111
|
+
for n in ("README.md", "readme.md", "README.rst", "README.txt"):
|
|
112
|
+
if (p / n).exists():
|
|
113
|
+
text = (p / n).read_text(errors="replace")[:6000]
|
|
114
|
+
break
|
|
115
|
+
w = repo.get("work") or {}
|
|
116
|
+
commits = "\n".join(f"- {c['subject']}" for c in scanner.graph(p, 15)) or "none"
|
|
117
|
+
deps = ", ".join(d["name"] for d in repo.get("deps", [])[:25]) or "none"
|
|
118
|
+
return PROMPT.format(
|
|
119
|
+
name=repo["name"], path=repo["path"], kind=repo.get("kind", ""), branch=repo.get("branch", ""),
|
|
120
|
+
dirty="yes" if repo.get("dirty") else "no", ahead=repo.get("ahead", 0),
|
|
121
|
+
stack=", ".join(repo["stack"]) or "unknown", setup="; ".join(repo["setup"]) or "none",
|
|
122
|
+
run="; ".join(repo["run"]) or "none", deps=deps,
|
|
123
|
+
branches=", ".join(f"{b['name']} ({b['state']})" for b in w.get("branches", [])[:8]) or "none",
|
|
124
|
+
stashes="; ".join(s["message"] for s in w.get("stashes", [])[:5]) or "none",
|
|
125
|
+
commits=commits, tree=tree(p), readme=text or "(no README)")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
KEYS = ("what", "stack", "run", "status", "watch")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def parse(text):
|
|
132
|
+
"""The agent's answer as {what, stack, run, status, watch}; falls back to the raw text."""
|
|
133
|
+
cleaned = re.sub(r"^```(?:json)?\s*|\s*```$", "", text.strip())
|
|
134
|
+
for candidate in (cleaned, cleaned[cleaned.find("{"):cleaned.rfind("}") + 1]):
|
|
135
|
+
try:
|
|
136
|
+
data = json.loads(candidate)
|
|
137
|
+
except ValueError:
|
|
138
|
+
continue
|
|
139
|
+
if isinstance(data, dict) and any(k in data for k in KEYS):
|
|
140
|
+
return {k: str(data.get(k, "")).strip()[:600] for k in KEYS}
|
|
141
|
+
return {"raw": text.strip()[:2000]}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def summarize(repo, settings=None, override=None, timeout=180):
|
|
145
|
+
argv, label = command(settings, override)
|
|
146
|
+
if not argv:
|
|
147
|
+
raise RuntimeError("no agent CLI found; install Claude Code, Codex or Gemini CLI, or set a custom command in Settings")
|
|
148
|
+
try:
|
|
149
|
+
r = subprocess.run(argv, input=build_prompt(repo), capture_output=True, text=True, encoding="utf-8",
|
|
150
|
+
errors="replace", timeout=timeout, cwd=repo["path"])
|
|
151
|
+
except FileNotFoundError:
|
|
152
|
+
raise RuntimeError(f"agent command not found: {argv[0]}")
|
|
153
|
+
except subprocess.TimeoutExpired:
|
|
154
|
+
raise RuntimeError("the agent took too long")
|
|
155
|
+
if r.returncode != 0 or not r.stdout.strip():
|
|
156
|
+
raise RuntimeError((r.stderr or "the agent returned nothing").strip()[:300])
|
|
157
|
+
return {**parse(r.stdout), "agent": label, "head": repo.get("head", ""), "at": time.time()}
|
gitroster/cli.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import json
|
|
3
|
+
import sys
|
|
4
|
+
import webbrowser
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from . import __version__, demo, scanner
|
|
8
|
+
from .server import App, serve
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main(argv=None):
|
|
12
|
+
ap = argparse.ArgumentParser(prog="gitroster",
|
|
13
|
+
description="Find the repos, envs and ports you forgot about.")
|
|
14
|
+
ap.add_argument("roots", nargs="*", default=[str(Path.home())], help="folders to scan (default: ~)")
|
|
15
|
+
ap.add_argument("--port", type=int, default=8765)
|
|
16
|
+
ap.add_argument("--json", action="store_true", help="print one scan as JSON and exit")
|
|
17
|
+
ap.add_argument("--open", action="store_true", help="open the page in a browser")
|
|
18
|
+
ap.add_argument("--agent-cmd", help='command for AI summaries; reads the prompt on stdin '
|
|
19
|
+
'(default: "claude -p", or $GITROSTER_AGENT)')
|
|
20
|
+
ap.add_argument("--demo", action="store_true", help="show sample repos instead of scanning your machine")
|
|
21
|
+
ap.add_argument("--version", action="version", version=f"gitroster {__version__}")
|
|
22
|
+
a = ap.parse_args(argv)
|
|
23
|
+
|
|
24
|
+
if a.json:
|
|
25
|
+
json.dump(scanner.scan(a.roots), sys.stdout, indent=2)
|
|
26
|
+
return 0
|
|
27
|
+
|
|
28
|
+
if a.demo:
|
|
29
|
+
roots, store = demo.setup()
|
|
30
|
+
app = App(roots, a.port, store=store, agent_cmd=a.agent_cmd, demo=True)
|
|
31
|
+
a.roots = ["sample repos"]
|
|
32
|
+
else:
|
|
33
|
+
app = App(a.roots, a.port, agent_cmd=a.agent_cmd)
|
|
34
|
+
url = f"http://127.0.0.1:{a.port}"
|
|
35
|
+
print(f"gitroster {__version__} on {url} (scanning {', '.join(a.roots)})")
|
|
36
|
+
if a.open:
|
|
37
|
+
webbrowser.open(url)
|
|
38
|
+
serve(app)
|
|
39
|
+
return 0
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
if __name__ == "__main__":
|
|
43
|
+
sys.exit(main())
|
gitroster/demo.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""`gitroster --demo`: a throwaway set of sample repos, so the UI can be tried or photographed without
|
|
2
|
+
scanning a real machine. Nothing here touches your own folders, settings or processes."""
|
|
3
|
+
import atexit
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
import tempfile
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from . import procs
|
|
12
|
+
from .store import Store
|
|
13
|
+
|
|
14
|
+
DAY = 86400
|
|
15
|
+
ME = ("Demo User", "demo@example.com")
|
|
16
|
+
|
|
17
|
+
# name, remote, files, [(days ago, message)], extra setup name
|
|
18
|
+
REPOS = [
|
|
19
|
+
("atlas-api", "https://github.com/demo-user/atlas-api.git", {
|
|
20
|
+
"README.md": "# Atlas API\n\nA small REST API that serves product data for the storefront.\n\n```bash\nnpm run dev\n```\n\nOpen http://localhost:3000\n",
|
|
21
|
+
"package.json": '{"name":"atlas-api","scripts":{"dev":"node server.js","start":"node server.js"},"dependencies":{"express":"^4.19.0","zod":"^3.23.0"}}',
|
|
22
|
+
".gitignore": "node_modules\n.env\n",
|
|
23
|
+
"server.js": "require('express')().listen(3000)\n"},
|
|
24
|
+
[(40, "Add product schema"), (9, "Validate query params"), (2, "Cache product list")], "ahead"),
|
|
25
|
+
("bookshelf", "https://github.com/demo-user/bookshelf.git", {
|
|
26
|
+
"README.md": "# Bookshelf\n\nTrack the books you read and what you thought of them.\n",
|
|
27
|
+
"requirements.txt": "flask==3.0.3\nsqlalchemy==2.0.30\n",
|
|
28
|
+
"app.py": "print('bookshelf')\n",
|
|
29
|
+
".gitignore": ".venv\n"},
|
|
30
|
+
[(120, "First version"), (30, "Add ratings"), (5, "Import from CSV")], "work"),
|
|
31
|
+
("dashboard-ui", "https://github.com/demo-user/dashboard-ui.git", {
|
|
32
|
+
"README.md": "# Dashboard UI\n\nFront end for the Atlas API, built with Vite.\n\nRuns on http://localhost:5173\n",
|
|
33
|
+
"package.json": '{"name":"dashboard-ui","scripts":{"dev":"vite"},"dependencies":{"vite":"^5.2.0","zod":"^3.22.0"}}',
|
|
34
|
+
".gitignore": "node_modules\n"},
|
|
35
|
+
[(60, "Scaffold with Vite"), (3, "Add chart panel")], None),
|
|
36
|
+
("rust-notes", "https://github.com/someone/rust-notes.git", {
|
|
37
|
+
"README.md": "# rust-notes\n\nA command line notebook written in Rust.\n",
|
|
38
|
+
"Cargo.toml": '[package]\nname = "rust-notes"\nversion = "0.3.0"\n\n[dependencies]\nclap = "4"\n',
|
|
39
|
+
".gitignore": "target\n"},
|
|
40
|
+
[(300, "Release 0.3.0")], "clone"),
|
|
41
|
+
("weather-cli", "https://github.com/demo-user/weather-cli.git", {
|
|
42
|
+
"README.md": "# weather-cli\n\nPrints the weather forecast in your terminal. Forked to add metric units.\n",
|
|
43
|
+
"go.mod": "module example.com/weather\n\ngo 1.22\n",
|
|
44
|
+
"main.go": "package main\nfunc main() {}\n"},
|
|
45
|
+
[(210, "Fork upstream"), (190, "Add metric units")], "fork"),
|
|
46
|
+
("old-experiment", None, {
|
|
47
|
+
"README.md": "# old experiment\n\nSomething I tried with a camera feed and never finished.\n",
|
|
48
|
+
"main.py": "print('todo')\n",
|
|
49
|
+
"requirements.txt": "opencv-python\n",
|
|
50
|
+
".env": "CAMERA_KEY=demo\n"},
|
|
51
|
+
[(480, "wip"), (470, "wip 2")], "dirty"),
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
SUMMARIES = {
|
|
55
|
+
"atlas-api": {
|
|
56
|
+
"what": "REST API that serves product data to the storefront.",
|
|
57
|
+
"stack": "Node.js, Express, Zod",
|
|
58
|
+
"run": "npm run dev, then open http://localhost:3000",
|
|
59
|
+
"status": "Active. The last work added caching for the product list.",
|
|
60
|
+
"watch": "Two commits are not pushed yet.",
|
|
61
|
+
},
|
|
62
|
+
"bookshelf": {
|
|
63
|
+
"what": "Small web app to track the books you read, with ratings and CSV import.",
|
|
64
|
+
"stack": "Python, Flask, SQLAlchemy",
|
|
65
|
+
"run": "python3 -m venv .venv, install requirements.txt, then python3 app.py",
|
|
66
|
+
"status": "Was working on CSV import: there are uncommitted changes and a stash.",
|
|
67
|
+
"watch": "Check the stash before starting something new.",
|
|
68
|
+
},
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
NOTES = {"bookshelf": "Finish the CSV import, then deploy to the home server."}
|
|
72
|
+
|
|
73
|
+
PORTS = [("atlas-api", 3000, "node server.js", "node"), ("dashboard-ui", 5173, "node node_modules/.bin/vite", "node")]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _git(path, *args, when=None):
|
|
77
|
+
env = {**os.environ, "GIT_AUTHOR_NAME": ME[0], "GIT_AUTHOR_EMAIL": ME[1],
|
|
78
|
+
"GIT_COMMITTER_NAME": ME[0], "GIT_COMMITTER_EMAIL": ME[1]}
|
|
79
|
+
if when:
|
|
80
|
+
env["GIT_AUTHOR_DATE"] = env["GIT_COMMITTER_DATE"] = f"{int(when)} +0000"
|
|
81
|
+
subprocess.run(["git", "-C", str(path), "-c", "commit.gpgsign=false", *args], env=env, check=True, capture_output=True)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _build(root, name, remote, files, commits, extra):
|
|
85
|
+
p = Path(root) / name
|
|
86
|
+
p.mkdir(parents=True)
|
|
87
|
+
_git(p, "init", "-q", "-b", "main")
|
|
88
|
+
_git(p, "config", "user.email", ME[1])
|
|
89
|
+
_git(p, "config", "user.name", ME[0])
|
|
90
|
+
for f, content in files.items():
|
|
91
|
+
(p / f).write_text(content)
|
|
92
|
+
now = time.time()
|
|
93
|
+
for i, (days, msg) in enumerate(commits):
|
|
94
|
+
(p / "CHANGELOG.txt").write_text("\n".join(m for _, m in commits[: i + 1]) + "\n")
|
|
95
|
+
_git(p, "add", "-A", "-f")
|
|
96
|
+
_git(p, "commit", "-q", "-m", msg, when=now - days * DAY)
|
|
97
|
+
if remote:
|
|
98
|
+
_git(p, "remote", "add", "origin", remote)
|
|
99
|
+
if extra == "clone": # the commits are not yours: your configured email no longer matches the author
|
|
100
|
+
_git(p, "config", "user.email", "me@example.org")
|
|
101
|
+
if extra == "fork":
|
|
102
|
+
_git(p, "remote", "add", "upstream", "https://github.com/original/weather-cli.git")
|
|
103
|
+
_git(p, "update-ref", "refs/remotes/origin/main", "HEAD~2" if extra == "ahead" else "HEAD")
|
|
104
|
+
_git(p, "config", "branch.main.remote", "origin")
|
|
105
|
+
_git(p, "config", "branch.main.merge", "refs/heads/main")
|
|
106
|
+
if extra == "work":
|
|
107
|
+
_git(p, "checkout", "-q", "-b", "csv-import")
|
|
108
|
+
(p / "importer.py").write_text("def load(path):\n pass\n")
|
|
109
|
+
_git(p, "add", "-A")
|
|
110
|
+
_git(p, "commit", "-q", "-m", "Start CSV importer", when=now - 5 * DAY)
|
|
111
|
+
_git(p, "checkout", "-q", "main")
|
|
112
|
+
(p / "scratch.py").write_text("print('try')\n")
|
|
113
|
+
_git(p, "add", "-A")
|
|
114
|
+
_git(p, "stash", "-q")
|
|
115
|
+
(p / "app.py").write_text("print('bookshelf, edited')\n")
|
|
116
|
+
if extra == "dirty":
|
|
117
|
+
(p / "main.py").write_text("print('half done')\n")
|
|
118
|
+
return p
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def setup():
|
|
122
|
+
"""Create the sample repos, fake the port list, and return (roots, store)."""
|
|
123
|
+
base = Path(tempfile.mkdtemp(prefix="gitroster-demo-")).resolve()
|
|
124
|
+
atexit.register(shutil.rmtree, base, ignore_errors=True)
|
|
125
|
+
root = base / "projects"
|
|
126
|
+
paths = {}
|
|
127
|
+
for name, remote, files, commits, extra in REPOS:
|
|
128
|
+
paths[name] = _build(root, name, remote, files, commits, extra)
|
|
129
|
+
store = Store(base / "config", base / "state") # state.parent is `base`, which the page shows as ~
|
|
130
|
+
for name, summary in SUMMARIES.items():
|
|
131
|
+
head = subprocess.run(["git", "-C", str(paths[name]), "rev-parse", "HEAD"], capture_output=True, text=True).stdout.strip()
|
|
132
|
+
store.update_meta(str(paths[name].resolve()), summary={**summary, "agent": "Demo", "head": head, "at": time.time() - 3600})
|
|
133
|
+
for name, note in NOTES.items():
|
|
134
|
+
store.update_meta(str(paths[name].resolve()), note=note)
|
|
135
|
+
store.update_meta(str(paths["atlas-api"].resolve()), pinned=True)
|
|
136
|
+
ports = [dict(procs._entry(port, "127.0.0.1", 40000 + i, proc, cmd, str(paths[name].resolve()), time.time() - 7200))
|
|
137
|
+
for i, (name, port, cmd, proc) in enumerate(PORTS)]
|
|
138
|
+
procs.listening = lambda: [dict(p) for p in ports] # never read the real machine's ports
|
|
139
|
+
procs.docker_containers = lambda: []
|
|
140
|
+
return [str(root)], store
|
gitroster/deps.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Read dependency manifests (npm, PyPI, Go, Cargo, Composer). Parsers are small and forgiving on purpose."""
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
MAX_DEPS = 500
|
|
8
|
+
PRUNE = {"node_modules", ".git", "__pycache__", "vendor", "dist", "build", "target", ".venv", "venv", "env"}
|
|
9
|
+
REQ_RE = re.compile(r"^([A-Za-z0-9][A-Za-z0-9._-]*)\s*(?:\[[^\]]*\])?\s*([^;#]*)")
|
|
10
|
+
STR_RE = re.compile(r"""["']([^"']+)["']""")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _read(path):
|
|
14
|
+
try:
|
|
15
|
+
return Path(path).read_text(encoding="utf-8", errors="replace")[:400000]
|
|
16
|
+
except OSError:
|
|
17
|
+
return ""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _norm_py(name):
|
|
21
|
+
return re.sub(r"[-_.]+", "-", name).lower()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _req(line):
|
|
25
|
+
m = REQ_RE.match(line.strip())
|
|
26
|
+
return (_norm_py(m.group(1)), m.group(2).strip()) if m else None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _toml_sections(text):
|
|
30
|
+
sections, cur = {}, ""
|
|
31
|
+
for line in text.splitlines():
|
|
32
|
+
m = re.match(r"^\s*\[{1,2}([^\]]+)\]{1,2}\s*$", line)
|
|
33
|
+
if m:
|
|
34
|
+
cur = m.group(1).strip()
|
|
35
|
+
sections.setdefault(cur, [])
|
|
36
|
+
elif cur is not None:
|
|
37
|
+
sections.setdefault(cur, []).append(line)
|
|
38
|
+
return sections
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _toml_value(v):
|
|
42
|
+
v = v.strip()
|
|
43
|
+
m = re.match(r"""^["']([^"']*)["']""", v)
|
|
44
|
+
if m:
|
|
45
|
+
return m.group(1)
|
|
46
|
+
m = re.search(r"""version\s*=\s*["']([^"']*)["']""", v)
|
|
47
|
+
return m.group(1) if m else "*"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _toml_table_deps(lines, eco, dev, skip=()):
|
|
51
|
+
out = []
|
|
52
|
+
for line in lines:
|
|
53
|
+
m = re.match(r"""^\s*["']?([A-Za-z0-9_.-]+)["']?\s*=\s*(.+)$""", line)
|
|
54
|
+
if m and m.group(1) not in skip:
|
|
55
|
+
out.append({"name": _norm_py(m.group(1)) if eco == "pypi" else m.group(1), "spec": _toml_value(m.group(2)), "eco": eco, "dev": dev})
|
|
56
|
+
return out
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _toml_array(lines, key):
|
|
60
|
+
"""Strings of `key = [ ... ]` (one or many lines)."""
|
|
61
|
+
out, collecting = [], False
|
|
62
|
+
for line in lines:
|
|
63
|
+
if not collecting:
|
|
64
|
+
m = re.match(rf"^\s*{re.escape(key)}\s*=\s*\[(.*)$", line)
|
|
65
|
+
if not m:
|
|
66
|
+
continue
|
|
67
|
+
collecting, line = True, m.group(1)
|
|
68
|
+
out += STR_RE.findall(line)
|
|
69
|
+
if "]" in re.sub(r"\"[^\"]*\"|'[^']*'", "", line): # a `]` outside quotes closes the array
|
|
70
|
+
collecting = False
|
|
71
|
+
return out
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def pyproject(text):
|
|
75
|
+
sec = _toml_sections(text)
|
|
76
|
+
name, deps = "", []
|
|
77
|
+
project = sec.get("project", [])
|
|
78
|
+
for line in project:
|
|
79
|
+
m = re.match(r"""^\s*name\s*=\s*["']([^"']+)["']""", line)
|
|
80
|
+
if m:
|
|
81
|
+
name = _norm_py(m.group(1))
|
|
82
|
+
for s in _toml_array(project, "dependencies"):
|
|
83
|
+
r = _req(s)
|
|
84
|
+
if r:
|
|
85
|
+
deps.append({"name": r[0], "spec": r[1], "eco": "pypi", "dev": False})
|
|
86
|
+
optional = sec.get("project.optional-dependencies", [])
|
|
87
|
+
for group in re.findall(r"^\s*([\w-]+)\s*=\s*\[", "\n".join(optional), re.M):
|
|
88
|
+
for s_ in _toml_array(optional, group):
|
|
89
|
+
r = _req(s_)
|
|
90
|
+
if r:
|
|
91
|
+
deps.append({"name": r[0], "spec": r[1], "eco": "pypi", "dev": True})
|
|
92
|
+
if "tool.poetry" in sec and not name:
|
|
93
|
+
for line in sec["tool.poetry"]:
|
|
94
|
+
m = re.match(r"""^\s*name\s*=\s*["']([^"']+)["']""", line)
|
|
95
|
+
if m:
|
|
96
|
+
name = _norm_py(m.group(1))
|
|
97
|
+
deps += _toml_table_deps(sec.get("tool.poetry.dependencies", []), "pypi", False, skip=("python",))
|
|
98
|
+
for key in ("tool.poetry.dev-dependencies", "tool.poetry.group.dev.dependencies"):
|
|
99
|
+
deps += _toml_table_deps(sec.get(key, []), "pypi", True)
|
|
100
|
+
return name, deps
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def requirements(text):
|
|
104
|
+
out = []
|
|
105
|
+
for line in text.splitlines():
|
|
106
|
+
line = line.strip()
|
|
107
|
+
if not line or line.startswith(("#", "-", "git+", "http")):
|
|
108
|
+
continue
|
|
109
|
+
r = _req(line)
|
|
110
|
+
if r:
|
|
111
|
+
out.append({"name": r[0], "spec": r[1], "eco": "pypi", "dev": False})
|
|
112
|
+
return out
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def package_json(text):
|
|
116
|
+
try:
|
|
117
|
+
data = json.loads(text)
|
|
118
|
+
except ValueError:
|
|
119
|
+
return "", []
|
|
120
|
+
if not isinstance(data, dict):
|
|
121
|
+
return "", []
|
|
122
|
+
out = []
|
|
123
|
+
for key, dev in (("dependencies", False), ("devDependencies", True)):
|
|
124
|
+
for k, v in (data.get(key) or {}).items():
|
|
125
|
+
out.append({"name": k, "spec": str(v), "eco": "npm", "dev": dev})
|
|
126
|
+
return str(data.get("name") or ""), out
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def composer_json(text):
|
|
130
|
+
try:
|
|
131
|
+
data = json.loads(text)
|
|
132
|
+
except ValueError:
|
|
133
|
+
return "", []
|
|
134
|
+
if not isinstance(data, dict):
|
|
135
|
+
return "", []
|
|
136
|
+
out = []
|
|
137
|
+
for key, dev in (("require", False), ("require-dev", True)):
|
|
138
|
+
for k, v in (data.get(key) or {}).items():
|
|
139
|
+
if "/" in k:
|
|
140
|
+
out.append({"name": k, "spec": str(v), "eco": "composer", "dev": dev})
|
|
141
|
+
return str(data.get("name") or ""), out
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def go_mod(text):
|
|
145
|
+
name, out, block = "", [], False
|
|
146
|
+
for line in text.splitlines():
|
|
147
|
+
line = line.split("//")[0].strip()
|
|
148
|
+
m = re.match(r"^module\s+(\S+)", line)
|
|
149
|
+
if m:
|
|
150
|
+
name = m.group(1)
|
|
151
|
+
elif re.match(r"^require\s*\($", line):
|
|
152
|
+
block = True
|
|
153
|
+
elif block and line == ")":
|
|
154
|
+
block = False
|
|
155
|
+
else:
|
|
156
|
+
m = re.match(r"^(?:require\s+)?(\S+)\s+(v\S+)$", line) if (block or line.startswith("require ")) else None
|
|
157
|
+
if m:
|
|
158
|
+
out.append({"name": m.group(1), "spec": m.group(2), "eco": "go", "dev": False})
|
|
159
|
+
return name, out
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def cargo(text):
|
|
163
|
+
sec = _toml_sections(text)
|
|
164
|
+
name = ""
|
|
165
|
+
for line in sec.get("package", []):
|
|
166
|
+
m = re.match(r"""^\s*name\s*=\s*["']([^"']+)["']""", line)
|
|
167
|
+
if m:
|
|
168
|
+
name = m.group(1)
|
|
169
|
+
out = _toml_table_deps(sec.get("dependencies", []), "cargo", False)
|
|
170
|
+
out += _toml_table_deps(sec.get("dev-dependencies", []), "cargo", True)
|
|
171
|
+
out += _toml_table_deps(sec.get("build-dependencies", []), "cargo", True)
|
|
172
|
+
return name, out
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _manifests(d):
|
|
176
|
+
"""(names, deps) for one folder."""
|
|
177
|
+
names, deps = [], []
|
|
178
|
+
for fname, fn, eco in (("package.json", package_json, "npm"), ("composer.json", composer_json, "composer"),
|
|
179
|
+
("go.mod", go_mod, "go"), ("Cargo.toml", cargo, "cargo"), ("pyproject.toml", pyproject, "pypi")):
|
|
180
|
+
text = _read(d / fname)
|
|
181
|
+
if text:
|
|
182
|
+
n, dd = fn(text)
|
|
183
|
+
if n:
|
|
184
|
+
names.append({"eco": eco, "name": n})
|
|
185
|
+
deps += dd
|
|
186
|
+
seen = {(x["name"]) for x in deps if x["eco"] == "pypi"}
|
|
187
|
+
for f in sorted(d.glob("requirements*.txt"))[:4]:
|
|
188
|
+
dev = "dev" in f.name or "test" in f.name
|
|
189
|
+
for x in requirements(_read(f)):
|
|
190
|
+
if x["name"] not in seen:
|
|
191
|
+
seen.add(x["name"])
|
|
192
|
+
x["dev"] = dev
|
|
193
|
+
deps.append(x)
|
|
194
|
+
return names, deps
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def collect(path):
|
|
198
|
+
"""Dependencies of a repo: its root and immediate subfolders that have a manifest."""
|
|
199
|
+
root = Path(path)
|
|
200
|
+
names, deps = _manifests(root)
|
|
201
|
+
for d in sorted(p for p in root.iterdir() if p.is_dir() and p.name not in PRUNE and not p.name.startswith(".")) if root.is_dir() else []:
|
|
202
|
+
n, dd = _manifests(d)
|
|
203
|
+
names += n
|
|
204
|
+
for x in dd:
|
|
205
|
+
x["where"] = d.name
|
|
206
|
+
deps += dd
|
|
207
|
+
return {"names": names, "deps": deps[:MAX_DEPS]}
|
gitroster/export.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Markdown export of the whole inventory."""
|
|
2
|
+
import time
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def _home(p):
|
|
6
|
+
import os
|
|
7
|
+
h = os.path.expanduser("~")
|
|
8
|
+
return "~" + p[len(h):] if p.startswith(h) else p
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def markdown(scan, meta):
|
|
12
|
+
repos = [r for r in scan.get("repos", []) if not meta.get(r["path"], {}).get("ignored")]
|
|
13
|
+
out = [f"# Repo inventory", "", f"{len(repos)} repos, scanned {time.strftime('%Y-%m-%d %H:%M', time.localtime(scan.get('scanned', time.time())))}", ""]
|
|
14
|
+
for r in repos:
|
|
15
|
+
m = meta.get(r["path"], {})
|
|
16
|
+
out += [f"## {r['name']}", "",
|
|
17
|
+
f"- path: `{_home(r['path'])}`",
|
|
18
|
+
f"- kind: {r['kind']}" + (f" · remote: {r['remote']}" if r.get("remote") else ""),
|
|
19
|
+
f"- stack: {', '.join(r['stack']) or '-'}"]
|
|
20
|
+
if r.get("summary"):
|
|
21
|
+
out.append(f"- about: {r['summary']}")
|
|
22
|
+
if m.get("note"):
|
|
23
|
+
out.append(f"- note: {m['note']}")
|
|
24
|
+
if r.get("setup"):
|
|
25
|
+
out.append("- setup: " + "; ".join(f"`{c}`" for c in r["setup"]))
|
|
26
|
+
if r.get("run"):
|
|
27
|
+
out.append("- run: " + "; ".join(f"`{c}`" for c in r["run"]))
|
|
28
|
+
if r.get("ports"):
|
|
29
|
+
out.append("- ports: " + ", ".join(str(p) for p in r["ports"]))
|
|
30
|
+
w = r.get("work") or {}
|
|
31
|
+
if w.get("stashes"):
|
|
32
|
+
out.append(f"- stashes: {len(w['stashes'])}")
|
|
33
|
+
for b in w.get("branches", []):
|
|
34
|
+
out.append(f"- branch `{b['name']}`: {b['state']}" + (f" (ahead {b['ahead']})" if b["ahead"] else ""))
|
|
35
|
+
for t in w.get("worktrees", []):
|
|
36
|
+
out.append(f"- worktree `{t['path']}` ({t['branch'] or 'detached'})" + (" MISSING" if t["missing"] else ""))
|
|
37
|
+
for h in r.get("health", []):
|
|
38
|
+
out.append(f"- WARNING: {h['text']}" + (f" ({', '.join(h['files'])})" if h["files"] else ""))
|
|
39
|
+
out.append("")
|
|
40
|
+
return "\n".join(out)
|