gitrupt 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.
- gitrupt/__init__.py +13 -0
- gitrupt/cli.py +546 -0
- gitrupt/config.py +269 -0
- gitrupt/git.py +590 -0
- gitrupt/hooks/__init__.py +7 -0
- gitrupt/hooks/install.py +255 -0
- gitrupt/hooks/pre_commit.py +103 -0
- gitrupt/hooks/pre_push.py +178 -0
- gitrupt/models.py +190 -0
- gitrupt/policy.py +36 -0
- gitrupt/reporting.py +316 -0
- gitrupt/risk.py +197 -0
- gitrupt/scanner.py +117 -0
- gitrupt/scanners/__init__.py +17 -0
- gitrupt/scanners/adapters.py +166 -0
- gitrupt/scanners/base.py +113 -0
- gitrupt/scanners/binaries.py +185 -0
- gitrupt/scanners/code_rules/__init__.py +36 -0
- gitrupt/scanners/code_rules/base.py +27 -0
- gitrupt/scanners/code_rules/go.py +65 -0
- gitrupt/scanners/code_rules/javascript.py +106 -0
- gitrupt/scanners/code_rules/php.py +71 -0
- gitrupt/scanners/code_rules/powershell.py +85 -0
- gitrupt/scanners/code_rules/python.py +153 -0
- gitrupt/scanners/code_rules/ruby.py +76 -0
- gitrupt/scanners/code_rules/rust.py +41 -0
- gitrupt/scanners/code_rules/shell.py +112 -0
- gitrupt/scanners/dependencies.py +244 -0
- gitrupt/scanners/ecosystems/__init__.py +30 -0
- gitrupt/scanners/ecosystems/base.py +60 -0
- gitrupt/scanners/ecosystems/node.py +128 -0
- gitrupt/scanners/ecosystems/python.py +157 -0
- gitrupt/scanners/entropy.py +123 -0
- gitrupt/scanners/forbidden_files.py +201 -0
- gitrupt/scanners/malware.py +219 -0
- gitrupt/scanners/osv_client.py +221 -0
- gitrupt/scanners/registry.py +66 -0
- gitrupt/scanners/secret_rules.py +368 -0
- gitrupt/scanners/secrets.py +558 -0
- gitrupt/scanners/suspicious_code.py +208 -0
- gitrupt/scanners/yara_loader.py +65 -0
- gitrupt/scanners/yara_rules_builtin.py +141 -0
- gitrupt-0.1.0.dist-info/METADATA +342 -0
- gitrupt-0.1.0.dist-info/RECORD +48 -0
- gitrupt-0.1.0.dist-info/WHEEL +5 -0
- gitrupt-0.1.0.dist-info/entry_points.txt +2 -0
- gitrupt-0.1.0.dist-info/licenses/LICENSE +23 -0
- gitrupt-0.1.0.dist-info/top_level.txt +1 -0
gitrupt/git.py
ADDED
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Git adapter for Gitrupt.
|
|
3
|
+
|
|
4
|
+
All Git operations go through this module via subprocess.
|
|
5
|
+
Never uses shell=True or constructs commands from untrusted input.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import subprocess
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from gitrupt.models import StagedFile, ScanTarget
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class GitError(Exception):
|
|
20
|
+
"""Raised when a Git command fails or Git is unavailable."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class GitAdapter:
|
|
24
|
+
"""
|
|
25
|
+
Interface to the installed Git executable.
|
|
26
|
+
|
|
27
|
+
Uses subprocess with argument arrays — no shell interpolation.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, repo_root: str | None = None) -> None:
|
|
31
|
+
self._repo_root = repo_root
|
|
32
|
+
|
|
33
|
+
# -------------------------------------------------------------------------
|
|
34
|
+
# Repository detection
|
|
35
|
+
# -------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
@staticmethod
|
|
38
|
+
def is_git_available() -> bool:
|
|
39
|
+
"""Check whether Git is installed and accessible."""
|
|
40
|
+
try:
|
|
41
|
+
result = subprocess.run(
|
|
42
|
+
["git", "--version"],
|
|
43
|
+
check=False,
|
|
44
|
+
capture_output=True,
|
|
45
|
+
text=True,
|
|
46
|
+
timeout=5,
|
|
47
|
+
)
|
|
48
|
+
return result.returncode == 0
|
|
49
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
50
|
+
return False
|
|
51
|
+
|
|
52
|
+
@staticmethod
|
|
53
|
+
def find_repo_root(start: str | Path | None = None) -> str | None:
|
|
54
|
+
"""
|
|
55
|
+
Find the root of the Git repository containing `start`.
|
|
56
|
+
Returns None if not inside a Git repository.
|
|
57
|
+
"""
|
|
58
|
+
cwd = str(start) if start else None
|
|
59
|
+
try:
|
|
60
|
+
result = subprocess.run(
|
|
61
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
62
|
+
check=False,
|
|
63
|
+
capture_output=True,
|
|
64
|
+
text=True,
|
|
65
|
+
cwd=cwd,
|
|
66
|
+
timeout=10,
|
|
67
|
+
)
|
|
68
|
+
if result.returncode == 0:
|
|
69
|
+
return result.stdout.strip()
|
|
70
|
+
return None
|
|
71
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
@staticmethod
|
|
75
|
+
def find_git_dir(repo_root: str) -> str | None:
|
|
76
|
+
"""Find the .git directory for the repository."""
|
|
77
|
+
try:
|
|
78
|
+
result = subprocess.run(
|
|
79
|
+
["git", "rev-parse", "--git-dir"],
|
|
80
|
+
check=False,
|
|
81
|
+
capture_output=True,
|
|
82
|
+
text=True,
|
|
83
|
+
cwd=repo_root,
|
|
84
|
+
timeout=10,
|
|
85
|
+
)
|
|
86
|
+
if result.returncode == 0:
|
|
87
|
+
git_dir = result.stdout.strip()
|
|
88
|
+
# May be relative; resolve relative to repo_root
|
|
89
|
+
path = Path(repo_root) / git_dir
|
|
90
|
+
return str(path.resolve())
|
|
91
|
+
return None
|
|
92
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
@staticmethod
|
|
96
|
+
def find_hooks_dir(repo_root: str) -> str | None:
|
|
97
|
+
"""Find the Git hooks directory."""
|
|
98
|
+
try:
|
|
99
|
+
result = subprocess.run(
|
|
100
|
+
["git", "rev-parse", "--git-path", "hooks"],
|
|
101
|
+
check=False,
|
|
102
|
+
capture_output=True,
|
|
103
|
+
text=True,
|
|
104
|
+
cwd=repo_root,
|
|
105
|
+
timeout=10,
|
|
106
|
+
)
|
|
107
|
+
if result.returncode == 0:
|
|
108
|
+
hooks_path = result.stdout.strip()
|
|
109
|
+
path = Path(repo_root) / hooks_path
|
|
110
|
+
return str(path.resolve())
|
|
111
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
112
|
+
pass
|
|
113
|
+
|
|
114
|
+
# Fallback to standard location
|
|
115
|
+
git_dir = GitAdapter.find_git_dir(repo_root)
|
|
116
|
+
if git_dir:
|
|
117
|
+
return str(Path(git_dir) / "hooks")
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
# -------------------------------------------------------------------------
|
|
121
|
+
# Staged file information
|
|
122
|
+
# -------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
@staticmethod
|
|
125
|
+
def get_staged_files(repo_root: str) -> list[StagedFile]:
|
|
126
|
+
"""
|
|
127
|
+
Return a list of staged files with their status.
|
|
128
|
+
|
|
129
|
+
Uses: git diff --cached --name-status -z
|
|
130
|
+
The -z flag uses NUL separators to safely handle filenames with spaces/newlines.
|
|
131
|
+
"""
|
|
132
|
+
try:
|
|
133
|
+
result = subprocess.run(
|
|
134
|
+
["git", "diff", "--cached", "--name-status", "-z"],
|
|
135
|
+
check=False,
|
|
136
|
+
capture_output=True,
|
|
137
|
+
text=True,
|
|
138
|
+
cwd=repo_root,
|
|
139
|
+
timeout=30,
|
|
140
|
+
)
|
|
141
|
+
except (FileNotFoundError, subprocess.TimeoutExpired) as e:
|
|
142
|
+
raise GitError(f"Failed to list staged files: {e}") from e
|
|
143
|
+
|
|
144
|
+
if result.returncode != 0:
|
|
145
|
+
raise GitError(f"git diff --cached failed: {result.stderr.strip()}")
|
|
146
|
+
|
|
147
|
+
return _parse_name_status(result.stdout)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@staticmethod
|
|
152
|
+
def get_staged_file_bytes(repo_root: str, file_path: str) -> bytes | None:
|
|
153
|
+
"""
|
|
154
|
+
Return the raw bytes of the staged (index) version of a file.
|
|
155
|
+
|
|
156
|
+
Uses: git show :path (binary-safe — no text decoding).
|
|
157
|
+
Returns None for missing files or on error.
|
|
158
|
+
"""
|
|
159
|
+
try:
|
|
160
|
+
result = subprocess.run(
|
|
161
|
+
["git", "show", f":{file_path}"],
|
|
162
|
+
check=False,
|
|
163
|
+
capture_output=True,
|
|
164
|
+
cwd=repo_root,
|
|
165
|
+
timeout=30,
|
|
166
|
+
)
|
|
167
|
+
except (FileNotFoundError, subprocess.TimeoutExpired) as e:
|
|
168
|
+
logger.warning("Failed to read staged bytes of %s: %s", file_path, e)
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
if result.returncode != 0:
|
|
172
|
+
return None
|
|
173
|
+
return result.stdout
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@staticmethod
|
|
186
|
+
def get_staged_diff(repo_root: str) -> str:
|
|
187
|
+
"""
|
|
188
|
+
Return the unified diff of all staged changes.
|
|
189
|
+
|
|
190
|
+
Uses: git diff --cached
|
|
191
|
+
"""
|
|
192
|
+
try:
|
|
193
|
+
result = subprocess.run(
|
|
194
|
+
["git", "diff", "--cached", "--unified=3"],
|
|
195
|
+
check=False,
|
|
196
|
+
capture_output=True,
|
|
197
|
+
text=True,
|
|
198
|
+
cwd=repo_root,
|
|
199
|
+
timeout=60,
|
|
200
|
+
errors="replace", # Handle non-UTF-8 content gracefully
|
|
201
|
+
)
|
|
202
|
+
except (FileNotFoundError, subprocess.TimeoutExpired) as e:
|
|
203
|
+
raise GitError(f"Failed to get staged diff: {e}") from e
|
|
204
|
+
|
|
205
|
+
if result.returncode != 0:
|
|
206
|
+
logger.warning("git diff --cached returned %d: %s", result.returncode, result.stderr)
|
|
207
|
+
return ""
|
|
208
|
+
|
|
209
|
+
return result.stdout
|
|
210
|
+
|
|
211
|
+
@staticmethod
|
|
212
|
+
def get_staged_file_content(repo_root: str, file_path: str) -> str | None:
|
|
213
|
+
"""
|
|
214
|
+
Get the staged (index) content of a specific file.
|
|
215
|
+
|
|
216
|
+
Uses: git show :path
|
|
217
|
+
"""
|
|
218
|
+
try:
|
|
219
|
+
result = subprocess.run(
|
|
220
|
+
["git", "show", f":{file_path}"],
|
|
221
|
+
check=False,
|
|
222
|
+
capture_output=True,
|
|
223
|
+
text=True,
|
|
224
|
+
cwd=repo_root,
|
|
225
|
+
timeout=15,
|
|
226
|
+
errors="replace",
|
|
227
|
+
)
|
|
228
|
+
except (FileNotFoundError, subprocess.TimeoutExpired) as e:
|
|
229
|
+
logger.warning("Failed to get content of %s: %s", file_path, e)
|
|
230
|
+
return None
|
|
231
|
+
|
|
232
|
+
if result.returncode != 0:
|
|
233
|
+
return None
|
|
234
|
+
|
|
235
|
+
return result.stdout
|
|
236
|
+
|
|
237
|
+
@staticmethod
|
|
238
|
+
def is_binary_file(repo_root: str, file_path: str) -> bool:
|
|
239
|
+
"""
|
|
240
|
+
Determine whether a staged file is binary.
|
|
241
|
+
|
|
242
|
+
Uses: git diff --cached --numstat
|
|
243
|
+
"""
|
|
244
|
+
try:
|
|
245
|
+
result = subprocess.run(
|
|
246
|
+
["git", "diff", "--cached", "--numstat", "--", file_path],
|
|
247
|
+
check=False,
|
|
248
|
+
capture_output=True,
|
|
249
|
+
text=True,
|
|
250
|
+
cwd=repo_root,
|
|
251
|
+
timeout=10,
|
|
252
|
+
)
|
|
253
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
254
|
+
return False
|
|
255
|
+
|
|
256
|
+
if result.returncode != 0:
|
|
257
|
+
return False
|
|
258
|
+
|
|
259
|
+
# Binary files show "-\t-\t" in numstat output
|
|
260
|
+
output = result.stdout.strip()
|
|
261
|
+
if output and output.startswith("-\t-\t"):
|
|
262
|
+
return True
|
|
263
|
+
return False
|
|
264
|
+
|
|
265
|
+
@staticmethod
|
|
266
|
+
def get_file_size(repo_root: str, file_path: str) -> int:
|
|
267
|
+
"""Get the staged size of a file in bytes."""
|
|
268
|
+
try:
|
|
269
|
+
result = subprocess.run(
|
|
270
|
+
["git", "cat-file", "-s", f":{file_path}"],
|
|
271
|
+
check=False,
|
|
272
|
+
capture_output=True,
|
|
273
|
+
text=True,
|
|
274
|
+
cwd=repo_root,
|
|
275
|
+
timeout=10,
|
|
276
|
+
)
|
|
277
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
278
|
+
return 0
|
|
279
|
+
|
|
280
|
+
if result.returncode != 0:
|
|
281
|
+
return 0
|
|
282
|
+
|
|
283
|
+
try:
|
|
284
|
+
return int(result.stdout.strip())
|
|
285
|
+
except ValueError:
|
|
286
|
+
return 0
|
|
287
|
+
|
|
288
|
+
# -------------------------------------------------------------------------
|
|
289
|
+
# High-level helpers
|
|
290
|
+
# -------------------------------------------------------------------------
|
|
291
|
+
|
|
292
|
+
@staticmethod
|
|
293
|
+
def build_scan_target(repo_root: str) -> ScanTarget:
|
|
294
|
+
"""
|
|
295
|
+
Build a ScanTarget from the current staged state.
|
|
296
|
+
|
|
297
|
+
This is the primary entry point for scanners.
|
|
298
|
+
"""
|
|
299
|
+
staged_files = GitAdapter.get_staged_files(repo_root)
|
|
300
|
+
|
|
301
|
+
# Enrich file metadata
|
|
302
|
+
enriched: list[StagedFile] = []
|
|
303
|
+
for sf in staged_files:
|
|
304
|
+
if sf.status == "D":
|
|
305
|
+
# Deleted files — no content to scan
|
|
306
|
+
enriched.append(sf)
|
|
307
|
+
continue
|
|
308
|
+
|
|
309
|
+
is_binary = GitAdapter.is_binary_file(repo_root, sf.path)
|
|
310
|
+
size = GitAdapter.get_file_size(repo_root, sf.path)
|
|
311
|
+
enriched.append(
|
|
312
|
+
StagedFile(
|
|
313
|
+
path=sf.path,
|
|
314
|
+
status=sf.status,
|
|
315
|
+
old_path=sf.old_path,
|
|
316
|
+
is_binary=is_binary,
|
|
317
|
+
size_bytes=size,
|
|
318
|
+
)
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
staged_diff = GitAdapter.get_staged_diff(repo_root)
|
|
322
|
+
|
|
323
|
+
return ScanTarget(
|
|
324
|
+
staged_files=enriched,
|
|
325
|
+
staged_diff=staged_diff,
|
|
326
|
+
repo_root=repo_root,
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
@staticmethod
|
|
330
|
+
def get_git_version() -> str | None:
|
|
331
|
+
"""Return the installed Git version string."""
|
|
332
|
+
try:
|
|
333
|
+
result = subprocess.run(
|
|
334
|
+
["git", "--version"],
|
|
335
|
+
check=False,
|
|
336
|
+
capture_output=True,
|
|
337
|
+
text=True,
|
|
338
|
+
timeout=5,
|
|
339
|
+
)
|
|
340
|
+
if result.returncode == 0:
|
|
341
|
+
return result.stdout.strip()
|
|
342
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
343
|
+
pass
|
|
344
|
+
return None
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
# -------------------------------------------------------------------------
|
|
351
|
+
# Ref-aware content reads (used by pre-push)
|
|
352
|
+
# -------------------------------------------------------------------------
|
|
353
|
+
|
|
354
|
+
@staticmethod
|
|
355
|
+
def get_file_content_at(repo_root: str, ref_prefix: str, path: str) -> str | None:
|
|
356
|
+
"""
|
|
357
|
+
Read a file's text content from a specific ref.
|
|
358
|
+
|
|
359
|
+
ref_prefix is a Git ref prefix ending in ':':
|
|
360
|
+
':' -> the index
|
|
361
|
+
'HEAD:' -> the HEAD commit
|
|
362
|
+
'<sha>:' -> that commit
|
|
363
|
+
"""
|
|
364
|
+
try:
|
|
365
|
+
result = subprocess.run(
|
|
366
|
+
["git", "show", f"{ref_prefix}{path}"],
|
|
367
|
+
check=False,
|
|
368
|
+
capture_output=True,
|
|
369
|
+
text=True,
|
|
370
|
+
cwd=repo_root,
|
|
371
|
+
timeout=30,
|
|
372
|
+
errors="replace",
|
|
373
|
+
)
|
|
374
|
+
except (FileNotFoundError, subprocess.TimeoutExpired) as e:
|
|
375
|
+
logger.warning("Failed to read %s%s: %s", ref_prefix, path, e)
|
|
376
|
+
return None
|
|
377
|
+
|
|
378
|
+
if result.returncode != 0:
|
|
379
|
+
return None
|
|
380
|
+
return result.stdout
|
|
381
|
+
|
|
382
|
+
@staticmethod
|
|
383
|
+
def get_file_bytes_at(repo_root: str, ref_prefix: str, path: str) -> bytes | None:
|
|
384
|
+
"""Read a file's raw bytes from a specific ref (binary-safe)."""
|
|
385
|
+
try:
|
|
386
|
+
result = subprocess.run(
|
|
387
|
+
["git", "show", f"{ref_prefix}{path}"],
|
|
388
|
+
check=False,
|
|
389
|
+
capture_output=True,
|
|
390
|
+
cwd=repo_root,
|
|
391
|
+
timeout=30,
|
|
392
|
+
)
|
|
393
|
+
except (FileNotFoundError, subprocess.TimeoutExpired) as e:
|
|
394
|
+
logger.warning("Failed to read bytes of %s%s: %s", ref_prefix, path, e)
|
|
395
|
+
return None
|
|
396
|
+
|
|
397
|
+
if result.returncode != 0:
|
|
398
|
+
return None
|
|
399
|
+
return result.stdout
|
|
400
|
+
|
|
401
|
+
# -------------------------------------------------------------------------
|
|
402
|
+
# Outgoing-range operations (used by pre-push)
|
|
403
|
+
# -------------------------------------------------------------------------
|
|
404
|
+
|
|
405
|
+
@staticmethod
|
|
406
|
+
def get_diff_files(repo_root: str, base: str, head: str) -> list[StagedFile]:
|
|
407
|
+
"""List files changed between two refs (uses the same parser as staged)."""
|
|
408
|
+
try:
|
|
409
|
+
result = subprocess.run(
|
|
410
|
+
["git", "diff", "--name-status", "-z", base, head],
|
|
411
|
+
check=False,
|
|
412
|
+
capture_output=True,
|
|
413
|
+
text=True,
|
|
414
|
+
cwd=repo_root,
|
|
415
|
+
timeout=60,
|
|
416
|
+
)
|
|
417
|
+
except (FileNotFoundError, subprocess.TimeoutExpired) as e:
|
|
418
|
+
raise GitError(f"Failed to list outgoing files: {e}") from e
|
|
419
|
+
|
|
420
|
+
if result.returncode != 0:
|
|
421
|
+
raise GitError(f"git diff {base}..{head} failed: {result.stderr.strip()}")
|
|
422
|
+
|
|
423
|
+
return _parse_name_status(result.stdout)
|
|
424
|
+
|
|
425
|
+
@staticmethod
|
|
426
|
+
def get_diff_text(repo_root: str, base: str, head: str) -> str:
|
|
427
|
+
"""Return the unified diff between two refs."""
|
|
428
|
+
try:
|
|
429
|
+
result = subprocess.run(
|
|
430
|
+
["git", "diff", "--unified=3", base, head],
|
|
431
|
+
check=False,
|
|
432
|
+
capture_output=True,
|
|
433
|
+
text=True,
|
|
434
|
+
cwd=repo_root,
|
|
435
|
+
timeout=120,
|
|
436
|
+
errors="replace",
|
|
437
|
+
)
|
|
438
|
+
except (FileNotFoundError, subprocess.TimeoutExpired) as e:
|
|
439
|
+
raise GitError(f"Failed to compute outgoing diff: {e}") from e
|
|
440
|
+
|
|
441
|
+
if result.returncode != 0:
|
|
442
|
+
logger.warning(
|
|
443
|
+
"git diff %s..%s returned %d: %s",
|
|
444
|
+
base, head, result.returncode, result.stderr,
|
|
445
|
+
)
|
|
446
|
+
return ""
|
|
447
|
+
|
|
448
|
+
return result.stdout
|
|
449
|
+
|
|
450
|
+
@staticmethod
|
|
451
|
+
def build_push_scan_target(
|
|
452
|
+
repo_root: str,
|
|
453
|
+
remote_sha: str,
|
|
454
|
+
local_sha: str,
|
|
455
|
+
) -> ScanTarget:
|
|
456
|
+
"""
|
|
457
|
+
Build a ScanTarget for a pre-push scan.
|
|
458
|
+
|
|
459
|
+
- Deletions are skipped.
|
|
460
|
+
- When remote_sha is all zeros (new branch), the base is Git's empty
|
|
461
|
+
tree so every file in the pushed tree is treated as added.
|
|
462
|
+
- File binary/size metadata is computed from the commit content.
|
|
463
|
+
"""
|
|
464
|
+
EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
|
|
465
|
+
ZERO = "0" * 40
|
|
466
|
+
|
|
467
|
+
base = remote_sha if remote_sha and remote_sha != ZERO else EMPTY_TREE
|
|
468
|
+
head = local_sha
|
|
469
|
+
|
|
470
|
+
files = GitAdapter.get_diff_files(repo_root, base, head)
|
|
471
|
+
|
|
472
|
+
enriched: list[StagedFile] = []
|
|
473
|
+
for sf in files:
|
|
474
|
+
if sf.status == "D":
|
|
475
|
+
enriched.append(sf)
|
|
476
|
+
continue
|
|
477
|
+
|
|
478
|
+
data = GitAdapter.get_file_bytes_at(repo_root, f"{head}:", sf.path)
|
|
479
|
+
if data is None:
|
|
480
|
+
enriched.append(sf)
|
|
481
|
+
continue
|
|
482
|
+
|
|
483
|
+
is_binary = b"\x00" in data[:8192]
|
|
484
|
+
enriched.append(
|
|
485
|
+
StagedFile(
|
|
486
|
+
path=sf.path,
|
|
487
|
+
status=sf.status,
|
|
488
|
+
old_path=sf.old_path,
|
|
489
|
+
is_binary=is_binary,
|
|
490
|
+
size_bytes=len(data),
|
|
491
|
+
)
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
diff = GitAdapter.get_diff_text(repo_root, base, head)
|
|
495
|
+
|
|
496
|
+
return ScanTarget(
|
|
497
|
+
staged_files=enriched,
|
|
498
|
+
staged_diff=diff,
|
|
499
|
+
repo_root=repo_root,
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
@staticmethod
|
|
506
|
+
def get_git_binary() -> str | None:
|
|
507
|
+
"""Return the absolute path to the git executable on PATH."""
|
|
508
|
+
import shutil
|
|
509
|
+
return shutil.which("git")
|
|
510
|
+
|
|
511
|
+
@staticmethod
|
|
512
|
+
def get_core_hooks_path(repo_root: str) -> str | None:
|
|
513
|
+
"""
|
|
514
|
+
Return the value of core.hooksPath if set, else None.
|
|
515
|
+
|
|
516
|
+
When core.hooksPath is set, Git uses it instead of .git/hooks.
|
|
517
|
+
Gitrupt must inform the user so they know where the hook actually lives.
|
|
518
|
+
"""
|
|
519
|
+
try:
|
|
520
|
+
result = subprocess.run(
|
|
521
|
+
["git", "config", "--get", "core.hooksPath"],
|
|
522
|
+
check=False,
|
|
523
|
+
capture_output=True,
|
|
524
|
+
text=True,
|
|
525
|
+
cwd=repo_root,
|
|
526
|
+
timeout=5,
|
|
527
|
+
)
|
|
528
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
529
|
+
return None
|
|
530
|
+
|
|
531
|
+
if result.returncode != 0:
|
|
532
|
+
return None
|
|
533
|
+
|
|
534
|
+
value = result.stdout.strip()
|
|
535
|
+
return value or None
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
539
|
+
# Parsing helpers
|
|
540
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def _parse_name_status(output: str) -> list[StagedFile]:
|
|
544
|
+
"""
|
|
545
|
+
Parse the output of: git diff --cached --name-status -z
|
|
546
|
+
|
|
547
|
+
Format per entry:
|
|
548
|
+
STATUS\0PATH\0 (for A, M, D, T)
|
|
549
|
+
STATUS\0OLD_PATH\0NEW_PATH\0 (for R, C with similarity score in status like R90)
|
|
550
|
+
"""
|
|
551
|
+
if not output:
|
|
552
|
+
return []
|
|
553
|
+
|
|
554
|
+
files: list[StagedFile] = []
|
|
555
|
+
parts = output.split("\0")
|
|
556
|
+
i = 0
|
|
557
|
+
|
|
558
|
+
while i < len(parts):
|
|
559
|
+
part = parts[i].strip()
|
|
560
|
+
if not part:
|
|
561
|
+
i += 1
|
|
562
|
+
continue
|
|
563
|
+
|
|
564
|
+
status_code = part[0].upper()
|
|
565
|
+
|
|
566
|
+
if status_code in ("R", "C"):
|
|
567
|
+
# Rename or copy: STATUS\0old_path\0new_path
|
|
568
|
+
if i + 2 >= len(parts):
|
|
569
|
+
break
|
|
570
|
+
old_path = parts[i + 1]
|
|
571
|
+
new_path = parts[i + 2]
|
|
572
|
+
i += 3
|
|
573
|
+
if old_path and new_path:
|
|
574
|
+
files.append(
|
|
575
|
+
StagedFile(
|
|
576
|
+
path=new_path,
|
|
577
|
+
status=status_code,
|
|
578
|
+
old_path=old_path,
|
|
579
|
+
)
|
|
580
|
+
)
|
|
581
|
+
else:
|
|
582
|
+
# Standard: STATUS\0path
|
|
583
|
+
if i + 1 >= len(parts):
|
|
584
|
+
break
|
|
585
|
+
path = parts[i + 1]
|
|
586
|
+
i += 2
|
|
587
|
+
if path:
|
|
588
|
+
files.append(StagedFile(path=path, status=status_code))
|
|
589
|
+
|
|
590
|
+
return files
|