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 ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
whygit/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
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)