gitpro 0.3.1__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.
- gitpro/__init__.py +4 -0
- gitpro/__main__.py +5 -0
- gitpro/agents.py +1928 -0
- gitpro/approval_policy.py +151 -0
- gitpro/args.py +82 -0
- gitpro/audit.py +800 -0
- gitpro/change_contract.py +117 -0
- gitpro/changes.py +853 -0
- gitpro/cli.py +499 -0
- gitpro/clone.py +505 -0
- gitpro/completion.py +407 -0
- gitpro/config.py +517 -0
- gitpro/devcontainer.py +442 -0
- gitpro/discover.py +1579 -0
- gitpro/doctor.py +572 -0
- gitpro/envcmd.py +370 -0
- gitpro/example.toml +102 -0
- gitpro/files.py +139 -0
- gitpro/forge.py +1244 -0
- gitpro/guard.py +1121 -0
- gitpro/health.py +1241 -0
- gitpro/hot.py +38 -0
- gitpro/jsonfile.py +195 -0
- gitpro/jsonout.py +26 -0
- gitpro/keys.py +59 -0
- gitpro/lint.py +50 -0
- gitpro/lost.py +874 -0
- gitpro/mcp.py +1055 -0
- gitpro/operation_contract.py +178 -0
- gitpro/operation_io.py +256 -0
- gitpro/operation_reconcile.py +191 -0
- gitpro/operations.py +841 -0
- gitpro/pin.py +89 -0
- gitpro/plan.py +199 -0
- gitpro/policy.py +1808 -0
- gitpro/prompt.py +304 -0
- gitpro/render.py +474 -0
- gitpro/resolve.py +898 -0
- gitpro/scan.py +358 -0
- gitpro/scanroots.py +146 -0
- gitpro/setup.py +260 -0
- gitpro/trailers.py +218 -0
- gitpro/tui.py +178 -0
- gitpro/undo.py +63 -0
- gitpro/view.py +155 -0
- gitpro-0.3.1.dist-info/METADATA +103 -0
- gitpro-0.3.1.dist-info/RECORD +50 -0
- gitpro-0.3.1.dist-info/WHEEL +4 -0
- gitpro-0.3.1.dist-info/entry_points.txt +2 -0
- gitpro-0.3.1.dist-info/licenses/LICENSE +21 -0
gitpro/discover.py
ADDED
|
@@ -0,0 +1,1579 @@
|
|
|
1
|
+
"""`gitpro discover` and `init --discover`: suggest [[identity]] blocks and [scan] roots from what is on the machine.
|
|
2
|
+
|
|
3
|
+
Sources (spec 04): editor recents (the VS Code family, JetBrains), ~/.ssh/config host aliases,
|
|
4
|
+
includeIf rules in the global gitconfig and the repos on disk; on request also shell history
|
|
5
|
+
(--history) and the gh/glab logins (--online). Discovery only reads: it prints a TOML snippet to
|
|
6
|
+
paste and never writes the config. `init --discover` appends the snippet, every line commented, to
|
|
7
|
+
the file init has just created.
|
|
8
|
+
|
|
9
|
+
Privacy: history lines are never stored or shown, only the sanitised clone URLs found in them (no
|
|
10
|
+
userinfo, query or fragment; anything token-like drops the URL). Private keys, gh's hosts.yml,
|
|
11
|
+
.netrc and .git-credentials are never opened; of ~/.ssh only config files and .pub files are read.
|
|
12
|
+
Tokens are never read: with --online gh and glab are asked for their logins, and gh for the
|
|
13
|
+
verified emails of the active login.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import contextlib
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
import shlex
|
|
22
|
+
import subprocess
|
|
23
|
+
import sys
|
|
24
|
+
import time
|
|
25
|
+
from collections import Counter
|
|
26
|
+
from datetime import date
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Dict, Iterator, List, Optional, Tuple
|
|
29
|
+
from urllib.parse import quote, unquote, urlsplit
|
|
30
|
+
|
|
31
|
+
from . import config, files, jsonout, undo
|
|
32
|
+
from .config import EMAIL_RE, HOST_RE, ID_RE, OWNER_RE, PATH_RE, PLACEHOLDER_RE
|
|
33
|
+
|
|
34
|
+
PROG = "gitpro"
|
|
35
|
+
SOURCES = ("history", "vscode", "jetbrains", "ssh", "includeif", "repos", "gh", "glab")
|
|
36
|
+
DEFAULT_SOURCES = ("vscode", "jetbrains", "ssh", "includeif", "repos")
|
|
37
|
+
ONLINE_SOURCES = ("gh", "glab")
|
|
38
|
+
BUDGET = 5.0 # seconds per source (history, repos)
|
|
39
|
+
HISTORY_CAP = 200_000 # lines per history file
|
|
40
|
+
MAX_DEPTH = 3 # how deep `repos` walks under each root
|
|
41
|
+
HOME_SKIP = ["~/Library", "~/snap", "~/go/pkg", "__pycache__"]
|
|
42
|
+
TIMEOUT = 30 # seconds for each gh/glab/git call
|
|
43
|
+
FORGE_HOSTS = ("github.com", "gitlab.com", "bitbucket.org", "codeberg.org", "dev.azure.com", "ssh.dev.azure.com")
|
|
44
|
+
VSCODE_APPS = ("Code", "Code - Insiders", "VSCodium", "Cursor")
|
|
45
|
+
VSCODE_KEY = "history.recentlyOpenedPathsList"
|
|
46
|
+
JSON_KEYS = ("sources", "hosts", "aliases", "roots", "accounts", "rules", "config", "toml", "warnings")
|
|
47
|
+
|
|
48
|
+
_TOKEN_RE = re.compile(r"(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}"
|
|
49
|
+
r"|glpat-[A-Za-z0-9_-]{15,}|glptt-[A-Za-z0-9_-]{15,}|gldt-[A-Za-z0-9_-]{15,}"
|
|
50
|
+
r"|x-access-token:|oauth2:|Authorization")
|
|
51
|
+
_BAD_CHARS = re.compile(r"[\s\x00-\x1f\x7f]")
|
|
52
|
+
_CONTROL = re.compile(r"[\x00-\x1f\x7f]")
|
|
53
|
+
_SHOW_CONTROL = re.compile("[\x00-\x1f\x7f-\x9f\u2028\u2029]") # replaced by ? in what is shown
|
|
54
|
+
_TOML_ESCAPE = re.compile("[\x7f-\x9f\u2028\u2029]") # json.dumps leaves these as they are
|
|
55
|
+
_SCHEME = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*://")
|
|
56
|
+
_SCP = re.compile(r"^(?:([^@/:]+)@)?([^@/:\[\]]+):(.*)$")
|
|
57
|
+
_GLOB = re.compile(r"[*?\[]")
|
|
58
|
+
_CLONE_RE = re.compile(r"\b(git|gh|glab)\s+(repo\s+)?clone\b")
|
|
59
|
+
_ZSH_EXT = re.compile(r"^: \d+:\d+;")
|
|
60
|
+
_SEPARATOR = set(";&|()<>")
|
|
61
|
+
# git clone options that take the next word as their value
|
|
62
|
+
_GIT_VALUE_OPTS = {"-b", "--branch", "-o", "--origin", "-c", "--config", "--depth", "--reference",
|
|
63
|
+
"--reference-if-able", "--template", "--separate-git-dir", "-u", "--upload-pack", "-j",
|
|
64
|
+
"--jobs", "--filter", "--server-option", "--shallow-since", "--shallow-exclude",
|
|
65
|
+
"--bundle-uri", "--ref-format", "--revision"}
|
|
66
|
+
_FORGE_VALUE_OPTS = {"-u", "--upstream-remote-name", "-g", "--group"}
|
|
67
|
+
_BLOCK_MARK = re.compile(r"^# (BEGIN|END) (?:gitpro|git-identities|git_identities\.py)\b")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ---------------------------------------------------------------- small helpers
|
|
71
|
+
|
|
72
|
+
def _plural(n: int, word: str, many: Optional[str] = None) -> str:
|
|
73
|
+
return f"{n} {word if n == 1 else (many or word + 's')}"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _tilde(path, home: Optional[Path] = None) -> str:
|
|
77
|
+
"""path with the home directory (--home, else the real one) shown as ~."""
|
|
78
|
+
if home is None:
|
|
79
|
+
return undo.tilde(Path(path))
|
|
80
|
+
p, h = str(path), str(home).rstrip("/")
|
|
81
|
+
return "~" if p == h else "~/" + p[len(h) + 1:] if p.startswith(h + "/") else p
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _temp_dirs() -> List[str]:
|
|
85
|
+
import tempfile
|
|
86
|
+
out = {"/tmp", "/var/tmp", os.environ.get("TMPDIR") or "/tmp", tempfile.gettempdir()}
|
|
87
|
+
for t in list(out):
|
|
88
|
+
with contextlib.suppress(OSError):
|
|
89
|
+
out.add(os.path.realpath(t))
|
|
90
|
+
return sorted(os.path.normpath(t) for t in out if t)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _is_ssh_url(url: str) -> bool:
|
|
94
|
+
"""scp-like (host:path) or ssh:// URLs, the only ones an ssh Host alias applies to."""
|
|
95
|
+
m = _SCHEME.match(url)
|
|
96
|
+
return m is None or m.group(0)[:-3].lower() in ("ssh", "git+ssh", "ssh+git")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _under(path: str, root: str) -> bool:
|
|
100
|
+
root = root.rstrip("/")
|
|
101
|
+
return path == root or path.startswith(root + "/") or root == ""
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _usable_name(n: object) -> bool:
|
|
105
|
+
return isinstance(n, str) and bool(n.strip()) and not _CONTROL.search(n) and not re.search(r"[<>]", n)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _usable_email(e: object) -> bool:
|
|
109
|
+
return (isinstance(e, str) and bool(EMAIL_RE.match(e)) and not _CONTROL.search(e)
|
|
110
|
+
and not PLACEHOLDER_RE.search(e.rsplit("@", 1)[1]))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _usable_host(h: object) -> bool:
|
|
114
|
+
return isinstance(h, str) and bool(HOST_RE.match(h)) and not PLACEHOLDER_RE.search(h)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _is_forge(host: str) -> bool:
|
|
118
|
+
return host in FORGE_HOSTS or host.endswith(".visualstudio.com")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _env(strip=()) -> Dict[str, str]:
|
|
122
|
+
drop = set(strip) | {"GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_PREFIX", "GIT_COMMON_DIR"}
|
|
123
|
+
return {k: v for k, v in os.environ.items() if k not in drop}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _first_line(text: str) -> str:
|
|
127
|
+
line = next((ln.strip() for ln in (text or "").splitlines() if ln.strip()), "")
|
|
128
|
+
line = _CONTROL.sub("?", _TOKEN_RE.sub("…", line))
|
|
129
|
+
return line[:200]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ---------------------------------------------------------------- sanitise
|
|
133
|
+
|
|
134
|
+
def _clean(url: object) -> Tuple[Optional[str], bool]:
|
|
135
|
+
"""(sanitised url or None, whether it was dropped for looking like a secret)."""
|
|
136
|
+
if not isinstance(url, str) or not url:
|
|
137
|
+
return None, False
|
|
138
|
+
if _TOKEN_RE.search(url):
|
|
139
|
+
return None, True
|
|
140
|
+
if _BAD_CHARS.search(url):
|
|
141
|
+
return None, False
|
|
142
|
+
if _SCHEME.match(url):
|
|
143
|
+
try:
|
|
144
|
+
parts = urlsplit(url)
|
|
145
|
+
parts.port # noqa: B018 -- raises ValueError on a bad port
|
|
146
|
+
except ValueError:
|
|
147
|
+
return None, False
|
|
148
|
+
if parts.scheme.lower() == "file":
|
|
149
|
+
return None, False
|
|
150
|
+
netloc = parts.netloc.rpartition("@")[2]
|
|
151
|
+
if not netloc or "@" in parts.path:
|
|
152
|
+
return None, False
|
|
153
|
+
out = f"{parts.scheme.lower()}://{netloc}{parts.path}"
|
|
154
|
+
else:
|
|
155
|
+
m = _SCP.match(url)
|
|
156
|
+
if not m:
|
|
157
|
+
return None, False
|
|
158
|
+
user, host, path = m.groups()
|
|
159
|
+
path = re.split(r"[?#]", path, maxsplit=1)[0]
|
|
160
|
+
if "@" in path:
|
|
161
|
+
return None, False
|
|
162
|
+
out = (f"{user}@" if user and ID_RE.match(user) else "") + f"{host}:{path}"
|
|
163
|
+
if _TOKEN_RE.search(out):
|
|
164
|
+
return None, True
|
|
165
|
+
from .resolve import parse_url
|
|
166
|
+
host, owner, repo = parse_url(out)
|
|
167
|
+
if not (host and owner and repo and HOST_RE.match(host)):
|
|
168
|
+
return None, False
|
|
169
|
+
return out, False
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def sanitise(url: str) -> Optional[str]:
|
|
173
|
+
"""url without userinfo, query and fragment (an scp-style ssh user is kept when it is a plain
|
|
174
|
+
name such as git); None when it looks like it holds a secret or has no host/owner/repo."""
|
|
175
|
+
return _clean(url)[0]
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# ---------------------------------------------------------------- shell history
|
|
179
|
+
|
|
180
|
+
def _unmetafy(b: bytes) -> bytes:
|
|
181
|
+
"""zsh writes some bytes as 0x83 followed by the byte XOR 0x20."""
|
|
182
|
+
if b"\x83" not in b:
|
|
183
|
+
return b
|
|
184
|
+
out = bytearray()
|
|
185
|
+
it = iter(b)
|
|
186
|
+
for c in it:
|
|
187
|
+
if c == 0x83:
|
|
188
|
+
nxt = next(it, None)
|
|
189
|
+
if nxt is None:
|
|
190
|
+
break
|
|
191
|
+
out.append(nxt ^ 0x20)
|
|
192
|
+
else:
|
|
193
|
+
out.append(c)
|
|
194
|
+
return bytes(out)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _history_files(home: Path) -> List[Tuple[Path, str]]:
|
|
198
|
+
"""Existing history files with their kind (bash, zsh, fish, auto); only stats them."""
|
|
199
|
+
data = os.environ.get("XDG_DATA_HOME") or str(home / ".local/share")
|
|
200
|
+
cands = [(home / ".bash_history", "bash"), (home / ".zsh_history", "zsh"),
|
|
201
|
+
(Path(data) / "fish/fish_history", "fish")]
|
|
202
|
+
hf = os.environ.get("HISTFILE")
|
|
203
|
+
if hf:
|
|
204
|
+
p = Path(os.path.expanduser(hf))
|
|
205
|
+
p = p if p.is_absolute() else home / p
|
|
206
|
+
name = p.name.lower()
|
|
207
|
+
cands.append((p, "zsh" if "zsh" in name else "fish" if "fish" in name else "auto"))
|
|
208
|
+
out, seen = [], set()
|
|
209
|
+
for p, kind in cands:
|
|
210
|
+
try:
|
|
211
|
+
if not p.is_file():
|
|
212
|
+
continue
|
|
213
|
+
key = p.resolve()
|
|
214
|
+
except OSError:
|
|
215
|
+
continue
|
|
216
|
+
if key not in seen:
|
|
217
|
+
seen.add(key)
|
|
218
|
+
out.append((p, kind))
|
|
219
|
+
return out
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _decode(line: bytes, kind: str) -> str:
|
|
223
|
+
if kind == "zsh":
|
|
224
|
+
return _unmetafy(line).decode("utf-8", "replace")
|
|
225
|
+
try:
|
|
226
|
+
return line.decode("utf-8")
|
|
227
|
+
except UnicodeDecodeError:
|
|
228
|
+
pass
|
|
229
|
+
if kind == "auto":
|
|
230
|
+
try:
|
|
231
|
+
return _unmetafy(line).decode("utf-8")
|
|
232
|
+
except UnicodeDecodeError:
|
|
233
|
+
pass
|
|
234
|
+
return line.decode("utf-8", "replace")
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _tail(path: Path, n: int, deadline: float) -> Tuple[List[bytes], bool]:
|
|
238
|
+
"""The last n lines of a file, read backwards from its end; (lines, whether older lines were left out)."""
|
|
239
|
+
chunks: List[bytes] = []
|
|
240
|
+
count = 0
|
|
241
|
+
with open(path, "rb") as f:
|
|
242
|
+
pos = f.seek(0, os.SEEK_END)
|
|
243
|
+
while pos > 0 and count <= n and time.monotonic() <= deadline:
|
|
244
|
+
step = min(1 << 20, pos)
|
|
245
|
+
pos -= step
|
|
246
|
+
f.seek(pos)
|
|
247
|
+
chunk = f.read(step)
|
|
248
|
+
chunks.append(chunk)
|
|
249
|
+
count += chunk.count(b"\n")
|
|
250
|
+
data = b"".join(reversed(chunks))
|
|
251
|
+
lines = data.split(b"\n")
|
|
252
|
+
if lines and lines[-1] == b"":
|
|
253
|
+
lines.pop()
|
|
254
|
+
if pos > 0 and lines:
|
|
255
|
+
lines.pop(0) # the part of a line cut by where reading started
|
|
256
|
+
return lines[-n:] if n else [], pos > 0 or len(lines) > n
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _history_commands(path: Path, kind: str, cut: List[bool]) -> Iterator[str]:
|
|
260
|
+
"""The commands in the newest HISTORY_CAP lines of one history file, within its own BUDGET.
|
|
261
|
+
Sets cut[0] when older lines were left out or time ran out."""
|
|
262
|
+
deadline = time.monotonic() + BUDGET
|
|
263
|
+
lines, cut[0] = _tail(path, HISTORY_CAP, deadline)
|
|
264
|
+
for raw in lines:
|
|
265
|
+
if time.monotonic() > deadline:
|
|
266
|
+
cut[0] = True
|
|
267
|
+
return
|
|
268
|
+
text = _decode(raw.rstrip(b"\r"), kind)
|
|
269
|
+
if kind == "fish" or (kind == "auto" and text.startswith("- cmd: ")):
|
|
270
|
+
if not text.startswith("- cmd: "):
|
|
271
|
+
continue
|
|
272
|
+
text = text[7:]
|
|
273
|
+
m = _ZSH_EXT.match(text)
|
|
274
|
+
yield text[m.end():] if m else text
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _tokens(cmd: str) -> List[str]:
|
|
278
|
+
lex = shlex.shlex(cmd, posix=True, punctuation_chars=True)
|
|
279
|
+
lex.whitespace_split = True
|
|
280
|
+
try:
|
|
281
|
+
return list(lex)
|
|
282
|
+
except ValueError:
|
|
283
|
+
return cmd.split()
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _forge_shorthand(arg: str, prog: str) -> Optional[str]:
|
|
287
|
+
"""gh/glab accept OWNER/REPO (glab: GROUP/SUB/REPO); turn it into an https URL."""
|
|
288
|
+
if _SCHEME.match(arg) or re.match(r"^[^/]+@[^/]+:", arg):
|
|
289
|
+
return arg
|
|
290
|
+
parts = [p for p in arg.split("/") if p]
|
|
291
|
+
default = "github.com" if prog == "gh" else "gitlab.com"
|
|
292
|
+
if len(parts) >= 3 and "." in parts[0]:
|
|
293
|
+
host = parts.pop(0)
|
|
294
|
+
elif len(parts) == 2 or (prog == "glab" and len(parts) > 2):
|
|
295
|
+
host = default
|
|
296
|
+
else:
|
|
297
|
+
return None
|
|
298
|
+
return f"https://{host}/{'/'.join(parts)}"
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _target(arg: str, home: Path) -> Optional[Path]:
|
|
302
|
+
"""A clone target dir as an absolute path; None when it is relative (the cwd is unknown)."""
|
|
303
|
+
for pre in ("~/", "$HOME/", "${HOME}/"):
|
|
304
|
+
if arg.startswith(pre):
|
|
305
|
+
p = str(home) + "/" + arg[len(pre):]
|
|
306
|
+
break
|
|
307
|
+
else:
|
|
308
|
+
if arg in ("~", "$HOME", "${HOME}"):
|
|
309
|
+
p = str(home)
|
|
310
|
+
elif arg.startswith("/"):
|
|
311
|
+
p = arg
|
|
312
|
+
else:
|
|
313
|
+
return None
|
|
314
|
+
if _CONTROL.search(p):
|
|
315
|
+
return None
|
|
316
|
+
return Path(os.path.normpath(p))
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def parse_clone(cmd: str, home: Path) -> List[Tuple[str, Optional[Path]]]:
|
|
320
|
+
"""(url as written, target dir or None) for each git/gh/glab clone in a shell command."""
|
|
321
|
+
segs: List[List[str]] = [[]]
|
|
322
|
+
for t in _tokens(cmd):
|
|
323
|
+
if t and set(t) <= _SEPARATOR:
|
|
324
|
+
segs.append([])
|
|
325
|
+
else:
|
|
326
|
+
segs[-1].append(t)
|
|
327
|
+
out = []
|
|
328
|
+
for seg in segs:
|
|
329
|
+
for k, tok in enumerate(seg):
|
|
330
|
+
prog = tok.rsplit("/", 1)[-1]
|
|
331
|
+
if prog == "git" and seg[k + 1:k + 2] == ["clone"]:
|
|
332
|
+
args = seg[k + 2:]
|
|
333
|
+
elif prog in ("gh", "glab") and seg[k + 1:k + 3] == ["repo", "clone"]:
|
|
334
|
+
args = seg[k + 3:]
|
|
335
|
+
else:
|
|
336
|
+
continue
|
|
337
|
+
opts = _GIT_VALUE_OPTS if prog == "git" else _FORGE_VALUE_OPTS
|
|
338
|
+
pos: List[str] = []
|
|
339
|
+
n = 0
|
|
340
|
+
group = False
|
|
341
|
+
while n < len(args):
|
|
342
|
+
a = args[n]
|
|
343
|
+
if a == "--":
|
|
344
|
+
if prog == "git":
|
|
345
|
+
pos.extend(args[n + 1:])
|
|
346
|
+
break
|
|
347
|
+
if a.startswith("-") and len(a) > 1:
|
|
348
|
+
group = group or a in ("-g", "--group")
|
|
349
|
+
n += 2 if a in opts else 1
|
|
350
|
+
continue
|
|
351
|
+
pos.append(a)
|
|
352
|
+
n += 1
|
|
353
|
+
if not pos or group:
|
|
354
|
+
break
|
|
355
|
+
url: Optional[str] = pos[0]
|
|
356
|
+
if prog != "git":
|
|
357
|
+
url = _forge_shorthand(pos[0], prog)
|
|
358
|
+
if url:
|
|
359
|
+
out.append((url, _target(pos[1], home) if len(pos) > 1 else None))
|
|
360
|
+
break
|
|
361
|
+
return out
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
# ---------------------------------------------------------------- the collection context
|
|
365
|
+
|
|
366
|
+
class _Ctx:
|
|
367
|
+
def __init__(self, home: Path):
|
|
368
|
+
self.home = home
|
|
369
|
+
self.warnings: List[str] = []
|
|
370
|
+
self.findings: List[str] = [] # -v lines
|
|
371
|
+
self.owners: Dict[Tuple[str, str], dict] = {}
|
|
372
|
+
self.roots: List[str] = [] # candidate roots
|
|
373
|
+
self.repo_paths: Dict[str, str] = {} # resolved path -> path as found
|
|
374
|
+
self.ssh_map: Dict[str, dict] = {} # lowercased ssh Host alias -> entry
|
|
375
|
+
self.include_files: set = set() # resolved paths of includeIf targets (not gitpro's)
|
|
376
|
+
self.walked = 0
|
|
377
|
+
|
|
378
|
+
def warn(self, msg: str) -> None:
|
|
379
|
+
self.warnings.append(msg)
|
|
380
|
+
print(f"⚠ {msg}", file=sys.stderr, flush=True)
|
|
381
|
+
|
|
382
|
+
def note(self, msg: str) -> None:
|
|
383
|
+
self.findings.append(_c(msg))
|
|
384
|
+
|
|
385
|
+
def add_root(self, p) -> None:
|
|
386
|
+
p = os.path.normpath(str(p))
|
|
387
|
+
home = str(self.home)
|
|
388
|
+
if p == "/" or _under(home, p) and p != home: # never / or a parent of the home directory
|
|
389
|
+
return
|
|
390
|
+
if not _under(p, home) and any(_under(p, t) for t in _temp_dirs()):
|
|
391
|
+
return # a clone in /tmp is not a place to scan
|
|
392
|
+
if p not in self.roots:
|
|
393
|
+
self.roots.append(p)
|
|
394
|
+
|
|
395
|
+
def rec(self, host: str, owner: str) -> dict:
|
|
396
|
+
"""The record of host/owner; owners that differ only in case share one (forges ignore case)."""
|
|
397
|
+
rec = self.owners.setdefault((host, owner.lower()), {
|
|
398
|
+
"repos": set(), "emails": Counter(), "names": Counter(), "paths": [], "aliases": Counter(),
|
|
399
|
+
"rule": None, "sources": set(), "spellings": Counter()})
|
|
400
|
+
rec["spellings"][owner] += 1
|
|
401
|
+
return rec
|
|
402
|
+
|
|
403
|
+
def add_url(self, source: str, url: str, *, path=None, person: Optional[dict] = None) -> None:
|
|
404
|
+
from .resolve import parse_url
|
|
405
|
+
host, owner, repo = parse_url(url)
|
|
406
|
+
if not (host and owner and repo) or _GLOB.search(owner):
|
|
407
|
+
return
|
|
408
|
+
host = host.lower()
|
|
409
|
+
# an ssh Host alias renames the host of ssh remotes only; https://<alias>/ is a real host name
|
|
410
|
+
alias = self.ssh_map.get(host) if _is_ssh_url(url) else None
|
|
411
|
+
if alias:
|
|
412
|
+
host = alias["hostname"].lower()
|
|
413
|
+
rec = self.rec(host, owner)
|
|
414
|
+
rec["sources"].add(source)
|
|
415
|
+
if path is not None:
|
|
416
|
+
rec["paths"].append(os.path.normpath(str(path)))
|
|
417
|
+
if alias and not alias["same_forge"]:
|
|
418
|
+
rec["aliases"][alias["alias"]] += 1
|
|
419
|
+
key = repo.lower()
|
|
420
|
+
if key in rec["repos"]:
|
|
421
|
+
return
|
|
422
|
+
rec["repos"].add(key)
|
|
423
|
+
person = person or {}
|
|
424
|
+
email, name = person.get("user.email"), person.get("user.name")
|
|
425
|
+
if isinstance(email, str) and EMAIL_RE.match(email) and not _CONTROL.search(email):
|
|
426
|
+
rec["emails"][email] += 1
|
|
427
|
+
if isinstance(name, str) and name.strip() and not re.search(r"[\x00-\x08\x0a-\x1f\x7f]", name):
|
|
428
|
+
rec["names"][name.strip()] += 1
|
|
429
|
+
|
|
430
|
+
def add_repo(self, source: str, path: Path) -> None:
|
|
431
|
+
try:
|
|
432
|
+
key = str(Path(path).resolve())
|
|
433
|
+
except OSError:
|
|
434
|
+
return
|
|
435
|
+
if key in self.repo_paths:
|
|
436
|
+
return
|
|
437
|
+
self.repo_paths[key] = os.path.normpath(str(path))
|
|
438
|
+
from . import scan
|
|
439
|
+
try:
|
|
440
|
+
repo = scan.inspect(Path(path))
|
|
441
|
+
except (config.ConfigError, OSError, subprocess.SubprocessError, UnicodeError):
|
|
442
|
+
self.note(f"{source} {_tilde(path, self.home)} (could not inspect)")
|
|
443
|
+
return
|
|
444
|
+
url = sanitise(repo.remote) if repo.remote else None
|
|
445
|
+
self.note(f"{source} {_tilde(path, self.home)} {url or ('(remote not usable)' if repo.remote else '(no remote)')}")
|
|
446
|
+
if url:
|
|
447
|
+
self.add_url(source, url, path=path, person=self._person(repo))
|
|
448
|
+
|
|
449
|
+
def _person(self, repo) -> dict:
|
|
450
|
+
"""user.email/user.name of a repo when they say something about its owner: set in the repo
|
|
451
|
+
itself, or by an includeIf file (not the plain global value every repo shares)."""
|
|
452
|
+
out = {}
|
|
453
|
+
for key in ("user.email", "user.name"):
|
|
454
|
+
if key in repo.local:
|
|
455
|
+
out[key] = repo.local[key]
|
|
456
|
+
elif key in repo.now:
|
|
457
|
+
value, origin = repo.now[key]
|
|
458
|
+
if origin.startswith("file:"):
|
|
459
|
+
p = Path(origin[5:])
|
|
460
|
+
p = p if p.is_absolute() else repo.path / p
|
|
461
|
+
try:
|
|
462
|
+
if str(p.resolve()) in self.include_files:
|
|
463
|
+
out[key] = value
|
|
464
|
+
except OSError:
|
|
465
|
+
pass
|
|
466
|
+
return out
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
# ---------------------------------------------------------------- sources
|
|
470
|
+
|
|
471
|
+
def _history(home: Path, ctx: _Ctx) -> dict:
|
|
472
|
+
"""Clone lines in the newest HISTORY_CAP lines of each history file (each with its own BUDGET)."""
|
|
473
|
+
found = _history_files(home)
|
|
474
|
+
info: dict = {"files": [], "lines": 0, "secrets_skipped": 0, "truncated": []}
|
|
475
|
+
if not found:
|
|
476
|
+
ctx.note("history: no shell history file found")
|
|
477
|
+
for p, kind in found:
|
|
478
|
+
cut = [False]
|
|
479
|
+
try:
|
|
480
|
+
for cmd in _history_commands(p, kind, cut):
|
|
481
|
+
if not _CLONE_RE.search(cmd):
|
|
482
|
+
continue
|
|
483
|
+
info["lines"] += 1
|
|
484
|
+
for raw, target in parse_clone(cmd, home):
|
|
485
|
+
url, secret = _clean(raw)
|
|
486
|
+
if secret:
|
|
487
|
+
info["secrets_skipped"] += 1
|
|
488
|
+
if url is None:
|
|
489
|
+
continue
|
|
490
|
+
ctx.add_url("history", url, path=target)
|
|
491
|
+
ctx.note(f"history {url}" + (f" → {_tilde(target, home)}" if target else ""))
|
|
492
|
+
if target is not None:
|
|
493
|
+
ctx.add_root(target.parent)
|
|
494
|
+
except OSError as e:
|
|
495
|
+
ctx.note(f"history: could not read {_tilde(p, home)} ({_first_line(e.strerror or str(e))})")
|
|
496
|
+
continue
|
|
497
|
+
info["files"].append(str(p))
|
|
498
|
+
if cut[0]:
|
|
499
|
+
info["truncated"].append(str(p))
|
|
500
|
+
ctx.note(f"history: read only the newest {HISTORY_CAP} lines (or {BUDGET:g} s) of {_tilde(p, home)}")
|
|
501
|
+
return info
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _vscdb_recents(sqlite3, db: Path):
|
|
505
|
+
uri = "file:" + quote(str(db)) + "?mode=ro&immutable=1"
|
|
506
|
+
con = sqlite3.connect(uri, uri=True, timeout=1)
|
|
507
|
+
try:
|
|
508
|
+
row = con.execute("SELECT value FROM ItemTable WHERE key = ?", (VSCODE_KEY,)).fetchone()
|
|
509
|
+
finally:
|
|
510
|
+
con.close()
|
|
511
|
+
if row is None:
|
|
512
|
+
return []
|
|
513
|
+
value = row[0]
|
|
514
|
+
if isinstance(value, bytes):
|
|
515
|
+
value = value.decode("utf-8")
|
|
516
|
+
data = json.loads(value)
|
|
517
|
+
if not isinstance(data, dict):
|
|
518
|
+
raise ValueError("unexpected format")
|
|
519
|
+
entries = data.get("entries") or []
|
|
520
|
+
if not isinstance(entries, list):
|
|
521
|
+
raise ValueError("unexpected format")
|
|
522
|
+
return entries
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _vscode(home: Path, ctx: _Ctx) -> Tuple[dict, List[Path]]:
|
|
526
|
+
info = {"files": [], "entries": 0, "remote_entries": 0}
|
|
527
|
+
paths: List[Path] = []
|
|
528
|
+
dbs = [base / app / "User/globalStorage/state.vscdb"
|
|
529
|
+
for base in (home / ".config", home / "Library/Application Support") for app in VSCODE_APPS]
|
|
530
|
+
dbs = [p for p in dbs if p.is_file()]
|
|
531
|
+
if not dbs:
|
|
532
|
+
ctx.note("vscode: state.vscdb not found")
|
|
533
|
+
return info, paths
|
|
534
|
+
try:
|
|
535
|
+
import sqlite3
|
|
536
|
+
except ImportError:
|
|
537
|
+
ctx.warn("vscode: python has no sqlite3 module; skipped")
|
|
538
|
+
return info, paths
|
|
539
|
+
for db in dbs:
|
|
540
|
+
if not os.access(db, os.R_OK): # unreadable: skipped silently (spec 04 section 7), -v only
|
|
541
|
+
ctx.note(f"vscode: {_tilde(db, home)} is not readable; skipped")
|
|
542
|
+
continue
|
|
543
|
+
info["files"].append(str(db))
|
|
544
|
+
try:
|
|
545
|
+
entries = _vscdb_recents(sqlite3, db)
|
|
546
|
+
except (sqlite3.Error, ValueError, UnicodeError) as e:
|
|
547
|
+
ctx.warn(f"vscode: could not read recents ({_first_line(str(e))})")
|
|
548
|
+
continue
|
|
549
|
+
for e in entries:
|
|
550
|
+
if not isinstance(e, dict):
|
|
551
|
+
continue
|
|
552
|
+
ws = e.get("workspace")
|
|
553
|
+
uris = [e.get("folderUri"), e.get("fileUri"), ws.get("configPath") if isinstance(ws, dict) else None]
|
|
554
|
+
uris = [u for u in uris if isinstance(u, str)]
|
|
555
|
+
if any(u.startswith("vscode-remote:") for u in uris):
|
|
556
|
+
info["remote_entries"] += 1
|
|
557
|
+
continue
|
|
558
|
+
folder = e.get("folderUri")
|
|
559
|
+
if not isinstance(folder, str):
|
|
560
|
+
continue
|
|
561
|
+
sp = urlsplit(folder)
|
|
562
|
+
if sp.scheme != "file" or sp.netloc not in ("", "localhost"):
|
|
563
|
+
continue
|
|
564
|
+
info["entries"] += 1
|
|
565
|
+
paths.append(Path(unquote(sp.path)))
|
|
566
|
+
return info, paths
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def _jetbrains(home: Path, ctx: _Ctx) -> Tuple[dict, List[Path]]:
|
|
570
|
+
info = {"files": [], "entries": 0}
|
|
571
|
+
paths: List[Path] = []
|
|
572
|
+
xmls: List[Path] = []
|
|
573
|
+
for base in (home / ".config/JetBrains", home / "Library/Application Support/JetBrains"):
|
|
574
|
+
if base.is_dir():
|
|
575
|
+
xmls += sorted(base.glob("*/options/recentProjects.xml"))
|
|
576
|
+
if not xmls:
|
|
577
|
+
ctx.note("jetbrains: recentProjects.xml not found")
|
|
578
|
+
return info, paths
|
|
579
|
+
import xml.etree.ElementTree as ET
|
|
580
|
+
seen = set()
|
|
581
|
+
for f in xmls:
|
|
582
|
+
info["files"].append(str(f))
|
|
583
|
+
try:
|
|
584
|
+
tree = ET.parse(str(f))
|
|
585
|
+
except (ET.ParseError, OSError) as e:
|
|
586
|
+
ctx.warn(f"jetbrains: could not read {_tilde(f, home)} ({_first_line(str(e))})")
|
|
587
|
+
continue
|
|
588
|
+
found = [el.get("key") for el in tree.iter("entry")]
|
|
589
|
+
for opt in tree.iter("option"):
|
|
590
|
+
if opt.get("name") == "recentPaths":
|
|
591
|
+
found += [sub.get("value") for sub in opt.iter("option") if sub is not opt]
|
|
592
|
+
for raw in found:
|
|
593
|
+
if not isinstance(raw, str) or not raw.startswith(("$USER_HOME$", "/")):
|
|
594
|
+
continue
|
|
595
|
+
p = os.path.normpath(raw.replace("$USER_HOME$", str(home), 1))
|
|
596
|
+
if p not in seen:
|
|
597
|
+
seen.add(p)
|
|
598
|
+
paths.append(Path(p))
|
|
599
|
+
info["entries"] = len(paths)
|
|
600
|
+
return info, paths
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def _ssh_words(value: str) -> List[str]:
|
|
604
|
+
try:
|
|
605
|
+
return shlex.split(value)
|
|
606
|
+
except ValueError:
|
|
607
|
+
return value.split()
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def _ssh(home: Path, ctx: _Ctx) -> Tuple[dict, List[dict]]:
|
|
611
|
+
"""Host entries of ~/.ssh/config, its Include files (depth 1) and ~/.ssh/config.d/*; the gitpro
|
|
612
|
+
block is skipped. Only Host, HostName, User, Port, IdentityFile and Include are looked at."""
|
|
613
|
+
sshdir = home / ".ssh"
|
|
614
|
+
read: List[Path] = []
|
|
615
|
+
by_alias: Dict[str, dict] = {}
|
|
616
|
+
|
|
617
|
+
def glob_ssh(pattern: str) -> List[Path]:
|
|
618
|
+
import glob
|
|
619
|
+
p = os.path.expanduser(pattern) if pattern.startswith("~") else pattern
|
|
620
|
+
if pattern.startswith("~/"):
|
|
621
|
+
p = str(home) + pattern[1:]
|
|
622
|
+
if not os.path.isabs(p):
|
|
623
|
+
p = str(sshdir / p)
|
|
624
|
+
return [Path(x) for x in sorted(glob.glob(p)) if os.path.isfile(x)]
|
|
625
|
+
|
|
626
|
+
def parse(path: Path, top: bool) -> None:
|
|
627
|
+
if path in read or _not_ssh_config(path):
|
|
628
|
+
return
|
|
629
|
+
try:
|
|
630
|
+
if not path.is_file() or path.stat().st_size > 1_000_000:
|
|
631
|
+
return
|
|
632
|
+
with open(path, "rb") as f:
|
|
633
|
+
head = f.read(64)
|
|
634
|
+
if _KEY_HEAD.match(head): # a key after all: stop at its first line
|
|
635
|
+
ctx.note(f"ssh: {_tilde(path, home)} looks like a key; not read")
|
|
636
|
+
return
|
|
637
|
+
text = (head + f.read()).decode("utf-8", "replace")
|
|
638
|
+
except OSError:
|
|
639
|
+
return
|
|
640
|
+
read.append(path)
|
|
641
|
+
current: Optional[List[dict]] = None
|
|
642
|
+
managed = False
|
|
643
|
+
for raw in text.splitlines():
|
|
644
|
+
line = raw.strip()
|
|
645
|
+
m = _BLOCK_MARK.match(line)
|
|
646
|
+
if m:
|
|
647
|
+
managed = m[1] == "BEGIN"
|
|
648
|
+
current = None
|
|
649
|
+
continue
|
|
650
|
+
if managed or not line or line.startswith("#"):
|
|
651
|
+
continue
|
|
652
|
+
m = re.match(r"^(\S+?)(?:\s*=\s*|\s+)(.*)$", line)
|
|
653
|
+
if not m:
|
|
654
|
+
continue
|
|
655
|
+
key, value = m[1].lower(), m[2].strip()
|
|
656
|
+
if key == "include":
|
|
657
|
+
if top:
|
|
658
|
+
for pat in _ssh_words(value):
|
|
659
|
+
for inc in glob_ssh(pat):
|
|
660
|
+
parse(inc, False)
|
|
661
|
+
continue
|
|
662
|
+
if key == "match":
|
|
663
|
+
current = None
|
|
664
|
+
elif key == "host":
|
|
665
|
+
current = []
|
|
666
|
+
for pat in _ssh_words(value):
|
|
667
|
+
if any(c in pat for c in "*?!") or _CONTROL.search(pat):
|
|
668
|
+
continue
|
|
669
|
+
e = by_alias.setdefault(pat.lower(), {"alias": pat, "file": str(path)})
|
|
670
|
+
current.append(e)
|
|
671
|
+
elif current and key in ("hostname", "user", "port", "identityfile"):
|
|
672
|
+
words = _ssh_words(value)
|
|
673
|
+
if words:
|
|
674
|
+
for e in current:
|
|
675
|
+
e.setdefault(key, words[0]) # ssh: the first value obtained wins
|
|
676
|
+
|
|
677
|
+
parse(sshdir / "config", True)
|
|
678
|
+
if (sshdir / "config.d").is_dir():
|
|
679
|
+
for p in sorted((sshdir / "config.d").iterdir()):
|
|
680
|
+
parse(p, False)
|
|
681
|
+
if not read:
|
|
682
|
+
ctx.note("ssh: ~/.ssh/config not found")
|
|
683
|
+
entries = []
|
|
684
|
+
for e in by_alias.values():
|
|
685
|
+
hn = e.get("hostname")
|
|
686
|
+
if not hn or "%" in hn or hn.lower() == e["alias"].lower() or not HOST_RE.match(hn):
|
|
687
|
+
continue
|
|
688
|
+
port = e.get("port")
|
|
689
|
+
entry = {
|
|
690
|
+
"alias": e["alias"], "hostname": hn, "user": e.get("user"),
|
|
691
|
+
"port": int(port) if port and port.isdigit() and 0 < int(port) < 65536 else None,
|
|
692
|
+
"key": e.get("identityfile"), "file": e["file"], "pub_email": None, "same_forge": False}
|
|
693
|
+
alias, hn_l = e["alias"].lower(), hn.lower()
|
|
694
|
+
if _is_forge(alias) or hn_l in SAME_FORGE:
|
|
695
|
+
# `Host github.com / HostName ssh.github.com / Port 443`: the forge itself reached another
|
|
696
|
+
# way, not an identity alias. Remotes keep (or get) the forge's host; no ssh_alias/ssh_port.
|
|
697
|
+
ctx.note(f"ssh {_c(e['alias'])} → {hn}: the forge itself (another port or address); not an alias")
|
|
698
|
+
if _is_forge(alias):
|
|
699
|
+
continue
|
|
700
|
+
entry.update(hostname=SAME_FORGE[hn_l], same_forge=True)
|
|
701
|
+
else:
|
|
702
|
+
entry["pub_email"] = _pub_email(e.get("identityfile"), home)
|
|
703
|
+
entries.append(entry)
|
|
704
|
+
return {"files": [str(p) for p in read], "aliases": 0}, entries
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def _not_ssh_config(path: Path) -> bool:
|
|
708
|
+
"""Files an `Include ~/.ssh/*` or config.d may name that are keys or host lists, never configs."""
|
|
709
|
+
name = path.name
|
|
710
|
+
return (name.startswith("id_") or name.endswith((".pub", ".pem", ".key", ".ppk", ".p12", ".pfx"))
|
|
711
|
+
or name.startswith(("known_hosts", "authorized_keys")) or path.with_name(name + ".pub").exists())
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
_KEY_HEAD = re.compile(rb"^\s*(-----BEGIN|PuTTY-User-Key-File|openssh-key-v1)")
|
|
715
|
+
SAME_FORGE = {"ssh.github.com": "github.com", "altssh.gitlab.com": "gitlab.com"}
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def _pub_email(key: Optional[str], home: Path) -> Optional[str]:
|
|
719
|
+
"""The email in the comment of <IdentityFile>.pub (never the private key itself)."""
|
|
720
|
+
if not key or "%" in key or _CONTROL.search(key):
|
|
721
|
+
return None
|
|
722
|
+
p = str(home) + key[1:] if key.startswith("~/") else key
|
|
723
|
+
priv = Path(p) if os.path.isabs(p) else home / p
|
|
724
|
+
pub = priv.with_name(priv.name + ".pub")
|
|
725
|
+
try:
|
|
726
|
+
if not pub.is_file() or pub.stat().st_size > 16384:
|
|
727
|
+
return None
|
|
728
|
+
words = pub.read_text(encoding="utf-8", errors="replace").split("\n", 1)[0].split()
|
|
729
|
+
except OSError:
|
|
730
|
+
return None
|
|
731
|
+
for w in words[2:]:
|
|
732
|
+
if _usable_email(w):
|
|
733
|
+
return w
|
|
734
|
+
return None
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def _git(*args: str, cwd: Path) -> Optional[subprocess.CompletedProcess]:
|
|
738
|
+
try:
|
|
739
|
+
return subprocess.run(["git", *args], cwd=str(cwd), stdin=subprocess.DEVNULL, capture_output=True,
|
|
740
|
+
text=True, errors="replace", timeout=TIMEOUT, env=_env())
|
|
741
|
+
except (OSError, subprocess.SubprocessError):
|
|
742
|
+
return None
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
def _gitdir_root(pattern: str, home: Path, origin: Optional[Path]) -> Optional[str]:
|
|
746
|
+
if pattern.startswith("~/"):
|
|
747
|
+
pattern = str(home) + pattern[1:]
|
|
748
|
+
elif pattern.startswith("./") and origin is not None:
|
|
749
|
+
pattern = str(origin.parent) + pattern[1:]
|
|
750
|
+
elif not pattern.startswith("/"):
|
|
751
|
+
return None # "**/x": anywhere
|
|
752
|
+
m = _GLOB.search(pattern)
|
|
753
|
+
root = pattern.rstrip("/") if m is None else pattern[:m.start()].rsplit("/", 1)[0]
|
|
754
|
+
root = os.path.normpath(root) if root else ""
|
|
755
|
+
return root if root not in ("", "/") and not _CONTROL.search(root) else None
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def _pattern_owner(pattern: str) -> Tuple[Optional[str], Optional[str]]:
|
|
759
|
+
"""(host, owner) of a hasconfig:remote.*.url: pattern such as git@gitlab.com:grp/**."""
|
|
760
|
+
from .resolve import parse_url
|
|
761
|
+
host, owner, repo = parse_url(pattern)
|
|
762
|
+
if not host or not HOST_RE.match(host):
|
|
763
|
+
return None, None
|
|
764
|
+
segs = (owner.split("/") if owner else []) + ([repo] if repo else [])
|
|
765
|
+
lead = []
|
|
766
|
+
for s in segs:
|
|
767
|
+
if _GLOB.search(s):
|
|
768
|
+
break
|
|
769
|
+
lead.append(s)
|
|
770
|
+
if len(lead) == len(segs):
|
|
771
|
+
lead = lead[:-1]
|
|
772
|
+
owner = "/".join(lead) or None
|
|
773
|
+
return host.lower(), owner
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
HASCONFIG = "hasconfig:remote.*.url:"
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
def _redact_pattern(pattern: str) -> Optional[str]:
|
|
780
|
+
"""A hasconfig URL pattern without its userinfo (an scp user that is a plain name such as git is
|
|
781
|
+
kept); None when it looks like it holds a token."""
|
|
782
|
+
if _TOKEN_RE.search(pattern):
|
|
783
|
+
return None
|
|
784
|
+
m = re.match(r"^([A-Za-z][A-Za-z0-9+.-]*://)([^/]*)(.*)$", pattern, re.S)
|
|
785
|
+
if m:
|
|
786
|
+
out = m[1] + m[2].rpartition("@")[2] + m[3]
|
|
787
|
+
else:
|
|
788
|
+
authority = pattern.split("/", 1)[0]
|
|
789
|
+
at = authority.rfind("@")
|
|
790
|
+
user = authority[:at]
|
|
791
|
+
out = pattern if at < 0 else (f"{user}@" if ID_RE.match(user) else "") + pattern[at + 1:]
|
|
792
|
+
return None if _TOKEN_RE.search(out) else _c(out)
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
def _includeif(home: Path, ctx: _Ctx) -> List[dict]:
|
|
796
|
+
if not home.is_dir():
|
|
797
|
+
ctx.note("includeif: no home directory; skipped")
|
|
798
|
+
return []
|
|
799
|
+
r = _git("config", "--global", "-z", "--show-origin", "--get-regexp", r"^includeif\.", cwd=home)
|
|
800
|
+
if r is None:
|
|
801
|
+
ctx.note("includeif: could not run git; skipped")
|
|
802
|
+
return []
|
|
803
|
+
if r.returncode not in (0, 1):
|
|
804
|
+
ctx.warn(f"includeif: could not read the global gitconfig ({_first_line(r.stderr)})")
|
|
805
|
+
return []
|
|
806
|
+
rules = []
|
|
807
|
+
toks = r.stdout.split("\0")
|
|
808
|
+
for origin, entry in zip(toks[0::2], toks[1::2]):
|
|
809
|
+
key, _, value = entry.partition("\n")
|
|
810
|
+
if not key.endswith(".path") or _TOKEN_RE.search(key):
|
|
811
|
+
continue
|
|
812
|
+
cond = key[len("includeif."):-len(".path")]
|
|
813
|
+
if cond.startswith(HASCONFIG):
|
|
814
|
+
pattern = _redact_pattern(cond[len(HASCONFIG):])
|
|
815
|
+
if pattern is None:
|
|
816
|
+
ctx.note("includeif: a hasconfig rule whose URL looks like it holds a secret; skipped")
|
|
817
|
+
continue
|
|
818
|
+
cond = HASCONFIG + pattern
|
|
819
|
+
ofile = Path(origin[5:]) if origin.startswith("file:") else None
|
|
820
|
+
value = value.strip()
|
|
821
|
+
if value.startswith("~/"):
|
|
822
|
+
inc: Optional[Path] = home / value[2:]
|
|
823
|
+
elif os.path.isabs(value):
|
|
824
|
+
inc = Path(value)
|
|
825
|
+
elif ofile is not None and value:
|
|
826
|
+
inc = ofile.parent / value
|
|
827
|
+
else:
|
|
828
|
+
inc = None
|
|
829
|
+
rule = {"condition": _CONTROL.sub("?", cond), "kind": None, "root": None, "host": None, "owner": None,
|
|
830
|
+
"file": str(inc) if inc else None, "name": None, "email": None}
|
|
831
|
+
if cond.startswith(("gitdir:", "gitdir/i:")):
|
|
832
|
+
rule["kind"] = "gitdir"
|
|
833
|
+
rule["root"] = _gitdir_root(cond.split(":", 1)[1], home, ofile)
|
|
834
|
+
elif cond.startswith(HASCONFIG):
|
|
835
|
+
rule["kind"] = "hasconfig"
|
|
836
|
+
rule["host"], rule["owner"] = _pattern_owner(cond[len(HASCONFIG):])
|
|
837
|
+
else:
|
|
838
|
+
continue
|
|
839
|
+
if inc is not None and inc.is_file():
|
|
840
|
+
try:
|
|
841
|
+
head = inc.read_text(encoding="utf-8", errors="replace")[:4096]
|
|
842
|
+
except OSError:
|
|
843
|
+
head = ""
|
|
844
|
+
if files.is_generated(head):
|
|
845
|
+
ctx.note(f"includeif {rule['condition']} → {_tilde(inc, home)} (written by gitpro; skipped)")
|
|
846
|
+
continue
|
|
847
|
+
try:
|
|
848
|
+
ctx.include_files.add(str(inc.resolve()))
|
|
849
|
+
except OSError:
|
|
850
|
+
pass
|
|
851
|
+
for k in ("name", "email"):
|
|
852
|
+
g = _git("config", "-f", str(inc), "--get", f"user.{k}", cwd=home)
|
|
853
|
+
if g is not None and g.returncode == 0 and g.stdout.strip():
|
|
854
|
+
rule[k] = g.stdout.strip()
|
|
855
|
+
who = " ".join(x for x in (rule["name"], f"<{rule['email']}>" if rule["email"] else None) if x)
|
|
856
|
+
ctx.note(f"includeif {rule['condition']} → {_tilde(inc, home) if inc else '?'}" + (f" ({who})" if who else ""))
|
|
857
|
+
rules.append(rule)
|
|
858
|
+
if not rules:
|
|
859
|
+
ctx.note("includeif: no includeIf rules in the global gitconfig")
|
|
860
|
+
return rules
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
# ---------------------------------------------------------------- online: gh / glab
|
|
864
|
+
|
|
865
|
+
def _run_cli(argv: List[str], env: Dict[str, str]) -> Optional[subprocess.CompletedProcess]:
|
|
866
|
+
try:
|
|
867
|
+
return subprocess.run(argv, stdin=subprocess.DEVNULL, capture_output=True, text=True, errors="replace",
|
|
868
|
+
timeout=TIMEOUT, env=env)
|
|
869
|
+
except (OSError, subprocess.SubprocessError):
|
|
870
|
+
return None
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
def _account(host: str, cli: str, login: str, active: bool) -> Optional[dict]:
|
|
874
|
+
if not (isinstance(host, str) and HOST_RE.match(host) and isinstance(login, str) and ID_RE.match(login)):
|
|
875
|
+
return None
|
|
876
|
+
return {"host": host.lower(), "cli": cli, "login": login, "emails": [], "verified": False, "_active": active}
|
|
877
|
+
|
|
878
|
+
|
|
879
|
+
def _parse_logins(text: str, cli: str) -> List[dict]:
|
|
880
|
+
out: List[dict] = []
|
|
881
|
+
for line in text.splitlines():
|
|
882
|
+
m = re.search(r"Logged in to (\S+) (?:account|as) (\S+)", line)
|
|
883
|
+
if m:
|
|
884
|
+
acc = _account(m[1], cli, m[2], cli == "glab")
|
|
885
|
+
if acc:
|
|
886
|
+
acc["_active"] = None if cli == "gh" else True
|
|
887
|
+
out.append(acc)
|
|
888
|
+
continue
|
|
889
|
+
m = re.search(r"Active account: (true|false)", line)
|
|
890
|
+
if m and out:
|
|
891
|
+
out[-1]["_active"] = m[1] == "true"
|
|
892
|
+
for a in out: # older gh prints no "Active account" line: the first login per host is the active one
|
|
893
|
+
if a["_active"] is None:
|
|
894
|
+
a["_active"] = not any(b["_active"] for b in out if b["host"] == a["host"] and b is not a)
|
|
895
|
+
return out
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
def _gh_logins(env: Dict[str, str]) -> List[dict]:
|
|
899
|
+
r = _run_cli(["gh", "auth", "status", "--json", "hosts"], env)
|
|
900
|
+
if r is not None and r.returncode == 0:
|
|
901
|
+
try:
|
|
902
|
+
hosts = json.loads(r.stdout)["hosts"]
|
|
903
|
+
except (ValueError, KeyError, TypeError):
|
|
904
|
+
hosts = None
|
|
905
|
+
if isinstance(hosts, dict):
|
|
906
|
+
out = []
|
|
907
|
+
for host, items in hosts.items():
|
|
908
|
+
for it in items if isinstance(items, list) else []:
|
|
909
|
+
if isinstance(it, dict) and it.get("state", "success") == "success":
|
|
910
|
+
acc = _account(host, "gh", it.get("login"), bool(it.get("active")))
|
|
911
|
+
if acc:
|
|
912
|
+
out.append(acc)
|
|
913
|
+
return out
|
|
914
|
+
r = _run_cli(["gh", "auth", "status"], env) # gh before --json: parse the text form
|
|
915
|
+
return _parse_logins(r.stdout + "\n" + r.stderr, "gh") if r is not None else []
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
def _gh_emails(acc: dict, env: Dict[str, str], ctx: _Ctx) -> None:
|
|
919
|
+
r = _run_cli(["gh", "api", "user/emails", "--hostname", acc["host"]], env)
|
|
920
|
+
emails = None
|
|
921
|
+
if r is not None and r.returncode == 0:
|
|
922
|
+
try:
|
|
923
|
+
data = json.loads(r.stdout)
|
|
924
|
+
emails = [e["email"] for e in data if isinstance(e, dict) and e.get("verified") and _usable_email(e.get("email"))]
|
|
925
|
+
except (ValueError, KeyError, TypeError):
|
|
926
|
+
emails = None
|
|
927
|
+
if emails is None:
|
|
928
|
+
detail = _first_line(r.stderr) if r is not None else "gh failed"
|
|
929
|
+
ctx.warn(f"gh: could not read the verified emails of {acc['login']} on {acc['host']} ({detail}); "
|
|
930
|
+
f"to allow it: gh auth refresh -h {acc['host']} -s user:email")
|
|
931
|
+
return
|
|
932
|
+
acc["emails"] = emails
|
|
933
|
+
acc["verified"] = bool(emails)
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
def _online(sources: List[str], ctx: _Ctx, quiet: bool) -> Tuple[List[dict], Dict[str, Optional[dict]]]:
|
|
937
|
+
from . import forge
|
|
938
|
+
accounts: List[dict] = []
|
|
939
|
+
info: Dict[str, Optional[dict]] = {"gh": None, "glab": None}
|
|
940
|
+
gh_env = dict(_env(forge._GH_TOKEN_VARS), GH_PROMPT_DISABLED="1", GH_NO_UPDATE_NOTIFIER="1", NO_COLOR="1")
|
|
941
|
+
if "gh" in sources and forge.available("gh"):
|
|
942
|
+
accs = _gh_logins(gh_env)
|
|
943
|
+
info["gh"] = {"logins": len(accs)}
|
|
944
|
+
if not accs:
|
|
945
|
+
ctx.warn("gh has no login; account discovery skipped")
|
|
946
|
+
accounts += accs
|
|
947
|
+
if "glab" in sources and forge.available("glab"):
|
|
948
|
+
r = _run_cli(["glab", "auth", "status"], dict(_env(forge._GLAB_TOKEN_VARS), NO_PROMPT="1", NO_COLOR="1"))
|
|
949
|
+
accs = _parse_logins(r.stdout + "\n" + r.stderr, "glab") if r is not None else []
|
|
950
|
+
info["glab"] = {"logins": len(accs)}
|
|
951
|
+
if not accs:
|
|
952
|
+
ctx.warn("glab has no login; account discovery skipped")
|
|
953
|
+
accounts += accs
|
|
954
|
+
if accounts:
|
|
955
|
+
what = "for your logins" + (" and verified emails" if any(a["cli"] == "gh" for a in accounts) else "")
|
|
956
|
+
target = sys.stderr if quiet else sys.stdout
|
|
957
|
+
with contextlib.redirect_stdout(target):
|
|
958
|
+
forge.announce(what, [forge.Account(a["host"], a["cli"], a["login"]) for a in accounts],
|
|
959
|
+
sends="only what gh/glab send to sign in (they use their own stored login)")
|
|
960
|
+
for a in accounts:
|
|
961
|
+
if a["cli"] == "gh" and a["_active"]:
|
|
962
|
+
_gh_emails(a, gh_env, ctx)
|
|
963
|
+
for a in accounts:
|
|
964
|
+
ctx.note(f"{a['cli']} {a['host']} {a['login']}" + (f" verified: {', '.join(a['emails'])}" if a["emails"] else ""))
|
|
965
|
+
return accounts, info
|
|
966
|
+
|
|
967
|
+
|
|
968
|
+
# ---------------------------------------------------------------- the existing config
|
|
969
|
+
|
|
970
|
+
def _expand(r: str, home: Path) -> str:
|
|
971
|
+
if r == "~":
|
|
972
|
+
return str(home)
|
|
973
|
+
return os.path.normpath(str(home) + r[1:] if r.startswith("~/") else r)
|
|
974
|
+
|
|
975
|
+
|
|
976
|
+
def _load_config(ctx: _Ctx, explicit: Optional[Path], find: bool, home: Path):
|
|
977
|
+
info = {"path": None, "loaded": False, "ids": [], "emails": [], "scan_roots": None, "exclude": [], "aliases": []}
|
|
978
|
+
if not find and explicit is None:
|
|
979
|
+
return info, None
|
|
980
|
+
try:
|
|
981
|
+
path, how = config.find(explicit, home)
|
|
982
|
+
except config.ConfigError:
|
|
983
|
+
return info, None
|
|
984
|
+
info["path"] = str(path)
|
|
985
|
+
try:
|
|
986
|
+
raw = config._read(path)
|
|
987
|
+
except config.ConfigError as e:
|
|
988
|
+
ctx.warn(f"{e}; its identities and [scan] roots are ignored")
|
|
989
|
+
return info, None
|
|
990
|
+
try:
|
|
991
|
+
sc = config.load_scan(path)
|
|
992
|
+
if isinstance(raw.get("scan"), dict) and "roots" in raw["scan"]:
|
|
993
|
+
info["scan_roots"] = list(sc.roots)
|
|
994
|
+
info["exclude"] = list(sc.exclude)
|
|
995
|
+
except config.ConfigError as e:
|
|
996
|
+
ctx.warn(f"{e}; its [scan] roots are ignored")
|
|
997
|
+
try:
|
|
998
|
+
cfg = config.load_all(path, how)
|
|
999
|
+
except config.ConfigError as e:
|
|
1000
|
+
ctx.warn(f"{e}; its identities are ignored")
|
|
1001
|
+
return info, None
|
|
1002
|
+
info.update(loaded=True, ids=[i.id for i in cfg.identities],
|
|
1003
|
+
emails=sorted({i.email.lower() for i in cfg.identities if i.email}),
|
|
1004
|
+
aliases=[i.ssh_alias for i in cfg.identities if i.ssh_alias] + list(cfg.default.ssh_hosts))
|
|
1005
|
+
return info, cfg
|
|
1006
|
+
|
|
1007
|
+
|
|
1008
|
+
def _config_id(cfg, host: str, owner: str) -> Optional[str]:
|
|
1009
|
+
if cfg is None:
|
|
1010
|
+
return None
|
|
1011
|
+
from .resolve import match_url
|
|
1012
|
+
for url in (f"git@{host}:{owner}/x.git", f"https://{host}/{owner}/x.git", f"ssh://git@{host}/{owner}/x.git"):
|
|
1013
|
+
ident, _ = match_url(url, cfg)
|
|
1014
|
+
if ident is not None:
|
|
1015
|
+
return ident.id
|
|
1016
|
+
return None
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
# ---------------------------------------------------------------- collect
|
|
1020
|
+
|
|
1021
|
+
def _collapse(paths) -> List[str]:
|
|
1022
|
+
out: List[str] = []
|
|
1023
|
+
for p in sorted(set(paths), key=lambda s: (s.rstrip("/").count("/"), s)):
|
|
1024
|
+
if not any(_under(p, q) for q in out):
|
|
1025
|
+
out.append(p)
|
|
1026
|
+
return sorted(out)
|
|
1027
|
+
|
|
1028
|
+
|
|
1029
|
+
def collect(sources, *, history: bool = False, online: bool = False, home: Optional[Path] = None,
|
|
1030
|
+
config_path: Optional[Path] = None, find_config: bool = True, quiet: bool = False) -> dict:
|
|
1031
|
+
"""Read the sources and join them. Returns the --json body (sources, hosts, aliases, roots,
|
|
1032
|
+
accounts, rules, config, toml, warnings) plus "findings" (the -v lines) and "history_hint".
|
|
1033
|
+
history/online gate their sources whatever `sources` says. quiet: the online announcement goes
|
|
1034
|
+
to stderr (for --json)."""
|
|
1035
|
+
home = Path(home) if home else Path.home()
|
|
1036
|
+
sources = [s for s in SOURCES if s in sources
|
|
1037
|
+
and (s != "history" or history) and (s not in ONLINE_SOURCES or online)]
|
|
1038
|
+
ctx = _Ctx(home)
|
|
1039
|
+
cfg_info, cfg = _load_config(ctx, config_path, find_config, home)
|
|
1040
|
+
srcs: Dict[str, Optional[dict]] = {s: None for s in SOURCES}
|
|
1041
|
+
|
|
1042
|
+
ssh_entries: List[dict] = []
|
|
1043
|
+
if "ssh" in sources:
|
|
1044
|
+
srcs["ssh"], ssh_entries = _ssh(home, ctx)
|
|
1045
|
+
ctx.ssh_map = {e["alias"].lower(): e for e in ssh_entries}
|
|
1046
|
+
rules: List[dict] = []
|
|
1047
|
+
if "includeif" in sources:
|
|
1048
|
+
rules = _includeif(home, ctx)
|
|
1049
|
+
srcs["includeif"] = {"rules": len(rules)}
|
|
1050
|
+
for r in rules:
|
|
1051
|
+
if r["root"]:
|
|
1052
|
+
ctx.add_root(r["root"])
|
|
1053
|
+
if "history" in sources:
|
|
1054
|
+
srcs["history"] = _history(home, ctx)
|
|
1055
|
+
editor: List[Tuple[str, Path]] = []
|
|
1056
|
+
if "vscode" in sources:
|
|
1057
|
+
srcs["vscode"], paths = _vscode(home, ctx)
|
|
1058
|
+
editor += [("vscode", p) for p in paths]
|
|
1059
|
+
if "jetbrains" in sources:
|
|
1060
|
+
srcs["jetbrains"], paths = _jetbrains(home, ctx)
|
|
1061
|
+
editor += [("jetbrains", p) for p in paths]
|
|
1062
|
+
for src, p in editor:
|
|
1063
|
+
try:
|
|
1064
|
+
is_repo = (p / ".git").exists()
|
|
1065
|
+
except OSError:
|
|
1066
|
+
is_repo = False
|
|
1067
|
+
if is_repo:
|
|
1068
|
+
ctx.add_repo(src, p)
|
|
1069
|
+
ctx.add_root(p.parent)
|
|
1070
|
+
|
|
1071
|
+
cfg_roots = [_expand(r, home) for r in (cfg_info["scan_roots"] or [])]
|
|
1072
|
+
if "repos" in sources:
|
|
1073
|
+
from . import scan
|
|
1074
|
+
walk = [r for r in _collapse(ctx.roots + cfg_roots) if os.path.isdir(r)]
|
|
1075
|
+
exclude = list(cfg_info["exclude"])
|
|
1076
|
+
if not walk and cfg_info["scan_roots"] is None and home.is_dir():
|
|
1077
|
+
# nothing else says where repos live: look under ~ (hidden dirs and node_modules are
|
|
1078
|
+
# skipped by scan.find; these caches too)
|
|
1079
|
+
walk = [str(home)]
|
|
1080
|
+
exclude += HOME_SKIP
|
|
1081
|
+
ctx.note(f"repos: no roots found; looked under ~ (depth {MAX_DEPTH})")
|
|
1082
|
+
found = scan.find([Path(r) for r in walk], exclude=exclude, max_depth=MAX_DEPTH)
|
|
1083
|
+
srcs["repos"] = {"roots": walk, "count": len(found)}
|
|
1084
|
+
deadline = time.monotonic() + BUDGET
|
|
1085
|
+
for n, p in enumerate(found):
|
|
1086
|
+
if time.monotonic() > deadline:
|
|
1087
|
+
ctx.warn(f"repos: stopped after {BUDGET:g} s ({n} of {len(found)} repos looked at)")
|
|
1088
|
+
break
|
|
1089
|
+
ctx.add_repo("repos", p)
|
|
1090
|
+
|
|
1091
|
+
accounts: List[dict] = []
|
|
1092
|
+
if online and any(s in sources for s in ONLINE_SOURCES):
|
|
1093
|
+
accounts, info = _online(sources, ctx, quiet)
|
|
1094
|
+
srcs.update(info)
|
|
1095
|
+
|
|
1096
|
+
d = _join(ctx, rules, accounts, ssh_entries, cfg, cfg_info, cfg_roots)
|
|
1097
|
+
d["sources"] = srcs
|
|
1098
|
+
if srcs["ssh"] is not None:
|
|
1099
|
+
srcs["ssh"]["aliases"] = len(d["aliases"])
|
|
1100
|
+
d["history_hint"] = None if "history" in sources else [_tilde(p, home) for p, _ in _history_files(home)]
|
|
1101
|
+
d["home"] = str(home)
|
|
1102
|
+
d["toml"] = render_toml(d)
|
|
1103
|
+
return d
|
|
1104
|
+
|
|
1105
|
+
|
|
1106
|
+
def _join(ctx: _Ctx, rules, accounts, ssh_entries, cfg, cfg_info, cfg_roots) -> dict:
|
|
1107
|
+
for r in rules:
|
|
1108
|
+
if r["kind"] == "hasconfig" and r["host"] and r["owner"]:
|
|
1109
|
+
rec = ctx.rec(r["host"], r["owner"])
|
|
1110
|
+
rec["rule"] = rec["rule"] or r
|
|
1111
|
+
for rec in ctx.owners.values():
|
|
1112
|
+
if rec["rule"] is None:
|
|
1113
|
+
rec["rule"] = next((r for r in rules if r["kind"] == "gitdir" and r["root"]
|
|
1114
|
+
and any(_under(p, r["root"]) for p in rec["paths"])), None)
|
|
1115
|
+
seen = {h for h, _ in ctx.owners} | {a["host"] for a in accounts}
|
|
1116
|
+
def shown(key, rec) -> str:
|
|
1117
|
+
return rec["spellings"].most_common(1)[0][0] if rec["spellings"] else key[1]
|
|
1118
|
+
|
|
1119
|
+
aliases = []
|
|
1120
|
+
for e in sorted(ssh_entries, key=lambda e: e["alias"]):
|
|
1121
|
+
hn = e["hostname"].lower()
|
|
1122
|
+
if not e["same_forge"] and (_is_forge(hn) or hn in seen):
|
|
1123
|
+
aliases.append({"alias": e["alias"], "host": hn, "user": e["user"], "port": e["port"], "key": e["key"],
|
|
1124
|
+
"file": e["file"], "pub_email": e["pub_email"],
|
|
1125
|
+
"used_by": sorted(shown(k, rec) for k, rec in ctx.owners.items()
|
|
1126
|
+
if rec["aliases"].get(e["alias"]))})
|
|
1127
|
+
ctx.note(f"ssh {e['alias']} → {hn}" + (f" key {e['key']}" if e["key"] else "")
|
|
1128
|
+
+ f" ({_tilde(e['file'], ctx.home)})")
|
|
1129
|
+
|
|
1130
|
+
by_host: Dict[str, List[dict]] = {}
|
|
1131
|
+
for k, rec in ctx.owners.items():
|
|
1132
|
+
h, o = k[0], shown(k, rec)
|
|
1133
|
+
cid = _config_id(cfg, h, o)
|
|
1134
|
+
r = rec["rule"]
|
|
1135
|
+
by_host.setdefault(h, []).append({
|
|
1136
|
+
"owner": o, "repos": len(rec["repos"]), "emails": dict(rec["emails"].most_common()),
|
|
1137
|
+
"names": dict(rec["names"].most_common()), "in_config": cid is not None, "config_id": cid,
|
|
1138
|
+
"aliases": dict(rec["aliases"]), "sources": sorted(rec["sources"]),
|
|
1139
|
+
"rule": {"condition": r["condition"], "name": r["name"], "email": r["email"]} if r else None})
|
|
1140
|
+
hosts = [{"host": h, "owners": sorted(os_, key=lambda o: (-o["repos"], o["owner"]))} for h, os_ in by_host.items()]
|
|
1141
|
+
hosts.sort(key=lambda h: (-sum(o["repos"] for o in h["owners"]), h["host"]))
|
|
1142
|
+
|
|
1143
|
+
real = list(ctx.repo_paths.values())
|
|
1144
|
+
roots = [{"path": r, "repos": sum(1 for p in real if _under(p, r)), "exists": True}
|
|
1145
|
+
for r in _collapse(ctx.roots + cfg_roots) if os.path.isdir(r)]
|
|
1146
|
+
accs = [{k: v for k, v in a.items() if not k.startswith("_")} for a in accounts]
|
|
1147
|
+
return {"hosts": hosts, "aliases": aliases, "roots": roots, "accounts": accs, "rules": rules,
|
|
1148
|
+
"config": cfg_info, "warnings": ctx.warnings, "findings": ctx.findings}
|
|
1149
|
+
|
|
1150
|
+
|
|
1151
|
+
# ---------------------------------------------------------------- TOML
|
|
1152
|
+
|
|
1153
|
+
def _s(v: str) -> str:
|
|
1154
|
+
"""v as a TOML basic string. Beyond JSON's escapes, DEL, the C1 controls and U+2028/U+2029 are
|
|
1155
|
+
escaped too, so no editor or splitlines() sees a line break inside the value."""
|
|
1156
|
+
return _TOML_ESCAPE.sub(lambda m: f"\\u{ord(m[0]):04x}", json.dumps(v, ensure_ascii=False))
|
|
1157
|
+
|
|
1158
|
+
|
|
1159
|
+
def _c(text: str) -> str:
|
|
1160
|
+
"""text for a comment or the terminal: control characters (and Unicode line breaks) as ?."""
|
|
1161
|
+
return _SHOW_CONTROL.sub("?", str(text))
|
|
1162
|
+
|
|
1163
|
+
|
|
1164
|
+
def _new_id(owner: str, host: str, taken: set) -> str:
|
|
1165
|
+
"""scan.derive_id, but always a valid id (never "-")."""
|
|
1166
|
+
from .scan import derive_id
|
|
1167
|
+
base = re.sub(r"^[^A-Za-z0-9]+", "", re.sub(r"[^A-Za-z0-9._-]", "-", owner)) or host.split(".")[0] or "id"
|
|
1168
|
+
base = base if ID_RE.match(base) else "id"
|
|
1169
|
+
return derive_id(base, host, taken)
|
|
1170
|
+
|
|
1171
|
+
|
|
1172
|
+
def _line(s: str, comment: Optional[str] = None) -> str:
|
|
1173
|
+
return s if not comment else f"{s.ljust(32)} # {_c(comment)}"
|
|
1174
|
+
|
|
1175
|
+
|
|
1176
|
+
def _top(counts, ok) -> Tuple[Optional[str], int]:
|
|
1177
|
+
best = [(n, v) for v, n in (counts or {}).items() if ok(v)]
|
|
1178
|
+
best.sort(key=lambda x: -x[0])
|
|
1179
|
+
return (best[0][1], best[0][0]) if best else (None, 0)
|
|
1180
|
+
|
|
1181
|
+
|
|
1182
|
+
def _template() -> List[str]:
|
|
1183
|
+
return ["[[identity]]",
|
|
1184
|
+
_line('id = "work"', "fill in: a short name for this identity"),
|
|
1185
|
+
_line('name = ""', "fill in"),
|
|
1186
|
+
_line('email = ""', "fill in"),
|
|
1187
|
+
_line('host = ""', "fill in, e.g. github.com"),
|
|
1188
|
+
_line("owners = []", "fill in: the users, orgs or groups of this identity"),
|
|
1189
|
+
""]
|
|
1190
|
+
|
|
1191
|
+
|
|
1192
|
+
def render_toml(d: dict) -> str:
|
|
1193
|
+
"""The snippet to paste: [scan] roots (or a comment when the config already sets them), one
|
|
1194
|
+
[[identity]] per (host, email), notes as comments. Every value is a TOML string escaped with
|
|
1195
|
+
JSON rules; values gitpro's config check would reject are left blank with a comment."""
|
|
1196
|
+
cfgi = d.get("config") or {}
|
|
1197
|
+
taken = set(cfgi.get("ids") or ())
|
|
1198
|
+
taken_aliases = set(cfgi.get("aliases") or ())
|
|
1199
|
+
cfg_emails = set(cfgi.get("emails") or ())
|
|
1200
|
+
home = Path(d["home"]) if d.get("home") else Path.home()
|
|
1201
|
+
out: List[str] = []
|
|
1202
|
+
notes: List[str] = []
|
|
1203
|
+
|
|
1204
|
+
roots = []
|
|
1205
|
+
for r in d.get("roots") or []:
|
|
1206
|
+
shown = _tilde(r["path"], home)
|
|
1207
|
+
if config.scan_root_error(shown) is None:
|
|
1208
|
+
roots.append(shown)
|
|
1209
|
+
scan_roots = cfgi.get("scan_roots")
|
|
1210
|
+
if scan_roots is None:
|
|
1211
|
+
out += ["[scan]", "roots = [" + ", ".join(_s(r) for r in roots or ["~"]) + "]", ""]
|
|
1212
|
+
else:
|
|
1213
|
+
have = [_expand(r, home) for r in scan_roots]
|
|
1214
|
+
new = [r for r in roots if not any(_under(_expand(r, home), h) for h in have)]
|
|
1215
|
+
if new:
|
|
1216
|
+
out += ["# add to [scan] roots: " + _c(", ".join(_s(r) for r in new)), ""]
|
|
1217
|
+
|
|
1218
|
+
blocks: List[dict] = []
|
|
1219
|
+
index: Dict[Tuple[str, str], dict] = {}
|
|
1220
|
+
for h in d.get("hosts") or []:
|
|
1221
|
+
host = h["host"]
|
|
1222
|
+
if not _usable_host(host):
|
|
1223
|
+
notes.append(f"{host}: not a host name gitpro accepts (or an example placeholder); skipped")
|
|
1224
|
+
continue
|
|
1225
|
+
for o in h.get("owners") or []:
|
|
1226
|
+
where = f"{host}/{o['owner']}"
|
|
1227
|
+
if o.get("in_config"):
|
|
1228
|
+
notes.append(f"{where}: already in config" + (f" (identity {o['config_id']})" if o.get("config_id") else ""))
|
|
1229
|
+
continue
|
|
1230
|
+
if not OWNER_RE.match(o["owner"]):
|
|
1231
|
+
notes.append(f"{where}: owners can hold only A-Z a-z 0-9 . _ - /; add it by hand if it is yours")
|
|
1232
|
+
continue
|
|
1233
|
+
rule = o.get("rule") or {}
|
|
1234
|
+
email = _top(o.get("emails"), _usable_email)[0]
|
|
1235
|
+
if email is None and _usable_email(rule.get("email")):
|
|
1236
|
+
email = rule["email"]
|
|
1237
|
+
verified = None
|
|
1238
|
+
if email is None: # a verified email of the gh login named like the owner
|
|
1239
|
+
verified = next((a for a in d.get("accounts") or [] if a["host"] == host
|
|
1240
|
+
and a["login"].lower() == o["owner"].lower()
|
|
1241
|
+
and any(_usable_email(e) for e in a.get("emails") or ())), None)
|
|
1242
|
+
if verified is not None:
|
|
1243
|
+
email = next(e for e in verified["emails"] if _usable_email(e))
|
|
1244
|
+
key = (host, email or "")
|
|
1245
|
+
if key not in index:
|
|
1246
|
+
index[key] = {"host": host, "email": email, "owners": [],
|
|
1247
|
+
"verified": f"{verified['cli']} login {verified['login']}" if verified else None}
|
|
1248
|
+
blocks.append(index[key])
|
|
1249
|
+
index[key]["owners"].append(o)
|
|
1250
|
+
|
|
1251
|
+
aliases = [a for a in d.get("aliases") or [] if a["alias"] not in taken_aliases]
|
|
1252
|
+
assigned: set = set()
|
|
1253
|
+
accounts = d.get("accounts") or []
|
|
1254
|
+
used_accounts: set = set()
|
|
1255
|
+
body: List[str] = []
|
|
1256
|
+
for b in blocks:
|
|
1257
|
+
owners = b["owners"]
|
|
1258
|
+
names: Counter = Counter()
|
|
1259
|
+
for o in owners:
|
|
1260
|
+
for n, c in (o.get("names") or {}).items():
|
|
1261
|
+
if _usable_name(n):
|
|
1262
|
+
names[n] += c
|
|
1263
|
+
rule = next((o["rule"] for o in owners if o.get("rule")), None) or {}
|
|
1264
|
+
ident = _new_id(owners[0]["owner"], b["host"], taken)
|
|
1265
|
+
taken.add(ident)
|
|
1266
|
+
lines = ["[[identity]]", f"id = {_s(ident)}"]
|
|
1267
|
+
if names:
|
|
1268
|
+
name, n = names.most_common(1)[0]
|
|
1269
|
+
others = [x for x in names if x != name][:2]
|
|
1270
|
+
lines.append(_line(f"name = {_s(name)}", f"seen in {_plural(n, 'repo')}"
|
|
1271
|
+
+ (f"; also seen: {', '.join(others)}" if others else "")))
|
|
1272
|
+
elif _usable_name(rule.get("name")):
|
|
1273
|
+
lines.append(_line(f"name = {_s(rule['name'])}", f"from includeIf {rule['condition']}"))
|
|
1274
|
+
else:
|
|
1275
|
+
lines.append(_line('name = ""', "not seen; fill in"))
|
|
1276
|
+
if b["email"]:
|
|
1277
|
+
n = sum((o.get("emails") or {}).get(b["email"], 0) for o in owners)
|
|
1278
|
+
lines.append(_line(f"email = {_s(b['email'])}", f"seen in {_plural(n, 'repo')}" if n
|
|
1279
|
+
else f"verified on the {b['verified']}" if b.get("verified")
|
|
1280
|
+
else f"from includeIf {rule.get('condition', '')}"))
|
|
1281
|
+
else:
|
|
1282
|
+
lines.append(_line('email = ""', "fill in"))
|
|
1283
|
+
lines.append(f"host = {_s(b['host'])}")
|
|
1284
|
+
check = [o for o in owners if o.get("repos", 0) <= 1]
|
|
1285
|
+
lines.append(_line("owners = [" + ", ".join(_s(o["owner"]) for o in owners) + "]",
|
|
1286
|
+
", ".join(f"{o['owner']}: {_plural(o.get('repos', 0), 'repo')}" for o in check)
|
|
1287
|
+
+ ", check" if check else None))
|
|
1288
|
+
|
|
1289
|
+
mine = {o["owner"] for o in owners}
|
|
1290
|
+
cands = [a for a in aliases if a["host"] == b["host"] and a["alias"] not in assigned]
|
|
1291
|
+
scored = sorted(((len(mine & set(a.get("used_by") or ())), a) for a in cands), key=lambda x: -x[0])
|
|
1292
|
+
pick = scored[0][1] if scored and scored[0][0] > 0 else None
|
|
1293
|
+
if pick is None and b["email"]:
|
|
1294
|
+
pick = next((a for a in cands if (a.get("pub_email") or "").lower() == b["email"].lower()), None)
|
|
1295
|
+
if pick is not None:
|
|
1296
|
+
assigned.add(pick["alias"])
|
|
1297
|
+
lines += _alias_lines(pick, b["host"], home)
|
|
1298
|
+
|
|
1299
|
+
for cli in ("gh", "glab"):
|
|
1300
|
+
accs = [a for a in accounts if a["host"] == b["host"] and a["cli"] == cli
|
|
1301
|
+
and (cli, a["host"], a["login"]) not in used_accounts and ID_RE.match(a["login"])]
|
|
1302
|
+
acc = next((a for a in accs if b["email"] and b["email"].lower() in {e.lower() for e in a["emails"]}), None)
|
|
1303
|
+
why = "has this email verified"
|
|
1304
|
+
if acc is None:
|
|
1305
|
+
acc = next((a for a in accs if a["login"].lower() in {m.lower() for m in mine}), None)
|
|
1306
|
+
why = "login matches an owner"
|
|
1307
|
+
if acc is not None:
|
|
1308
|
+
used_accounts.add((cli, acc["host"], acc["login"]))
|
|
1309
|
+
lines.append(_line(f"{cli}_account = {_s(acc['login'])}", f"{cli} login; {why}"))
|
|
1310
|
+
body += lines + [""]
|
|
1311
|
+
if b["email"]:
|
|
1312
|
+
cfg_emails.add(b["email"].lower())
|
|
1313
|
+
|
|
1314
|
+
for r in d.get("rules") or []:
|
|
1315
|
+
if r.get("kind") != "gitdir" or not _usable_email(r.get("email")) or r["email"].lower() in cfg_emails:
|
|
1316
|
+
continue
|
|
1317
|
+
cfg_emails.add(r["email"].lower())
|
|
1318
|
+
base = Path(r["root"]).name if r.get("root") else "includeif"
|
|
1319
|
+
ident = _new_id(base or "includeif", "includeif", taken)
|
|
1320
|
+
taken.add(ident)
|
|
1321
|
+
body += [f"# from includeIf {_c(r['condition'])}" + (f" in {_c(_tilde(r['file'], home))}" if r.get("file") else "")
|
|
1322
|
+
+ ": no repo seen, fill in host and owners",
|
|
1323
|
+
"[[identity]]", f"id = {_s(ident)}",
|
|
1324
|
+
_line(f"name = {_s(r['name'])}", None) if _usable_name(r.get("name")) else _line('name = ""', "fill in"),
|
|
1325
|
+
f"email = {_s(r['email'])}",
|
|
1326
|
+
_line('host = ""', "fill in, e.g. github.com"),
|
|
1327
|
+
_line("owners = []", "fill in: the users, orgs or groups"), ""]
|
|
1328
|
+
|
|
1329
|
+
for a in aliases:
|
|
1330
|
+
if a["alias"] not in assigned:
|
|
1331
|
+
notes.append(f"ssh alias {a['alias']} → {a['host']}" + (f" (key {a['key']})" if a.get("key") else "")
|
|
1332
|
+
+ ": no repo seen using it; add ssh_alias and ssh_key to the identity it belongs to")
|
|
1333
|
+
out += [f"# {_c(n)}" for n in notes] + ([""] if notes else [])
|
|
1334
|
+
out += body or _template()
|
|
1335
|
+
return "\n".join(out).rstrip("\n") + "\n"
|
|
1336
|
+
|
|
1337
|
+
|
|
1338
|
+
def _alias_lines(a: dict, host: str, home: Optional[Path] = None) -> List[str]:
|
|
1339
|
+
key = a.get("key")
|
|
1340
|
+
alias_ok = bool(HOST_RE.match(a["alias"])) and a["alias"] != host
|
|
1341
|
+
key_ok = isinstance(key, str) and bool(PATH_RE.match(key)) and ".." not in key.split("/")
|
|
1342
|
+
src = f"from {_tilde(a['file'], home)}" if a.get("file") else None
|
|
1343
|
+
if not (alias_ok and key_ok):
|
|
1344
|
+
why = "needs a key path gitpro accepts (~/... or /..., no spaces)" if alias_ok else "not a name gitpro accepts"
|
|
1345
|
+
lines = [_line(f"# ssh_alias = {_s(a['alias'])}", why)]
|
|
1346
|
+
if key:
|
|
1347
|
+
lines.append(f"# ssh_key = {_s(key)}")
|
|
1348
|
+
return lines
|
|
1349
|
+
lines = [_line(f"ssh_alias = {_s(a['alias'])}", src), f"ssh_key = {_s(key)}"]
|
|
1350
|
+
if a.get("user") and a["user"] != "git" and ID_RE.match(a["user"]):
|
|
1351
|
+
lines.append(f"ssh_user = {_s(a['user'])}")
|
|
1352
|
+
if a.get("port") and a["port"] != 22:
|
|
1353
|
+
lines.append(f"ssh_port = {int(a['port'])}")
|
|
1354
|
+
return lines
|
|
1355
|
+
|
|
1356
|
+
|
|
1357
|
+
# ---------------------------------------------------------------- text output
|
|
1358
|
+
|
|
1359
|
+
def _summary(d: dict) -> List[str]:
|
|
1360
|
+
s = d["sources"]
|
|
1361
|
+
home = Path(d["home"]) if d.get("home") else None
|
|
1362
|
+
parts = []
|
|
1363
|
+
if s.get("vscode") is not None:
|
|
1364
|
+
v = s["vscode"]
|
|
1365
|
+
parts.append(f"vscode ({_plural(v['entries'], 'recent')}"
|
|
1366
|
+
+ (f", {_plural(v['remote_entries'], 'remote entry', 'remote entries')} skipped"
|
|
1367
|
+
if v["remote_entries"] else "") + ")")
|
|
1368
|
+
if s.get("jetbrains") is not None:
|
|
1369
|
+
parts.append(f"jetbrains ({_plural(s['jetbrains']['entries'], 'recent')})")
|
|
1370
|
+
if s.get("ssh") is not None:
|
|
1371
|
+
parts.append(f"ssh ({_plural(s['ssh']['aliases'], 'alias', 'aliases')})")
|
|
1372
|
+
if s.get("includeif") is not None:
|
|
1373
|
+
parts.append(f"includeif ({_plural(s['includeif']['rules'], 'rule')})")
|
|
1374
|
+
if s.get("repos") is not None:
|
|
1375
|
+
parts.append(f"repos ({s['repos']['count']})")
|
|
1376
|
+
for cli in ("gh", "glab"):
|
|
1377
|
+
if s.get(cli) is not None:
|
|
1378
|
+
parts.append(f"{cli} ({_plural(s[cli]['logins'], 'login')})")
|
|
1379
|
+
lines = ["discovered from: " + ", ".join(parts)] if parts else []
|
|
1380
|
+
h = s.get("history")
|
|
1381
|
+
if h is not None:
|
|
1382
|
+
if not h["files"]:
|
|
1383
|
+
lines.append("history: no shell history file found")
|
|
1384
|
+
else:
|
|
1385
|
+
lines.append(f"history: {_plural(h['lines'], 'clone line')} in {', '.join(_tilde(f, home) for f in h['files'])}"
|
|
1386
|
+
+ (f" ({h['secrets_skipped']} skipped: looked like a secret)" if h["secrets_skipped"] else ""))
|
|
1387
|
+
if h.get("truncated"):
|
|
1388
|
+
lines.append(f"history: read only the newest {HISTORY_CAP} lines of "
|
|
1389
|
+
+ ", ".join(_tilde(f, home) for f in h["truncated"]))
|
|
1390
|
+
elif d.get("history_hint") is not None:
|
|
1391
|
+
what = ", ".join(d["history_hint"]) or "your shell history"
|
|
1392
|
+
lines.append(f"history: not read (add --history to read {what} for git clone lines)")
|
|
1393
|
+
return lines
|
|
1394
|
+
|
|
1395
|
+
|
|
1396
|
+
def _nothing(d: dict) -> bool:
|
|
1397
|
+
return not (d["hosts"] or d["aliases"] or d["rules"] or d["accounts"] or d["roots"])
|
|
1398
|
+
|
|
1399
|
+
|
|
1400
|
+
def print_text(d: dict, verbose: bool, final: str, summary: bool = True) -> None:
|
|
1401
|
+
home = Path(d["home"]) if d.get("home") else None
|
|
1402
|
+
if summary:
|
|
1403
|
+
for ln in _summary(d):
|
|
1404
|
+
print(ln)
|
|
1405
|
+
if verbose and d["findings"]:
|
|
1406
|
+
print("\nfindings:")
|
|
1407
|
+
for ln in d["findings"]:
|
|
1408
|
+
print(f" {ln}")
|
|
1409
|
+
print()
|
|
1410
|
+
if _nothing(d):
|
|
1411
|
+
print("nothing discovered; roots: ~")
|
|
1412
|
+
else:
|
|
1413
|
+
if d["hosts"]:
|
|
1414
|
+
print("hosts and owners seen:")
|
|
1415
|
+
w = max(len(h["host"]) for h in d["hosts"])
|
|
1416
|
+
for h in d["hosts"]:
|
|
1417
|
+
items = []
|
|
1418
|
+
for i, o in enumerate(h["owners"]):
|
|
1419
|
+
n = f"{_plural(o['repos'], 'repo')}" if i == 0 else str(o["repos"])
|
|
1420
|
+
items.append(f"{o['owner']} ({n}{', in config' if o['in_config'] else ''})")
|
|
1421
|
+
print(f" {h['host']:<{w}} " + " ".join(items))
|
|
1422
|
+
if d["aliases"]:
|
|
1423
|
+
print("ssh aliases:")
|
|
1424
|
+
for a in d["aliases"]:
|
|
1425
|
+
extra = "".join(f" {k} {_c(a[k])}" for k in ("user", "port") if a.get(k))
|
|
1426
|
+
print(f" {_c(a['alias'])} → {a['host']}" + (f" key {_c(a['key'])}" if a.get("key") else "") + extra)
|
|
1427
|
+
if d["roots"]:
|
|
1428
|
+
print("roots:")
|
|
1429
|
+
print(" " + " ".join(f"{_c(_tilde(r['path'], home))} ({_plural(r['repos'], 'repo') if i == 0 else r['repos']})"
|
|
1430
|
+
for i, r in enumerate(d["roots"])))
|
|
1431
|
+
emails: Dict[str, List[str]] = {}
|
|
1432
|
+
for h in d["hosts"]:
|
|
1433
|
+
for o in h["owners"]:
|
|
1434
|
+
for e, n in o["emails"].items():
|
|
1435
|
+
emails.setdefault(e, []).append(f"{h['host']}/{o['owner']} ×{n}")
|
|
1436
|
+
if emails:
|
|
1437
|
+
print("emails seen in repos:")
|
|
1438
|
+
for e, where in emails.items():
|
|
1439
|
+
print(f" {_c(e)} ({', '.join(where)})")
|
|
1440
|
+
if d["accounts"]:
|
|
1441
|
+
print("accounts:")
|
|
1442
|
+
for a in d["accounts"]:
|
|
1443
|
+
print(f" {a['host']} {a['cli']} {a['login']}"
|
|
1444
|
+
+ (f" verified: {', '.join(a['emails'])}" if a["emails"] else ""))
|
|
1445
|
+
cfg_path = d["config"].get("path")
|
|
1446
|
+
print()
|
|
1447
|
+
print(f"# ---- paste into {_tilde(cfg_path, home) if cfg_path else '~/' + config.USER_CONFIG} and fill in the blanks ----")
|
|
1448
|
+
print(d["toml"].rstrip("\n"))
|
|
1449
|
+
print()
|
|
1450
|
+
print(final)
|
|
1451
|
+
|
|
1452
|
+
|
|
1453
|
+
# ---------------------------------------------------------------- commands
|
|
1454
|
+
|
|
1455
|
+
def choose_sources(given, history: bool, online: bool) -> Tuple[List[str], Optional[str]]:
|
|
1456
|
+
"""(sources, error message or None) from --source values (each may be comma-separated)."""
|
|
1457
|
+
if not given:
|
|
1458
|
+
out = list(DEFAULT_SOURCES)
|
|
1459
|
+
if history:
|
|
1460
|
+
out.insert(0, "history")
|
|
1461
|
+
if online:
|
|
1462
|
+
out += list(ONLINE_SOURCES)
|
|
1463
|
+
return out, None
|
|
1464
|
+
out = []
|
|
1465
|
+
for value in given:
|
|
1466
|
+
for s in (x.strip() for x in value.split(",")):
|
|
1467
|
+
if not s:
|
|
1468
|
+
continue
|
|
1469
|
+
if s not in SOURCES:
|
|
1470
|
+
return [], f"unknown source {s!r} ({', '.join(SOURCES)})"
|
|
1471
|
+
if s == "history" and not history:
|
|
1472
|
+
return [], "history is read only with --history"
|
|
1473
|
+
if s in ONLINE_SOURCES and not online:
|
|
1474
|
+
return [], f"{s} is asked only with --online"
|
|
1475
|
+
if s not in out:
|
|
1476
|
+
out.append(s)
|
|
1477
|
+
return out, None
|
|
1478
|
+
|
|
1479
|
+
|
|
1480
|
+
def _no_cli(sources) -> bool:
|
|
1481
|
+
from shutil import which
|
|
1482
|
+
return any(s in sources for s in ONLINE_SOURCES) and not (which("gh") or which("glab"))
|
|
1483
|
+
|
|
1484
|
+
|
|
1485
|
+
NO_CLI = "--online needs gh or glab on PATH, and neither was found; install one or run without --online"
|
|
1486
|
+
|
|
1487
|
+
|
|
1488
|
+
def cmd_discover(args) -> int:
|
|
1489
|
+
from .args import fail
|
|
1490
|
+
sources, err = choose_sources(args.source, args.history, args.online)
|
|
1491
|
+
if err:
|
|
1492
|
+
return fail(err, args)
|
|
1493
|
+
if args.online and _no_cli(sources):
|
|
1494
|
+
return fail(f"discover {NO_CLI}", args)
|
|
1495
|
+
d = collect(sources, history=args.history, online=args.online, home=args.home, config_path=args.config,
|
|
1496
|
+
quiet=args.json)
|
|
1497
|
+
if args.source:
|
|
1498
|
+
d["history_hint"] = None
|
|
1499
|
+
if args.json:
|
|
1500
|
+
jsonout.emit("discover", {k: d[k] for k in JSON_KEYS})
|
|
1501
|
+
else:
|
|
1502
|
+
print_text(d, args.verbose, f"→ then: {PROG} plan")
|
|
1503
|
+
return 0
|
|
1504
|
+
|
|
1505
|
+
|
|
1506
|
+
def register(sub, common, confirm) -> None:
|
|
1507
|
+
from .args import capability
|
|
1508
|
+
p = sub.add_parser("discover", parents=[common],
|
|
1509
|
+
help="suggest identities and scan roots from what is on this machine (prints TOML, writes nothing)")
|
|
1510
|
+
p.add_argument("--source", action="append", metavar="S[,S...]",
|
|
1511
|
+
help=f"only these sources ({', '.join(SOURCES)})")
|
|
1512
|
+
p.add_argument("--history", action="store_true", help="also read git clone lines from your shell history")
|
|
1513
|
+
p.add_argument("--online", action="store_true", help="also ask gh/glab for your logins and verified emails")
|
|
1514
|
+
p.add_argument("--json", action="store_true", help="machine-readable output")
|
|
1515
|
+
p.add_argument("-v", "--verbose", action="store_true", help="list each finding with its source")
|
|
1516
|
+
p.set_defaults(func=cmd_discover)
|
|
1517
|
+
capability("discover")
|
|
1518
|
+
|
|
1519
|
+
|
|
1520
|
+
def extend(subs) -> None:
|
|
1521
|
+
from .cli import capability, extend_command
|
|
1522
|
+
extend_command(subs, "init", _init_flags, _init_handler)
|
|
1523
|
+
capability("init.discover")
|
|
1524
|
+
|
|
1525
|
+
|
|
1526
|
+
def _init_flags(p) -> None:
|
|
1527
|
+
p.add_argument("--discover", action="store_true",
|
|
1528
|
+
help="append identities discovered on this machine, commented, to the new config")
|
|
1529
|
+
p.add_argument("--history", action="store_true", help="with --discover: also read shell history")
|
|
1530
|
+
p.add_argument("--online", action="store_true", help="with --discover: also ask gh/glab for your logins")
|
|
1531
|
+
|
|
1532
|
+
|
|
1533
|
+
def _commented(d: dict) -> str:
|
|
1534
|
+
lines = ["", f"# ---- discovered on {date.today().isoformat()} by {PROG} init --discover; uncomment what is right ----"]
|
|
1535
|
+
lines += [f"# {ln}" if ln.strip() else "" for ln in d["toml"].rstrip("\n").split("\n")]
|
|
1536
|
+
return "\n".join(lines) + "\n"
|
|
1537
|
+
|
|
1538
|
+
|
|
1539
|
+
def _init_handler(args) -> Optional[int]:
|
|
1540
|
+
from .args import fail
|
|
1541
|
+
if not args.discover:
|
|
1542
|
+
if args.history or args.online:
|
|
1543
|
+
return fail("--history and --online go with --discover", args)
|
|
1544
|
+
return None
|
|
1545
|
+
from importlib import resources
|
|
1546
|
+
|
|
1547
|
+
from . import cli
|
|
1548
|
+
sources, _ = choose_sources(None, args.history, args.online)
|
|
1549
|
+
if args.online and _no_cli(sources):
|
|
1550
|
+
return fail(f"init --discover {NO_CLI}", args)
|
|
1551
|
+
home = Path.home()
|
|
1552
|
+
target = (args.path or home / config.USER_CONFIG).expanduser()
|
|
1553
|
+
if target.exists():
|
|
1554
|
+
rc = cli.cmd_init(args)
|
|
1555
|
+
d = collect(sources, history=args.history, online=args.online, home=home,
|
|
1556
|
+
config_path=target if target.is_file() else None, find_config=target.is_file())
|
|
1557
|
+
print_text(d, False, "→ paste what you need")
|
|
1558
|
+
return rc
|
|
1559
|
+
d = collect(sources, history=args.history, online=args.online, home=home, find_config=False)
|
|
1560
|
+
rc = cli.cmd_init(args)
|
|
1561
|
+
example = resources.files(__package__).joinpath("example.toml").read_text()
|
|
1562
|
+
if cli.preview_only(args):
|
|
1563
|
+
print(_commented(d))
|
|
1564
|
+
return rc
|
|
1565
|
+
try:
|
|
1566
|
+
created = rc == 0 and target.read_text() == example
|
|
1567
|
+
except OSError:
|
|
1568
|
+
created = False
|
|
1569
|
+
if not created: # someone else wrote the file meanwhile: never append to it
|
|
1570
|
+
print_text(d, False, "→ paste what you need")
|
|
1571
|
+
return rc
|
|
1572
|
+
with open(target, "a", encoding="utf-8") as f:
|
|
1573
|
+
f.write(_commented(d))
|
|
1574
|
+
for ln in _summary(d):
|
|
1575
|
+
print(ln)
|
|
1576
|
+
n = d["toml"].count("[[identity]]")
|
|
1577
|
+
print(f"✓ appended the discovered section ({_plural(n, 'identity block')}, commented) to {target}; "
|
|
1578
|
+
"uncomment what is right")
|
|
1579
|
+
return rc
|