git-topology 0.1.2

Shared clustering and topology primitives for git-cognitive and git-semantic
Documentation
use anyhow::{Context, Result};
use fs2::FileExt;
use std::path::Path;
use walkdir::WalkDir;

use crate::chunking;
use crate::cluster::{is_stale, read_cluster_map, write_cluster_map, ClusterMap};
use crate::clustering::embed_and_cluster;
use crate::embeddings::{config::EmbeddingConfig, create_provider};

const IGNORED_DIRS: &[&str] = &[
    ".git",
    "target",
    "node_modules",
    ".next",
    "dist",
    "build",
    "__pycache__",
];

pub fn run_index(repo_path: &Path, config: EmbeddingConfig) -> Result<ClusterMap> {
    if !is_stale(repo_path) {
        return Ok(read_cluster_map(repo_path)?.unwrap_or_else(ClusterMap::empty));
    }

    let lock_path = repo_path.join(".git").join("topology.lock");
    let lock_file = std::fs::OpenOptions::new()
        .create(true)
        .truncate(true)
        .write(true)
        .open(&lock_path)
        .context("Failed to open topology lock file")?;
    lock_file
        .lock_exclusive()
        .context("Failed to acquire topology lock")?;

    if !is_stale(repo_path) {
        lock_file.unlock().ok();
        return Ok(read_cluster_map(repo_path)?.unwrap_or_else(ClusterMap::empty));
    }

    let result = run_index_inner(repo_path, config);
    lock_file.unlock().ok();
    result
}

fn run_index_inner(repo_path: &Path, config: EmbeddingConfig) -> Result<ClusterMap> {
    let mut provider = create_provider(&config)?;
    provider.init()?;

    println!("Scanning files...");
    let file_texts = collect_file_texts(repo_path)?;

    if file_texts.is_empty() {
        println!("No supported source files found.");
        return Ok(ClusterMap::empty());
    }

    println!(
        "Embedding {} files with {}...",
        file_texts.len(),
        provider.provider_name()
    );
    let map =
        embed_and_cluster(file_texts, provider.as_mut()).context("Failed to build cluster map")?;

    println!("Writing {} clusters to topology/v1...", map.clusters.len());
    write_cluster_map(repo_path, &map).context("Failed to write cluster map")?;

    Ok(map)
}

fn collect_file_texts(repo_path: &Path) -> Result<Vec<(String, String)>> {
    let mut result = Vec::new();

    for entry in WalkDir::new(repo_path)
        .follow_links(false)
        .into_iter()
        .filter_entry(|e| {
            let name = e.file_name().to_string_lossy();
            !IGNORED_DIRS.contains(&name.as_ref())
        })
        .filter_map(|e| e.ok())
        .filter(|e| e.file_type().is_file())
    {
        let path = entry.path();
        let file_str = path.to_string_lossy();

        if chunking::languages::detect_language(&file_str).is_none() {
            continue;
        }

        let relative = path
            .strip_prefix(repo_path)
            .unwrap_or(path)
            .to_string_lossy()
            .to_string();

        let text = match std::fs::read_to_string(path) {
            Ok(t) => t,
            Err(_) => continue,
        };

        if text.trim().is_empty() {
            continue;
        }

        let chunks = chunking::chunk_code(&text, Some(&file_str)).unwrap_or_default();
        let combined = chunks
            .iter()
            .map(|c| c.text.as_str())
            .collect::<Vec<_>>()
            .join("\n");

        result.push((relative, combined));
    }

    Ok(result)
}