Skip to main content

zub/types/
commit.rs

1use std::collections::BTreeMap;
2
3use serde::{Deserialize, Serialize};
4
5use crate::hash::Hash;
6
7/// a commit object pointing to a tree with metadata
8#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
9pub struct Commit {
10    /// root tree hash
11    pub tree: Hash,
12    /// parent commit hashes (empty for initial, 1 for linear, 2+ for merge)
13    pub parents: Vec<Hash>,
14    /// author identity
15    pub author: String,
16    /// unix timestamp (seconds since epoch)
17    pub timestamp: i64,
18    /// commit message
19    pub message: String,
20    /// optional key-value metadata (uses BTreeMap for deterministic serialization)
21    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
22    pub metadata: BTreeMap<String, String>,
23}
24
25impl Commit {
26    /// create a new commit
27    pub fn new(
28        tree: Hash,
29        parents: Vec<Hash>,
30        author: impl Into<String>,
31        message: impl Into<String>,
32    ) -> Self {
33        Self {
34            tree,
35            parents,
36            author: author.into(),
37            timestamp: std::time::SystemTime::now()
38                .duration_since(std::time::UNIX_EPOCH)
39                .map(|d| d.as_secs() as i64)
40                .unwrap_or(0),
41            message: message.into(),
42            metadata: BTreeMap::new(),
43        }
44    }
45
46    /// create a new commit with explicit timestamp
47    pub fn with_timestamp(
48        tree: Hash,
49        parents: Vec<Hash>,
50        author: impl Into<String>,
51        timestamp: i64,
52        message: impl Into<String>,
53    ) -> Self {
54        Self {
55            tree,
56            parents,
57            author: author.into(),
58            timestamp,
59            message: message.into(),
60            metadata: BTreeMap::new(),
61        }
62    }
63
64    /// add metadata key-value pair
65    pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
66        self.metadata.insert(key.into(), value.into());
67        self
68    }
69
70    /// is this an initial commit (no parents)
71    pub fn is_root(&self) -> bool {
72        self.parents.is_empty()
73    }
74
75    /// is this a merge commit (multiple parents)
76    pub fn is_merge(&self) -> bool {
77        self.parents.len() > 1
78    }
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84
85    #[test]
86    fn test_commit_new() {
87        let c = Commit::new(Hash::ZERO, vec![], "author", "message");
88        assert_eq!(c.tree, Hash::ZERO);
89        assert!(c.parents.is_empty());
90        assert_eq!(c.author, "author");
91        assert_eq!(c.message, "message");
92        assert!(c.is_root());
93        assert!(!c.is_merge());
94    }
95
96    #[test]
97    fn test_commit_with_parents() {
98        let parent =
99            Hash::from_hex("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789")
100                .unwrap();
101        let c = Commit::new(Hash::ZERO, vec![parent], "author", "message");
102        assert!(!c.is_root());
103        assert!(!c.is_merge());
104    }
105
106    #[test]
107    fn test_commit_merge() {
108        let p1 = Hash::from_hex("1111111111111111111111111111111111111111111111111111111111111111")
109            .unwrap();
110        let p2 = Hash::from_hex("2222222222222222222222222222222222222222222222222222222222222222")
111            .unwrap();
112        let c = Commit::new(Hash::ZERO, vec![p1, p2], "author", "merge");
113        assert!(c.is_merge());
114    }
115
116    #[test]
117    fn test_commit_with_metadata() {
118        let c = Commit::new(Hash::ZERO, vec![], "author", "message")
119            .with_metadata("key1", "value1")
120            .with_metadata("key2", "value2");
121        assert_eq!(c.metadata.get("key1"), Some(&"value1".to_string()));
122        assert_eq!(c.metadata.get("key2"), Some(&"value2".to_string()));
123    }
124
125    #[test]
126    fn test_commit_cbor_roundtrip() {
127        let c = Commit::with_timestamp(Hash::ZERO, vec![], "author", 1234567890, "message")
128            .with_metadata("foo", "bar");
129
130        let mut bytes = Vec::new();
131        ciborium::into_writer(&c, &mut bytes).unwrap();
132
133        let parsed: Commit = ciborium::from_reader(&bytes[..]).unwrap();
134        assert_eq!(c, parsed);
135    }
136
137    #[test]
138    fn test_commit_cbor_determinism() {
139        // metadata insertion order shouldn't affect output (BTreeMap)
140        let mut c1 = Commit::with_timestamp(Hash::ZERO, vec![], "a", 0, "m");
141        c1.metadata.insert("z".to_string(), "1".to_string());
142        c1.metadata.insert("a".to_string(), "2".to_string());
143
144        let mut c2 = Commit::with_timestamp(Hash::ZERO, vec![], "a", 0, "m");
145        c2.metadata.insert("a".to_string(), "2".to_string());
146        c2.metadata.insert("z".to_string(), "1".to_string());
147
148        let mut bytes1 = Vec::new();
149        let mut bytes2 = Vec::new();
150        ciborium::into_writer(&c1, &mut bytes1).unwrap();
151        ciborium::into_writer(&c2, &mut bytes2).unwrap();
152
153        assert_eq!(bytes1, bytes2);
154    }
155}