gitpro 0.3.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gitpro/__init__.py +4 -0
- gitpro/__main__.py +5 -0
- gitpro/agents.py +1928 -0
- gitpro/approval_policy.py +151 -0
- gitpro/args.py +82 -0
- gitpro/audit.py +800 -0
- gitpro/change_contract.py +117 -0
- gitpro/changes.py +853 -0
- gitpro/cli.py +499 -0
- gitpro/clone.py +505 -0
- gitpro/completion.py +407 -0
- gitpro/config.py +517 -0
- gitpro/devcontainer.py +442 -0
- gitpro/discover.py +1579 -0
- gitpro/doctor.py +572 -0
- gitpro/envcmd.py +370 -0
- gitpro/example.toml +102 -0
- gitpro/files.py +139 -0
- gitpro/forge.py +1244 -0
- gitpro/guard.py +1121 -0
- gitpro/health.py +1241 -0
- gitpro/hot.py +38 -0
- gitpro/jsonfile.py +195 -0
- gitpro/jsonout.py +26 -0
- gitpro/keys.py +59 -0
- gitpro/lint.py +50 -0
- gitpro/lost.py +874 -0
- gitpro/mcp.py +1055 -0
- gitpro/operation_contract.py +178 -0
- gitpro/operation_io.py +256 -0
- gitpro/operation_reconcile.py +191 -0
- gitpro/operations.py +841 -0
- gitpro/pin.py +89 -0
- gitpro/plan.py +199 -0
- gitpro/policy.py +1808 -0
- gitpro/prompt.py +304 -0
- gitpro/render.py +474 -0
- gitpro/resolve.py +898 -0
- gitpro/scan.py +358 -0
- gitpro/scanroots.py +146 -0
- gitpro/setup.py +260 -0
- gitpro/trailers.py +218 -0
- gitpro/tui.py +178 -0
- gitpro/undo.py +63 -0
- gitpro/view.py +155 -0
- gitpro-0.3.1.dist-info/METADATA +103 -0
- gitpro-0.3.1.dist-info/RECORD +50 -0
- gitpro-0.3.1.dist-info/WHEEL +4 -0
- gitpro-0.3.1.dist-info/entry_points.txt +2 -0
- gitpro-0.3.1.dist-info/licenses/LICENSE +21 -0
gitpro/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")
|