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/doctor.py ADDED
@@ -0,0 +1,572 @@
1
+ """`gitpro doctor`: run every check gitpro has and list what to fix, worst first in the summary line.
2
+
3
+ SECTIONS is a list of (name, fn, needs): fn(ctx) returns a section dict, needs names an optional module
4
+ (None for built-ins). An optional module may define doctor_section(ctx) -> dict to own its row; otherwise
5
+ the adapter here calls its status function. A section that raises becomes `⚠ <name>: check failed (...)`;
6
+ a missing optional module `· <name>: not installed (optional)`. Reports only: never fixes, never prints
7
+ secret values. Module level imports only the standard library (cli builds the parser without plan/view).
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import importlib
13
+ import os
14
+ import platform
15
+ import re
16
+ import shutil
17
+ import subprocess
18
+ import sys
19
+ import traceback
20
+ from dataclasses import dataclass, field
21
+ from pathlib import Path
22
+ from typing import Any, Callable, Dict, List, Optional, Tuple
23
+
24
+ STATUSES = ("ok", "warning", "error", "skipped", "missing")
25
+ SYMBOL = {"ok": "✓", "warning": "⚠", "error": "✗", "skipped": "·", "missing": "·"}
26
+ GIT_HINT = "install git 2.36+ (Ubuntu: ppa:git-core/ppa, macOS: brew install git)"
27
+ ENV_EXACT = ("GIT_SSH_COMMAND", "GIT_CONFIG_GLOBAL", "GIT_CONFIG_SYSTEM", "GIT_CONFIG_COUNT", "GIT_CONFIG_PARAMETERS",
28
+ "GH_TOKEN", "GH_ENTERPRISE_TOKEN",
29
+ "GITLAB_TOKEN")
30
+ ENV_PREFIXES = ("GIT_AUTHOR_", "GIT_COMMITTER_")
31
+ TIMEOUT = 10
32
+ NAME_W, SUMMARY_W = 9, 55
33
+
34
+
35
+ class Skip(Exception):
36
+ """Raised by a section that cannot run (no config, not in a repo, offline): status "skipped"."""
37
+
38
+
39
+ @dataclass
40
+ class Ctx:
41
+ home: Path
42
+ config_path: Optional[Path] = None # -c
43
+ dir: Optional[Path] = None
44
+ online: bool = False
45
+ verbose: bool = False
46
+ cfg: Any = None
47
+ cfg_error: Optional[str] = None
48
+ versions: Dict[str, str] = field(default_factory=dict)
49
+
50
+ def need_cfg(self):
51
+ if self.cfg is None:
52
+ raise Skip("needs a config")
53
+ return self.cfg
54
+
55
+ def env(self):
56
+ from . import plan
57
+ return plan.Env(self.home)
58
+
59
+ def tilde(self, path: object) -> str:
60
+ p, h = str(path), str(self.home)
61
+ return "~" + p[len(h):] if p == h or p.startswith(h + "/") else p
62
+
63
+
64
+ def section(status: str = "ok", summary: str = "", problems=(), details=(), quiet: bool = False) -> dict:
65
+ """A section result; status "ok" is raised to the worst problem level."""
66
+ probs = [_problem(p) for p in problems]
67
+ if status == "ok" and probs:
68
+ status = "error" if any(p["level"] == "error" for p in probs) else "warning"
69
+ return {"status": status, "summary": summary, "problems": probs, "details": list(details), "quiet": quiet}
70
+
71
+
72
+ def problem(message: str, fix: str = "", level: str = "warning") -> dict:
73
+ return {"message": message, "fix": fix, "level": level}
74
+
75
+
76
+ def _problem(p: object, level: str = "warning") -> dict:
77
+ """Normalise a problem: a dict, a string, or an object with message/fix (resolve.Problem)."""
78
+ if isinstance(p, dict):
79
+ return problem(str(p.get("message", "")), str(p.get("fix") or ""), p.get("level") or level)
80
+ if isinstance(p, str):
81
+ return problem(p, "", level)
82
+ fix = getattr(p, "fix", "") or ""
83
+ if getattr(p, "fix_argv", None):
84
+ from . import resolve
85
+ fix = resolve._shown_fix(p)
86
+ return problem(str(getattr(p, "message", p)), fix, getattr(p, "level", None) or level)
87
+
88
+
89
+ def _run(argv: List[str], **kw) -> Optional[subprocess.CompletedProcess]:
90
+ try:
91
+ return subprocess.run(argv, capture_output=True, text=True, timeout=TIMEOUT, **kw)
92
+ except (OSError, subprocess.SubprocessError):
93
+ return None
94
+
95
+
96
+ def _read(path: Path) -> str:
97
+ try:
98
+ return path.read_text(encoding="utf-8", errors="replace")
99
+ except OSError:
100
+ return ""
101
+
102
+
103
+ # ---------------------------------------------------------------- built-in sections
104
+
105
+ def check_versions(ctx: Ctx) -> dict:
106
+ from . import __version__
107
+ ctx.versions.update(gitpro=__version__, python=platform.python_version(), os=platform.system().lower())
108
+ probs = []
109
+ r = _run(["git", "--version"])
110
+ m = re.search(r"(\d+)\.(\d+)(?:\.\d+)?\S*", r.stdout) if r and r.returncode == 0 else None
111
+ if m is None:
112
+ ctx.versions["git"] = ""
113
+ probs.append(problem("git not found", GIT_HINT, "error"))
114
+ else:
115
+ ctx.versions["git"] = m[0]
116
+ if (int(m[1]), int(m[2])) < (2, 36):
117
+ probs.append(problem(f"git {m[1]}.{m[2]} is too old; includeIf hasconfig needs 2.36+", GIT_HINT, "error"))
118
+ r = _run(["ssh", "-V"])
119
+ ssh = (r.stderr or r.stdout).strip().split(",")[0] if r else ""
120
+ if not ssh:
121
+ probs.append(problem("ssh not found", "install OpenSSH"))
122
+ summary = f"git {ctx.versions['git'] or '?'} · {ssh or 'no ssh'} · python {ctx.versions['python']}"
123
+ return section("ok", summary, probs, quiet=True)
124
+
125
+
126
+ def check_config(ctx: Ctx) -> dict:
127
+ from . import config
128
+ try:
129
+ path, how = config.find(ctx.config_path, ctx.home)
130
+ ctx.cfg = config.load_all(path, how)
131
+ except config.ConfigError as e:
132
+ msg, sep, hint = str(e).partition("; create one with: ")
133
+ ctx.cfg_error = str(e)
134
+ fix = hint.strip() if sep else "fix the config file (gitpro plan shows the error)"
135
+ return section("error", msg, [problem(msg, fix, "error")])
136
+ n = len(ctx.cfg.identities)
137
+ where = f"{ctx.tilde(ctx.cfg.path)} ({ctx.cfg.how})" if ctx.cfg.how else ctx.tilde(ctx.cfg.path)
138
+ return section("ok", f"{where} {n} identit{'y' if n == 1 else 'ies'}")
139
+
140
+
141
+ def check_applied(ctx: Ctx) -> dict:
142
+ from . import files, plan
143
+ changes = files.diff(*plan.build(ctx.need_cfg(), ctx.env()))
144
+ if not changes:
145
+ return section("ok", "up to date")
146
+ n = len(changes)
147
+ details = [f"{c.action} {ctx.tilde(c.path)}" for c in changes]
148
+ return section("ok", f"{n} pending change{'s' if n != 1 else ''}", [problem(
149
+ f"{n} pending change{'s' if n != 1 else ''}", "gitpro apply", "error")], details)
150
+
151
+
152
+ def _block(text: str):
153
+ from . import render
154
+ return render._BLOCK_RE.search(text)
155
+
156
+
157
+ def _meaningful(text: str) -> bool:
158
+ """Any line that is not blank or a comment?"""
159
+ return any(ln.strip() and not ln.lstrip().startswith(("#", ";")) for ln in text.splitlines())
160
+
161
+
162
+ def check_files(ctx: Ctx) -> dict:
163
+ from . import plan, render
164
+ cfg = ctx.need_cfg()
165
+ env = ctx.env()
166
+ probs, parts = [], []
167
+ gc, sc = ctx.home / ".gitconfig", ctx.home / ".ssh/config"
168
+ m = _block(_read(gc))
169
+ if m is None:
170
+ probs.append(problem("~/.gitconfig has no gitpro block", "gitpro apply", "error"))
171
+ elif _meaningful(_read(gc)[m.end():]):
172
+ probs.append(problem("~/.gitconfig: gitpro block not last (settings after it win)", "gitpro apply", "error"))
173
+ else:
174
+ parts.append("~/.gitconfig block last ✓")
175
+ m = _block(_read(sc))
176
+ if m is None:
177
+ probs.append(problem("~/.ssh/config has no gitpro block", "gitpro apply", "error"))
178
+ elif _meaningful(_read(sc)[:m.start()]):
179
+ probs.append(problem("~/.ssh/config: gitpro block not first (Host blocks above it win)", "gitpro apply",
180
+ "error"))
181
+ else:
182
+ parts.append("~/.ssh/config block first ✓")
183
+ generated = [ctx.home / plan.MANAGED_GITCONFIG, ctx.home / ".ssh" / plan.MANAGED_SSH]
184
+ generated += [env.expand(i.file) for i in cfg.identities if i.file]
185
+ for p in generated:
186
+ if not render.is_generated(_read(p)):
187
+ probs.append(problem(f"{ctx.tilde(p)} missing", "gitpro apply", "error"))
188
+ return section("ok", " ".join(parts) if not probs else (probs[0]["message"]), probs,
189
+ [f"generated: {ctx.tilde(p)}" for p in generated])
190
+
191
+
192
+ _STRAY_RE = re.compile(r"also sets these directly")
193
+
194
+
195
+ def check_lint(ctx: Ctx) -> dict:
196
+ from . import lint
197
+ cfg = ctx.need_cfg()
198
+ probs = []
199
+ for msg in lint.warnings(cfg.default, list(cfg.identities), ctx.env()):
200
+ if _STRAY_RE.search(msg):
201
+ for line in msg.splitlines()[1:]:
202
+ key = line.split()[0] if line.split() else ""
203
+ if key and key != "core.sshcommand": # the env section reports core.sshCommand
204
+ probs.append(problem(f"~/.gitconfig also sets {key} directly", "remove it from ~/.gitconfig"))
205
+ continue
206
+ text, _sep, fix = msg.partition(" -- ")
207
+ fix = fix[len("run: "):] if fix.startswith("run: ") else fix
208
+ probs.append(problem(text.replace(str(ctx.home), "~"), fix.replace(str(ctx.home), "~")))
209
+ return section("ok", probs[0]["message"] if probs else "no warnings", probs)
210
+
211
+
212
+ def _hosts(cfg) -> List[Tuple[str, Optional[int]]]:
213
+ out = [(h, None) for h in cfg.default.ssh_hosts]
214
+ out += [(i.host, i.ssh_port) for i in cfg.identities if i.host]
215
+ return list(dict.fromkeys(out))
216
+
217
+
218
+ def check_ssh(ctx: Ctx) -> dict:
219
+ from . import plan
220
+ cfg = ctx.need_cfg()
221
+ ssh_dir = ctx.home / ".ssh"
222
+ probs, parts, details = [], [], []
223
+ if ssh_dir.is_dir():
224
+ mode = ssh_dir.stat().st_mode & 0o777
225
+ if mode & 0o077:
226
+ probs.append(problem(f"~/.ssh is {mode:04o}; ssh wants 0700", "chmod 700 ~/.ssh"))
227
+ else:
228
+ parts.append(f"~/.ssh {mode:04o} ✓")
229
+ managed = ssh_dir / plan.MANAGED_SSH
230
+ if managed.is_file():
231
+ mode = managed.stat().st_mode & 0o777
232
+ if mode & 0o077:
233
+ probs.append(problem(f"~/.ssh/{plan.MANAGED_SSH} is {mode:04o}", f"chmod 600 ~/.ssh/{plan.MANAGED_SSH}"))
234
+ else:
235
+ parts.append(f"{plan.MANAGED_SSH} {mode:04o} ✓")
236
+ text = _read(managed)
237
+ if "Host " in text and "IdentitiesOnly yes" not in text:
238
+ probs.append(problem(f"~/.ssh/{plan.MANAGED_SSH} lacks IdentitiesOnly", "gitpro apply"))
239
+ known = ssh_dir / "known_hosts"
240
+ if shutil.which("ssh-keygen"):
241
+ for host, port in _hosts(cfg):
242
+ name = f"[{host}]:{port}" if port and port != 22 else host
243
+ r = _run(["ssh-keygen", "-F", name, "-f", str(known)])
244
+ if r is None or r.returncode != 0:
245
+ probs.append(problem(f"{name} not in ~/.ssh/known_hosts",
246
+ "connect once and compare the host key with the provider's published one"))
247
+ else:
248
+ details.append(f"known host: {name}")
249
+ for p in (ctx.home / ".gitconfig", ssh_dir / "config", ctx.home / plan.MANAGED_GITCONFIG, managed):
250
+ old = re.search(r"^# (?:BEGIN|END|Generated by) (git-identities|git_identities\.py)\b", _read(p), re.M)
251
+ if old:
252
+ probs.append(problem(f"{ctx.tilde(p)} has an old {old[1]} block", "gitpro apply"))
253
+ return section("ok", " ".join(parts) or "no ~/.ssh", probs, details)
254
+
255
+
256
+ def check_repo(ctx: Ctx) -> dict:
257
+ from . import resolve
258
+ cfg = ctx.need_cfg()
259
+ where = Path(ctx.dir) if ctx.dir else Path.cwd()
260
+ top = resolve.is_repo(where)
261
+ if top is None:
262
+ raise Skip("not in a git repository")
263
+ rep = resolve.compare(where, cfg)
264
+ exp = rep.expected
265
+ who = exp.id or ("default" if exp.email else "no identity")
266
+ return section("ok", f"{ctx.tilde(top)} → {who}", [_problem(p, "error") for p in rep.problems])
267
+
268
+
269
+ def _sshcommand_outside_block(text: str) -> bool:
270
+ from . import render
271
+ sect = ""
272
+ for line in render.without_block(text).splitlines():
273
+ s = line.strip()
274
+ m = re.match(r"^\[\s*([A-Za-z0-9.-]+)", s)
275
+ if m:
276
+ sect = m[1].lower()
277
+ elif sect == "core" and re.match(r"(?i)^sshcommand\s*=", s):
278
+ return True
279
+ return False
280
+
281
+
282
+ def check_env(ctx: Ctx) -> dict:
283
+ from . import prompt
284
+ names = sorted(k for k in os.environ if k.startswith(ENV_PREFIXES) and not k.endswith("_DATE"))
285
+ names += [k for k in ENV_EXACT if k in os.environ]
286
+ probs = [problem(f"{k} is set in this shell", f"unset {k}") for k in dict.fromkeys(names)]
287
+ if _sshcommand_outside_block(_read(ctx.home / ".gitconfig")):
288
+ probs.append(problem("~/.gitconfig sets core.sshCommand outside the gitpro block (it overrides the per-"
289
+ "identity key)", "remove it from ~/.gitconfig"))
290
+ cache = prompt.cache_path(ctx.home)
291
+ details = [f"prompt cache: {ctx.tilde(cache)} ({'present' if cache.exists() else 'absent'}; safe to delete)"]
292
+ return section("ok", probs[0]["message"] if probs else "no identity overrides in the environment", probs,
293
+ details)
294
+
295
+
296
+ # ---------------------------------------------------------------- optional sections (adapters)
297
+
298
+ def _adapt(obj: object, level: str = "warning") -> dict:
299
+ """A section from whatever a status function returns: a section-like dict, a list of problems, a str."""
300
+ if isinstance(obj, dict):
301
+ probs = [_problem(p, level) for p in obj.get("problems") or ()]
302
+ if obj.get("error"):
303
+ probs.append(problem(str(obj["error"]), str(obj.get("fix") or ""), level))
304
+ status = obj.get("status") if obj.get("status") in STATUSES else "ok"
305
+ if obj.get("ok") is False and not probs and status == "ok":
306
+ status = level
307
+ summary = obj.get("summary")
308
+ if not summary:
309
+ simple = [f"{k} {v}" for k, v in obj.items()
310
+ if k not in ("problems", "details", "status", "ok", "error", "fix", "summary")
311
+ and isinstance(v, (str, int)) and not isinstance(v, bool)]
312
+ summary = " · ".join(simple)
313
+ return section(status, str(summary or (probs[0]["message"] if probs else "ok")), probs,
314
+ [str(d) for d in obj.get("details") or ()])
315
+ if isinstance(obj, (list, tuple)):
316
+ probs = [_problem(p, level) for p in obj]
317
+ return section("ok", probs[0]["message"] if probs else "no problems", probs)
318
+ return section("ok", str(obj))
319
+
320
+
321
+ def _needs_cli(ctx: Ctx) -> Optional[dict]:
322
+ if not (shutil.which("gh") or shutil.which("glab")):
323
+ return problem("needs gh or glab", "install gh (GitHub) or glab (GitLab), then re-run with --online")
324
+ return None
325
+
326
+
327
+ def _accepts(fn: Callable, name: str) -> bool:
328
+ import inspect
329
+ try:
330
+ params = inspect.signature(fn).parameters
331
+ except (TypeError, ValueError):
332
+ return False
333
+ return name in params or any(p.kind is p.VAR_KEYWORD for p in params.values())
334
+
335
+
336
+ def check_keys(ctx: Ctx, health) -> dict:
337
+ cfg = ctx.need_cfg()
338
+ missing = _needs_cli(ctx) if ctx.online else None
339
+ # doctor announces the online checks once (run); health must not repeat it mid-report
340
+ kw = {"announce": False} if _accepts(health.check_keys, "announce") else {}
341
+ sec = _adapt(health.check_keys(cfg, online=bool(ctx.online and not missing), **kw))
342
+ if missing:
343
+ sec = section(sec["status"], sec["summary"], [*sec["problems"], missing], sec["details"])
344
+ return sec
345
+
346
+
347
+ def check_guard(ctx: Ctx, guard) -> dict:
348
+ st = guard.status()
349
+ if not isinstance(st, dict) or "installed" not in st:
350
+ return _adapt(st)
351
+ probs = [problem(str(st["error"]), str(st.get("fix") or "gitpro guard install"))] if st.get("error") else []
352
+ installed = st["installed"] # 06: "global" | "none" (older stubs: bool)
353
+ if installed not in (None, False, "", "none"):
354
+ where = f" ({installed})" if isinstance(installed, str) and installed != "global" else ""
355
+ summary = f"installed{where}, {st.get('on_mismatch') or 'block'}"
356
+ else:
357
+ summary = "not installed (optional: gitpro guard install)"
358
+ cfg = ctx.cfg
359
+ if cfg is not None and getattr(getattr(cfg, "guard", None), "hooks", False) and not probs:
360
+ probs.append(problem("[guard] hooks = true but the hooks are not installed", "gitpro guard install"))
361
+ return section("ok", summary, probs)
362
+
363
+
364
+ def _agents_section(data: object) -> dict:
365
+ """10's status_data() dict -> a section; anything else goes through _adapt."""
366
+ if not isinstance(data, dict) or not isinstance(data.get("agents"), dict):
367
+ return _adapt(data)
368
+ claude = data["agents"].get("claude") or {}
369
+ hook = "claude hook installed" if claude.get("user") or claude.get("project") else \
370
+ "claude hook not installed (optional: gitpro agent install)"
371
+ parts = [hook] + (["plugin enabled"] if claude.get("plugin") else [])
372
+ probs = []
373
+ for w in data.get("warnings") or ():
374
+ msg, _sep, fix = str(w).partition(" → ")
375
+ probs.append(problem(msg, fix))
376
+ details = [f"[agents] hook = {data['mode']}"] if data.get("mode") else []
377
+ details += [f"{a}: {i.get('user') or 'not installed'}" for a, i in data["agents"].items() if isinstance(i, dict)]
378
+ return section("ok", " · ".join(parts), probs, details)
379
+
380
+
381
+ def check_agents(ctx: Ctx, agents) -> dict:
382
+ # agents.status(args) is 10's CLI command; status_data(home, explicit_config) is the data
383
+ data_fn = getattr(agents, "status_data", None)
384
+ if data_fn is None:
385
+ return section("ok", "status unavailable", [problem("gitpro.agents has no status_data()")])
386
+ sec = _agents_section(data_fn(ctx.home, ctx.config_path))
387
+ mcp = _optional("gitpro.mcp")
388
+ mcp_hook = getattr(mcp, "doctor_section", None) if mcp is not None else None
389
+ mcp_data = getattr(mcp, "status_data", None) if mcp is not None else None
390
+ if mcp_hook or mcp_data:
391
+ extra = _adapt(mcp_hook(ctx) if mcp_hook else mcp_data(ctx.home))
392
+ sec = section("ok" if sec["status"] == "ok" else sec["status"], f"{sec['summary']} · {extra['summary']}",
393
+ [*sec["problems"], *extra["problems"]], sec["details"] + extra["details"])
394
+ return sec
395
+
396
+
397
+ def check_policy(ctx: Ctx, policy) -> dict:
398
+ probs = [_problem(p, "error") for p in policy.check_machine(ctx.need_cfg()) or ()]
399
+ return section("ok", probs[0]["message"] if probs else "machine meets the team policy", probs)
400
+
401
+
402
+ def check_forge(ctx: Ctx, health) -> dict:
403
+ if not ctx.online:
404
+ sec = section("skipped", "offline (--online checks gh/glab sign-in)")
405
+ sec["quiet"] = True
406
+ return sec
407
+ cfg = ctx.need_cfg()
408
+ missing = _needs_cli(ctx)
409
+ if missing:
410
+ return section("ok", "needs gh or glab", [missing])
411
+ return _adapt(health.auth(cfg, list(cfg.identities), TIMEOUT))
412
+
413
+
414
+ def _optional(name: str):
415
+ """The module, or None when it is not installed (only a missing module of that exact name counts)."""
416
+ if name in sys.modules and sys.modules[name] is None:
417
+ return None
418
+ try:
419
+ return importlib.import_module(name)
420
+ except ModuleNotFoundError as e:
421
+ if e.name == name:
422
+ return None
423
+ raise
424
+
425
+
426
+ def _with(adapter: Callable, name: str) -> Callable[[Ctx], dict]:
427
+ """fn(ctx) for an optional module: its doctor_section(ctx) when it has one, else adapter(ctx, mod)."""
428
+ def fn(ctx: Ctx) -> dict:
429
+ mod = sys.modules.get(name)
430
+ hook = getattr(mod, "doctor_section", None)
431
+ return _adapt(hook(ctx)) if hook else adapter(ctx, mod)
432
+ fn.__name__ = f"check_{name.rsplit('.', 1)[-1]}"
433
+ return fn
434
+
435
+
436
+ SECTIONS: List[Tuple[str, Callable[[Ctx], dict], Optional[str]]] = [
437
+ ("versions", check_versions, None),
438
+ ("config", check_config, None),
439
+ ("applied", check_applied, None),
440
+ ("files", check_files, None),
441
+ ("lint", check_lint, None),
442
+ ("keys", _with(check_keys, "gitpro.health"), "gitpro.health"),
443
+ ("ssh", check_ssh, None),
444
+ ("guard", _with(check_guard, "gitpro.guard"), "gitpro.guard"),
445
+ ("agents", _with(check_agents, "gitpro.agents"), "gitpro.agents"),
446
+ ("repo", check_repo, None),
447
+ ("policy", _with(check_policy, "gitpro.policy"), "gitpro.policy"),
448
+ ("env", check_env, None),
449
+ ("forge", _with(check_forge, "gitpro.health"), "gitpro.health"),
450
+ ]
451
+
452
+
453
+ # ---------------------------------------------------------------- running and printing
454
+
455
+ ONLINE_ONLY = ("forge",) # rows that only mean something with --online
456
+
457
+
458
+ def _one(ctx: Ctx, name: str, fn: Callable[[Ctx], dict], needs: Optional[str]) -> dict:
459
+ try:
460
+ if needs and _optional(needs) is None:
461
+ quiet = name in ONLINE_ONLY and not ctx.online
462
+ return {"name": name, **section("missing", "not installed (optional)", quiet=quiet)}
463
+ sec = fn(ctx)
464
+ except Skip as e:
465
+ sec = section("skipped", f"skipped ({e})")
466
+ except Exception as e: # a broken check never stops the report
467
+ last = traceback.format_exception_only(type(e), e)[-1].strip()
468
+ sec = section("warning", f"check failed ({e})", [problem(f"check failed ({e})")], [last])
469
+ sec["failed"] = True
470
+ if sec.get("status") not in STATUSES:
471
+ sec = _adapt(sec)
472
+ return {"name": name, **sec}
473
+
474
+
475
+ def announce(ctx: Ctx, out=None) -> None:
476
+ """forge.announce once, before any online check; out is stderr under --json (never the JSON)."""
477
+ import contextlib
478
+
479
+ from . import config, forge
480
+ try:
481
+ path, how = config.find(ctx.config_path, ctx.home)
482
+ accs = forge.accounts(config.load_all(path, how))
483
+ except Exception: # the config section reports it
484
+ accs = []
485
+ with contextlib.redirect_stdout(out or sys.stdout):
486
+ forge.announce("whether your keys are registered and you are signed in", accs,
487
+ "your account names and public key fingerprints")
488
+
489
+
490
+ def run(cfg_path: Optional[Path], dir: Optional[Path], online: bool, *, verbose: bool = False,
491
+ home: Optional[Path] = None, announce_to=None) -> dict:
492
+ """The whole report as the --json object (ok, command, versions, sections, summary). With online,
493
+ prints forge's announcement line first, to announce_to (default stdout)."""
494
+ ctx = Ctx(Path(home) if home else Path.home(), Path(cfg_path) if cfg_path else None,
495
+ Path(dir) if dir else None, online, verbose)
496
+ if online:
497
+ announce(ctx, announce_to)
498
+ sections = [_one(ctx, name, fn, needs) for name, fn, needs in SECTIONS]
499
+ errors = sum(s["status"] == "error" for s in sections)
500
+ warnings = sum(s["status"] == "warning" for s in sections)
501
+ versions = {k: ctx.versions.get(k, "") for k in ("gitpro", "python", "git", "os")}
502
+ if not versions["gitpro"]:
503
+ from . import __version__
504
+ versions.update(gitpro=__version__, python=platform.python_version(), os=platform.system().lower())
505
+ return {"ok": errors == 0, "command": "doctor", "versions": versions, "sections": sections,
506
+ "summary": {"problems": errors, "warnings": warnings}}
507
+
508
+
509
+ def _plural(n: int, word: str) -> str:
510
+ return f"{n} {word}{'' if n == 1 else 's'}"
511
+
512
+
513
+ def _fixtext(p: dict) -> str:
514
+ return f" → {p['fix']}" if p.get("fix") else ""
515
+
516
+
517
+ def render_text(rep: dict, verbose: bool = False) -> str:
518
+ v = rep["versions"]
519
+ lines = [f"gitpro {v['gitpro']} python {v['python']} git {v['git'] or '?'} {v['os']}"]
520
+ pad = " " * (NAME_W + 1)
521
+ for s in rep["sections"]:
522
+ sym = SYMBOL[s["status"]]
523
+ if s.get("quiet") and s["status"] in ("ok", "skipped", "missing") and not verbose:
524
+ continue
525
+ if s.get("failed") or s["status"] == "missing":
526
+ lines.append(f"{sym} {s['name']}: {s['summary']}")
527
+ else:
528
+ head = f"{s['name']:<{NAME_W}} {s['summary']:<{SUMMARY_W}} {sym}"
529
+ probs = list(s["problems"])
530
+ if probs:
531
+ first = probs.pop(0)
532
+ if first["message"] != s["summary"]:
533
+ head += f" {first['message']}"
534
+ head += _fixtext(first)
535
+ lines.append(head)
536
+ lines += [f"{pad}{SYMBOL.get(p['level'], '⚠')} {p['message']}{_fixtext(p)}" for p in probs]
537
+ if verbose:
538
+ lines += [f"{pad}· {d}" for d in s["details"]]
539
+ n, w = rep["summary"]["problems"], rep["summary"]["warnings"]
540
+ if n or w:
541
+ tail = " → fix the ✗ lines first" if n else ""
542
+ lines.append(f"{_plural(n, 'problem')}, {_plural(w, 'warning')}{tail}")
543
+ else:
544
+ lines.append("✓ all checks passed")
545
+ return "\n".join(lines) + "\n"
546
+
547
+
548
+ def cmd_doctor(args: argparse.Namespace) -> int:
549
+ from . import jsonout
550
+ as_json = jsonout.wants_json(args)
551
+ rep = run(args.config, args.dir, args.online, verbose=args.verbose, home=args.home,
552
+ announce_to=sys.stderr if as_json else sys.stdout)
553
+ if as_json:
554
+ out = dict(rep)
555
+ out["sections"] = [{k: v for k, v in s.items() if k not in ("quiet", "failed")} for s in rep["sections"]]
556
+ for s in out["sections"]:
557
+ s["problems"] = [{"message": p["message"], "fix": p["fix"], "level": p["level"]} for p in s["problems"]]
558
+ jsonout.emit("doctor", out)
559
+ else:
560
+ sys.stdout.write(render_text(rep, args.verbose))
561
+ return 0 if rep["ok"] else 1
562
+
563
+
564
+ def register(sub, common, confirm) -> None:
565
+ from .args import capability
566
+ sp = sub.add_parser("doctor", parents=[common], help="run every check and list what to fix")
567
+ sp.add_argument("dir", nargs="?", type=Path, metavar="DIR", help="repo to check (default: the current directory)")
568
+ sp.add_argument("--online", action="store_true", help="also ask gh/glab whether keys and sign-ins are valid")
569
+ sp.add_argument("--json", action="store_true", help="machine-readable output")
570
+ sp.add_argument("-v", "--verbose", action="store_true", help="detail lines under each section")
571
+ sp.set_defaults(func=cmd_doctor)
572
+ capability("doctor")