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/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
whygit/__main__.py
ADDED
whygit/cli.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Command line front end: whygit, whygit status, whygit explain, whygit mcp."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from . import __version__
|
|
10
|
+
from .engine import PathNotFound, diagnose, explain_error, explain_state
|
|
11
|
+
from .render import Style, render_diagnosis, render_explanation, render_state, want_color
|
|
12
|
+
|
|
13
|
+
EPILOG = """examples:
|
|
14
|
+
whygit explain what's going on in this repository
|
|
15
|
+
whygit status plain summary: branch, upstream, changes
|
|
16
|
+
whygit explain "fatal: refusing to merge unrelated histories"
|
|
17
|
+
git push 2>&1 | whygit explain -
|
|
18
|
+
whygit mcp run as an MCP server (for Claude Code, Cursor, Claude Desktop)
|
|
19
|
+
|
|
20
|
+
whygit only reads your repository. It never runs the fixes it suggests."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _common(p: argparse.ArgumentParser, suppress: bool) -> None:
|
|
24
|
+
d = argparse.SUPPRESS if suppress else None
|
|
25
|
+
p.add_argument(
|
|
26
|
+
"--path", "-C", default=d if suppress else ".", help="repository folder to look at (default: current folder)"
|
|
27
|
+
)
|
|
28
|
+
p.add_argument("--json", action="store_true", default=d if suppress else False, help="print JSON instead of text")
|
|
29
|
+
p.add_argument("--no-color", action="store_true", default=d if suppress else False, help="never use colour")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
33
|
+
parser = argparse.ArgumentParser(
|
|
34
|
+
prog="whygit",
|
|
35
|
+
description="Explains what state your Git repository is in and how to fix it, in plain English.",
|
|
36
|
+
epilog=EPILOG,
|
|
37
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
38
|
+
)
|
|
39
|
+
parser.add_argument("--version", action="version", version=f"whygit {__version__}")
|
|
40
|
+
_common(parser, suppress=False)
|
|
41
|
+
sub = parser.add_subparsers(dest="command", metavar="command")
|
|
42
|
+
p = sub.add_parser("diagnose", help="list problems, most urgent first (the default)")
|
|
43
|
+
_common(p, suppress=True)
|
|
44
|
+
p = sub.add_parser("status", help="plain summary of the repository")
|
|
45
|
+
_common(p, suppress=True)
|
|
46
|
+
p = sub.add_parser("explain", help="explain a git error message ('-' reads it from stdin)")
|
|
47
|
+
p.add_argument("text", nargs="*", help="the error text, or - to read from stdin")
|
|
48
|
+
_common(p, suppress=True)
|
|
49
|
+
sub.add_parser("mcp", help="run the MCP server over stdio")
|
|
50
|
+
return parser
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _setup_stdout() -> None:
|
|
54
|
+
for stream in (sys.stdout, sys.stderr):
|
|
55
|
+
try:
|
|
56
|
+
stream.reconfigure(errors="replace")
|
|
57
|
+
except (AttributeError, ValueError):
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def main(argv: list[str] | None = None) -> int:
|
|
62
|
+
_setup_stdout()
|
|
63
|
+
args = build_parser().parse_args(argv)
|
|
64
|
+
if args.command == "mcp":
|
|
65
|
+
from .server import main as server_main
|
|
66
|
+
|
|
67
|
+
server_main()
|
|
68
|
+
return 0
|
|
69
|
+
style = Style(want_color(force_off=args.no_color or args.json))
|
|
70
|
+
try:
|
|
71
|
+
if args.command == "status":
|
|
72
|
+
res = explain_state(args.path)
|
|
73
|
+
_emit(res, args.json, lambda r: render_state(r, style))
|
|
74
|
+
return 0
|
|
75
|
+
if args.command == "explain":
|
|
76
|
+
text = " ".join(args.text)
|
|
77
|
+
if text.strip() in ("", "-"):
|
|
78
|
+
if sys.stdin is not None and sys.stdin.isatty():
|
|
79
|
+
print(
|
|
80
|
+
"Paste the error text, then press Ctrl-D (macOS/Linux) or Ctrl-Z then Enter (Windows):",
|
|
81
|
+
file=sys.stderr,
|
|
82
|
+
)
|
|
83
|
+
text = sys.stdin.read() if sys.stdin is not None else ""
|
|
84
|
+
if not text.strip():
|
|
85
|
+
print("whygit explain: no error text given.", file=sys.stderr)
|
|
86
|
+
return 2
|
|
87
|
+
res = explain_error(text, args.path)
|
|
88
|
+
_emit(res, args.json, lambda r: render_explanation(r, style))
|
|
89
|
+
return 0 if res.get("recognised") else 3
|
|
90
|
+
res = diagnose(args.path)
|
|
91
|
+
_emit(res, args.json, lambda r: render_diagnosis(r, style))
|
|
92
|
+
return 1 if any(f["severity"] == "blocking" for f in res["findings"]) else 0
|
|
93
|
+
except PathNotFound as exc:
|
|
94
|
+
print(
|
|
95
|
+
f"whygit: the folder {exc} does not exist. Use --path with your project folder, or cd into it first.",
|
|
96
|
+
file=sys.stderr,
|
|
97
|
+
)
|
|
98
|
+
return 2
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _emit(res: dict, as_json: bool, render) -> None:
|
|
102
|
+
if as_json:
|
|
103
|
+
print(json.dumps(res, indent=2, ensure_ascii=False))
|
|
104
|
+
else:
|
|
105
|
+
print(render(res))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
if __name__ == "__main__":
|
|
109
|
+
sys.exit(main())
|
whygit/engine.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Public entry points shared by the CLI and the MCP server. Everything returned
|
|
2
|
+
here is plain data (dicts, lists, strings) with credentials redacted."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
from .errors import match_errors
|
|
9
|
+
from .findings import Finding, sort_findings
|
|
10
|
+
from .gitcmd import redact, redact_obj
|
|
11
|
+
from .rules import RULES, rule_order
|
|
12
|
+
from .state import PathNotFound, RepoState, ago, collect
|
|
13
|
+
|
|
14
|
+
__all__ = ["diagnose", "explain_state", "explain_error", "findings_for", "PathNotFound"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def findings_for(st: RepoState) -> list[Finding]:
|
|
18
|
+
found = []
|
|
19
|
+
quiet = set()
|
|
20
|
+
for r in RULES:
|
|
21
|
+
f = r.detect(st)
|
|
22
|
+
if f is not None:
|
|
23
|
+
found.append(f)
|
|
24
|
+
if r.quiet:
|
|
25
|
+
quiet.add(r.id)
|
|
26
|
+
if any(f.rule_id == "home_repo" and f.severity == "blocking" for f in found):
|
|
27
|
+
# Everything else is noise (and some advice, like `git add .`, would be harmful).
|
|
28
|
+
found = [f for f in found if f.rule_id in ("home_repo", "index_lock")]
|
|
29
|
+
if any(f.severity in ("blocking", "warning") for f in found):
|
|
30
|
+
found = [f for f in found if f.rule_id not in quiet]
|
|
31
|
+
return sort_findings(found, rule_order())
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _summary_counts(findings: list[Finding]) -> str:
|
|
35
|
+
if not findings:
|
|
36
|
+
return "No problems found."
|
|
37
|
+
parts = []
|
|
38
|
+
for sev in ("blocking", "warning", "info"):
|
|
39
|
+
n = sum(1 for f in findings if f.severity == sev)
|
|
40
|
+
if n:
|
|
41
|
+
parts.append(f"{n} {sev}")
|
|
42
|
+
return ", ".join(parts)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _where(st: RepoState) -> dict:
|
|
46
|
+
d: dict = {"path": str(st.path)}
|
|
47
|
+
if st.toplevel is not None:
|
|
48
|
+
d["repo_root"] = str(st.toplevel)
|
|
49
|
+
if st.is_repo:
|
|
50
|
+
if st.detached:
|
|
51
|
+
d["branch"] = None
|
|
52
|
+
d["head"] = f"detached at {(st.head_oid or '')[:7]}"
|
|
53
|
+
else:
|
|
54
|
+
d["branch"] = st.branch
|
|
55
|
+
return d
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def diagnose(path: str | os.PathLike | None = None) -> dict:
|
|
59
|
+
st = collect(path)
|
|
60
|
+
findings = findings_for(st)
|
|
61
|
+
out = _where(st)
|
|
62
|
+
out["summary"] = _summary_counts(findings)
|
|
63
|
+
out["findings"] = [f.to_dict() for f in findings]
|
|
64
|
+
return redact_obj(out)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _names(entries, limit=8) -> list[str]:
|
|
68
|
+
names = [e.path for e in entries]
|
|
69
|
+
if len(names) > limit:
|
|
70
|
+
return names[:limit] + [f"... and {len(names) - limit} more"]
|
|
71
|
+
return names
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def explain_state(path: str | os.PathLike | None = None) -> dict:
|
|
75
|
+
st = collect(path)
|
|
76
|
+
out = _where(st)
|
|
77
|
+
lines: list[str] = []
|
|
78
|
+
if not st.git_available:
|
|
79
|
+
out["is_repo"] = False
|
|
80
|
+
out["summary"] = ["Git is not installed or not on PATH."]
|
|
81
|
+
return out
|
|
82
|
+
if not st.is_repo:
|
|
83
|
+
out["is_repo"] = False
|
|
84
|
+
reason = {
|
|
85
|
+
"not_found": "This folder is not inside a Git repository.",
|
|
86
|
+
"dubious": "Git refuses to read this repository because another user owns the folder.",
|
|
87
|
+
"inside_git_dir": "You are inside the hidden .git folder.",
|
|
88
|
+
"bare": "This is a bare repository (history only, no working files).",
|
|
89
|
+
}.get(st.not_repo_reason, "Git could not read this folder: " + st.error_text)
|
|
90
|
+
out["summary"] = [reason]
|
|
91
|
+
return redact_obj(out)
|
|
92
|
+
out["is_repo"] = True
|
|
93
|
+
if "rebase" in st.in_progress:
|
|
94
|
+
name = f"branch '{st.rebase_head_name}'" if st.rebase_head_name else "a branch"
|
|
95
|
+
step = f" (step {st.rebase_step})" if st.rebase_step else ""
|
|
96
|
+
lines.append(f"Rebasing {name}{step}; HEAD is detached until the rebase finishes.")
|
|
97
|
+
elif st.detached:
|
|
98
|
+
lines.append(f"Not on a branch: HEAD is detached at commit {(st.head_oid or '')[:7]}.")
|
|
99
|
+
elif st.unborn:
|
|
100
|
+
lines.append(f"On branch '{st.branch}', which has no commits yet.")
|
|
101
|
+
else:
|
|
102
|
+
lines.append(f"On branch '{st.branch}'.")
|
|
103
|
+
if st.last_commit:
|
|
104
|
+
lines.append(f"Last commit: {st.last_commit}.")
|
|
105
|
+
out["last_commit"] = st.last_commit
|
|
106
|
+
if st.upstream:
|
|
107
|
+
out["upstream"] = st.upstream
|
|
108
|
+
if st.upstream_gone:
|
|
109
|
+
lines.append(f"It tracks {st.upstream}, which no longer exists on the remote.")
|
|
110
|
+
else:
|
|
111
|
+
out["ahead"], out["behind"] = st.ahead, st.behind
|
|
112
|
+
if st.ahead == 0 and st.behind == 0:
|
|
113
|
+
rel = "up to date with"
|
|
114
|
+
elif st.ahead and st.behind:
|
|
115
|
+
rel = f"{st.ahead} ahead and {st.behind} behind"
|
|
116
|
+
elif st.ahead:
|
|
117
|
+
rel = f"{st.ahead} ahead of"
|
|
118
|
+
else:
|
|
119
|
+
rel = f"{st.behind} behind"
|
|
120
|
+
lines.append(f"It is {rel} {st.upstream}. {st.fetch_note()}")
|
|
121
|
+
out["last_fetch"] = ago(st.last_fetch) if st.last_fetch else "never (only cloned)"
|
|
122
|
+
elif not st.detached:
|
|
123
|
+
lines.append("It is not linked to a remote branch (no upstream).")
|
|
124
|
+
out["remotes"] = dict(st.remotes)
|
|
125
|
+
if not st.remotes:
|
|
126
|
+
lines.append("No remote is set, so nothing is connected to GitHub.")
|
|
127
|
+
op = [o for o in st.in_progress]
|
|
128
|
+
if op:
|
|
129
|
+
out["in_progress"] = op
|
|
130
|
+
lines.append("In progress: " + ", ".join(op) + ".")
|
|
131
|
+
changes = {
|
|
132
|
+
"conflicted": _names(st.unmerged),
|
|
133
|
+
"staged": _names(st.staged),
|
|
134
|
+
"modified_not_staged": _names(st.unstaged),
|
|
135
|
+
"untracked": _names(st.untracked),
|
|
136
|
+
}
|
|
137
|
+
out["changes"] = {k: v for k, v in changes.items() if v}
|
|
138
|
+
counts = [
|
|
139
|
+
(len(st.unmerged), "conflicted"),
|
|
140
|
+
(len(st.staged), "staged for the next commit"),
|
|
141
|
+
(len(st.unstaged), "changed but not staged"),
|
|
142
|
+
(len(st.untracked), "new and untracked"),
|
|
143
|
+
]
|
|
144
|
+
bits = [f"{n} {label}" for n, label in counts if n]
|
|
145
|
+
lines.append(("Files: " + ", ".join(bits) + ".") if bits else "No uncommitted changes.")
|
|
146
|
+
if st.stash_count:
|
|
147
|
+
out["stashes"] = st.stash_count
|
|
148
|
+
lines.append(
|
|
149
|
+
f"{st.stash_count} stash entr{'y' if st.stash_count == 1 else 'ies'} saved (see `git stash list`)."
|
|
150
|
+
)
|
|
151
|
+
out["summary"] = lines
|
|
152
|
+
return redact_obj(out)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def explain_error(error_text: str, path: str | os.PathLike | None = None) -> dict:
|
|
156
|
+
text = error_text or ""
|
|
157
|
+
st = collect(path) if path not in (None, "") else None
|
|
158
|
+
matches = match_errors(text, st)
|
|
159
|
+
out: dict = {"recognised": bool(matches)}
|
|
160
|
+
if st is not None:
|
|
161
|
+
out.update(_where(st))
|
|
162
|
+
if matches:
|
|
163
|
+
out["matches"] = [dict(f.to_dict(), matched_line=line) for f, line in matches]
|
|
164
|
+
# Add anything else urgent in the repository that the error didn't mention.
|
|
165
|
+
if st is not None and st.is_repo:
|
|
166
|
+
seen = {f.rule_id for f, _ in matches}
|
|
167
|
+
extra = [f.to_dict() for f in findings_for(st) if f.severity == "blocking" and f.rule_id not in seen]
|
|
168
|
+
if extra:
|
|
169
|
+
out["also_in_this_repo"] = extra
|
|
170
|
+
else:
|
|
171
|
+
out["message"] = (
|
|
172
|
+
"whygit doesn't recognise this message. Here is what it can see in the repository instead."
|
|
173
|
+
if st is not None
|
|
174
|
+
else "whygit doesn't recognise this message. Pass the repository path to get a diagnosis of its current state."
|
|
175
|
+
)
|
|
176
|
+
if st is not None:
|
|
177
|
+
out["diagnosis"] = [f.to_dict() for f in findings_for(st)]
|
|
178
|
+
out["error_excerpt"] = redact(text.strip())[:500]
|
|
179
|
+
return redact_obj(out)
|