Skip to main content

zub/object/
commit.rs

1use std::fs::{self, File};
2use std::io::Write;
3use std::path::PathBuf;
4
5use sha2::{Digest, Sha256};
6
7use crate::error::{Error, IoResultExt, Result};
8use crate::hash::Hash;
9use crate::repo::Repo;
10use crate::types::Commit;
11
12/// write a commit to the object store
13///
14/// commits are serialized as CBOR, then zstd compressed.
15/// the hash is computed over the compressed bytes.
16pub fn write_commit(repo: &Repo, commit: &Commit) -> Result<Hash> {
17    // serialize to cbor
18    let mut cbor_bytes = Vec::new();
19    ciborium::into_writer(commit, &mut cbor_bytes)?;
20
21    // compress with zstd (level 3)
22    let compressed = zstd::encode_all(&cbor_bytes[..], 3).map_err(|e| Error::Io {
23        path: PathBuf::from("<zstd>"),
24        source: e,
25    })?;
26
27    // hash the compressed bytes
28    let hash = Hash::from_bytes(Sha256::digest(&compressed).into());
29
30    let (dir, file) = hash.to_path_components();
31    let commit_dir = repo.commits_path().join(&dir);
32    let commit_path = commit_dir.join(&file);
33
34    // dedup: if commit already exists, we're done
35    if commit_path.exists() {
36        return Ok(hash);
37    }
38
39    // ensure directory exists
40    fs::create_dir_all(&commit_dir).with_path(&commit_dir)?;
41
42    // atomic write: temp -> fsync -> rename
43    let tmp_path = repo.tmp_path().join(uuid::Uuid::new_v4().to_string());
44    {
45        let mut tmp_file = File::create(&tmp_path).with_path(&tmp_path)?;
46        tmp_file.write_all(&compressed).with_path(&tmp_path)?;
47        tmp_file.sync_all().with_path(&tmp_path)?;
48    }
49
50    // rename to final location
51    fs::rename(&tmp_path, &commit_path).with_path(&commit_path)?;
52
53    // fsync parent directory
54    let dir_file = File::open(&commit_dir).with_path(&commit_dir)?;
55    dir_file.sync_all().with_path(&commit_dir)?;
56
57    Ok(hash)
58}
59
60/// read a commit from the object store
61pub fn read_commit(repo: &Repo, hash: &Hash) -> Result<Commit> {
62    let path = commit_path(repo, hash);
63
64    let compressed = fs::read(&path).map_err(|e| {
65        if e.kind() == std::io::ErrorKind::NotFound {
66            Error::ObjectNotFound(*hash)
67        } else {
68            Error::Io {
69                path: path.clone(),
70                source: e,
71            }
72        }
73    })?;
74
75    // verify hash
76    let actual_hash = Hash::from_bytes(Sha256::digest(&compressed).into());
77    if actual_hash != *hash {
78        return Err(Error::CorruptObject(*hash));
79    }
80
81    // decompress
82    let cbor_bytes = zstd::decode_all(&compressed[..]).map_err(|e| Error::Io {
83        path: path.clone(),
84        source: e,
85    })?;
86
87    // deserialize
88    let commit: Commit = ciborium::from_reader(&cbor_bytes[..])?;
89
90    Ok(commit)
91}
92
93/// get the filesystem path to a commit object
94pub fn commit_path(repo: &Repo, hash: &Hash) -> PathBuf {
95    let (dir, file) = hash.to_path_components();
96    repo.commits_path().join(dir).join(file)
97}
98
99/// check if a commit exists in the object store
100#[allow(dead_code)]
101pub fn commit_exists(repo: &Repo, hash: &Hash) -> bool {
102    commit_path(repo, hash).exists()
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108    use tempfile::tempdir;
109
110    fn test_repo() -> (tempfile::TempDir, Repo) {
111        let dir = tempdir().unwrap();
112        let repo_path = dir.path().join("repo");
113        let repo = Repo::init(&repo_path).unwrap();
114        (dir, repo)
115    }
116
117    #[test]
118    fn test_write_and_read_commit() {
119        let (_dir, repo) = test_repo();
120
121        let commit =
122            Commit::with_timestamp(Hash::ZERO, vec![], "author", 1234567890, "test commit");
123
124        let hash = write_commit(&repo, &commit).unwrap();
125        assert!(commit_exists(&repo, &hash));
126
127        let read_commit = read_commit(&repo, &hash).unwrap();
128        assert_eq!(commit, read_commit);
129    }
130
131    #[test]
132    fn test_commit_deduplication() {
133        let (_dir, repo) = test_repo();
134
135        let commit = Commit::with_timestamp(Hash::ZERO, vec![], "author", 1234567890, "test");
136
137        let h1 = write_commit(&repo, &commit).unwrap();
138        let h2 = write_commit(&repo, &commit).unwrap();
139
140        assert_eq!(h1, h2);
141    }
142
143    #[test]
144    fn test_commit_with_parents() {
145        let (_dir, repo) = test_repo();
146
147        let parent =
148            Hash::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
149                .unwrap();
150        let commit = Commit::with_timestamp(
151            Hash::ZERO,
152            vec![parent],
153            "author",
154            1234567890,
155            "child commit",
156        );
157
158        let hash = write_commit(&repo, &commit).unwrap();
159        let read_commit = read_commit(&repo, &hash).unwrap();
160
161        assert_eq!(read_commit.parents.len(), 1);
162        assert_eq!(read_commit.parents[0], parent);
163    }
164
165    #[test]
166    fn test_commit_with_metadata() {
167        let (_dir, repo) = test_repo();
168
169        let commit = Commit::with_timestamp(Hash::ZERO, vec![], "author", 1234567890, "test")
170            .with_metadata("key1", "value1")
171            .with_metadata("key2", "value2");
172
173        let hash = write_commit(&repo, &commit).unwrap();
174        let read_commit = read_commit(&repo, &hash).unwrap();
175
176        assert_eq!(
177            read_commit.metadata.get("key1"),
178            Some(&"value1".to_string())
179        );
180        assert_eq!(
181            read_commit.metadata.get("key2"),
182            Some(&"value2".to_string())
183        );
184    }
185
186    #[test]
187    fn test_read_nonexistent_commit() {
188        let (_dir, repo) = test_repo();
189
190        let fake_hash =
191            Hash::from_hex("2222222222222222222222222222222222222222222222222222222222222222")
192                .unwrap();
193        let result = read_commit(&repo, &fake_hash);
194
195        assert!(matches!(result, Err(Error::ObjectNotFound(_))));
196    }
197
198    #[test]
199    fn test_merge_commit() {
200        let (_dir, repo) = test_repo();
201
202        let p1 = Hash::from_hex("1111111111111111111111111111111111111111111111111111111111111111")
203            .unwrap();
204        let p2 = Hash::from_hex("2222222222222222222222222222222222222222222222222222222222222222")
205            .unwrap();
206
207        let commit = Commit::with_timestamp(
208            Hash::ZERO,
209            vec![p1, p2],
210            "author",
211            1234567890,
212            "merge commit",
213        );
214
215        assert!(commit.is_merge());
216
217        let hash = write_commit(&repo, &commit).unwrap();
218        let read_commit = read_commit(&repo, &hash).unwrap();
219
220        assert!(read_commit.is_merge());
221        assert_eq!(read_commit.parents.len(), 2);
222    }
223}