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
12pub fn write_commit(repo: &Repo, commit: &Commit) -> Result<Hash> {
17 let mut cbor_bytes = Vec::new();
19 ciborium::into_writer(commit, &mut cbor_bytes)?;
20
21 let compressed = zstd::encode_all(&cbor_bytes[..], 3).map_err(|e| Error::Io {
23 path: PathBuf::from("<zstd>"),
24 source: e,
25 })?;
26
27 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 if commit_path.exists() {
36 return Ok(hash);
37 }
38
39 fs::create_dir_all(&commit_dir).with_path(&commit_dir)?;
41
42 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 fs::rename(&tmp_path, &commit_path).with_path(&commit_path)?;
52
53 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
60pub 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 let actual_hash = Hash::from_bytes(Sha256::digest(&compressed).into());
77 if actual_hash != *hash {
78 return Err(Error::CorruptObject(*hash));
79 }
80
81 let cbor_bytes = zstd::decode_all(&compressed[..]).map_err(|e| Error::Io {
83 path: path.clone(),
84 source: e,
85 })?;
86
87 let commit: Commit = ciborium::from_reader(&cbor_bytes[..])?;
89
90 Ok(commit)
91}
92
93pub 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#[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}