thin-fetch 0.1.0

Low-level Git crate to fetch objects with high granularity
Documentation
use std::collections::{HashMap, HashSet};

use crate::object::{Commit, Object, Tree};
use crate::{Error, WithError as _, bail, error};

/// Commit reference with all entries collected from the repo.
#[derive(Debug)]
pub struct Checkout<'a> {
    pub commit: &'a Commit,
    pub entries: HashMap<String, DirEntry<'a>>,
}

impl<'a> Checkout<'a> {
    /// Collect checkout from a repository of objects.
    ///
    /// # Errors
    ///
    /// Returns an error when an object cannot be found.
    pub fn from_pack(pack: &'a HashMap<String, Object>, oid: &str) -> Result<Self, Error> {
        let commit_obj = pack
            .get(oid)
            .with_err(|| error!("commit {oid} not found"))?;

        let Object::Commit(commit) = commit_obj else {
            bail!("expected {oid} to be a commit, got {commit_obj:?}")
        };

        let tree_id = commit.commit.tree.to_string();

        let tree_obj = pack
            .get(&tree_id)
            .with_err(|| error!("root tree {tree_id} not found"))?;

        let mut entries = HashMap::new();

        let Object::Tree(tree) = tree_obj else {
            bail!("expected {tree_id} to be a tree, got {tree_obj:?}")
        };

        collect_entries(pack, tree, ".", &mut entries);

        Ok(Self { commit, entries })
    }

    /// Get difference between checkouts.
    #[must_use]
    pub fn get_diff(&self, other: &Checkout) -> Vec<BlobDiff> {
        let all_paths = self
            .entries
            .values()
            .chain(other.entries.values())
            .filter(|entry| !entry.dir)
            .map(|entry| entry.path.as_str())
            .collect::<HashSet<&str>>();

        let mut result = vec![];

        for path in all_paths {
            let this = self.entries.get(path);
            let other = other.entries.get(path);

            if this != other {
                let old = other.map(|a| a.hash.clone());
                let new = this.map(|a| a.hash.clone());

                result.push(BlobDiff {
                    path: path.to_owned(),
                    old_oid: old,
                    new_oid: new,
                });
            }
        }

        result.sort_by(|a, b| a.path.cmp(&b.path));

        result
    }
}

fn collect_entries<'a>(
    pack: &'a HashMap<String, Object>,
    tree: &'a Tree,
    path: &str,
    entries: &mut HashMap<String, DirEntry<'a>>,
) {
    for entry in &tree.entries {
        let hash = &entry.hash;
        let path = format!("{path}/{}", entry.name);

        let object = pack.get(hash);

        if let Some(Object::Tree(tree)) = object {
            collect_entries(pack, tree, &path, entries);
        }

        entries.insert(
            path.clone(),
            DirEntry {
                path,
                hash: hash.clone(),
                dir: entry.is_dir(),
                object,
            },
        );
    }
}

#[derive(Debug, Clone)]
pub struct BlobDiff {
    pub path: String,

    pub old_oid: Option<String>,
    pub new_oid: Option<String>,
}

#[derive(Debug, Clone)]
pub struct DirEntry<'a> {
    pub path: String,
    pub hash: String,
    pub dir: bool,
    pub object: Option<&'a Object>,
}

impl PartialEq<DirEntry<'_>> for DirEntry<'_> {
    fn eq(&self, other: &DirEntry) -> bool {
        self.path == other.path && self.hash == other.hash
    }
}