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