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