weavatrix-git 0.3.1

Fast, bounded, evidence-carrying Git reader with an optional read-only MCP server
Documentation
use crate::{HashKind, ObjectId, Result, error::invalid};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ObjectKind {
    Commit,
    Tree,
    Blob,
    Tag,
}

impl ObjectKind {
    pub(crate) fn parse(value: &[u8]) -> Result<Self> {
        match value {
            b"commit" => Ok(Self::Commit),
            b"tree" => Ok(Self::Tree),
            b"blob" => Ok(Self::Blob),
            b"tag" => Ok(Self::Tag),
            _ => Err(invalid(format!(
                "unknown object kind {}",
                String::from_utf8_lossy(value)
            ))),
        }
    }

    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Commit => "commit",
            Self::Tree => "tree",
            Self::Blob => "blob",
            Self::Tag => "tag",
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Object {
    pub id: ObjectId,
    pub kind: ObjectKind,
    pub data: Vec<u8>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Signature {
    pub name: String,
    pub email: String,
    pub timestamp: i64,
    pub timezone_minutes: i16,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Commit {
    pub id: ObjectId,
    pub tree: ObjectId,
    pub parents: Vec<ObjectId>,
    pub author: Option<Signature>,
    pub committer: Option<Signature>,
    pub encoding: Option<String>,
    pub message: Vec<u8>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommitMetadata {
    pub id: ObjectId,
    pub tree: ObjectId,
    pub parents: Vec<ObjectId>,
    pub committer_time: i64,
}

impl Commit {
    pub(crate) fn parse(id: ObjectId, data: &[u8], max_parents: usize) -> Result<Self> {
        let (headers, message) = split_headers(data)?;
        let mut tree = None;
        let mut parents = Vec::new();
        let mut author = None;
        let mut committer = None;
        let mut encoding = None;
        for (name, value) in headers {
            match name {
                b"tree" => tree = Some(parse_oid(value, id.kind())?),
                b"parent" => {
                    if parents.len() >= max_parents {
                        return Err(crate::GitError::LimitExceeded {
                            resource: "commit parents",
                            limit: max_parents,
                        });
                    }
                    parents.push(parse_oid(value, id.kind())?);
                }
                b"author" => author = parse_signature(value),
                b"committer" => committer = parse_signature(value),
                b"encoding" => encoding = Some(String::from_utf8_lossy(value).into_owned()),
                _ => {}
            }
        }
        Ok(Self {
            id,
            tree: tree.ok_or_else(|| invalid("commit has no tree header"))?,
            parents,
            author,
            committer,
            encoding,
            message: message.to_vec(),
        })
    }

    #[must_use]
    pub fn message_lossy(&self) -> String {
        String::from_utf8_lossy(&self.message).into_owned()
    }

    #[must_use]
    pub fn summary_lossy(&self) -> String {
        let message = String::from_utf8_lossy(&self.message);
        message.lines().next().unwrap_or_default().to_owned()
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Tag {
    pub id: ObjectId,
    pub target: ObjectId,
    pub target_kind: ObjectKind,
    pub name: String,
    pub tagger: Option<Signature>,
    pub message: Vec<u8>,
}

impl Tag {
    pub(crate) fn parse(id: ObjectId, data: &[u8]) -> Result<Self> {
        let (headers, message) = split_headers(data)?;
        let mut target = None;
        let mut target_kind = None;
        let mut name = None;
        let mut tagger = None;
        for (key, value) in headers {
            match key {
                b"object" => target = Some(parse_oid(value, id.kind())?),
                b"type" => target_kind = Some(ObjectKind::parse(value)?),
                b"tag" => name = Some(String::from_utf8_lossy(value).into_owned()),
                b"tagger" => tagger = parse_signature(value),
                _ => {}
            }
        }
        Ok(Self {
            id,
            target: target.ok_or_else(|| invalid("tag has no object header"))?,
            target_kind: target_kind.ok_or_else(|| invalid("tag has no type header"))?,
            name: name.ok_or_else(|| invalid("tag has no tag header"))?,
            tagger,
            message: message.to_vec(),
        })
    }
}

type Headers<'a> = Vec<(&'a [u8], &'a [u8])>;

fn split_headers(data: &[u8]) -> Result<(Headers<'_>, &[u8])> {
    let separator = data
        .windows(2)
        .position(|value| value == b"\n\n")
        .ok_or_else(|| invalid("object headers are not terminated"))?;
    let mut headers = Vec::new();
    for line in data[..separator].split(|byte| *byte == b'\n') {
        if line.first() == Some(&b' ') {
            continue;
        }
        let split = line
            .iter()
            .position(|byte| *byte == b' ')
            .ok_or_else(|| invalid("object header has no value"))?;
        headers.push((&line[..split], &line[split + 1..]));
    }
    Ok((headers, &data[separator + 2..]))
}

fn parse_oid(value: &[u8], kind: HashKind) -> Result<ObjectId> {
    ObjectId::from_hex_for(
        std::str::from_utf8(value).map_err(|_| invalid("object id is not ASCII"))?,
        kind,
    )
}

pub(crate) fn parse_signature(value: &[u8]) -> Option<Signature> {
    let text = std::str::from_utf8(value).ok()?;
    let email_start = text.rfind(" <")?;
    let email_end = text[email_start + 2..]
        .find("> ")?
        .saturating_add(email_start + 2);
    let mut tail = text[email_end + 2..].split_ascii_whitespace();
    let timestamp = tail.next()?.parse().ok()?;
    let timezone = tail.next()?;
    if timezone.len() != 5 {
        return None;
    }
    let sign = if timezone.starts_with('-') { -1 } else { 1 };
    let hours: i16 = timezone[1..3].parse().ok()?;
    let minutes: i16 = timezone[3..5].parse().ok()?;
    Some(Signature {
        name: text[..email_start].to_owned(),
        email: text[email_start + 2..email_end].to_owned(),
        timestamp,
        timezone_minutes: sign * (hours * 60 + minutes),
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_commit_headers_and_signature() {
        let id: ObjectId = "1111111111111111111111111111111111111111".parse().unwrap();
        let tree = "2222222222222222222222222222222222222222";
        let raw = format!(
            "tree {tree}\nauthor Ada <ada@example.com> 42 +0230\ncommitter Ada <ada@example.com> 43 +0230\n\nsubject\nbody\n"
        );
        let commit = Commit::parse(id, raw.as_bytes(), 16).unwrap();
        assert_eq!(commit.tree.to_string(), tree);
        assert_eq!(commit.summary_lossy(), "subject");
        assert_eq!(commit.author.unwrap().timezone_minutes, 150);
    }

    #[test]
    fn parses_parents_encoding_and_annotated_tag() {
        let id: ObjectId = "1111111111111111111111111111111111111111".parse().unwrap();
        let target = "2222222222222222222222222222222222222222";
        let raw = format!(
            "tree {target}\nparent {target}\nencoding ISO-8859-1\n\
             author Invalid\ncommitter Ada <ada@example.com> 43 -0130\n\nmessage"
        );
        let commit = Commit::parse(id, raw.as_bytes(), 2).unwrap();
        assert_eq!(commit.parents.len(), 1);
        assert_eq!(commit.encoding.as_deref(), Some("ISO-8859-1"));
        assert!(commit.author.is_none());
        assert_eq!(commit.committer.as_ref().unwrap().timezone_minutes, -90);
        assert_eq!(commit.message_lossy(), "message");

        let raw = format!(
            "object {target}\ntype blob\ntag v1.0\n\
             tagger Ada <ada@example.com> 44 +0000\n\nrelease"
        );
        let tag = Tag::parse(id, raw.as_bytes()).unwrap();
        assert_eq!(tag.target.to_string(), target);
        assert_eq!(tag.target_kind, ObjectKind::Blob);
        assert_eq!(tag.name, "v1.0");
        assert_eq!(tag.message, b"release");
    }

    #[test]
    fn rejects_malformed_objects_and_parent_limit() {
        let id: ObjectId = "1111111111111111111111111111111111111111".parse().unwrap();
        assert!(Commit::parse(id, b"author x\n\nmessage", 1).is_err());
        let target = "2222222222222222222222222222222222222222";
        let raw = format!("tree {target}\nparent {target}\nparent {target}\n\nmessage");
        assert!(Commit::parse(id, raw.as_bytes(), 1).is_err());
        assert!(Tag::parse(id, b"object bad\n\nmessage").is_err());
        assert!(ObjectKind::parse(b"unknown").is_err());
        assert_eq!(ObjectKind::Tree.as_str(), "tree");
        assert_eq!(ObjectKind::Tag.as_str(), "tag");
    }
}