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/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())
|