1use chrono::{DateTime, Local, TimeZone};
2
3use crate::{errors::GitError, reference::Reference, user::User};
4
5#[derive(Debug, PartialEq, Eq)]
7pub struct Commit {
8 pub(crate) hash: String,
9 pub(crate) reference: Option<Reference>,
10 pub(crate) author: User,
11 pub(crate) authored_date: Option<DateTime<Local>>,
12 pub(crate) message: Option<String>,
13 pub(crate) committer: Option<User>,
14 pub(crate) committed_date: DateTime<Local>,
15 pub(crate) summary: Option<String>,
16}
17
18impl Commit {
19 #[must_use]
21 #[inline]
22 pub fn hash(&self) -> &str {
23 self.hash.as_str()
24 }
25
26 #[must_use]
28 #[inline]
29 pub const fn reference(&self) -> &Option<Reference> {
30 &self.reference
31 }
32
33 #[must_use]
35 #[inline]
36 pub const fn author(&self) -> &User {
37 &self.author
38 }
39
40 #[must_use]
42 #[inline]
43 pub const fn authored_date(&self) -> &Option<DateTime<Local>> {
44 &self.authored_date
45 }
46
47 #[must_use]
49 #[inline]
50 pub const fn committer(&self) -> &Option<User> {
51 &self.committer
52 }
53
54 #[must_use]
56 #[inline]
57 pub const fn committed_date(&self) -> &DateTime<Local> {
58 &self.committed_date
59 }
60
61 #[must_use]
63 #[inline]
64 pub fn summary(&self) -> Option<&str> {
65 self.summary.as_deref()
66 }
67
68 #[must_use]
70 #[inline]
71 pub fn message(&self) -> Option<&str> {
72 self.message.as_deref()
73 }
74
75 fn new(commit: &git2::Commit<'_>, reference: Option<&git2::Reference<'_>>) -> Self {
76 let author = User::new(commit.author().name(), commit.author().email());
77 let message = commit.message().map(String::from);
78 let summary = commit.summary().map(String::from);
79 let committed_date = Local.timestamp_opt(commit.time().seconds(), 0).unwrap();
81 let authored_date = Local
82 .timestamp_opt(commit.author().when().seconds(), 0)
83 .single()
84 .unwrap_or(committed_date);
85
86 let try_committer = User::new(commit.committer().name(), commit.committer().email());
87 let committer = (try_committer.is_some() && try_committer != author).then_some(try_committer);
88
89 Self {
90 hash: format!("{}", commit.id()),
91 reference: reference.map(Reference::from),
92 author,
93 authored_date: if authored_date == committed_date {
94 None
95 }
96 else {
97 Some(authored_date)
98 },
99 message,
100 committer,
101 committed_date,
102 summary,
103 }
104 }
105}
106
107impl TryFrom<&git2::Reference<'_>> for Commit {
108 type Error = GitError;
109
110 #[inline]
111 fn try_from(reference: &git2::Reference<'_>) -> Result<Self, Self::Error> {
112 let commit = reference
113 .peel_to_commit()
114 .map_err(|e| GitError::CommitLoad { cause: e })?;
115 Ok(Self::new(&commit, Some(reference)))
116 }
117}
118
119impl From<&git2::Commit<'_>> for Commit {
120 #[inline]
121 fn from(commit: &git2::Commit<'_>) -> Self {
122 Self::new(commit, None)
123 }
124}
125
126#[cfg(test)]
127mod tests {
128 use claim::{assert_none, assert_some_eq};
129 use testutils::assert_err_eq;
130
131 use super::*;
132 use crate::testutil::{
133 create_commit,
134 with_temp_repository,
135 CommitBuilder,
136 CreateCommitOptions,
137 ReferenceBuilder,
138 JAN_2021_EPOCH,
139 };
140
141 #[test]
142 fn hash() {
143 let commit = CommitBuilder::new("0123456789ABCDEF").build();
144 assert_eq!(commit.hash(), "0123456789ABCDEF");
145 }
146
147 #[test]
148 fn reference() {
149 let commit = CommitBuilder::new("0123456789ABCDEF")
150 .reference(ReferenceBuilder::new("0123456789ABCDEF").build())
151 .build();
152
153 assert_some_eq!(commit.reference(), &ReferenceBuilder::new("0123456789ABCDEF").build());
154 }
155
156 #[test]
157 fn author() {
158 let commit = CommitBuilder::new("0123456789ABCDEF")
159 .author(User::new(Some("name"), Some("name@example.com")))
160 .build();
161 assert_eq!(commit.author(), &User::new(Some("name"), Some("name@example.com")));
162 }
163
164 #[test]
165 fn committed_date() {
166 let commit = CommitBuilder::new("0123456789ABCDEF")
167 .commit_time(JAN_2021_EPOCH)
168 .build();
169 assert_eq!(
170 commit.committed_date(),
171 &DateTime::parse_from_rfc3339("2021-01-01T00:00:00Z").unwrap()
172 );
173 }
174
175 #[test]
176 fn summary() {
177 let commit = CommitBuilder::new("0123456789ABCDEF").summary("title").build();
178 assert_some_eq!(commit.summary(), "title");
179 }
180
181 #[test]
182 fn message() {
183 let commit = CommitBuilder::new("0123456789ABCDEF").message("title\n\nbody").build();
184 assert_some_eq!(commit.message(), "title\n\nbody");
185 }
186
187 #[test]
188 fn new_authored_date_same_committed_date() {
189 with_temp_repository(|repository| {
190 create_commit(
191 &repository,
192 Some(CreateCommitOptions::new().author_time(JAN_2021_EPOCH)),
193 );
194 let commit = repository.find_commit("refs/heads/main").unwrap();
195 assert_none!(commit.authored_date());
196 });
197 }
198
199 #[test]
200 fn new_authored_date_different_than_committed() {
201 with_temp_repository(|repository| {
202 create_commit(
203 &repository,
204 Some(
205 CreateCommitOptions::new()
206 .commit_time(JAN_2021_EPOCH)
207 .author_time(JAN_2021_EPOCH + 1),
208 ),
209 );
210 let commit = repository.find_commit("refs/heads/main").unwrap();
211 assert_some_eq!(
212 commit.authored_date(),
213 &DateTime::parse_from_rfc3339("2021-01-01T00:00:01Z").unwrap()
214 );
215 });
216 }
217
218 #[test]
219 fn new_committer_different_than_author() {
220 with_temp_repository(|repository| {
221 create_commit(&repository, Some(CreateCommitOptions::new().committer("Committer")));
222 let commit = repository.find_commit("refs/heads/main").unwrap();
223 assert_some_eq!(
224 commit.committer(),
225 &User::new(Some("Committer"), Some("committer@example.com"))
226 );
227 });
228 }
229
230 #[test]
231 fn new_committer_same_as_author() {
232 with_temp_repository(|repository| {
233 let commit = repository.find_commit("refs/heads/main").unwrap();
234 assert_none!(commit.committer());
235 });
236 }
237
238 #[test]
239 fn try_from_success() {
240 with_temp_repository(|repository| {
241 let repo = repository.repository();
242 let repo_lock = repo.lock();
243 let reference = repo_lock.find_reference("refs/heads/main").unwrap();
244 let commit = Commit::try_from(&reference).unwrap();
245
246 assert_eq!(commit.reference.unwrap().shortname(), "main");
247 });
248 }
249
250 #[test]
251 fn try_from_error() {
252 with_temp_repository(|repository| {
253 let repo = repository.repository();
254 let repo_lock = repo.lock();
255 let blob = repo_lock.blob(b"foo").unwrap();
256 _ = repo_lock.reference("refs/blob", blob, false, "blob").unwrap();
257
258 let reference = repo_lock.find_reference("refs/blob").unwrap();
259 assert_err_eq!(Commit::try_from(&reference), GitError::CommitLoad {
260 cause: git2::Error::new(
261 git2::ErrorCode::InvalidSpec,
262 git2::ErrorClass::Object,
263 format!(
264 "the git_object of id '{blob}' can not be successfully peeled into a commit (git_object_t=1).",
265 )
266 )
267 });
268 });
269 }
270}