git/
commit.rs

1use chrono::{DateTime, Local, TimeZone};
2
3use crate::{errors::GitError, reference::Reference, user::User};
4
5/// Represents a commit.
6#[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	/// Get the hash of the commit
20	#[must_use]
21	#[inline]
22	pub fn hash(&self) -> &str {
23		self.hash.as_str()
24	}
25
26	/// Get the reference to the commit
27	#[must_use]
28	#[inline]
29	pub const fn reference(&self) -> &Option<Reference> {
30		&self.reference
31	}
32
33	/// Get the author of the commit.
34	#[must_use]
35	#[inline]
36	pub const fn author(&self) -> &User {
37		&self.author
38	}
39
40	/// Get the author of the commit.
41	#[must_use]
42	#[inline]
43	pub const fn authored_date(&self) -> &Option<DateTime<Local>> {
44		&self.authored_date
45	}
46
47	/// Get the committer of the commit.
48	#[must_use]
49	#[inline]
50	pub const fn committer(&self) -> &Option<User> {
51		&self.committer
52	}
53
54	/// Get the committed date of the commit.
55	#[must_use]
56	#[inline]
57	pub const fn committed_date(&self) -> &DateTime<Local> {
58		&self.committed_date
59	}
60
61	/// Get the commit message summary.
62	#[must_use]
63	#[inline]
64	pub fn summary(&self) -> Option<&str> {
65		self.summary.as_deref()
66	}
67
68	/// Get the full commit message.
69	#[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		// this should never panic, since msecs is always zero
80		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}