Skip to main content

git_core/
commit.rs

1use crate::error::{GitError, Result};
2use crate::hash::GitHash;
3
4#[derive(Debug, Clone)]
5pub struct Signature {
6    pub name: String,
7    pub email: String,
8    /// Unix timestamp (seconds since epoch).
9    pub timestamp: i64,
10    /// UTC offset in seconds (e.g. +0800 → 28800).
11    pub tz_offset_secs: i32,
12}
13
14#[derive(Debug, Clone)]
15pub struct CommitObject {
16    pub hash: GitHash,
17    pub tree: GitHash,
18    pub parents: Vec<GitHash>,
19    pub author: Signature,
20    pub committer: Signature,
21    pub message: String,
22    /// True iff a `gpgsig` header was present (the commit is cryptographically
23    /// signed). The signature's validity is not checked here — only its
24    /// presence, which is what a signing-policy audit reasons about.
25    pub is_signed: bool,
26}
27
28impl CommitObject {
29    /// Parse the raw commit object bytes (after the object header is stripped).
30    pub fn parse(hash: GitHash, data: &[u8]) -> Result<Self> {
31        let text = std::str::from_utf8(data)
32            .map_err(|e| GitError::InvalidObject(format!("commit not valid UTF-8: {e}")))?;
33
34        let mut tree = None;
35        let mut parents = Vec::new();
36        let mut author = None;
37        let mut committer = None;
38        let mut is_signed = false;
39        let mut message_start = text.len();
40
41        for (i, line) in text.lines().enumerate() {
42            if line.is_empty() {
43                // Blank line separates header from message body.
44                let byte_pos = text
45                    .char_indices()
46                    .filter(|(_, c)| *c == '\n')
47                    .nth(i)
48                    .map_or(text.len(), |(pos, _)| pos + 1);
49                message_start = byte_pos;
50                break;
51            }
52            if let Some(rest) = line.strip_prefix("tree ") {
53                tree = Some(GitHash::from_hex(rest.trim())?);
54            } else if let Some(rest) = line.strip_prefix("parent ") {
55                parents.push(GitHash::from_hex(rest.trim())?);
56            } else if let Some(rest) = line.strip_prefix("author ") {
57                author = Some(parse_signature(rest)?);
58            } else if let Some(rest) = line.strip_prefix("committer ") {
59                committer = Some(parse_signature(rest)?);
60            } else if line.strip_prefix("gpgsig ").is_some() {
61                // A signed commit carries `gpgsig <signature>`; its continuation
62                // lines start with a single space and match no header prefix, so
63                // they fall through harmlessly. We record only the presence —
64                // signature validity is out of scope for the reader.
65                is_signed = true;
66            }
67        }
68
69        Ok(Self {
70            hash,
71            tree: tree.ok_or_else(|| GitError::InvalidObject("commit missing tree".into()))?,
72            parents,
73            author: author
74                .ok_or_else(|| GitError::InvalidObject("commit missing author".into()))?,
75            committer: committer
76                .ok_or_else(|| GitError::InvalidObject("commit missing committer".into()))?,
77            message: text[message_start..].to_string(),
78            is_signed,
79        })
80    }
81}
82
83fn parse_signature(s: &str) -> Result<Signature> {
84    // Format: "Name <email> timestamp tz_offset"
85    let err = || GitError::InvalidObject(format!("invalid signature: {s:?}"));
86
87    let email_end = s.rfind('>').ok_or_else(err)?;
88    let email_start = s.rfind('<').ok_or_else(err)?;
89    if email_start >= email_end {
90        return Err(err());
91    }
92    let name = s[..email_start].trim().to_string();
93    let email = s[email_start + 1..email_end].to_string();
94
95    let rest = s[email_end + 1..].trim();
96    let mut parts = rest.split_whitespace();
97    let ts: i64 = parts.next().and_then(|t| t.parse().ok()).ok_or_else(err)?;
98    let tz = parts.next().unwrap_or("+0000");
99
100    let sign = if tz.starts_with('-') { -1i32 } else { 1 };
101    let tz_digits = tz.trim_start_matches(['+', '-']);
102    let tz_offset_secs = if tz_digits.len() == 4 {
103        let hh: i32 = tz_digits[..2].parse().unwrap_or(0);
104        let mm: i32 = tz_digits[2..].parse().unwrap_or(0);
105        sign * (hh * 3600 + mm * 60)
106    } else {
107        0
108    };
109
110    Ok(Signature {
111        name,
112        email,
113        timestamp: ts,
114        tz_offset_secs,
115    })
116}