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/server.py ADDED
@@ -0,0 +1,77 @@
1
+ """MCP server front end (stdio). Three read-only tools over the same engine as the CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from typing import Annotated, Any
7
+
8
+ from mcp.server.mcpserver import MCPServer
9
+ from mcp.server.mcpserver.exceptions import ToolError
10
+ from mcp.types import ToolAnnotations
11
+ from pydantic import Field
12
+
13
+ from . import __version__, engine
14
+ from .engine import PathNotFound
15
+
16
+ INSTRUCTIONS = (
17
+ "whygit explains the state of a local Git repository in plain English and suggests fixes. "
18
+ "It only reads; it never changes the repository and never runs the commands it suggests. "
19
+ "Use diagnose_repo when something seems wrong, explain_git_error when you have git or GitHub error text, "
20
+ "and explain_state for a neutral summary. Ahead/behind counts are as of the last fetch; it does not contact GitHub."
21
+ )
22
+
23
+ mcp = MCPServer("whygit", instructions=INSTRUCTIONS, version=__version__)
24
+
25
+ READ_ONLY = ToolAnnotations(read_only_hint=True, destructive_hint=False, idempotent_hint=True, open_world_hint=False)
26
+
27
+ PathArg = Annotated[
28
+ str,
29
+ Field(
30
+ description="Repository folder (or any folder inside it). Relative paths resolve against the server's working directory; ~ is allowed."
31
+ ),
32
+ ]
33
+
34
+
35
+ def _missing(path: str) -> ToolError:
36
+ return ToolError(
37
+ f"The folder '{path}' does not exist (relative paths resolve against the server's working directory, "
38
+ f"{os.getcwd()}). Pass the absolute path of the repository root, the folder that contains .git."
39
+ )
40
+
41
+
42
+ @mcp.tool(title="Diagnose a Git repository", annotations=READ_ONLY)
43
+ def diagnose_repo(path: PathArg = ".") -> dict[str, Any]:
44
+ """Use when a repository seems stuck or broken: lists every problem found (conflicts, rebase in progress, detached HEAD, diverged branch, large or secret files...), most urgent first, each with a plain explanation and the commands for the user to run."""
45
+ try:
46
+ return engine.diagnose(path)
47
+ except PathNotFound:
48
+ raise _missing(path) from None
49
+
50
+
51
+ @mcp.tool(title="Explain a Git error message", annotations=READ_ONLY)
52
+ def explain_git_error(
53
+ error_text: Annotated[
54
+ str, Field(description="The error output from git or GitHub, pasted verbatim.", min_length=1, max_length=50000)
55
+ ],
56
+ path: Annotated[
57
+ str | None, Field(description="Optional repository folder; when given, the explanation uses its current state.")
58
+ ] = None,
59
+ ) -> dict[str, Any]:
60
+ """Use when a git command printed an error (push rejected, authentication failed, unrelated histories, merge conflict...): says what it means and the fix. Pass path to tailor the fix to the repository."""
61
+ try:
62
+ return engine.explain_error(error_text, path)
63
+ except PathNotFound:
64
+ raise _missing(path or "") from None
65
+
66
+
67
+ @mcp.tool(title="Summarise a Git repository's state", annotations=READ_ONLY)
68
+ def explain_state(path: PathArg = ".") -> dict[str, Any]:
69
+ """Use for a neutral plain-English summary of a repository: branch, upstream and ahead/behind (as of the last fetch), remotes, staged/changed/untracked files, and any operation in progress."""
70
+ try:
71
+ return engine.explain_state(path)
72
+ except PathNotFound:
73
+ raise _missing(path) from None
74
+
75
+
76
+ def main() -> None:
77
+ mcp.run()
whygit/state.py ADDED
@@ -0,0 +1,486 @@
1
+ """Collects a read-only snapshot of a repository's state."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import time
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+
10
+ from .gitcmd import GitNotFound, redact, run_git
11
+
12
+ MB = 1024 * 1024
13
+ GITHUB_HARD_LIMIT = 100 * MB
14
+ GITHUB_WARN_LIMIT = 50 * MB
15
+ NOTEBOOK_WARN_SIZE = 1 * MB
16
+
17
+
18
+ class PathNotFound(Exception):
19
+ pass
20
+
21
+
22
+ @dataclass
23
+ class Entry:
24
+ path: str
25
+ xy: str # two status letters: index then worktree, e.g. "M." or "UU"
26
+ kind: str # "changed", "renamed", "unmerged", "untracked"
27
+ mode_index: str = ""
28
+ orig_path: str = ""
29
+
30
+ @property
31
+ def staged(self) -> bool:
32
+ return self.kind in ("changed", "renamed") and self.xy[0] != "."
33
+
34
+ @property
35
+ def unstaged(self) -> bool:
36
+ return self.kind in ("changed", "renamed") and self.xy[1] != "."
37
+
38
+
39
+ @dataclass
40
+ class BigFile:
41
+ path: str
42
+ size: int
43
+ where: str # "staged" or "unpushed"
44
+
45
+
46
+ @dataclass
47
+ class RepoState:
48
+ path: Path
49
+ home: Path
50
+ git_available: bool = True
51
+ git_version: str = ""
52
+ is_repo: bool = False
53
+ not_repo_reason: str = "" # "not_found", "dubious", "inside_git_dir", "bare", "other"
54
+ error_text: str = ""
55
+ toplevel: Path | None = None
56
+ git_dir: Path | None = None
57
+ common_dir: Path | None = None
58
+ branch: str | None = None
59
+ head_oid: str | None = None
60
+ unborn: bool = False
61
+ detached: bool = False
62
+ upstream: str | None = None
63
+ upstream_gone: bool = False
64
+ ahead: int | None = None
65
+ behind: int | None = None
66
+ last_fetch: float | None = None
67
+ remotes: dict[str, str] = field(default_factory=dict)
68
+ local_branches: list[str] = field(default_factory=list)
69
+ remote_branches: list[str] = field(default_factory=list)
70
+ entries: list[Entry] = field(default_factory=list)
71
+ in_progress: list[str] = field(default_factory=list)
72
+ rebase_head_name: str = ""
73
+ rebase_onto: str = ""
74
+ rebase_step: str = ""
75
+ rebase_edit_stop: bool = False
76
+ leftover_markers: list[str] = field(default_factory=list)
77
+ user_name: str | None = None
78
+ user_email: str | None = None
79
+ index_lock: bool = False
80
+ orphan_commits: int = 0
81
+ nested_repos: list[str] = field(default_factory=list)
82
+ unregistered_gitlinks: list[str] = field(default_factory=list)
83
+ outer_repo: Path | None = None
84
+ big_files: list[BigFile] = field(default_factory=list)
85
+ sensitive: list[BigFile] = field(default_factory=list)
86
+ big_notebooks: list[BigFile] = field(default_factory=list)
87
+ last_commit: str = ""
88
+ stash_count: int = 0
89
+ gitmodules: set[str] = field(default_factory=set)
90
+
91
+ @property
92
+ def unmerged(self) -> list[Entry]:
93
+ return [e for e in self.entries if e.kind == "unmerged"]
94
+
95
+ @property
96
+ def staged(self) -> list[Entry]:
97
+ return [e for e in self.entries if e.staged]
98
+
99
+ @property
100
+ def unstaged(self) -> list[Entry]:
101
+ return [e for e in self.entries if e.unstaged]
102
+
103
+ @property
104
+ def untracked(self) -> list[Entry]:
105
+ return [e for e in self.entries if e.kind == "untracked"]
106
+
107
+ @property
108
+ def home_is_repo_root(self) -> bool:
109
+ return self.toplevel is not None and same_path(self.toplevel, self.home)
110
+
111
+ def fetch_note(self) -> str:
112
+ if self.last_fetch is None:
113
+ return "This is as of when you cloned or last fetched. Run `git fetch` to check GitHub again."
114
+ return f"This is as of your last fetch ({ago(self.last_fetch)}). Run `git fetch` to check GitHub again."
115
+
116
+
117
+ def same_path(a: Path, b: Path) -> bool:
118
+ try:
119
+ return os.path.normcase(str(a.resolve())) == os.path.normcase(str(b.resolve()))
120
+ except OSError:
121
+ return False
122
+
123
+
124
+ def is_under(child: Path, parent: Path) -> bool:
125
+ try:
126
+ c = os.path.normcase(str(child.resolve()))
127
+ p = os.path.normcase(str(parent.resolve()))
128
+ except OSError:
129
+ return False
130
+ return c == p or c.startswith(p.rstrip(os.sep) + os.sep)
131
+
132
+
133
+ def ago(ts: float) -> str:
134
+ secs = max(0, int(time.time() - ts))
135
+ if secs < 90:
136
+ return "just now"
137
+ mins = secs // 60
138
+ if mins < 90:
139
+ return f"{mins} minutes ago"
140
+ hours = mins // 60
141
+ if hours < 36:
142
+ return f"{hours} hours ago"
143
+ return f"{hours // 24} days ago"
144
+
145
+
146
+ def resolve_path(path: str | os.PathLike | None, base: Path | None = None) -> Path:
147
+ raw = str(path) if path not in (None, "") else "."
148
+ p = Path(os.path.expanduser(raw))
149
+ if not p.is_absolute():
150
+ p = (base or Path.cwd()) / p
151
+ try:
152
+ p = p.resolve()
153
+ except OSError:
154
+ pass
155
+ if not p.exists():
156
+ raise PathNotFound(str(p))
157
+ if p.is_file():
158
+ p = p.parent
159
+ return p
160
+
161
+
162
+ def _lines0(text: str) -> list[str]:
163
+ return [t for t in text.split("\0") if t]
164
+
165
+
166
+ def collect(path: str | os.PathLike | None = None, deep: bool = True) -> RepoState:
167
+ p = resolve_path(path)
168
+ st = RepoState(path=p, home=Path.home())
169
+ try:
170
+ v = run_git(p, "version")
171
+ except GitNotFound:
172
+ st.git_available = False
173
+ return st
174
+ st.git_version = v.stdout.strip()
175
+
176
+ r = run_git(p, "rev-parse", "--is-inside-git-dir", "--is-bare-repository", "--git-dir", "--git-common-dir")
177
+ if not r.ok:
178
+ st.error_text = redact(r.stderr.strip())
179
+ low = r.stderr.lower()
180
+ if "dubious ownership" in low:
181
+ st.not_repo_reason = "dubious"
182
+ elif "not a git repository" in low:
183
+ st.not_repo_reason = "not_found"
184
+ else:
185
+ st.not_repo_reason = "other"
186
+ return st
187
+ inside_git_dir, is_bare, git_dir, common_dir = (r.stdout.splitlines() + ["", "", "", ""])[:4]
188
+ st.git_dir = (p / git_dir).resolve()
189
+ st.common_dir = (p / common_dir).resolve()
190
+ if is_bare == "true":
191
+ st.not_repo_reason = "bare"
192
+ return st
193
+ if inside_git_dir == "true":
194
+ st.not_repo_reason = "inside_git_dir"
195
+ return st
196
+ top = run_git(p, "rev-parse", "--show-toplevel")
197
+ if not top.ok:
198
+ st.not_repo_reason = "other"
199
+ st.error_text = redact(top.stderr.strip())
200
+ return st
201
+ st.is_repo = True
202
+ st.toplevel = Path(top.stdout.strip()).resolve()
203
+ _read_status(st)
204
+ _read_markers(st)
205
+ _read_config(st)
206
+ _read_refs(st)
207
+ if deep:
208
+ _read_outer_repo(st)
209
+ _read_nested(st)
210
+ _read_blobs(st)
211
+ _read_extras(st)
212
+ return st
213
+
214
+
215
+ def _read_status(st: RepoState) -> None:
216
+ # Listing untracked files in a whole home folder can take minutes; skip it there.
217
+ untracked = "no" if st.home_is_repo_root else "normal"
218
+ r = run_git(st.toplevel, "status", "--porcelain=v2", "--branch", "-z", f"--untracked-files={untracked}")
219
+ if not r.ok:
220
+ st.error_text = redact(r.stderr.strip())
221
+ return
222
+ fields = r.stdout.split("\0")
223
+ i = 0
224
+ ab_seen = False
225
+ while i < len(fields):
226
+ line = fields[i]
227
+ i += 1
228
+ if not line:
229
+ continue
230
+ if line.startswith("# "):
231
+ key, _, val = line[2:].partition(" ")
232
+ if key == "branch.oid":
233
+ st.unborn = val == "(initial)"
234
+ st.head_oid = None if st.unborn else val
235
+ elif key == "branch.head":
236
+ st.detached = val == "(detached)"
237
+ st.branch = None if st.detached else val
238
+ elif key == "branch.upstream":
239
+ st.upstream = val
240
+ elif key == "branch.ab":
241
+ ab_seen = True
242
+ a, b = val.split()
243
+ st.ahead, st.behind = int(a), abs(int(b))
244
+ continue
245
+ tag = line[0]
246
+ if tag == "1":
247
+ parts = line.split(" ", 8)
248
+ st.entries.append(Entry(parts[8], parts[1], "changed", mode_index=parts[4]))
249
+ elif tag == "2":
250
+ parts = line.split(" ", 9)
251
+ orig = fields[i] if i < len(fields) else ""
252
+ i += 1
253
+ st.entries.append(Entry(parts[9], parts[1], "renamed", mode_index=parts[4], orig_path=orig))
254
+ elif tag == "u":
255
+ parts = line.split(" ", 10)
256
+ st.entries.append(Entry(parts[10], parts[1], "unmerged"))
257
+ elif tag == "?":
258
+ st.entries.append(Entry(line[2:], "??", "untracked"))
259
+ if st.upstream and not ab_seen:
260
+ st.upstream_gone = True
261
+
262
+
263
+ def _read_markers(st: RepoState) -> None:
264
+ g = st.git_dir
265
+ if g is None:
266
+ return
267
+ rm, ra = g / "rebase-merge", g / "rebase-apply"
268
+ if rm.is_dir():
269
+ st.in_progress.append("rebase")
270
+ st.rebase_head_name = _read_text(rm / "head-name").replace("refs/heads/", "")
271
+ st.rebase_onto = _read_text(rm / "onto")[:9]
272
+ n, e = _read_text(rm / "msgnum"), _read_text(rm / "end")
273
+ st.rebase_step = f"{n} of {e}" if n and e else ""
274
+ st.rebase_edit_stop = (rm / "amend").exists()
275
+ elif ra.is_dir():
276
+ if (ra / "applying").exists():
277
+ st.in_progress.append("am")
278
+ else:
279
+ st.in_progress.append("rebase")
280
+ st.rebase_head_name = _read_text(ra / "head-name").replace("refs/heads/", "")
281
+ n, e = _read_text(ra / "next"), _read_text(ra / "last")
282
+ st.rebase_step = f"{n} of {e}" if n and e else ""
283
+ if (g / "MERGE_HEAD").exists():
284
+ st.in_progress.append("merge")
285
+ if (g / "CHERRY_PICK_HEAD").exists():
286
+ st.in_progress.append("cherry-pick")
287
+ if (g / "REVERT_HEAD").exists():
288
+ st.in_progress.append("revert")
289
+ if (g / "BISECT_LOG").exists() or (g / "BISECT_START").exists():
290
+ st.in_progress.append("bisect")
291
+ st.index_lock = (g / "index.lock").exists()
292
+ if st.in_progress and not st.unmerged:
293
+ r = run_git(st.toplevel, "diff", "--cached", "--check")
294
+ for line in r.stdout.splitlines():
295
+ if "leftover conflict marker" in line:
296
+ st.leftover_markers.append(line.split(":", 1)[0])
297
+ st.leftover_markers = sorted(set(st.leftover_markers))
298
+ fh = (st.common_dir or g) / "FETCH_HEAD"
299
+ try:
300
+ st.last_fetch = fh.stat().st_mtime
301
+ except OSError:
302
+ st.last_fetch = None
303
+
304
+
305
+ def _read_text(p: Path) -> str:
306
+ try:
307
+ with open(p, encoding="utf-8", errors="replace") as fh:
308
+ return fh.read().strip()
309
+ except OSError:
310
+ return ""
311
+
312
+
313
+ def _read_config(st: RepoState) -> None:
314
+ n = run_git(st.toplevel, "config", "--get", "user.name")
315
+ e = run_git(st.toplevel, "config", "--get", "user.email")
316
+ st.user_name = n.stdout.strip() or None
317
+ st.user_email = e.stdout.strip() or None
318
+ r = run_git(st.toplevel, "config", "--get-regexp", r"^remote\..*\.url$")
319
+ for line in r.stdout.splitlines():
320
+ key, _, url = line.partition(" ")
321
+ name = key[len("remote.") : -len(".url")]
322
+ st.remotes[name] = redact(url.strip())
323
+
324
+
325
+ def _read_refs(st: RepoState) -> None:
326
+ r = run_git(st.toplevel, "for-each-ref", "--format=%(refname)", "refs/heads", "refs/remotes")
327
+ for ref in r.stdout.splitlines():
328
+ if ref.startswith("refs/heads/"):
329
+ st.local_branches.append(ref[len("refs/heads/") :])
330
+ elif ref.startswith("refs/remotes/") and not ref.endswith("/HEAD"):
331
+ st.remote_branches.append(ref[len("refs/remotes/") :])
332
+ if st.detached and st.head_oid:
333
+ c = run_git(st.toplevel, "rev-list", "--count", "HEAD", "--not", "--branches", "--remotes", "--tags")
334
+ if c.ok and c.stdout.strip().isdigit():
335
+ st.orphan_commits = int(c.stdout.strip())
336
+
337
+
338
+ def _read_outer_repo(st: RepoState) -> None:
339
+ parent = st.toplevel.parent
340
+ if parent == st.toplevel:
341
+ return
342
+ r = run_git(parent, "rev-parse", "--show-toplevel")
343
+ if r.ok and r.stdout.strip():
344
+ st.outer_repo = Path(r.stdout.strip()).resolve()
345
+
346
+
347
+ _SKIP_DIRS = {"node_modules", ".venv", "venv", "env", "__pycache__", ".ipynb_checkpoints", ".tox", ".mypy_cache"}
348
+
349
+
350
+ def _read_nested(st: RepoState) -> None:
351
+ top = st.toplevel
352
+ gm = top / ".gitmodules"
353
+ if gm.exists():
354
+ for line in _read_text(gm).splitlines():
355
+ k, _, v = line.strip().partition("=")
356
+ if k.strip() == "path":
357
+ st.gitmodules.add(v.strip())
358
+ # Folders git sees as untracked that hold their own repository.
359
+ budget = [3000]
360
+ for e in st.untracked:
361
+ if e.path.endswith("/"):
362
+ d = top / e.path.rstrip("/")
363
+ st.nested_repos.extend(_find_git_dirs(d, top, 3, budget))
364
+ # Embedded repositories already added without .gitmodules.
365
+ r = run_git(top, "ls-files", "-s", "-z")
366
+ for rec in _lines0(r.stdout):
367
+ meta, _, path = rec.partition("\t")
368
+ if meta.startswith("160000") and path not in st.gitmodules:
369
+ st.unregistered_gitlinks.append(path)
370
+ for e in st.staged:
371
+ if e.mode_index == "160000" and e.path not in st.gitmodules and e.path not in st.unregistered_gitlinks:
372
+ st.unregistered_gitlinks.append(e.path)
373
+
374
+
375
+ def _find_git_dirs(d: Path, top: Path, depth: int, budget: list[int]) -> list[str]:
376
+ found: list[str] = []
377
+ if budget[0] <= 0 or depth < 0:
378
+ return found
379
+ if (d / ".git").exists():
380
+ return [d.relative_to(top).as_posix()]
381
+ try:
382
+ children = list(os.scandir(d))
383
+ except OSError:
384
+ return found
385
+ for c in children:
386
+ budget[0] -= 1
387
+ if budget[0] <= 0:
388
+ break
389
+ if c.is_dir(follow_symlinks=False) and c.name not in _SKIP_DIRS and not c.name.startswith("."):
390
+ found.extend(_find_git_dirs(Path(c.path), top, depth - 1, budget))
391
+ return found
392
+
393
+
394
+ SENSITIVE_NAMES = {
395
+ ".env",
396
+ "id_rsa",
397
+ "id_dsa",
398
+ "id_ecdsa",
399
+ "id_ed25519",
400
+ "credentials.json",
401
+ "kaggle.json",
402
+ ".netrc",
403
+ "_netrc",
404
+ ".pypirc",
405
+ "secrets.toml",
406
+ }
407
+ SENSITIVE_SUFFIXES = (".pem", ".key", ".p12", ".pfx", ".keystore", ".jks")
408
+ SAFE_ENV_SUFFIXES = (".example", ".sample", ".template", ".dist")
409
+
410
+
411
+ def is_sensitive(path: str) -> bool:
412
+ name = path.rsplit("/", 1)[-1].lower()
413
+ if name in SENSITIVE_NAMES:
414
+ return True
415
+ if name.startswith(".env.") and not name.endswith(SAFE_ENV_SUFFIXES):
416
+ return True
417
+ if name.endswith(SENSITIVE_SUFFIXES):
418
+ return True
419
+ if name.endswith(".json") and (
420
+ name.startswith("client_secret") or "service-account" in name or "service_account" in name
421
+ ):
422
+ return True
423
+ return False
424
+
425
+
426
+ def _read_blobs(st: RepoState) -> None:
427
+ top = st.toplevel
428
+ staged: list[tuple[str, str]] = [] # (oid, path)
429
+ r = run_git(top, "diff", "--cached", "--raw", "-z", "--no-abbrev", "--no-renames", "--diff-filter=AM")
430
+ if r.ok:
431
+ parts = _lines0(r.stdout)
432
+ for i in range(0, len(parts) - 1, 2):
433
+ meta, path = parts[i], parts[i + 1]
434
+ cols = meta.split()
435
+ if len(cols) >= 4 and cols[1] != "160000":
436
+ staged.append((cols[3], path))
437
+ unpushed: list[tuple[str, str]] = []
438
+ if st.head_oid:
439
+ r = run_git(top, "rev-list", "--objects", "HEAD", "--not", "--remotes", timeout=30)
440
+ if r.ok:
441
+ for line in r.stdout.splitlines():
442
+ oid, _, path = line.partition(" ")
443
+ if path:
444
+ unpushed.append((oid, path))
445
+ sizes = _object_sizes(top, [o for o, _ in staged] + [o for o, _ in unpushed])
446
+ seen: set[tuple[str, str]] = set()
447
+ for where, items in (("staged", staged), ("unpushed", unpushed)):
448
+ for oid, path in items:
449
+ typ, size = sizes.get(oid, ("", 0))
450
+ if where == "unpushed" and typ != "blob":
451
+ continue
452
+ if size >= GITHUB_WARN_LIMIT and (path, "big") not in seen:
453
+ seen.add((path, "big"))
454
+ st.big_files.append(BigFile(path, size, where))
455
+ if is_sensitive(path) and (path, "sens") not in seen:
456
+ seen.add((path, "sens"))
457
+ st.sensitive.append(BigFile(path, size, where))
458
+ if where == "staged" and path.lower().endswith(".ipynb") and size >= NOTEBOOK_WARN_SIZE:
459
+ st.big_notebooks.append(BigFile(path, size, where))
460
+
461
+
462
+ def _object_sizes(top: Path, oids: list[str]) -> dict[str, tuple[str, int]]:
463
+ if not oids:
464
+ return {}
465
+ out: dict[str, tuple[str, int]] = {}
466
+ r = run_git(
467
+ top,
468
+ "cat-file",
469
+ "--batch-check=%(objectname) %(objecttype) %(objectsize)",
470
+ input_text="\n".join(oids) + "\n",
471
+ timeout=30,
472
+ )
473
+ for line in r.stdout.splitlines():
474
+ cols = line.split()
475
+ if len(cols) == 3 and cols[2].isdigit():
476
+ out[cols[0]] = (cols[1], int(cols[2]))
477
+ return out
478
+
479
+
480
+ def _read_extras(st: RepoState) -> None:
481
+ if st.head_oid:
482
+ r = run_git(st.toplevel, "log", "-n", "1", "--format=%h %s (%cr)")
483
+ st.last_commit = r.stdout.strip()
484
+ r = run_git(st.toplevel, "rev-list", "--walk-reflogs", "--count", "refs/stash")
485
+ if r.ok and r.stdout.strip().isdigit():
486
+ st.stash_count = int(r.stdout.strip())