Add VCS repo cloning support (git/hg/svn/fossil)

Smart source detection: like Hydra for RSS feeds, auto-detect
repository URLs and clone instead of HTTP crawling.

- repo.py: detect_vcs(), clone_repo(), walk_files(), symlink_to_vault()
- Symlink storage: files in repo_vault/ symlink to content-addressed vault/
- Database: repo_uri, repo_path, commit_hash, vcs_type columns
- Auto-detect GitHub, GitLab, Bitbucket, sr.ht, hg.mozilla.org, etc.
This commit is contained in:
Russell Ballestrini 2026-01-02 14:37:53 -05:00
parent 55146915e5
commit 0696e92ae3
4 changed files with 718 additions and 1 deletions

162
neopig.py
View file

@ -46,6 +46,7 @@ from filevault import AsyncVault, hash_to_path
from database import Database, SCORE_SCREENSHOT, SCORE_OG_IMAGE, SCORE_THUMBNAIL, SCORE_FULL_RES
from screenshot import ScreenshotCapture, ScreenshotConfig
from domain_vault import VaultManager, DomainHtmlVault, DomainMediaVault, DomainLinkpeekVault, extract_media_urls
from repo import detect_vcs, clone_repo_async, pull_repo_async, get_repo_path, walk_files, get_commit_hash, get_file_language, is_binary_file
from tqdm import tqdm
logger = logging.getLogger(__name__)
@ -911,6 +912,19 @@ class NeoPig:
"""
keywords = keywords or []
# Check if target is a VCS repository (git, hg, svn, etc.)
vcs_type, clone_url = detect_vcs(target_uri)
if vcs_type:
logger.info(f"Detected {vcs_type} repository: {clone_url}")
return await self.clone_and_index(
target_uri=target_uri,
clone_url=clone_url,
vcs_type=vcs_type,
keywords=keywords,
job_id=job_id,
quiet=quiet,
)
# Create crawl job (unless one was provided)
if job_id is None:
job_id = await self.db.create_crawl_job(
@ -1430,6 +1444,154 @@ class NeoPig:
except Exception as e:
logger.warning(f"Screenshot failed for {page_uri}: {e}")
async def clone_and_index(
self,
target_uri: str,
clone_url: str,
vcs_type: str,
keywords: List[str] = None,
job_id: int = None,
quiet: bool = False,
pull: bool = False,
) -> Dict[str, Any]:
"""
Clone a VCS repository and index its files.
Like Hydra mode for feeds, VCS detection is a "smart source" that
bypasses slow HTTP crawling for high-priority ingestion.
Args:
target_uri: Original URI
clone_url: Clone URL (may differ from target_uri)
vcs_type: 'git', 'hg', 'svn', or 'fossil'
keywords: Keywords to tag files with
job_id: Optional existing job ID
quiet: Disable progress output
pull: Update existing clone instead of fresh clone
Returns:
Indexing statistics
"""
from filevault import content_hash
keywords = keywords or []
repo_vault_base = Path(self.vault.path).parent / 'repo_vault'
# Create job
if job_id is None:
job_id = await self.db.create_crawl_job(
target_uri=target_uri,
keywords=keywords,
mode='code',
)
start_job_logging(job_id)
logger.info(f"Starting VCS clone job {job_id}")
logger.info(f"Repository: {clone_url}")
logger.info(f"VCS type: {vcs_type}")
logger.info(f"Keywords: {keywords}")
start_time = datetime.now(timezone.utc)
stats = {
'vcs_type': vcs_type,
'clone_url': clone_url,
'files_indexed': 0,
'files_skipped': 0,
'bytes_stored': 0,
'errors': 0,
'commit_hash': None,
}
# Determine repo path
repo_path = get_repo_path(clone_url, repo_vault_base)
logger.info(f"Repo path: {repo_path}")
# Clone or pull
if repo_path.exists() and (repo_path / '.git').exists():
logger.info(f"Repository exists, pulling updates...")
success, msg = await pull_repo_async(repo_path)
if not success:
logger.error(f"Pull failed: {msg}")
stats['errors'] += 1
else:
logger.info(f"Pull: {msg}")
else:
logger.info(f"Cloning repository...")
success, msg = await clone_repo_async(clone_url, repo_path, vcs_type, shallow=True)
if not success:
logger.error(f"Clone failed: {msg}")
await self.db.complete_crawl_job(job_id, stats)
return stats
logger.info(f"Clone: {msg}")
# Get commit hash
stats['commit_hash'] = get_commit_hash(repo_path)
logger.info(f"Commit: {stats['commit_hash']}")
# Walk files and index
files = list(walk_files(repo_path, include_binary=True))
logger.info(f"Found {len(files)} files to index")
pbar = tqdm(files, unit="files", disable=quiet)
for file_path in pbar:
try:
content = file_path.read_bytes()
h = content_hash(content)
ext = file_path.suffix or '.txt'
file_size = len(content)
# Store in vault
vault_path = await self.vault.store(h, content, ext)
stats['bytes_stored'] += file_size
# Replace file with symlink
rel_path = os.path.relpath(vault_path, file_path.parent)
file_path.unlink()
file_path.symlink_to(rel_path)
# Get file metadata
repo_rel_path = str(file_path.relative_to(repo_path))
language = get_file_language(file_path)
is_binary = is_binary_file(Path(vault_path))
# Create media record with repo metadata
await self.db.create_media_record(
md5_hash=h,
media_uri=f"{clone_url}/blob/HEAD/{repo_rel_path}",
page_uri=target_uri,
page_title=repo_rel_path,
media_type='code',
mime_type=f"text/{language}" if language and not is_binary else "application/octet-stream",
file_size=file_size,
alt_text=language or '',
keywords=keywords,
crawl_job_id=job_id,
score=SCORE_FULL_RES,
# VCS repo metadata
repo_uri=clone_url,
repo_path=repo_rel_path,
commit_hash=stats['commit_hash'],
vcs_type=vcs_type,
)
stats['files_indexed'] += 1
pbar.set_postfix_str(f"indexed: {stats['files_indexed']}, {stats['bytes_stored'] // 1024}KB")
except Exception as e:
logger.warning(f"Error indexing {file_path}: {e}")
stats['errors'] += 1
stats['files_skipped'] += 1
pbar.close()
# Complete job
duration = (datetime.now(timezone.utc) - start_time).total_seconds()
stats['duration_seconds'] = duration
await self.db.complete_crawl_job(job_id, stats)
logger.info(f"VCS indexing complete: {stats['files_indexed']} files, {stats['bytes_stored'] // 1024}KB stored")
return stats
async def backfill_missing_screenshots(self, domain: str = None, crawl_job_id: int = None, fast_mode: bool = False, quiet: bool = False, create_job: bool = True):
"""Capture screenshots for pages that don't have them yet.