docugraph 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.
- docugraph/__init__.py +24 -0
- docugraph/_version.py +28 -0
- docugraph/agents/__init__.py +27 -0
- docugraph/agents/coding_agent.py +532 -0
- docugraph/agents/tools/__init__.py +62 -0
- docugraph/agents/tools/graph_query.py +203 -0
- docugraph/agents/tools/memory_ops.py +305 -0
- docugraph/agents/tools/search_code.py +305 -0
- docugraph/agents/tools/search_docs.py +148 -0
- docugraph/core/__init__.py +34 -0
- docugraph/core/config.py +183 -0
- docugraph/core/embeddings.py +394 -0
- docugraph/core/llm.py +892 -0
- docugraph/core/models.py +145 -0
- docugraph/ingestion/__init__.py +64 -0
- docugraph/ingestion/api_docs.py +646 -0
- docugraph/ingestion/chunker.py +382 -0
- docugraph/ingestion/crawler.py +305 -0
- docugraph/ingestion/git_indexer.py +556 -0
- docugraph/ingestion/local_files.py +516 -0
- docugraph/interfaces/__init__.py +1 -0
- docugraph/interfaces/cli.py +983 -0
- docugraph/interfaces/mcp_server.py +918 -0
- docugraph/retrieval/__init__.py +54 -0
- docugraph/retrieval/context_builder.py +522 -0
- docugraph/retrieval/hybrid_search.py +534 -0
- docugraph/retrieval/reranker.py +532 -0
- docugraph/storage/__init__.py +14 -0
- docugraph/storage/graph_store.py +602 -0
- docugraph/storage/memory_store.py +529 -0
- docugraph/storage/vector_store.py +367 -0
- docugraph-0.1.0.dist-info/METADATA +354 -0
- docugraph-0.1.0.dist-info/RECORD +37 -0
- docugraph-0.1.0.dist-info/WHEEL +4 -0
- docugraph-0.1.0.dist-info/entry_points.txt +3 -0
- docugraph-0.1.0.dist-info/licenses/LICENSE +201 -0
- docugraph-0.1.0.dist-info/licenses/NOTICE +10 -0
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
"""Git repository indexing for documentation and code.
|
|
2
|
+
|
|
3
|
+
Supports:
|
|
4
|
+
- Cloning repositories (HTTPS, SSH)
|
|
5
|
+
- Indexing documentation files
|
|
6
|
+
- Optional code file indexing with docstring extraction
|
|
7
|
+
- Branch/tag checkout
|
|
8
|
+
- Incremental updates via git pull
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess # nosec B404 -- git CLI wrapper; argv is a list, never shell
|
|
15
|
+
from collections.abc import Iterator
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from datetime import UTC, datetime
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from docugraph.core.config import get_config
|
|
22
|
+
from docugraph.core.models import Document
|
|
23
|
+
from docugraph.ingestion.chunker import Chunker, ChunkerConfig
|
|
24
|
+
from docugraph.ingestion.local_files import (
|
|
25
|
+
IndexedFile,
|
|
26
|
+
LocalFileConfig,
|
|
27
|
+
LocalFileIndexer,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class GitIndexerConfig:
|
|
33
|
+
"""Configuration for git repository indexing."""
|
|
34
|
+
|
|
35
|
+
# Clone settings
|
|
36
|
+
clone_depth: int | None = None # None for full clone, 1 for shallow
|
|
37
|
+
clone_timeout: int = 300 # seconds
|
|
38
|
+
|
|
39
|
+
# Branch/ref to checkout
|
|
40
|
+
ref: str | None = None # branch, tag, or commit hash
|
|
41
|
+
|
|
42
|
+
# Where to store cloned repos
|
|
43
|
+
repos_dir: Path | None = None # Default: config.storage.data_dir / "repos"
|
|
44
|
+
|
|
45
|
+
# File indexing settings
|
|
46
|
+
include_docs: bool = True # Index documentation files
|
|
47
|
+
include_code: bool = False # Index code files with docstrings
|
|
48
|
+
|
|
49
|
+
# Documentation patterns (in addition to LocalFileConfig defaults)
|
|
50
|
+
doc_patterns: list[str] = field(
|
|
51
|
+
default_factory=lambda: [
|
|
52
|
+
"*.md",
|
|
53
|
+
"*.mdx",
|
|
54
|
+
"*.rst",
|
|
55
|
+
"*.txt",
|
|
56
|
+
"README*",
|
|
57
|
+
"CHANGELOG*",
|
|
58
|
+
"CONTRIBUTING*",
|
|
59
|
+
"docs/**/*",
|
|
60
|
+
"documentation/**/*",
|
|
61
|
+
]
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# Code patterns (when include_code=True)
|
|
65
|
+
code_patterns: list[str] = field(
|
|
66
|
+
default_factory=lambda: [
|
|
67
|
+
"*.py",
|
|
68
|
+
"*.js",
|
|
69
|
+
"*.ts",
|
|
70
|
+
"*.java",
|
|
71
|
+
"*.go",
|
|
72
|
+
"*.rs",
|
|
73
|
+
]
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# Directories to skip
|
|
77
|
+
skip_dirs: list[str] = field(
|
|
78
|
+
default_factory=lambda: [
|
|
79
|
+
".git",
|
|
80
|
+
"node_modules",
|
|
81
|
+
"__pycache__",
|
|
82
|
+
".venv",
|
|
83
|
+
"venv",
|
|
84
|
+
"dist",
|
|
85
|
+
"build",
|
|
86
|
+
".tox",
|
|
87
|
+
"target",
|
|
88
|
+
"vendor",
|
|
89
|
+
]
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
# Maximum file size (1MB default)
|
|
93
|
+
max_file_size: int = 1024 * 1024
|
|
94
|
+
|
|
95
|
+
# Clean up cloned repos after indexing
|
|
96
|
+
cleanup_after_index: bool = False
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass
|
|
100
|
+
class RepoInfo:
|
|
101
|
+
"""Information about a git repository."""
|
|
102
|
+
|
|
103
|
+
url: str
|
|
104
|
+
name: str
|
|
105
|
+
local_path: Path
|
|
106
|
+
branch: str | None
|
|
107
|
+
commit_hash: str | None
|
|
108
|
+
clone_time: datetime
|
|
109
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class GitIndexer:
|
|
113
|
+
"""Indexes git repositories for documentation and code.
|
|
114
|
+
|
|
115
|
+
Clones repositories, indexes documentation files, and optionally
|
|
116
|
+
extracts code documentation (docstrings, comments).
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
def __init__(
|
|
120
|
+
self,
|
|
121
|
+
config: GitIndexerConfig | None = None,
|
|
122
|
+
chunker: Chunker | None = None,
|
|
123
|
+
) -> None:
|
|
124
|
+
"""Initialize the git indexer.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
config: Indexer configuration
|
|
128
|
+
chunker: Optional chunker for document splitting
|
|
129
|
+
"""
|
|
130
|
+
self._config = config or GitIndexerConfig()
|
|
131
|
+
self._chunker = chunker
|
|
132
|
+
|
|
133
|
+
# Set up repos directory
|
|
134
|
+
if self._config.repos_dir is None:
|
|
135
|
+
app_config = get_config()
|
|
136
|
+
self._repos_dir = app_config.storage.data_dir / "repos"
|
|
137
|
+
else:
|
|
138
|
+
self._repos_dir = self._config.repos_dir
|
|
139
|
+
|
|
140
|
+
self._repos_dir.mkdir(parents=True, exist_ok=True)
|
|
141
|
+
|
|
142
|
+
def _extract_repo_name(self, url: str) -> str:
|
|
143
|
+
"""Extract repository name from URL.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
url: Git repository URL
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
Repository name
|
|
150
|
+
"""
|
|
151
|
+
# Handle various URL formats
|
|
152
|
+
# https://github.com/user/repo.git
|
|
153
|
+
# git@github.com:user/repo.git
|
|
154
|
+
# https://github.com/user/repo
|
|
155
|
+
|
|
156
|
+
name = url.rstrip("/")
|
|
157
|
+
if name.endswith(".git"):
|
|
158
|
+
name = name[:-4]
|
|
159
|
+
|
|
160
|
+
# Extract last path component
|
|
161
|
+
if "/" in name:
|
|
162
|
+
name = name.split("/")[-1]
|
|
163
|
+
elif ":" in name:
|
|
164
|
+
name = name.split(":")[-1].split("/")[-1]
|
|
165
|
+
|
|
166
|
+
return name or "repo"
|
|
167
|
+
|
|
168
|
+
def _run_git_command(
|
|
169
|
+
self,
|
|
170
|
+
args: list[str],
|
|
171
|
+
cwd: Path | None = None,
|
|
172
|
+
timeout: int | None = None,
|
|
173
|
+
) -> tuple[str, str, int]:
|
|
174
|
+
"""Run a git command.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
args: Git command arguments
|
|
178
|
+
cwd: Working directory
|
|
179
|
+
timeout: Command timeout in seconds
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
Tuple of (stdout, stderr, return_code)
|
|
183
|
+
"""
|
|
184
|
+
cmd = ["git"] + args
|
|
185
|
+
timeout = timeout or self._config.clone_timeout
|
|
186
|
+
|
|
187
|
+
try:
|
|
188
|
+
result = subprocess.run( # nosec B603 -- argv list, no shell; git only
|
|
189
|
+
cmd,
|
|
190
|
+
cwd=cwd,
|
|
191
|
+
capture_output=True,
|
|
192
|
+
text=True,
|
|
193
|
+
timeout=timeout,
|
|
194
|
+
)
|
|
195
|
+
return result.stdout, result.stderr, result.returncode
|
|
196
|
+
except subprocess.TimeoutExpired:
|
|
197
|
+
return "", "Command timed out", 1
|
|
198
|
+
except Exception as e:
|
|
199
|
+
return "", str(e), 1
|
|
200
|
+
|
|
201
|
+
def _get_current_commit(self, repo_path: Path) -> str | None:
|
|
202
|
+
"""Get the current commit hash.
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
repo_path: Repository path
|
|
206
|
+
|
|
207
|
+
Returns:
|
|
208
|
+
Commit hash or None
|
|
209
|
+
"""
|
|
210
|
+
stdout, _, code = self._run_git_command(
|
|
211
|
+
["rev-parse", "HEAD"],
|
|
212
|
+
cwd=repo_path,
|
|
213
|
+
timeout=10,
|
|
214
|
+
)
|
|
215
|
+
if code == 0:
|
|
216
|
+
return stdout.strip()
|
|
217
|
+
return None
|
|
218
|
+
|
|
219
|
+
def _get_current_branch(self, repo_path: Path) -> str | None:
|
|
220
|
+
"""Get the current branch name.
|
|
221
|
+
|
|
222
|
+
Args:
|
|
223
|
+
repo_path: Repository path
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
Branch name or None (if detached HEAD)
|
|
227
|
+
"""
|
|
228
|
+
stdout, _, code = self._run_git_command(
|
|
229
|
+
["rev-parse", "--abbrev-ref", "HEAD"],
|
|
230
|
+
cwd=repo_path,
|
|
231
|
+
timeout=10,
|
|
232
|
+
)
|
|
233
|
+
if code == 0:
|
|
234
|
+
branch = stdout.strip()
|
|
235
|
+
return None if branch == "HEAD" else branch
|
|
236
|
+
return None
|
|
237
|
+
|
|
238
|
+
def clone(
|
|
239
|
+
self,
|
|
240
|
+
url: str,
|
|
241
|
+
name: str | None = None,
|
|
242
|
+
ref: str | None = None,
|
|
243
|
+
) -> RepoInfo:
|
|
244
|
+
"""Clone a git repository.
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
url: Repository URL
|
|
248
|
+
name: Optional custom name (defaults to extracted from URL)
|
|
249
|
+
ref: Branch, tag, or commit to checkout
|
|
250
|
+
|
|
251
|
+
Returns:
|
|
252
|
+
RepoInfo with repository details
|
|
253
|
+
|
|
254
|
+
Raises:
|
|
255
|
+
RuntimeError: If clone fails
|
|
256
|
+
"""
|
|
257
|
+
name = name or self._extract_repo_name(url)
|
|
258
|
+
local_path = self._repos_dir / name
|
|
259
|
+
|
|
260
|
+
# Check if already cloned
|
|
261
|
+
if local_path.exists():
|
|
262
|
+
# Update existing repo
|
|
263
|
+
return self.update(local_path)
|
|
264
|
+
|
|
265
|
+
# Build clone command
|
|
266
|
+
clone_args = ["clone"]
|
|
267
|
+
|
|
268
|
+
if self._config.clone_depth is not None:
|
|
269
|
+
clone_args.extend(["--depth", str(self._config.clone_depth)])
|
|
270
|
+
|
|
271
|
+
if ref and self._config.clone_depth:
|
|
272
|
+
# For shallow clones, we need to specify the branch
|
|
273
|
+
clone_args.extend(["--branch", ref])
|
|
274
|
+
|
|
275
|
+
clone_args.extend([url, str(local_path)])
|
|
276
|
+
|
|
277
|
+
# Execute clone
|
|
278
|
+
stdout, stderr, code = self._run_git_command(clone_args)
|
|
279
|
+
|
|
280
|
+
if code != 0:
|
|
281
|
+
raise RuntimeError(f"Failed to clone {url}: {stderr}")
|
|
282
|
+
|
|
283
|
+
# Checkout specific ref if needed (and not already done via --branch)
|
|
284
|
+
if ref and not self._config.clone_depth:
|
|
285
|
+
checkout_args = ["checkout", ref]
|
|
286
|
+
_, stderr, code = self._run_git_command(checkout_args, cwd=local_path)
|
|
287
|
+
if code != 0:
|
|
288
|
+
raise RuntimeError(f"Failed to checkout {ref}: {stderr}")
|
|
289
|
+
|
|
290
|
+
return RepoInfo(
|
|
291
|
+
url=url,
|
|
292
|
+
name=name,
|
|
293
|
+
local_path=local_path,
|
|
294
|
+
branch=self._get_current_branch(local_path),
|
|
295
|
+
commit_hash=self._get_current_commit(local_path),
|
|
296
|
+
clone_time=datetime.now(UTC),
|
|
297
|
+
metadata={"url": url, "ref": ref},
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
def update(self, repo_path: Path | str) -> RepoInfo:
|
|
301
|
+
"""Update an existing repository with git pull.
|
|
302
|
+
|
|
303
|
+
Args:
|
|
304
|
+
repo_path: Path to repository
|
|
305
|
+
|
|
306
|
+
Returns:
|
|
307
|
+
Updated RepoInfo
|
|
308
|
+
|
|
309
|
+
Raises:
|
|
310
|
+
RuntimeError: If update fails
|
|
311
|
+
"""
|
|
312
|
+
repo_path = Path(repo_path).resolve()
|
|
313
|
+
|
|
314
|
+
if not (repo_path / ".git").exists():
|
|
315
|
+
raise RuntimeError(f"Not a git repository: {repo_path}")
|
|
316
|
+
|
|
317
|
+
# Get remote URL
|
|
318
|
+
stdout, _, code = self._run_git_command(
|
|
319
|
+
["remote", "get-url", "origin"],
|
|
320
|
+
cwd=repo_path,
|
|
321
|
+
timeout=10,
|
|
322
|
+
)
|
|
323
|
+
url = stdout.strip() if code == 0 else "unknown"
|
|
324
|
+
|
|
325
|
+
# Pull updates
|
|
326
|
+
stdout, stderr, code = self._run_git_command(
|
|
327
|
+
["pull", "--ff-only"],
|
|
328
|
+
cwd=repo_path,
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
if code != 0:
|
|
332
|
+
# Try fetch + reset as fallback
|
|
333
|
+
self._run_git_command(["fetch", "origin"], cwd=repo_path)
|
|
334
|
+
|
|
335
|
+
return RepoInfo(
|
|
336
|
+
url=url,
|
|
337
|
+
name=repo_path.name,
|
|
338
|
+
local_path=repo_path,
|
|
339
|
+
branch=self._get_current_branch(repo_path),
|
|
340
|
+
commit_hash=self._get_current_commit(repo_path),
|
|
341
|
+
clone_time=datetime.now(UTC),
|
|
342
|
+
metadata={"updated": True},
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
def _build_local_file_config(self) -> LocalFileConfig:
|
|
346
|
+
"""Build LocalFileConfig for indexing.
|
|
347
|
+
|
|
348
|
+
Returns:
|
|
349
|
+
LocalFileConfig with appropriate patterns
|
|
350
|
+
"""
|
|
351
|
+
patterns = []
|
|
352
|
+
|
|
353
|
+
if self._config.include_docs:
|
|
354
|
+
patterns.extend(self._config.doc_patterns)
|
|
355
|
+
|
|
356
|
+
if self._config.include_code:
|
|
357
|
+
patterns.extend(self._config.code_patterns)
|
|
358
|
+
|
|
359
|
+
return LocalFileConfig(
|
|
360
|
+
include_patterns=patterns,
|
|
361
|
+
exclude_patterns=[
|
|
362
|
+
"*.min.js",
|
|
363
|
+
"*.min.css",
|
|
364
|
+
"*.map",
|
|
365
|
+
"*.lock",
|
|
366
|
+
"package-lock.json",
|
|
367
|
+
"yarn.lock",
|
|
368
|
+
"Cargo.lock",
|
|
369
|
+
],
|
|
370
|
+
skip_dirs=self._config.skip_dirs,
|
|
371
|
+
max_file_size=self._config.max_file_size,
|
|
372
|
+
follow_symlinks=False,
|
|
373
|
+
extract_title=True,
|
|
374
|
+
include_hidden=False,
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
def index_repo(
|
|
378
|
+
self,
|
|
379
|
+
repo_path: Path | str,
|
|
380
|
+
repo_info: RepoInfo | None = None,
|
|
381
|
+
) -> Iterator[IndexedFile]:
|
|
382
|
+
"""Index files in a repository.
|
|
383
|
+
|
|
384
|
+
Args:
|
|
385
|
+
repo_path: Path to repository
|
|
386
|
+
repo_info: Optional repository info for metadata
|
|
387
|
+
|
|
388
|
+
Yields:
|
|
389
|
+
IndexedFile for each indexed file
|
|
390
|
+
"""
|
|
391
|
+
repo_path = Path(repo_path).resolve()
|
|
392
|
+
|
|
393
|
+
local_config = self._build_local_file_config()
|
|
394
|
+
indexer = LocalFileIndexer(config=local_config)
|
|
395
|
+
|
|
396
|
+
for indexed_file in indexer.index_directory(repo_path, recursive=True):
|
|
397
|
+
# Add repository metadata
|
|
398
|
+
if repo_info:
|
|
399
|
+
indexed_file.metadata.update(
|
|
400
|
+
{
|
|
401
|
+
"repo_url": repo_info.url,
|
|
402
|
+
"repo_name": repo_info.name,
|
|
403
|
+
"branch": repo_info.branch,
|
|
404
|
+
"commit": repo_info.commit_hash,
|
|
405
|
+
}
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
yield indexed_file
|
|
409
|
+
|
|
410
|
+
def clone_and_index(
|
|
411
|
+
self,
|
|
412
|
+
url: str,
|
|
413
|
+
name: str | None = None,
|
|
414
|
+
ref: str | None = None,
|
|
415
|
+
) -> tuple[RepoInfo, list[Document]]:
|
|
416
|
+
"""Clone a repository and index its contents.
|
|
417
|
+
|
|
418
|
+
Args:
|
|
419
|
+
url: Repository URL
|
|
420
|
+
name: Optional custom name
|
|
421
|
+
ref: Branch, tag, or commit to checkout
|
|
422
|
+
|
|
423
|
+
Returns:
|
|
424
|
+
Tuple of (RepoInfo, list of Documents)
|
|
425
|
+
"""
|
|
426
|
+
# Clone repository
|
|
427
|
+
repo_info = self.clone(url, name=name, ref=ref or self._config.ref)
|
|
428
|
+
|
|
429
|
+
# Index files
|
|
430
|
+
documents = []
|
|
431
|
+
for indexed_file in self.index_repo(repo_info.local_path, repo_info):
|
|
432
|
+
documents.append(indexed_file.to_document())
|
|
433
|
+
|
|
434
|
+
# Cleanup if configured
|
|
435
|
+
if self._config.cleanup_after_index:
|
|
436
|
+
self.cleanup(repo_info.local_path)
|
|
437
|
+
|
|
438
|
+
return repo_info, documents
|
|
439
|
+
|
|
440
|
+
def clone_and_chunk(
|
|
441
|
+
self,
|
|
442
|
+
url: str,
|
|
443
|
+
name: str | None = None,
|
|
444
|
+
ref: str | None = None,
|
|
445
|
+
) -> tuple[RepoInfo, list[Document], list[Any]]:
|
|
446
|
+
"""Clone a repository, index, and chunk its contents.
|
|
447
|
+
|
|
448
|
+
Args:
|
|
449
|
+
url: Repository URL
|
|
450
|
+
name: Optional custom name
|
|
451
|
+
ref: Branch, tag, or commit to checkout
|
|
452
|
+
|
|
453
|
+
Returns:
|
|
454
|
+
Tuple of (RepoInfo, documents, chunks)
|
|
455
|
+
"""
|
|
456
|
+
if self._chunker is None:
|
|
457
|
+
self._chunker = Chunker(ChunkerConfig())
|
|
458
|
+
|
|
459
|
+
repo_info, documents = self.clone_and_index(url, name=name, ref=ref)
|
|
460
|
+
|
|
461
|
+
# Chunk documents
|
|
462
|
+
all_chunks = []
|
|
463
|
+
for doc in documents:
|
|
464
|
+
chunks = self._chunker.chunk_document(doc)
|
|
465
|
+
all_chunks.extend(chunks)
|
|
466
|
+
|
|
467
|
+
return repo_info, documents, all_chunks
|
|
468
|
+
|
|
469
|
+
def cleanup(self, repo_path: Path | str) -> bool:
|
|
470
|
+
"""Remove a cloned repository.
|
|
471
|
+
|
|
472
|
+
Args:
|
|
473
|
+
repo_path: Path to repository
|
|
474
|
+
|
|
475
|
+
Returns:
|
|
476
|
+
True if cleanup succeeded
|
|
477
|
+
"""
|
|
478
|
+
repo_path = Path(repo_path).resolve()
|
|
479
|
+
|
|
480
|
+
if not repo_path.exists():
|
|
481
|
+
return True
|
|
482
|
+
|
|
483
|
+
# Safety check - must be under repos_dir
|
|
484
|
+
try:
|
|
485
|
+
repo_path.relative_to(self._repos_dir)
|
|
486
|
+
except ValueError:
|
|
487
|
+
return False
|
|
488
|
+
|
|
489
|
+
try:
|
|
490
|
+
shutil.rmtree(repo_path)
|
|
491
|
+
return True
|
|
492
|
+
except Exception:
|
|
493
|
+
return False
|
|
494
|
+
|
|
495
|
+
def list_repos(self) -> list[RepoInfo]:
|
|
496
|
+
"""List all cloned repositories.
|
|
497
|
+
|
|
498
|
+
Returns:
|
|
499
|
+
List of RepoInfo for each cloned repository
|
|
500
|
+
"""
|
|
501
|
+
repos = []
|
|
502
|
+
|
|
503
|
+
for path in self._repos_dir.iterdir():
|
|
504
|
+
if path.is_dir() and (path / ".git").exists():
|
|
505
|
+
# Get remote URL
|
|
506
|
+
stdout, _, code = self._run_git_command(
|
|
507
|
+
["remote", "get-url", "origin"],
|
|
508
|
+
cwd=path,
|
|
509
|
+
timeout=10,
|
|
510
|
+
)
|
|
511
|
+
url = stdout.strip() if code == 0 else "unknown"
|
|
512
|
+
|
|
513
|
+
repos.append(
|
|
514
|
+
RepoInfo(
|
|
515
|
+
url=url,
|
|
516
|
+
name=path.name,
|
|
517
|
+
local_path=path,
|
|
518
|
+
branch=self._get_current_branch(path),
|
|
519
|
+
commit_hash=self._get_current_commit(path),
|
|
520
|
+
clone_time=datetime.fromtimestamp(path.stat().st_mtime, tz=UTC),
|
|
521
|
+
)
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
return repos
|
|
525
|
+
|
|
526
|
+
def get_repo_stats(self, repo_path: Path | str) -> dict[str, Any]:
|
|
527
|
+
"""Get statistics about a repository.
|
|
528
|
+
|
|
529
|
+
Args:
|
|
530
|
+
repo_path: Path to repository
|
|
531
|
+
|
|
532
|
+
Returns:
|
|
533
|
+
Statistics dict
|
|
534
|
+
"""
|
|
535
|
+
repo_path = Path(repo_path).resolve()
|
|
536
|
+
|
|
537
|
+
local_config = self._build_local_file_config()
|
|
538
|
+
indexer = LocalFileIndexer(config=local_config)
|
|
539
|
+
|
|
540
|
+
return indexer.get_stats(repo_path, recursive=True)
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def get_git_indexer(
|
|
544
|
+
config: GitIndexerConfig | None = None,
|
|
545
|
+
chunker: Chunker | None = None,
|
|
546
|
+
) -> GitIndexer:
|
|
547
|
+
"""Factory function to get a GitIndexer instance.
|
|
548
|
+
|
|
549
|
+
Args:
|
|
550
|
+
config: Optional configuration
|
|
551
|
+
chunker: Optional chunker instance
|
|
552
|
+
|
|
553
|
+
Returns:
|
|
554
|
+
GitIndexer instance
|
|
555
|
+
"""
|
|
556
|
+
return GitIndexer(config=config, chunker=chunker)
|