polyglotminer 1.0.1__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.
@@ -0,0 +1,3 @@
1
+ """PolyglotMiner - Git Repository Analysis & Cross-Language Migration Research Tool."""
2
+
3
+ __version__ = "1.0.1"
polyglotminer/cache.py ADDED
@@ -0,0 +1,246 @@
1
+ """Persistent Repository Cache Pool for rapid Git history operations.
2
+
3
+ Maintains local clones in `.cache/repos/` so repositories are cloned once
4
+ using standard git CLI and updated via sub-second `git fetch`.
5
+ """
6
+
7
+ import os
8
+ from pathlib import Path
9
+ import shutil
10
+ import subprocess
11
+ import sys
12
+ from typing import Optional, Tuple, Union
13
+
14
+ from polyglotminer.scanner import (
15
+ format_git_auth_header,
16
+ get_github_auth_token,
17
+ invalidate_github_token,
18
+ is_auth_failure_error,
19
+ resolve_remote_url,
20
+ )
21
+
22
+
23
+ def get_cache_root() -> Path:
24
+ """Return the absolute path to workspace repository cache pool (.cache/repos)."""
25
+ base = Path(__file__).resolve().parent.parent / ".cache" / "repos"
26
+ base.mkdir(parents=True, exist_ok=True)
27
+ return base
28
+
29
+
30
+ def get_cached_repo_dir(remote_target: Union[str, Path]) -> Tuple[Path, str, str]:
31
+ """Return (local_cache_path, clone_url, repo_slug)."""
32
+ clone_url, default_name = resolve_remote_url(remote_target)
33
+ slug = default_name.replace("/", "_").replace("\\", "_")
34
+ short_name = default_name.split("/")[-1] if "/" in default_name else default_name
35
+
36
+ root = get_cache_root()
37
+ # Prioritize the unique owner-qualified slug to prevent collisions
38
+ if (root / slug / ".git").exists():
39
+ return root / slug, clone_url, default_name
40
+ if (root / short_name / ".git").exists():
41
+ return root / short_name, clone_url, default_name
42
+
43
+ target_dir = root / slug
44
+ return target_dir, clone_url, default_name
45
+
46
+
47
+ def resolve_repo_path(
48
+ target: Union[str, Path],
49
+ reports_dir: Optional[Union[str, Path]] = None,
50
+ ) -> Optional[Path]:
51
+ """Resolve a repository target string or Path to an existing local git repository Path if available."""
52
+ if not target:
53
+ return None
54
+ p = Path(target).resolve()
55
+ if p.exists() and (p / ".git").exists():
56
+ return p
57
+ cached_dir, _, _ = get_cached_repo_dir(str(target))
58
+ if cached_dir.exists() and (cached_dir / ".git").exists():
59
+ return cached_dir
60
+ root = get_cache_root()
61
+ slug = str(target).replace("/", "_").replace("\\", "_")
62
+ short_name = str(target).split("/")[-1] if "/" in str(target) else str(target)
63
+ for candidate in (root / slug, root / short_name, root / str(target)):
64
+ if candidate.exists() and (candidate / ".git").exists():
65
+ return candidate
66
+ return None
67
+
68
+
69
+ def _force_rmtree(path: Path) -> None:
70
+ """Force recursive directory removal on Windows handling read-only git files."""
71
+ import stat
72
+ if not path.exists():
73
+ return
74
+
75
+ def _handle_remove_readonly(func, p, exc=None):
76
+ try:
77
+ os.chmod(p, stat.S_IWRITE)
78
+ func(p)
79
+ except Exception:
80
+ pass
81
+
82
+ try:
83
+ if sys.version_info >= (3, 12):
84
+ shutil.rmtree(path, onexc=_handle_remove_readonly)
85
+ else:
86
+ shutil.rmtree(path, onerror=_handle_remove_readonly)
87
+ except Exception:
88
+ pass
89
+
90
+ if path.exists() and sys.platform == "win32":
91
+ try:
92
+ subprocess.run(["cmd", "/c", "rmdir", "/s", "/q", str(path)], capture_output=True, timeout=5)
93
+ except Exception:
94
+ pass
95
+
96
+
97
+ def get_or_clone_repository(
98
+ remote_target: Union[str, Path],
99
+ blobless: bool = True,
100
+ fetch: bool = False,
101
+ progress_callback: Optional[callable] = None,
102
+ ) -> Path:
103
+ """Obtain a cached repository Path, cloning if missing or fetching if present."""
104
+ env = dict(os.environ)
105
+ env["GIT_TERMINAL_PROMPT"] = "0"
106
+ env["GIT_ASKPASS"] = ""
107
+
108
+ cached_dir, clone_url, repo_slug = get_cached_repo_dir(remote_target)
109
+
110
+ # Clean up any partial/broken directories
111
+ git_dir = cached_dir / ".git"
112
+ if cached_dir.exists():
113
+ if git_dir.exists():
114
+ test_res = subprocess.run(
115
+ ["git", "-C", str(cached_dir), "rev-parse", "HEAD"],
116
+ capture_output=True,
117
+ env=env,
118
+ timeout=5,
119
+ )
120
+ if test_res.returncode == 0:
121
+ # Valid existing clone
122
+ if not blobless:
123
+ # Check if existing clone is blobless (missing blobs)
124
+ check_filter = subprocess.run(
125
+ ["git", "-C", str(cached_dir), "config", "--get", "remote.origin.partialclonefilter"],
126
+ capture_output=True,
127
+ text=True,
128
+ env=env,
129
+ )
130
+ if "blob:none" in check_filter.stdout:
131
+ # Re-clone cleanly with full objects so cat-file line counting is 100% local and instantaneous
132
+ _force_rmtree(cached_dir)
133
+
134
+ if cached_dir.exists():
135
+ if fetch:
136
+ if progress_callback:
137
+ progress_callback(1, 2, f"Updating cached repository {repo_slug} (git fetch)...")
138
+ try:
139
+ subprocess.run(
140
+ ["git", "fetch", "--all"],
141
+ cwd=str(cached_dir),
142
+ env=env,
143
+ stdin=subprocess.DEVNULL,
144
+ capture_output=True,
145
+ check=False,
146
+ timeout=30,
147
+ )
148
+ except Exception:
149
+ pass
150
+ return cached_dir
151
+
152
+ # Corrupted / invalid directory: remove cleanly
153
+ _force_rmtree(cached_dir)
154
+
155
+ # Repository not in cache: perform robust initial clone via git CLI
156
+ if progress_callback:
157
+ progress_callback(1, 2, f"Cloning {repo_slug} into cache pool...")
158
+
159
+ cached_dir.parent.mkdir(parents=True, exist_ok=True)
160
+ token = get_github_auth_token()
161
+
162
+ def _execute_clone(auth_token: Optional[str]) -> subprocess.CompletedProcess:
163
+ cmd = [
164
+ "git",
165
+ "-c", "credential.helper=",
166
+ "-c", "core.askPass=",
167
+ ]
168
+ if auth_token:
169
+ cmd.extend(["-c", f"http.extraHeader={format_git_auth_header(auth_token)}"])
170
+ cmd.extend(["clone", "--no-checkout"])
171
+
172
+ if blobless:
173
+ cmd.append("--filter=blob:none")
174
+
175
+ cmd.extend([
176
+ clone_url,
177
+ str(cached_dir),
178
+ ])
179
+
180
+ return subprocess.run(
181
+ cmd,
182
+ env=env,
183
+ stdin=subprocess.DEVNULL,
184
+ capture_output=True,
185
+ text=True,
186
+ timeout=120,
187
+ )
188
+
189
+ result = _execute_clone(token)
190
+
191
+ # Detect if clone failed due to authentication issues (e.g. expired/revoked token)
192
+ if result.returncode != 0 and token:
193
+ err_msg = result.stderr or result.stdout or ""
194
+ if is_auth_failure_error(err_msg):
195
+ invalidate_github_token()
196
+ if cached_dir.exists():
197
+ _force_rmtree(cached_dir)
198
+ if progress_callback:
199
+ progress_callback(1, 2, f"GitHub token rejected for {repo_slug}. Retrying unauthenticated clone...")
200
+ result = _execute_clone(None)
201
+
202
+ if result.returncode != 0:
203
+ if cached_dir.exists():
204
+ _force_rmtree(cached_dir)
205
+ clean_url = clone_url.split("@")[-1] if "@" in clone_url else clone_url
206
+ raise RuntimeError(f"Git clone failed for {clean_url}: {result.stderr or result.stdout}")
207
+
208
+ # Verify that the cloned repository is valid
209
+ test_res = subprocess.run(
210
+ ["git", "-C", str(cached_dir), "rev-parse", "HEAD"],
211
+ capture_output=True,
212
+ env=env,
213
+ timeout=5,
214
+ )
215
+ if test_res.returncode != 0:
216
+ _force_rmtree(cached_dir)
217
+ raise RuntimeError(f"Cloned repository {clone_url} has no valid HEAD ref.")
218
+
219
+ return cached_dir
220
+
221
+
222
+ def clear_repo_cache(target_slug: Optional[Union[str, Path]] = None) -> int:
223
+ """Clear cached repositories. If target_slug is None, clears all."""
224
+ root = get_cache_root()
225
+ if not root.exists():
226
+ return 0
227
+
228
+ count = 0
229
+ if target_slug:
230
+ cached_dir, _, _ = get_cached_repo_dir(str(target_slug))
231
+ if cached_dir.exists():
232
+ _force_rmtree(cached_dir)
233
+ count = 1
234
+ else:
235
+ slug = str(target_slug).replace("/", "_").replace("\\", "_")
236
+ short = str(target_slug).split("/")[-1]
237
+ for p in [root / slug, root / short, root / str(target_slug)]:
238
+ if p.exists():
239
+ _force_rmtree(p)
240
+ count += 1
241
+ else:
242
+ for child in root.iterdir():
243
+ if child.is_dir():
244
+ _force_rmtree(child)
245
+ count += 1
246
+ return count