whygit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- whygit/__init__.py +1 -0
- whygit/__main__.py +5 -0
- whygit/cli.py +109 -0
- whygit/engine.py +179 -0
- whygit/errors.py +1108 -0
- whygit/findings.py +88 -0
- whygit/gitcmd.py +183 -0
- whygit/render.py +185 -0
- whygit/rules.py +1252 -0
- whygit/server.py +77 -0
- whygit/state.py +486 -0
- whygit-0.1.0.dist-info/METADATA +226 -0
- whygit-0.1.0.dist-info/RECORD +16 -0
- whygit-0.1.0.dist-info/WHEEL +4 -0
- whygit-0.1.0.dist-info/entry_points.txt +3 -0
- whygit-0.1.0.dist-info/licenses/LICENSE +21 -0
whygit/findings.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
SEVERITIES = ("blocking", "warning", "info")
|
|
6
|
+
SEVERITY_RANK = {s: i for i, s in enumerate(SEVERITIES)}
|
|
7
|
+
|
|
8
|
+
DOCS = "https://git-scm.com/docs/"
|
|
9
|
+
BOOK = "https://git-scm.com/book/en/v2/"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class Step:
|
|
14
|
+
"""One thing for the person to do. command is None for manual steps
|
|
15
|
+
such as editing a file."""
|
|
16
|
+
|
|
17
|
+
does: str
|
|
18
|
+
command: str | None = None
|
|
19
|
+
destructive: bool = False
|
|
20
|
+
warning: str | None = None
|
|
21
|
+
|
|
22
|
+
def to_dict(self) -> dict:
|
|
23
|
+
d: dict = {"does": self.does}
|
|
24
|
+
if self.command:
|
|
25
|
+
d["command"] = self.command
|
|
26
|
+
if self.destructive:
|
|
27
|
+
d["destructive"] = True
|
|
28
|
+
if self.warning:
|
|
29
|
+
d["warning"] = self.warning
|
|
30
|
+
return d
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class FixOption:
|
|
35
|
+
title: str
|
|
36
|
+
steps: list[Step]
|
|
37
|
+
|
|
38
|
+
def to_dict(self) -> dict:
|
|
39
|
+
return {"title": self.title, "steps": [s.to_dict() for s in self.steps]}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class Finding:
|
|
44
|
+
rule_id: str
|
|
45
|
+
severity: str
|
|
46
|
+
title: str
|
|
47
|
+
explanation: str
|
|
48
|
+
fix: list[FixOption] = field(default_factory=list)
|
|
49
|
+
learn_more: str = ""
|
|
50
|
+
details: list[str] = field(default_factory=list)
|
|
51
|
+
details_label: str = ""
|
|
52
|
+
note: str = ""
|
|
53
|
+
|
|
54
|
+
def to_dict(self, max_details: int = 10) -> dict:
|
|
55
|
+
d: dict = {
|
|
56
|
+
"rule": self.rule_id,
|
|
57
|
+
"severity": self.severity,
|
|
58
|
+
"title": self.title,
|
|
59
|
+
"explanation": self.explanation,
|
|
60
|
+
}
|
|
61
|
+
if self.details:
|
|
62
|
+
shown = self.details[:max_details]
|
|
63
|
+
if len(self.details) > max_details:
|
|
64
|
+
shown = shown + [f"... and {len(self.details) - max_details} more"]
|
|
65
|
+
d["details"] = {"label": self.details_label or "Files", "items": shown}
|
|
66
|
+
if self.note:
|
|
67
|
+
d["note"] = self.note
|
|
68
|
+
if self.fix:
|
|
69
|
+
d["fix"] = [f.to_dict() for f in self.fix]
|
|
70
|
+
first = d["fix"][0]
|
|
71
|
+
if first["title"].startswith("Or "):
|
|
72
|
+
first["title"] = first["title"][3].upper() + first["title"][4:]
|
|
73
|
+
if self.learn_more:
|
|
74
|
+
d["learn_more"] = self.learn_more
|
|
75
|
+
return d
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def sort_findings(findings: list[Finding], order: dict[str, int] | None = None) -> list[Finding]:
|
|
79
|
+
order = order or {}
|
|
80
|
+
return sorted(findings, key=lambda f: (SEVERITY_RANK.get(f.severity, 9), order.get(f.rule_id, 999)))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def q(path: str) -> str:
|
|
84
|
+
"""Quote a path for a shell command when it needs it. Double quotes work in
|
|
85
|
+
bash, zsh, PowerShell and cmd for ordinary file names."""
|
|
86
|
+
if path and all(c.isalnum() or c in "._-/+@:" for c in path):
|
|
87
|
+
return path
|
|
88
|
+
return '"' + path.replace('"', '\\"') + '"'
|
whygit/gitcmd.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""The only place whygit starts a process. Every git call goes through run_git,
|
|
2
|
+
which accepts read-only subcommands and refuses everything else."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import subprocess
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
# Subcommands that only read. Some need their arguments checked as well,
|
|
13
|
+
# because the same subcommand can also write (config, remote, symbolic-ref...).
|
|
14
|
+
ALLOWED_SUBCOMMANDS = frozenset(
|
|
15
|
+
{
|
|
16
|
+
"status",
|
|
17
|
+
"rev-parse",
|
|
18
|
+
"symbolic-ref",
|
|
19
|
+
"for-each-ref",
|
|
20
|
+
"rev-list",
|
|
21
|
+
"config",
|
|
22
|
+
"ls-files",
|
|
23
|
+
"ls-tree",
|
|
24
|
+
"diff",
|
|
25
|
+
"log",
|
|
26
|
+
"remote",
|
|
27
|
+
"worktree",
|
|
28
|
+
"cat-file",
|
|
29
|
+
"show-ref",
|
|
30
|
+
"merge-base",
|
|
31
|
+
"version",
|
|
32
|
+
}
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
_CONFIG_READ_MODES = {"--get", "--get-all", "--get-regexp", "--list", "-l"}
|
|
36
|
+
_CONFIG_OK_FLAGS = _CONFIG_READ_MODES | {
|
|
37
|
+
"--local",
|
|
38
|
+
"--global",
|
|
39
|
+
"--system",
|
|
40
|
+
"--show-origin",
|
|
41
|
+
"--show-scope",
|
|
42
|
+
"--null",
|
|
43
|
+
"-z",
|
|
44
|
+
"--name-only",
|
|
45
|
+
"--includes",
|
|
46
|
+
"--no-includes",
|
|
47
|
+
"--bool",
|
|
48
|
+
"--int",
|
|
49
|
+
"--path",
|
|
50
|
+
}
|
|
51
|
+
_FORBIDDEN_ANYWHERE = ("--output", "--ext-diff", "--exec", "--upload-pack", "--receive-pack")
|
|
52
|
+
|
|
53
|
+
TIMEOUT_SECONDS = 20
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class GitRefused(Exception):
|
|
57
|
+
"""Raised when a caller asks run_git for something that is not read-only."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class GitNotFound(Exception):
|
|
61
|
+
"""Raised when no git executable can be started."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class GitResult:
|
|
66
|
+
args: tuple[str, ...]
|
|
67
|
+
returncode: int
|
|
68
|
+
stdout: str
|
|
69
|
+
stderr: str
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def ok(self) -> bool:
|
|
73
|
+
return self.returncode == 0
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def check_allowed(args: tuple[str, ...]) -> None:
|
|
77
|
+
if not args:
|
|
78
|
+
raise GitRefused("no git subcommand given")
|
|
79
|
+
sub, rest = args[0], args[1:]
|
|
80
|
+
if sub.startswith("-"):
|
|
81
|
+
raise GitRefused(f"global git options are not accepted: {sub}")
|
|
82
|
+
if sub not in ALLOWED_SUBCOMMANDS:
|
|
83
|
+
raise GitRefused(f"'git {sub}' is not on the read-only allowlist")
|
|
84
|
+
for a in rest:
|
|
85
|
+
if a.split("=", 1)[0] in _FORBIDDEN_ANYWHERE:
|
|
86
|
+
raise GitRefused(f"option {a} is not allowed")
|
|
87
|
+
if sub == "config":
|
|
88
|
+
flags = [a for a in rest if a.startswith("-")]
|
|
89
|
+
if not any(f in _CONFIG_READ_MODES for f in flags):
|
|
90
|
+
raise GitRefused("git config is only allowed with --get, --get-all, --get-regexp or --list")
|
|
91
|
+
for f in flags:
|
|
92
|
+
if f.split("=", 1)[0] not in _CONFIG_OK_FLAGS and not f.startswith("--type="):
|
|
93
|
+
raise GitRefused(f"git config option {f} is not allowed")
|
|
94
|
+
elif sub == "remote":
|
|
95
|
+
if list(rest) not in ([], ["-v"], ["--verbose"]):
|
|
96
|
+
raise GitRefused("git remote is only allowed as 'git remote' or 'git remote -v'")
|
|
97
|
+
elif sub == "worktree":
|
|
98
|
+
if not rest or rest[0] != "list" or any(a not in ("--porcelain", "-z", "list") for a in rest):
|
|
99
|
+
raise GitRefused("git worktree is only allowed as 'git worktree list'")
|
|
100
|
+
elif sub == "symbolic-ref":
|
|
101
|
+
positional = [a for a in rest if not a.startswith("-")]
|
|
102
|
+
bad = [a for a in rest if a.startswith("-") and a not in ("-q", "--quiet", "--short")]
|
|
103
|
+
if len(positional) > 1 or bad:
|
|
104
|
+
raise GitRefused("git symbolic-ref is only allowed to read a ref")
|
|
105
|
+
elif sub == "cat-file":
|
|
106
|
+
if any(a in ("--filters", "--textconv") for a in rest):
|
|
107
|
+
raise GitRefused("cat-file filters are not allowed")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _env() -> dict[str, str]:
|
|
111
|
+
env = dict(os.environ)
|
|
112
|
+
# Never let an ambient repository setting redirect us somewhere else.
|
|
113
|
+
for k in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_OBJECT_DIRECTORY", "GIT_NAMESPACE"):
|
|
114
|
+
env.pop(k, None)
|
|
115
|
+
env["GIT_OPTIONAL_LOCKS"] = "0"
|
|
116
|
+
env["GIT_TERMINAL_PROMPT"] = "0"
|
|
117
|
+
env["GIT_PAGER"] = "cat"
|
|
118
|
+
env["LC_ALL"] = "C"
|
|
119
|
+
env["LANGUAGE"] = "C"
|
|
120
|
+
return env
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# Global options added to every call: no pager, and no fsmonitor hook, so a
|
|
124
|
+
# repository's own config cannot make whygit run another program.
|
|
125
|
+
_GLOBAL_OPTS = ("--no-pager", "-c", "core.fsmonitor=false", "-c", "core.quotePath=false")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def run_git(cwd: str | Path, *args: str, input_text: str | None = None, timeout: float = TIMEOUT_SECONDS) -> GitResult:
|
|
129
|
+
check_allowed(tuple(args))
|
|
130
|
+
args = tuple(args)
|
|
131
|
+
if args[0] in ("diff", "log"):
|
|
132
|
+
# No external diff programs or text conversion filters.
|
|
133
|
+
args = (args[0], "--no-ext-diff", "--no-textconv", *args[1:])
|
|
134
|
+
cmd = ["git", *_GLOBAL_OPTS, *args]
|
|
135
|
+
kwargs: dict = {}
|
|
136
|
+
if os.name == "nt":
|
|
137
|
+
kwargs["creationflags"] = 0x08000000 # CREATE_NO_WINDOW
|
|
138
|
+
try:
|
|
139
|
+
proc = subprocess.run(
|
|
140
|
+
cmd,
|
|
141
|
+
cwd=str(cwd),
|
|
142
|
+
env=_env(),
|
|
143
|
+
input=input_text.encode("utf-8") if input_text is not None else None,
|
|
144
|
+
stdin=None if input_text is not None else subprocess.DEVNULL,
|
|
145
|
+
capture_output=True,
|
|
146
|
+
timeout=timeout,
|
|
147
|
+
check=False,
|
|
148
|
+
**kwargs,
|
|
149
|
+
)
|
|
150
|
+
except FileNotFoundError as exc:
|
|
151
|
+
raise GitNotFound(str(exc)) from exc
|
|
152
|
+
except subprocess.TimeoutExpired:
|
|
153
|
+
return GitResult(tuple(args), 124, "", "timed out")
|
|
154
|
+
return GitResult(
|
|
155
|
+
tuple(args),
|
|
156
|
+
proc.returncode,
|
|
157
|
+
proc.stdout.decode("utf-8", errors="replace"),
|
|
158
|
+
proc.stderr.decode("utf-8", errors="replace"),
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# Redaction of credentials in URLs and well-known token formats.
|
|
163
|
+
_URL_USERINFO = re.compile(r"(?i)\b([a-z][a-z0-9+.\-]*://)[^/@\s'\"]+@")
|
|
164
|
+
_TOKENS = re.compile(r"\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|glpat-[A-Za-z0-9_\-]{20,})\b")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def redact(text: str) -> str:
|
|
168
|
+
if not text:
|
|
169
|
+
return text
|
|
170
|
+
text = _URL_USERINFO.sub(r"\1***@", text)
|
|
171
|
+
return _TOKENS.sub("***", text)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def redact_obj(obj):
|
|
175
|
+
if isinstance(obj, str):
|
|
176
|
+
return redact(obj)
|
|
177
|
+
if isinstance(obj, list):
|
|
178
|
+
return [redact_obj(x) for x in obj]
|
|
179
|
+
if isinstance(obj, tuple):
|
|
180
|
+
return tuple(redact_obj(x) for x in obj)
|
|
181
|
+
if isinstance(obj, dict):
|
|
182
|
+
return {k: redact_obj(v) for k, v in obj.items()}
|
|
183
|
+
return obj
|
whygit/render.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Plain-text rendering for the terminal."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import sys
|
|
8
|
+
import textwrap
|
|
9
|
+
|
|
10
|
+
LABELS = {"blocking": "BLOCKING", "warning": "WARNING", "info": "INFO"}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Style:
|
|
14
|
+
def __init__(self, enabled: bool):
|
|
15
|
+
self.enabled = enabled
|
|
16
|
+
|
|
17
|
+
def _c(self, code: str, s: str) -> str:
|
|
18
|
+
return f"\033[{code}m{s}\033[0m" if self.enabled else s
|
|
19
|
+
|
|
20
|
+
def bold(self, s):
|
|
21
|
+
return self._c("1", s)
|
|
22
|
+
|
|
23
|
+
def dim(self, s):
|
|
24
|
+
return self._c("2", s)
|
|
25
|
+
|
|
26
|
+
def cmd(self, s):
|
|
27
|
+
return self._c("36", s)
|
|
28
|
+
|
|
29
|
+
def sev(self, sev, s):
|
|
30
|
+
return self._c({"blocking": "1;31", "warning": "1;33", "info": "1;34"}.get(sev, "1"), s)
|
|
31
|
+
|
|
32
|
+
def warn(self, s):
|
|
33
|
+
return self._c("1;31", s)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def want_color(stream=None, force_off: bool = False) -> bool:
|
|
37
|
+
stream = stream or sys.stdout
|
|
38
|
+
if force_off or os.environ.get("NO_COLOR") or os.environ.get("TERM") == "dumb":
|
|
39
|
+
return False
|
|
40
|
+
if not hasattr(stream, "isatty") or not stream.isatty():
|
|
41
|
+
return False
|
|
42
|
+
if os.name == "nt":
|
|
43
|
+
return _enable_windows_vt()
|
|
44
|
+
return True
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _enable_windows_vt() -> bool:
|
|
48
|
+
try:
|
|
49
|
+
import ctypes
|
|
50
|
+
|
|
51
|
+
kernel32 = ctypes.windll.kernel32
|
|
52
|
+
handle = kernel32.GetStdHandle(-11)
|
|
53
|
+
mode = ctypes.c_uint32()
|
|
54
|
+
if not kernel32.GetConsoleMode(handle, ctypes.byref(mode)):
|
|
55
|
+
return False
|
|
56
|
+
return bool(kernel32.SetConsoleMode(handle, mode.value | 0x0004))
|
|
57
|
+
except Exception:
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _width() -> int:
|
|
62
|
+
return max(60, min(100, shutil.get_terminal_size((88, 24)).columns - 2))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _wrap(text: str, indent: str, width: int) -> list[str]:
|
|
66
|
+
return textwrap.wrap(
|
|
67
|
+
text,
|
|
68
|
+
width=width,
|
|
69
|
+
initial_indent=indent,
|
|
70
|
+
subsequent_indent=indent,
|
|
71
|
+
break_on_hyphens=False,
|
|
72
|
+
break_long_words=False,
|
|
73
|
+
) or [indent]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def finding_lines(f: dict, s: Style, width: int) -> list[str]:
|
|
77
|
+
out = [s.sev(f["severity"], f"[{LABELS.get(f['severity'], f['severity'].upper())}]") + " " + s.bold(f["title"])]
|
|
78
|
+
out += _wrap(f["explanation"], " ", width)
|
|
79
|
+
if f.get("details"):
|
|
80
|
+
out.append(" " + f["details"]["label"] + ":")
|
|
81
|
+
for item in f["details"]["items"]:
|
|
82
|
+
out.append(" - " + item)
|
|
83
|
+
if f.get("note"):
|
|
84
|
+
out += _wrap(f["note"], " ", width)
|
|
85
|
+
fixes = f.get("fix") or []
|
|
86
|
+
if fixes:
|
|
87
|
+
out.append(" " + s.bold("How to fix:"))
|
|
88
|
+
for i, opt in enumerate(fixes):
|
|
89
|
+
title = opt["title"] if i == 0 else opt["title"]
|
|
90
|
+
out.append(" " + title + ":")
|
|
91
|
+
for n, step in enumerate(opt["steps"], 1):
|
|
92
|
+
num = f"{n}. "
|
|
93
|
+
pad = " " + " " * len(num)
|
|
94
|
+
if step.get("command"):
|
|
95
|
+
out.append(" " + num + s.cmd(step["command"]))
|
|
96
|
+
out += _wrap(step["does"], pad, width)
|
|
97
|
+
else:
|
|
98
|
+
wrapped = _wrap(step["does"], pad, width)
|
|
99
|
+
wrapped[0] = " " + num + wrapped[0].lstrip()
|
|
100
|
+
out += wrapped
|
|
101
|
+
if step.get("warning"):
|
|
102
|
+
out += [
|
|
103
|
+
s.warn(x) if j == 0 else x
|
|
104
|
+
for j, x in enumerate(_wrap("Warning: " + step["warning"], pad, width))
|
|
105
|
+
]
|
|
106
|
+
if f.get("learn_more"):
|
|
107
|
+
out.append(" " + s.dim("Learn more: " + f["learn_more"]))
|
|
108
|
+
return out
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def render_diagnosis(report: dict, s: Style) -> str:
|
|
112
|
+
width = _width()
|
|
113
|
+
head = "whygit: " + report.get("repo_root", report["path"])
|
|
114
|
+
if report.get("branch"):
|
|
115
|
+
head += f" (branch {report['branch']})"
|
|
116
|
+
elif report.get("head"):
|
|
117
|
+
head += f" ({report['head']})"
|
|
118
|
+
lines = [s.bold(head)]
|
|
119
|
+
if report.get("repo_root") and report["repo_root"] != report["path"]:
|
|
120
|
+
lines.append(s.dim("Run the commands below from the repository folder above; file paths are relative to it."))
|
|
121
|
+
lines.append("")
|
|
122
|
+
findings = report.get("findings", [])
|
|
123
|
+
if not findings:
|
|
124
|
+
lines.append("No problems found. Run `whygit status` for a plain summary of the repository.")
|
|
125
|
+
return "\n".join(lines)
|
|
126
|
+
for f in findings:
|
|
127
|
+
lines += finding_lines(f, s, width)
|
|
128
|
+
lines.append("")
|
|
129
|
+
lines.append(s.dim(report["summary"] + ". whygit never runs these commands for you; copy the ones you want."))
|
|
130
|
+
return "\n".join(lines)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def render_state(state: dict, s: Style) -> str:
|
|
134
|
+
width = _width()
|
|
135
|
+
head = "whygit status: " + state.get("repo_root", state["path"])
|
|
136
|
+
lines = [s.bold(head), ""]
|
|
137
|
+
for line in state.get("summary", []):
|
|
138
|
+
lines += _wrap(line, "", width)
|
|
139
|
+
ch = state.get("changes") or {}
|
|
140
|
+
labels = {
|
|
141
|
+
"conflicted": "Conflicted",
|
|
142
|
+
"staged": "Staged",
|
|
143
|
+
"modified_not_staged": "Changed, not staged",
|
|
144
|
+
"untracked": "Untracked",
|
|
145
|
+
}
|
|
146
|
+
for key, label in labels.items():
|
|
147
|
+
if ch.get(key):
|
|
148
|
+
lines.append("")
|
|
149
|
+
lines.append(label + ":")
|
|
150
|
+
lines += [" " + p for p in ch[key]]
|
|
151
|
+
if state.get("remotes"):
|
|
152
|
+
lines.append("")
|
|
153
|
+
lines.append("Remotes:")
|
|
154
|
+
lines += [f" {k} {v}" for k, v in state["remotes"].items()]
|
|
155
|
+
return "\n".join(lines)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def render_explanation(res: dict, s: Style) -> str:
|
|
159
|
+
width = _width()
|
|
160
|
+
lines: list[str] = []
|
|
161
|
+
if res.get("recognised"):
|
|
162
|
+
for f in res["matches"]:
|
|
163
|
+
if f.get("matched_line"):
|
|
164
|
+
lines.append(s.dim("Matched: " + f["matched_line"]))
|
|
165
|
+
lines += finding_lines(f, s, width)
|
|
166
|
+
lines.append("")
|
|
167
|
+
extra = res.get("also_in_this_repo") or []
|
|
168
|
+
if extra:
|
|
169
|
+
lines.append(s.bold("Also found in this repository:"))
|
|
170
|
+
lines.append("")
|
|
171
|
+
for f in extra:
|
|
172
|
+
lines += finding_lines(f, s, width)
|
|
173
|
+
lines.append("")
|
|
174
|
+
lines.append(s.dim("whygit never runs these commands for you; copy the ones you want."))
|
|
175
|
+
else:
|
|
176
|
+
lines += _wrap(res.get("message", "Not recognised."), "", width)
|
|
177
|
+
lines.append("")
|
|
178
|
+
diag = res.get("diagnosis")
|
|
179
|
+
if diag:
|
|
180
|
+
for f in diag:
|
|
181
|
+
lines += finding_lines(f, s, width)
|
|
182
|
+
lines.append("")
|
|
183
|
+
elif diag is not None:
|
|
184
|
+
lines.append("No problems found in the repository either.")
|
|
185
|
+
return "\n".join(lines).rstrip() + "\n"
|