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/opener.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Open a repo in an editor, a terminal or the file manager (Linux, macOS, Windows, WSL).
|
|
2
|
+
|
|
3
|
+
Editors and terminals are detected on this machine; the user picks one in Settings.
|
|
4
|
+
"""
|
|
5
|
+
import glob
|
|
6
|
+
import os
|
|
7
|
+
import shlex
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import threading
|
|
11
|
+
import sys
|
|
12
|
+
import time
|
|
13
|
+
|
|
14
|
+
from .pathscan import which
|
|
15
|
+
|
|
16
|
+
WIN = sys.platform == "win32"
|
|
17
|
+
MAC = sys.platform == "darwin"
|
|
18
|
+
WSL = bool(os.environ.get("WSL_DISTRO_NAME"))
|
|
19
|
+
HOME = os.path.expanduser("~")
|
|
20
|
+
|
|
21
|
+
# id, label, commands on PATH, macOS app name
|
|
22
|
+
EDITORS = [
|
|
23
|
+
("code", "VS Code", ["code"], "Visual Studio Code"),
|
|
24
|
+
("cursor", "Cursor", ["cursor"], "Cursor"),
|
|
25
|
+
("windsurf", "Windsurf", ["windsurf"], "Windsurf"),
|
|
26
|
+
("codium", "VSCodium", ["codium"], "VSCodium"),
|
|
27
|
+
("zed", "Zed", ["zed"], "Zed"),
|
|
28
|
+
("subl", "Sublime Text", ["subl"], "Sublime Text"),
|
|
29
|
+
("pycharm", "PyCharm", ["pycharm", "pycharm-professional", "pycharm-community", "charm", "pycharm64"], "PyCharm"),
|
|
30
|
+
("idea", "IntelliJ IDEA", ["idea", "intellij-idea-ultimate", "intellij-idea-community", "idea64"], "IntelliJ IDEA"),
|
|
31
|
+
("webstorm", "WebStorm", ["webstorm", "webstorm64"], "WebStorm"),
|
|
32
|
+
("phpstorm", "PhpStorm", ["phpstorm", "phpstorm64"], "PhpStorm"),
|
|
33
|
+
("goland", "GoLand", ["goland", "goland64"], "GoLand"),
|
|
34
|
+
("rustrover", "RustRover", ["rustrover", "rustrover64"], "RustRover"),
|
|
35
|
+
("clion", "CLion", ["clion", "clion64"], "CLion"),
|
|
36
|
+
("studio", "Android Studio", ["studio", "studio64"], "Android Studio"),
|
|
37
|
+
("xed", "Xcode", ["xed"], None),
|
|
38
|
+
]
|
|
39
|
+
# id, label, commands, argument template ({p} = folder), macOS app
|
|
40
|
+
TERMINALS = [
|
|
41
|
+
("wt", "Windows Terminal", ["wt"], ["-d", "{p}"], None),
|
|
42
|
+
("powershell", "PowerShell", ["pwsh", "powershell"], None, None),
|
|
43
|
+
("cmd", "Command Prompt", ["cmd"], None, None),
|
|
44
|
+
("Terminal", "Terminal", [], None, "Terminal"),
|
|
45
|
+
("iTerm", "iTerm", [], None, "iTerm"),
|
|
46
|
+
("Warp", "Warp", [], None, "Warp"),
|
|
47
|
+
("gnome-terminal", "GNOME Terminal", ["gnome-terminal"], ["--working-directory={p}"], None),
|
|
48
|
+
("konsole", "Konsole", ["konsole"], ["--workdir", "{p}"], None),
|
|
49
|
+
("xfce4-terminal", "Xfce Terminal", ["xfce4-terminal"], ["--working-directory={p}"], None),
|
|
50
|
+
("tilix", "Tilix", ["tilix"], ["-w", "{p}"], None),
|
|
51
|
+
("kitty", "kitty", ["kitty"], ["--directory", "{p}"], None),
|
|
52
|
+
("alacritty", "Alacritty", ["alacritty"], ["--working-directory", "{p}"], None),
|
|
53
|
+
("wezterm", "WezTerm", ["wezterm"], ["start", "--cwd", "{p}"], None),
|
|
54
|
+
("x-terminal-emulator", "System default", ["x-terminal-emulator"], None, None),
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _mac_app(name):
|
|
59
|
+
return bool(name) and any(os.path.isdir(f"{d}/{name}.app") or glob.glob(f"{d}/{name}*.app") for d in ("/Applications", f"{HOME}/Applications"))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
_which = which
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _find_cmd(cmds):
|
|
66
|
+
for c in cmds:
|
|
67
|
+
found = _which(c)
|
|
68
|
+
if found:
|
|
69
|
+
return found
|
|
70
|
+
for c in cmds: # JetBrains Toolbox installs launchers here
|
|
71
|
+
for pattern in (f"{HOME}/.local/share/JetBrains/Toolbox/scripts/{c}", f"{HOME}/Library/Application Support/JetBrains/Toolbox/scripts/{c}",
|
|
72
|
+
f"{os.environ.get('LOCALAPPDATA', '')}/JetBrains/Toolbox/scripts/{c}.cmd"):
|
|
73
|
+
if os.path.isfile(pattern):
|
|
74
|
+
return pattern
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _editor_launcher(entry):
|
|
79
|
+
_, _, cmds, app = entry
|
|
80
|
+
found = _find_cmd(cmds)
|
|
81
|
+
if found:
|
|
82
|
+
return [found]
|
|
83
|
+
if MAC and _mac_app(app):
|
|
84
|
+
return ["open", "-a", app]
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _terminal_available(entry):
|
|
89
|
+
tid, _, cmds, _, app = entry
|
|
90
|
+
if MAC:
|
|
91
|
+
return _mac_app(app) or bool(_find_cmd(cmds))
|
|
92
|
+
if WIN:
|
|
93
|
+
return tid in ("wt", "powershell", "cmd") and bool(_find_cmd(cmds) or tid == "cmd")
|
|
94
|
+
if WSL and tid == "wt":
|
|
95
|
+
return bool(_which("wt.exe"))
|
|
96
|
+
return not app and bool(cmds) and tid not in ("wt", "powershell", "cmd") and bool(_find_cmd(cmds))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
_detected = {"at": 0, "value": None}
|
|
100
|
+
_lock = threading.Lock()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _refresh():
|
|
104
|
+
if not _lock.acquire(blocking=False):
|
|
105
|
+
return
|
|
106
|
+
try:
|
|
107
|
+
_detected.update(value=_detect(), at=time.time())
|
|
108
|
+
finally:
|
|
109
|
+
_lock.release()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def warm():
|
|
113
|
+
"""Scan in the background at startup so the settings panel opens instantly."""
|
|
114
|
+
threading.Thread(target=_refresh, daemon=True).start()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def detect(max_age=600):
|
|
118
|
+
"""Editors and terminals found on this machine. Scanning PATH is slow on WSL, so the last result is
|
|
119
|
+
served at once and refreshed in the background when it is stale."""
|
|
120
|
+
if _detected["value"] is None:
|
|
121
|
+
with _lock: # waits for a background scan that is already running instead of returning nothing
|
|
122
|
+
if _detected["value"] is None:
|
|
123
|
+
_detected.update(value=_detect(), at=time.time())
|
|
124
|
+
elif time.time() - _detected["at"] > max_age:
|
|
125
|
+
warm()
|
|
126
|
+
return _detected["value"]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _detect():
|
|
130
|
+
editors = [{"id": e[0], "label": e[1]} for e in EDITORS if _editor_launcher(e)]
|
|
131
|
+
terminals = [{"id": t[0], "label": t[1]} for t in TERMINALS if _terminal_available(t)]
|
|
132
|
+
return {"editors": editors, "terminals": terminals}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _spawn(cmd, cwd=None, shell=False):
|
|
136
|
+
kw = {"creationflags": 0x08000200} if WIN else {"start_new_session": True}
|
|
137
|
+
subprocess.Popen(cmd, cwd=cwd, shell=shell, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
|
138
|
+
stderr=subprocess.DEVNULL, **kw)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _win_path(path):
|
|
142
|
+
out = subprocess.run(["wslpath", "-w", path], capture_output=True, text=True).stdout.strip()
|
|
143
|
+
return out or path
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def editor_cmd(settings):
|
|
147
|
+
"""Chosen editor, else $GITROSTER_EDITOR, else the first detected one. -> (command list, label)"""
|
|
148
|
+
choice = (settings or {}).get("editor", "")
|
|
149
|
+
if choice == "custom" and settings.get("editor_custom"):
|
|
150
|
+
return shlex.split(settings["editor_custom"]), "custom editor"
|
|
151
|
+
for e in EDITORS:
|
|
152
|
+
if e[0] == choice:
|
|
153
|
+
cmd = _editor_launcher(e)
|
|
154
|
+
if cmd:
|
|
155
|
+
return cmd, e[1]
|
|
156
|
+
custom = os.environ.get("GITROSTER_EDITOR")
|
|
157
|
+
if custom:
|
|
158
|
+
return shlex.split(custom), custom
|
|
159
|
+
for e in EDITORS:
|
|
160
|
+
cmd = _editor_launcher(e)
|
|
161
|
+
if cmd:
|
|
162
|
+
return cmd, e[1]
|
|
163
|
+
return None, ""
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _terminal_entry(settings):
|
|
167
|
+
want = (settings or {}).get("terminal", "")
|
|
168
|
+
avail = [t for t in TERMINALS if _terminal_available(t)]
|
|
169
|
+
return next((t for t in avail if t[0] == want), avail[0] if avail else None)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _open_terminal(path, settings):
|
|
173
|
+
t = _terminal_entry(settings)
|
|
174
|
+
if not t:
|
|
175
|
+
raise RuntimeError("no terminal found; pick one in Settings")
|
|
176
|
+
tid, label, cmds, args, app = t
|
|
177
|
+
if MAC:
|
|
178
|
+
if app and _mac_app(app):
|
|
179
|
+
_spawn(["open", "-a", app, path])
|
|
180
|
+
else:
|
|
181
|
+
_spawn([_find_cmd(cmds), *[a.replace("{p}", path) for a in (args or [])]], cwd=path)
|
|
182
|
+
elif WIN:
|
|
183
|
+
if tid == "wt":
|
|
184
|
+
_spawn(["wt", "-d", path])
|
|
185
|
+
elif tid == "powershell":
|
|
186
|
+
_spawn(["cmd", "/c", "start", cmds[0], "-NoExit", "-Command", f"Set-Location -LiteralPath '{path}'"])
|
|
187
|
+
else:
|
|
188
|
+
_spawn(["cmd", "/c", "start", "cmd", "/k", f'cd /d "{path}"'])
|
|
189
|
+
elif WSL and tid == "wt":
|
|
190
|
+
_spawn(["wt.exe", "-d", _win_path(path), "wsl.exe", "--cd", path])
|
|
191
|
+
else:
|
|
192
|
+
_spawn([_find_cmd(cmds), *[a.replace("{p}", path) for a in (args or [])]], cwd=path)
|
|
193
|
+
return label
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def open_repo(path, target, settings=None):
|
|
197
|
+
if not os.path.isdir(path):
|
|
198
|
+
raise ValueError("folder no longer exists")
|
|
199
|
+
if target == "editor":
|
|
200
|
+
cmd, label = editor_cmd(settings)
|
|
201
|
+
if not cmd:
|
|
202
|
+
raise RuntimeError("no editor found; pick one in Settings")
|
|
203
|
+
full = [*cmd, path]
|
|
204
|
+
_spawn(subprocess.list2cmdline(full) if WIN else full, shell=WIN)
|
|
205
|
+
return label
|
|
206
|
+
if target == "folder":
|
|
207
|
+
if WIN:
|
|
208
|
+
os.startfile(path)
|
|
209
|
+
elif MAC:
|
|
210
|
+
_spawn(["open", path])
|
|
211
|
+
elif WSL and _which("explorer.exe"):
|
|
212
|
+
_spawn(["explorer.exe", _win_path(path)])
|
|
213
|
+
else:
|
|
214
|
+
_spawn(["xdg-open", path])
|
|
215
|
+
return "file manager"
|
|
216
|
+
if target == "terminal":
|
|
217
|
+
return _open_terminal(path, settings)
|
|
218
|
+
raise ValueError("unknown target")
|
gitroster/pathscan.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Find programs on PATH quickly.
|
|
2
|
+
|
|
3
|
+
shutil.which stats every command in every PATH folder, which takes seconds on WSL where the
|
|
4
|
+
Windows folders are on a slow mount. Listing each folder once and looking names up is much faster.
|
|
5
|
+
"""
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
|
|
10
|
+
WIN = sys.platform == "win32"
|
|
11
|
+
_index = {"at": 0.0, "map": {}}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def index():
|
|
15
|
+
if not _index["map"] or time.time() - _index["at"] > 60:
|
|
16
|
+
found = {}
|
|
17
|
+
for d in os.environ.get("PATH", "").split(os.pathsep):
|
|
18
|
+
try:
|
|
19
|
+
names = os.listdir(d)
|
|
20
|
+
except OSError:
|
|
21
|
+
continue
|
|
22
|
+
for n in names:
|
|
23
|
+
full = os.path.join(d, n)
|
|
24
|
+
found.setdefault(n, full)
|
|
25
|
+
stem, ext = os.path.splitext(n)
|
|
26
|
+
if WIN and ext.lower() in (".exe", ".cmd", ".bat", ".com"):
|
|
27
|
+
found.setdefault(stem.lower(), full)
|
|
28
|
+
_index.update(at=time.time(), map=found)
|
|
29
|
+
return _index["map"]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def which(cmd):
|
|
33
|
+
path = index().get(cmd) or (index().get(cmd.lower()) if WIN else None)
|
|
34
|
+
return path if path and os.path.isfile(path) and os.access(path, os.X_OK) else None
|
gitroster/procs.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Listening ports, their processes, and Docker containers (Linux, macOS, Windows)."""
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
WIN = sys.platform == "win32"
|
|
11
|
+
MAC = sys.platform == "darwin"
|
|
12
|
+
EXPOSED = ("0.0.0.0", "*", "::")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _run(cmd, timeout=6):
|
|
16
|
+
try:
|
|
17
|
+
return subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace",
|
|
18
|
+
timeout=timeout).stdout
|
|
19
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
|
20
|
+
return ""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _entry(port, host, pid, name, cmd, cwd, since):
|
|
24
|
+
host = host.strip("[]")
|
|
25
|
+
return {"port": int(port), "host": host, "pid": int(pid), "proc": name, "cmd": cmd[:240], "cwd": cwd,
|
|
26
|
+
"since": since, "repo": None, "exposed": host in EXPOSED}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _dedupe(rows):
|
|
30
|
+
seen, out = set(), []
|
|
31
|
+
for r in sorted(rows, key=lambda r: (r["port"], r["pid"])):
|
|
32
|
+
if (r["port"], r["pid"]) not in seen:
|
|
33
|
+
seen.add((r["port"], r["pid"]))
|
|
34
|
+
out.append(r)
|
|
35
|
+
return out
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ---- Linux -----------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
def parse_ss(text):
|
|
41
|
+
"""-> [(host, port, pid, name)] from `ss -ltnpH`."""
|
|
42
|
+
res = []
|
|
43
|
+
for line in text.splitlines():
|
|
44
|
+
parts = line.split()
|
|
45
|
+
if len(parts) < 5:
|
|
46
|
+
continue
|
|
47
|
+
host, _, port = parts[3].rpartition(":")
|
|
48
|
+
m = re.search(r'\("([^"]*)",pid=(\d+)', line)
|
|
49
|
+
if m and port.isdigit():
|
|
50
|
+
res.append((host, port, m.group(2), m.group(1)))
|
|
51
|
+
return res
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _linux():
|
|
55
|
+
res = []
|
|
56
|
+
for host, port, pid, name in parse_ss(_run(["ss", "-ltnpH"])):
|
|
57
|
+
try:
|
|
58
|
+
cwd = os.readlink(f"/proc/{pid}/cwd")
|
|
59
|
+
cmd = Path(f"/proc/{pid}/cmdline").read_bytes().replace(b"\0", b" ").decode(errors="replace").strip()
|
|
60
|
+
since = os.stat(f"/proc/{pid}").st_mtime
|
|
61
|
+
except OSError:
|
|
62
|
+
cwd, cmd, since = "", "", 0
|
|
63
|
+
res.append(_entry(port, host, pid, name, cmd, cwd, since))
|
|
64
|
+
return res
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ---- macOS -----------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
def parse_lsof(text):
|
|
70
|
+
"""-> [(host, port, pid, name)] from `lsof -nP -iTCP -sTCP:LISTEN`."""
|
|
71
|
+
res = []
|
|
72
|
+
for line in text.splitlines()[1:]:
|
|
73
|
+
parts = line.split()
|
|
74
|
+
m = re.search(r"(\S+):(\d+) \(LISTEN\)", line)
|
|
75
|
+
if len(parts) > 8 and m and parts[1].isdigit():
|
|
76
|
+
res.append((m.group(1), m.group(2), parts[1], parts[0].replace("\\x20", " ")))
|
|
77
|
+
return res
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _mac():
|
|
81
|
+
res = []
|
|
82
|
+
for host, port, pid, name in parse_lsof(_run(["lsof", "-nP", "-iTCP", "-sTCP:LISTEN"])):
|
|
83
|
+
cwd = next((l[1:] for l in _run(["lsof", "-a", "-p", pid, "-d", "cwd", "-Fn"]).splitlines() if l.startswith("n")), "")
|
|
84
|
+
cmd = _run(["ps", "-o", "command=", "-p", pid]).strip()
|
|
85
|
+
started = _run(["ps", "-o", "lstart=", "-p", pid]).strip()
|
|
86
|
+
try:
|
|
87
|
+
since = time.mktime(time.strptime(started, "%a %b %d %H:%M:%S %Y"))
|
|
88
|
+
except ValueError:
|
|
89
|
+
since = 0
|
|
90
|
+
res.append(_entry(port, host, pid, name, cmd, cwd, since))
|
|
91
|
+
return res
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ---- Windows ---------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
def parse_netstat(text):
|
|
97
|
+
"""-> [(host, port, pid)] from `netstat -ano -p TCP` (LISTENING rows)."""
|
|
98
|
+
res = []
|
|
99
|
+
for line in text.splitlines():
|
|
100
|
+
parts = line.split()
|
|
101
|
+
if len(parts) >= 5 and parts[0] == "TCP" and parts[3].upper() == "LISTENING" and parts[4].isdigit():
|
|
102
|
+
host, _, port = parts[1].rpartition(":")
|
|
103
|
+
if port.isdigit():
|
|
104
|
+
res.append((host, port, parts[4]))
|
|
105
|
+
return res
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
PS_PROCS = ("Get-CimInstance Win32_Process | ForEach-Object { [pscustomobject]@{p=$_.ProcessId; n=$_.Name; "
|
|
109
|
+
"c=$_.CommandLine; t=[DateTimeOffset]$_.CreationDate | ForEach-Object { $_.ToUnixTimeSeconds() }} } "
|
|
110
|
+
"| ConvertTo-Json -Compress")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def parse_ps_procs(text):
|
|
114
|
+
try:
|
|
115
|
+
data = json.loads(text or "[]")
|
|
116
|
+
except ValueError:
|
|
117
|
+
return {}
|
|
118
|
+
data = [data] if isinstance(data, dict) else data
|
|
119
|
+
return {int(d["p"]): d for d in data if isinstance(d, dict) and d.get("p") is not None}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _windows():
|
|
123
|
+
rows = parse_netstat(_run(["netstat", "-ano", "-p", "TCP"]))
|
|
124
|
+
info = parse_ps_procs(_run(["powershell", "-NoProfile", "-NonInteractive", "-Command", PS_PROCS], 15)) if rows else {}
|
|
125
|
+
res = []
|
|
126
|
+
for host, port, pid in rows:
|
|
127
|
+
d = info.get(int(pid), {})
|
|
128
|
+
name = (d.get("n") or "").removesuffix(".exe")
|
|
129
|
+
res.append(_entry(port, host, pid, name, d.get("c") or "", "", d.get("t") or 0))
|
|
130
|
+
return res
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def listening():
|
|
134
|
+
"""Every listening TCP port we can attribute to a process."""
|
|
135
|
+
return _dedupe(_windows() if WIN else _mac() if MAC else _linux())
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def docker_containers():
|
|
139
|
+
out = _run(["docker", "ps", "--format", "{{json .}}"], 5)
|
|
140
|
+
res = []
|
|
141
|
+
for line in out.splitlines():
|
|
142
|
+
try:
|
|
143
|
+
c = json.loads(line)
|
|
144
|
+
except ValueError:
|
|
145
|
+
continue
|
|
146
|
+
wd = re.search(r"com\.docker\.compose\.project\.working_dir=([^,]+)", c.get("Labels", ""))
|
|
147
|
+
res.append({"name": c.get("Names", ""), "image": c.get("Image", ""), "ports": c.get("Ports", ""),
|
|
148
|
+
"status": c.get("Status", ""), "workdir": wd.group(1) if wd else "", "repo": None})
|
|
149
|
+
return res
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _owner(path, items):
|
|
153
|
+
best = None
|
|
154
|
+
for c in items:
|
|
155
|
+
cp = c["path"]
|
|
156
|
+
if path == cp or path.startswith(cp + os.sep):
|
|
157
|
+
if best is None or len(cp) > len(best["path"]):
|
|
158
|
+
best = c
|
|
159
|
+
return best
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def link(ports, containers, repos, envs):
|
|
163
|
+
"""Attach each port/container to a repo: by cwd, then by paths in the command line."""
|
|
164
|
+
for pt in ports:
|
|
165
|
+
o = _owner(pt["cwd"], repos) if pt["cwd"] else None
|
|
166
|
+
if not o:
|
|
167
|
+
for e in sorted(envs, key=lambda e: -len(e["path"])):
|
|
168
|
+
if e["path"] in pt["cmd"]:
|
|
169
|
+
o = _owner(e["path"], repos)
|
|
170
|
+
break
|
|
171
|
+
if not o:
|
|
172
|
+
for r in sorted(repos, key=lambda r: -len(r["path"])):
|
|
173
|
+
if r["path"] in pt["cmd"]:
|
|
174
|
+
o = r
|
|
175
|
+
break
|
|
176
|
+
pt["repo"] = o["path"] if o else None
|
|
177
|
+
for c in containers:
|
|
178
|
+
o = _owner(c["workdir"], repos) if c["workdir"] else None
|
|
179
|
+
c["repo"] = o["path"] if o else None
|
gitroster/repoinfo.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""Read-only views of a repo: overview, file tree, file contents, commits, search. Nothing here writes."""
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
import subprocess
|
|
5
|
+
import tempfile
|
|
6
|
+
import time
|
|
7
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from .scanner import run
|
|
11
|
+
|
|
12
|
+
REF_RE = re.compile(r"^[\w./@{}^~+-]{1,200}$")
|
|
13
|
+
HASH_RE = re.compile(r"^[0-9a-f]{4,40}$")
|
|
14
|
+
MAX_FILE = 300_000
|
|
15
|
+
MAX_DIFF = 200_000
|
|
16
|
+
|
|
17
|
+
LANGS = {
|
|
18
|
+
".py": "Python", ".js": "JavaScript", ".jsx": "JavaScript", ".mjs": "JavaScript", ".ts": "TypeScript", ".tsx": "TypeScript",
|
|
19
|
+
".html": "HTML", ".css": "CSS", ".scss": "CSS", ".astro": "Astro", ".vue": "Vue", ".svelte": "Svelte",
|
|
20
|
+
".go": "Go", ".rs": "Rust", ".java": "Java", ".kt": "Kotlin", ".swift": "Swift", ".dart": "Dart", ".c": "C", ".h": "C",
|
|
21
|
+
".cpp": "C++", ".cs": "C#", ".php": "PHP", ".rb": "Ruby", ".sh": "Shell", ".sql": "SQL", ".md": "Markdown",
|
|
22
|
+
".json": "JSON", ".yml": "YAML", ".yaml": "YAML", ".toml": "TOML", ".ipynb": "Notebook", ".lua": "Lua",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _git(path, *args, timeout=15):
|
|
27
|
+
return run(["git", "-C", str(path), *args], timeout)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def check_ref(ref):
|
|
31
|
+
if not REF_RE.match(ref or "") or ref.startswith("-") or ".." in ref:
|
|
32
|
+
raise ValueError("bad ref")
|
|
33
|
+
return ref
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def check_rel(rel):
|
|
37
|
+
rel = (rel or "").strip("/")
|
|
38
|
+
if rel.startswith("-") or ".." in rel.split("/") or "\\" in rel or "\0" in rel:
|
|
39
|
+
raise ValueError("bad path")
|
|
40
|
+
return rel
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
WEB_HOSTS = ("github.com", "gitlab.com", "bitbucket.org")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def web_url(path):
|
|
47
|
+
"""https page of the repo for well-known hosts, from origin (or the first remote)."""
|
|
48
|
+
from .scanner import norm_remote
|
|
49
|
+
remotes = _git(path, "remote", "-v").split()
|
|
50
|
+
urls = [remotes[i + 1] for i in range(0, len(remotes) - 1) if remotes[i + 1].count("/") or ":" in remotes[i + 1]]
|
|
51
|
+
origin = _git(path, "remote", "get-url", "origin").strip() or (urls[0] if urls else "")
|
|
52
|
+
n = norm_remote(origin)
|
|
53
|
+
if n.split("/")[0] not in WEB_HOSTS or n.count("/") < 2:
|
|
54
|
+
return None
|
|
55
|
+
return _keep_case(origin, n)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _keep_case(origin, normalized):
|
|
59
|
+
"""https URL with the owner/repo spelling from the remote (normalized form is lowercase)."""
|
|
60
|
+
tail = "/".join(normalized.split("/")[1:])
|
|
61
|
+
i = origin.lower().find(tail)
|
|
62
|
+
return "https://" + normalized.split("/")[0] + "/" + (origin[i:i + len(tail)] if i >= 0 else tail)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def contributor_url(name, email, web):
|
|
66
|
+
"""GitHub profile for a contributor when it can be told from a noreply address or the repo owner's name."""
|
|
67
|
+
if not web or not web.startswith("https://github.com/"):
|
|
68
|
+
return None
|
|
69
|
+
m = re.match(r"^(?:\d+\+)?([^@]+)@users\.noreply\.github\.com$", email or "")
|
|
70
|
+
user = m.group(1) if m else ""
|
|
71
|
+
if not user and name.lower() == web.split("/")[3].lower():
|
|
72
|
+
user = web.split("/")[3]
|
|
73
|
+
if not user:
|
|
74
|
+
return None
|
|
75
|
+
return "https://github.com/apps/" + user[:-5] if user.endswith("[bot]") else "https://github.com/" + user
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def overview(path):
|
|
79
|
+
p = Path(path)
|
|
80
|
+
files = [f for f in _git(p, "ls-files", "-z").split("\0") if f]
|
|
81
|
+
langs = {}
|
|
82
|
+
for f in files:
|
|
83
|
+
ext = os.path.splitext(f)[1].lower()
|
|
84
|
+
name = LANGS.get(ext)
|
|
85
|
+
if not name:
|
|
86
|
+
continue
|
|
87
|
+
try:
|
|
88
|
+
size = (p / f).stat().st_size
|
|
89
|
+
except OSError:
|
|
90
|
+
continue
|
|
91
|
+
item = langs.setdefault(name, {"name": name, "files": 0, "bytes": 0})
|
|
92
|
+
item["files"] += 1
|
|
93
|
+
item["bytes"] += size
|
|
94
|
+
languages = sorted(langs.values(), key=lambda x: -x["bytes"])[:8]
|
|
95
|
+
web = web_url(p)
|
|
96
|
+
contributors = []
|
|
97
|
+
for line in _git(p, "shortlog", "-sne", "HEAD").splitlines()[:10]:
|
|
98
|
+
n, _, who = line.strip().partition("\t")
|
|
99
|
+
m = re.match(r"^(.*?)\s*<([^>]*)>$", who)
|
|
100
|
+
name, email = (m.group(1), m.group(2)) if m else (who, "")
|
|
101
|
+
if n.isdigit():
|
|
102
|
+
contributors.append({"name": name, "commits": int(n), "url": contributor_url(name, email, web)})
|
|
103
|
+
tags = []
|
|
104
|
+
for line in _git(p, "tag", "--sort=-creatordate", "--format=%(refname:short)\t%(creatordate:unix)").splitlines()[:12]:
|
|
105
|
+
name, _, ts = line.partition("\t")
|
|
106
|
+
tags.append({"name": name, "time": int(ts) if ts.isdigit() else None})
|
|
107
|
+
total = _git(p, "rev-list", "--count", "HEAD").strip()
|
|
108
|
+
first = _git(p, "log", "--reverse", "--format=%ct", "-n1", "--max-parents=0", "HEAD").strip()
|
|
109
|
+
changed = {}
|
|
110
|
+
for f in _git(p, "log", "-n", "60", "--name-only", "--format=").splitlines():
|
|
111
|
+
if f:
|
|
112
|
+
changed[f] = changed.get(f, 0) + 1
|
|
113
|
+
recent = [{"file": f, "changes": n} for f, n in sorted(changed.items(), key=lambda kv: -kv[1])[:10]]
|
|
114
|
+
readme = ""
|
|
115
|
+
for name in ("README.md", "readme.md", "README.rst", "README.txt", "README"):
|
|
116
|
+
try:
|
|
117
|
+
readme = (p / name).read_text(encoding="utf-8", errors="replace")[:20000]
|
|
118
|
+
break
|
|
119
|
+
except OSError:
|
|
120
|
+
continue
|
|
121
|
+
return {"web_url": web, "languages": languages, "contributors": contributors, "tags": tags, "files_total": len(files),
|
|
122
|
+
"commits_total": int(total) if total.isdigit() else 0, "first_commit": int(first) if first.isdigit() else None,
|
|
123
|
+
"hot_files": recent, "readme": readme}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def tree(path, ref, rel):
|
|
127
|
+
ref, rel = check_ref(ref or "HEAD"), check_rel(rel)
|
|
128
|
+
spec = f"{ref}:{rel}" if rel else ref
|
|
129
|
+
out = _git(path, "ls-tree", "-l", spec)
|
|
130
|
+
entries = []
|
|
131
|
+
for line in out.splitlines():
|
|
132
|
+
meta, _, name = line.partition("\t")
|
|
133
|
+
parts = meta.split()
|
|
134
|
+
if len(parts) >= 4:
|
|
135
|
+
entries.append({"name": name, "type": parts[1], "size": int(parts[3]) if parts[3].isdigit() else None})
|
|
136
|
+
entries.sort(key=lambda e: (e["type"] != "tree", e["name"].lower()))
|
|
137
|
+
return entries
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def read_file(path, ref, rel):
|
|
141
|
+
ref, rel = check_ref(ref or "HEAD"), check_rel(rel)
|
|
142
|
+
if not rel:
|
|
143
|
+
raise ValueError("no file")
|
|
144
|
+
try:
|
|
145
|
+
proc = subprocess.run(["git", "-C", str(path), "show", f"{ref}:{rel}"], capture_output=True, timeout=15)
|
|
146
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
147
|
+
raise ValueError("could not read file")
|
|
148
|
+
if proc.returncode != 0:
|
|
149
|
+
raise ValueError("file not found at that ref")
|
|
150
|
+
data = proc.stdout
|
|
151
|
+
if b"\0" in data[:8000]:
|
|
152
|
+
return {"binary": True, "size": len(data), "text": ""}
|
|
153
|
+
return {"binary": False, "size": len(data), "truncated": len(data) > MAX_FILE,
|
|
154
|
+
"text": data[:MAX_FILE].decode("utf-8", errors="replace")}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def commit(path, h):
|
|
158
|
+
if not HASH_RE.match(h or ""):
|
|
159
|
+
raise ValueError("bad hash")
|
|
160
|
+
fmt = "%H%x1f%P%x1f%an%x1f%ct%x1f%B"
|
|
161
|
+
out = _git(path, "show", "--no-patch", f"--format={fmt}", h)
|
|
162
|
+
head = (out.split("\x1f") + [""] * 5)[:5]
|
|
163
|
+
files = []
|
|
164
|
+
for line in _git(path, "show", "--format=", "--numstat", "-M", h).splitlines():
|
|
165
|
+
a, _, rest = line.partition("\t")
|
|
166
|
+
d, _, name = rest.partition("\t")
|
|
167
|
+
if name:
|
|
168
|
+
files.append({"file": name, "added": int(a) if a.isdigit() else None, "deleted": int(d) if d.isdigit() else None})
|
|
169
|
+
diff = _git(path, "show", "--format=", "--patch", "--no-color", "-M", h, timeout=20)
|
|
170
|
+
return {"hash": head[0].strip(), "parents": head[1].split(), "author": head[2], "time": int(head[3]) if head[3].isdigit() else None,
|
|
171
|
+
"message": head[4].strip(), "files": files[:300], "diff": diff[:MAX_DIFF], "diff_truncated": len(diff) > MAX_DIFF}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def search_repo(repo, q, scope, regex=False, per=40):
|
|
175
|
+
path = repo["path"]
|
|
176
|
+
res = {"path": path, "name": repo["name"], "code": [], "files": [], "commits": [], "capped": False}
|
|
177
|
+
if "code" in scope:
|
|
178
|
+
cmd = ["git", "-C", path, "grep", "-n", "-I", "-i", "--no-color", "-E" if regex else "-F", "-e", q]
|
|
179
|
+
proc = None
|
|
180
|
+
try:
|
|
181
|
+
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, encoding="utf-8", errors="replace")
|
|
182
|
+
for line in proc.stdout:
|
|
183
|
+
f, _, rest = line.partition(":")
|
|
184
|
+
n, _, text = rest.partition(":")
|
|
185
|
+
if n.isdigit():
|
|
186
|
+
res["code"].append({"file": f, "line": int(n), "text": text.strip()[:240]})
|
|
187
|
+
if len(res["code"]) >= per:
|
|
188
|
+
res["capped"] = True
|
|
189
|
+
proc.kill()
|
|
190
|
+
break
|
|
191
|
+
proc.wait(timeout=5)
|
|
192
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
193
|
+
pass
|
|
194
|
+
finally:
|
|
195
|
+
if proc is not None and proc.stdout:
|
|
196
|
+
proc.stdout.close()
|
|
197
|
+
if "files" in scope:
|
|
198
|
+
low = q.lower()
|
|
199
|
+
res["files"] = [f for f in _git(path, "ls-files").splitlines() if low in f.lower()][:15]
|
|
200
|
+
if "commits" in scope:
|
|
201
|
+
for line in _git(path, "log", "--branches", "-i", "-F", f"--grep={q}", "-n", "8", "--format=%H%x1f%ct%x1f%s").splitlines():
|
|
202
|
+
h, _, rest = line.partition("\x1f")
|
|
203
|
+
ts, _, subject = rest.partition("\x1f")
|
|
204
|
+
res["commits"].append({"hash": h, "time": int(ts) if ts.isdigit() else None, "subject": subject[:200]})
|
|
205
|
+
return res
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def check_regex(q):
|
|
209
|
+
"""Raise ValueError when `q` is not a valid extended regular expression for `git grep -E`."""
|
|
210
|
+
with tempfile.TemporaryDirectory() as d:
|
|
211
|
+
(Path(d) / "f").write_text("x")
|
|
212
|
+
try:
|
|
213
|
+
r = subprocess.run(["git", "grep", "--no-index", "-E", "-q", "-e", q], cwd=d, capture_output=True, timeout=5)
|
|
214
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
215
|
+
return
|
|
216
|
+
if r.returncode > 1:
|
|
217
|
+
raise ValueError("invalid regular expression")
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def search(repos, q, scope=("code", "files", "commits"), regex=False):
|
|
221
|
+
q = (q or "").strip()
|
|
222
|
+
if len(q) < 2:
|
|
223
|
+
return []
|
|
224
|
+
if regex and "code" in scope:
|
|
225
|
+
check_regex(q)
|
|
226
|
+
with ThreadPoolExecutor(8) as ex:
|
|
227
|
+
results = list(ex.map(lambda r: search_repo(r, q, scope, regex), repos))
|
|
228
|
+
return [r for r in results if r["code"] or r["files"] or r["commits"]]
|