weavatrix-git 0.1.0

Dependency-free, evidence-carrying Git repository reader
Documentation
use std::{
    collections::{BTreeMap, BTreeSet},
    path::Path,
    thread,
};

use crate::{
    GitError, HistoryOptions, ObjectId, Repository, Result, TreeChange, diff, error::invalid,
};

#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct RepositoryId(usize);

impl RepositoryId {
    #[must_use]
    pub const fn index(self) -> usize {
        self.0
    }
}

pub struct RepositorySet {
    entries: Vec<Entry>,
    names: BTreeMap<String, RepositoryId>,
}

pub struct RepositoryHistory {
    pub repository: RepositoryId,
    pub head: ObjectId,
    pub commits: Vec<ObjectId>,
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SharedCommit {
    pub id: ObjectId,
    pub repositories: Vec<RepositoryId>,
}

struct Entry {
    id: RepositoryId,
    name: String,
    repository: Repository,
}

impl RepositorySet {
    pub fn open<I, S, P>(repositories: I) -> Result<Self>
    where
        I: IntoIterator<Item = (S, P)>,
        S: Into<String>,
        P: AsRef<Path>,
    {
        let mut entries = Vec::new();
        let mut names = BTreeMap::new();
        for (name, path) in repositories {
            let name = name.into();
            if name.is_empty() || name.contains(':') {
                return Err(invalid("repository name is empty or contains ':'"));
            }
            let id = RepositoryId(entries.len());
            if names.insert(name.clone(), id).is_some() {
                return Err(invalid(format!("duplicate repository name {name:?}")));
            }
            entries.push(Entry {
                id,
                name,
                repository: Repository::open(path)?,
            });
        }
        Ok(Self { entries, names })
    }

    #[must_use]
    pub fn len(&self) -> usize {
        self.entries.len()
    }

    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.entries.is_empty()
    }

    #[must_use]
    pub fn id(&self, name: &str) -> Option<RepositoryId> {
        self.names.get(name).copied()
    }

    #[must_use]
    pub fn name(&self, id: RepositoryId) -> Option<&str> {
        self.entries.get(id.0).map(|entry| entry.name.as_str())
    }

    #[must_use]
    pub fn repository(&self, id: RepositoryId) -> Option<&Repository> {
        self.entries.get(id.0).map(|entry| &entry.repository)
    }

    pub fn resolve(&self, id: RepositoryId, revision: &str) -> Result<ObjectId> {
        self.get(id)?.resolve(revision)
    }

    #[must_use]
    pub fn find_object(&self, id: ObjectId) -> Vec<RepositoryId> {
        self.entries
            .iter()
            .filter(|entry| entry.repository.contains(id))
            .map(|entry| entry.id)
            .collect()
    }

    pub fn histories(&self, options: HistoryOptions) -> Result<Vec<RepositoryHistory>> {
        self.entries
            .iter()
            .map(|entry| history(entry, options))
            .collect()
    }

    pub fn histories_parallel(&self, options: HistoryOptions) -> Result<Vec<RepositoryHistory>> {
        thread::scope(|scope| {
            let handles = self
                .entries
                .iter()
                .map(|entry| scope.spawn(move || history(entry, options)))
                .collect::<Vec<_>>();
            handles
                .into_iter()
                .map(|handle| {
                    handle
                        .join()
                        .map_err(|_| GitError::Unsupported("history worker panicked".to_owned()))?
                })
                .collect()
        })
    }

    pub fn shared_commits(&self, options: HistoryOptions) -> Result<Vec<SharedCommit>> {
        let mut repositories = BTreeMap::<ObjectId, BTreeSet<RepositoryId>>::new();
        for history in self.histories_parallel(options)? {
            for id in history.commits {
                repositories
                    .entry(id)
                    .or_default()
                    .insert(history.repository);
            }
        }
        Ok(repositories
            .into_iter()
            .filter_map(|(id, repositories)| {
                (repositories.len() > 1).then(|| SharedCommit {
                    id,
                    repositories: repositories.into_iter().collect(),
                })
            })
            .collect())
    }

    pub fn diff_commits(
        &self,
        old_repository: RepositoryId,
        old_revision: &str,
        new_repository: RepositoryId,
        new_revision: &str,
    ) -> Result<Vec<TreeChange>> {
        let old_repository = self.get(old_repository)?;
        let new_repository = self.get(new_repository)?;
        let old = old_repository.commit(old_repository.resolve(old_revision)?)?;
        let new = new_repository.commit(new_repository.resolve(new_revision)?)?;
        diff::across(old_repository, old.tree, new_repository, new.tree)
    }

    fn get(&self, id: RepositoryId) -> Result<&Repository> {
        self.repository(id)
            .ok_or_else(|| invalid(format!("unknown repository id {}", id.0)))
    }
}

fn history(entry: &Entry, options: HistoryOptions) -> Result<RepositoryHistory> {
    let head = entry
        .repository
        .head()?
        .target
        .ok_or_else(|| GitError::NotFound(format!("unborn HEAD in {}", entry.name)))?;
    Ok(RepositoryHistory {
        repository: entry.id,
        head,
        commits: entry.repository.history_ids(head, options)?,
    })
}