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/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"]]