git/
repository.rs

1use std::{
2	path::{Path, PathBuf},
3	sync::Arc,
4};
5
6use git2::{Oid, Signature};
7use parking_lot::Mutex;
8
9use crate::{
10	commit_diff_loader::CommitDiffLoader,
11	errors::{GitError, RepositoryLoadKind},
12	Commit,
13	CommitDiff,
14	CommitDiffLoaderOptions,
15	Config,
16	Reference,
17};
18
19/// A light cloneable, simple wrapper around the `git2::Repository` struct
20#[derive(Clone)]
21pub struct Repository {
22	repository: Arc<Mutex<git2::Repository>>,
23}
24
25impl Repository {
26	/// Find and open an existing repository, respecting git environment variables. This will check
27	/// for and use `$GIT_DIR`, and if unset will search for a repository starting in the current
28	/// directory, walking to the root.
29	///
30	/// # Errors
31	/// Will result in an error if the repository cannot be opened.
32	#[inline]
33	pub fn open_from_env() -> Result<Self, GitError> {
34		let repository = git2::Repository::open_from_env().map_err(|e| {
35			GitError::RepositoryLoad {
36				kind: RepositoryLoadKind::Environment,
37				cause: e,
38			}
39		})?;
40		Ok(Self {
41			repository: Arc::new(Mutex::new(repository)),
42		})
43	}
44
45	/// Attempt to open an already-existing repository at `path`.
46	///
47	/// # Errors
48	/// Will result in an error if the repository cannot be opened.
49	#[inline]
50	pub fn open_from_path(path: &Path) -> Result<Self, GitError> {
51		let repository = git2::Repository::open(path).map_err(|e| {
52			GitError::RepositoryLoad {
53				kind: RepositoryLoadKind::Path,
54				cause: e,
55			}
56		})?;
57		Ok(Self {
58			repository: Arc::new(Mutex::new(repository)),
59		})
60	}
61
62	/// Load the git configuration for the repository.
63	///
64	/// # Errors
65	/// Will result in an error if the configuration is invalid.
66	#[inline]
67	pub fn load_config(&self) -> Result<Config, GitError> {
68		self.repository
69			.lock()
70			.config()
71			.map_err(|e| GitError::ConfigLoad { cause: e })
72	}
73
74	/// Load a diff for a commit hash
75	///
76	/// # Errors
77	/// Will result in an error if the commit cannot be loaded.
78	#[inline]
79	pub fn load_commit_diff(&self, hash: &str, config: &CommitDiffLoaderOptions) -> Result<CommitDiff, GitError> {
80		let oid = self
81			.repository
82			.lock()
83			.revparse_single(hash)
84			.map_err(|e| GitError::CommitLoad { cause: e })?
85			.id();
86		let diff_loader_repository = Arc::clone(&self.repository);
87		let loader = CommitDiffLoader::new(diff_loader_repository, config);
88		// TODO this is ugly because it assumes one parent
89		Ok(loader
90			.load_from_hash(oid)
91			.map_err(|e| GitError::CommitLoad { cause: e })?
92			.remove(0))
93	}
94
95	/// Find a reference by the reference name.
96	///
97	/// # Errors
98	/// Will result in an error if the reference cannot be found.
99	#[inline]
100	pub fn find_reference(&self, reference: &str) -> Result<Reference, GitError> {
101		let repo = self.repository.lock();
102		let git2_reference = repo
103			.find_reference(reference)
104			.map_err(|e| GitError::ReferenceNotFound { cause: e })?;
105		Ok(Reference::from(&git2_reference))
106	}
107
108	/// Find a commit by a reference name.
109	///
110	/// # Errors
111	/// Will result in an error if the reference cannot be found or is not a commit.
112	#[inline]
113	pub fn find_commit(&self, reference: &str) -> Result<Commit, GitError> {
114		let repo = self.repository.lock();
115		let git2_reference = repo
116			.find_reference(reference)
117			.map_err(|e| GitError::ReferenceNotFound { cause: e })?;
118		Commit::try_from(&git2_reference)
119	}
120
121	pub(crate) fn repo_path(&self) -> PathBuf {
122		self.repository.lock().path().to_path_buf()
123	}
124
125	pub(crate) fn head_id(&self, head_name: &str) -> Result<Oid, git2::Error> {
126		let repo = self.repository.lock();
127		let ref_name = format!("refs/heads/{head_name}");
128		let revision = repo.revparse_single(ref_name.as_str())?;
129		Ok(revision.id())
130	}
131
132	pub(crate) fn commit_id_from_ref(&self, reference: &str) -> Result<Oid, git2::Error> {
133		let repo = self.repository.lock();
134		let commit = repo.find_reference(reference)?.peel_to_commit()?;
135		Ok(commit.id())
136	}
137
138	pub(crate) fn add_path_to_index(&self, path: &Path) -> Result<(), git2::Error> {
139		let repo = self.repository.lock();
140		let mut index = repo.index()?;
141		index.add_path(path)
142	}
143
144	pub(crate) fn remove_path_from_index(&self, path: &Path) -> Result<(), git2::Error> {
145		let repo = self.repository.lock();
146		let mut index = repo.index()?;
147		index.remove_path(path)
148	}
149
150	pub(crate) fn create_commit_on_index(
151		&self,
152		reference: &str,
153		author: &Signature<'_>,
154		committer: &Signature<'_>,
155		message: &str,
156	) -> Result<(), git2::Error> {
157		let repo = self.repository.lock();
158		let tree = repo.find_tree(repo.index()?.write_tree()?)?;
159		let head = repo.find_reference(reference)?.peel_to_commit()?;
160		_ = repo.commit(Some("HEAD"), author, committer, message, &tree, &[&head])?;
161		Ok(())
162	}
163
164	#[cfg(test)]
165	pub(crate) fn repository(&self) -> Arc<Mutex<git2::Repository>> {
166		Arc::clone(&self.repository)
167	}
168}
169
170impl From<git2::Repository> for Repository {
171	#[inline]
172	fn from(repository: git2::Repository) -> Self {
173		Self {
174			repository: Arc::new(Mutex::new(repository)),
175		}
176	}
177}
178
179impl std::fmt::Debug for Repository {
180	#[inline]
181	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
182		f.debug_struct("Repository")
183			.field("[path]", &self.repository.lock().path())
184			.finish()
185	}
186}
187
188// Paths in Windows makes these tests difficult, so disable
189#[cfg(all(unix, test))]
190mod tests {
191	use std::env::set_var;
192
193	use claim::assert_ok;
194	use git2::{ErrorClass, ErrorCode};
195	use testutils::assert_err_eq;
196
197	use super::*;
198	use crate::testutil::{commit_id_from_ref, create_commit, with_temp_bare_repository, with_temp_repository};
199
200	#[test]
201	#[serial_test::serial]
202	fn open_from_env() {
203		let path = Path::new(env!("CARGO_MANIFEST_DIR"))
204			.join("test")
205			.join("fixtures")
206			.join("simple");
207		set_var("GIT_DIR", path.to_str().unwrap());
208		assert_ok!(Repository::open_from_env());
209	}
210
211	#[test]
212	#[serial_test::serial]
213	fn open_from_env_error() {
214		let path = Path::new(env!("CARGO_MANIFEST_DIR"))
215			.join("test")
216			.join("fixtures")
217			.join("does-not-exist");
218		set_var("GIT_DIR", path.to_str().unwrap());
219		assert_err_eq!(Repository::open_from_env(), GitError::RepositoryLoad {
220			kind: RepositoryLoadKind::Environment,
221			cause: git2::Error::new(
222				ErrorCode::NotFound,
223				ErrorClass::Os,
224				format!(
225					"failed to resolve path '{}': No such file or directory",
226					path.to_string_lossy()
227				)
228			),
229		});
230	}
231
232	#[test]
233	fn open_from_path() {
234		let path = Path::new(env!("CARGO_MANIFEST_DIR"))
235			.join("test")
236			.join("fixtures")
237			.join("simple");
238		assert_ok!(Repository::open_from_path(&path));
239	}
240
241	#[test]
242	fn open_from_path_error() {
243		let path = Path::new(env!("CARGO_MANIFEST_DIR"))
244			.join("test")
245			.join("fixtures")
246			.join("does-not-exist");
247		assert_err_eq!(Repository::open_from_path(&path), GitError::RepositoryLoad {
248			kind: RepositoryLoadKind::Path,
249			cause: git2::Error::new(
250				ErrorCode::NotFound,
251				ErrorClass::Os,
252				format!(
253					"failed to resolve path '{}': No such file or directory",
254					path.to_string_lossy()
255				)
256			),
257		});
258	}
259
260	#[test]
261	fn load_config() {
262		with_temp_bare_repository(|repo| {
263			assert_ok!(repo.load_config());
264		});
265	}
266
267	#[test]
268	fn load_commit_diff() {
269		with_temp_repository(|repository| {
270			create_commit(&repository, None);
271			let id = commit_id_from_ref(&repository, "refs/heads/main");
272			assert_ok!(repository.load_commit_diff(id.to_string().as_str(), &CommitDiffLoaderOptions::new()));
273		});
274	}
275
276	#[test]
277	fn load_commit_diff_with_non_commit() {
278		with_temp_repository(|repository| {
279			let blob_ref = {
280				let git2_repository = repository.repository();
281				let git2_lock = git2_repository.lock();
282				let blob = git2_lock.blob(b"foo").unwrap();
283				_ = git2_lock.reference("refs/blob", blob, false, "blob").unwrap();
284				blob.to_string()
285			};
286
287			assert_err_eq!(
288				repository.load_commit_diff(blob_ref.as_str(), &CommitDiffLoaderOptions::new()),
289				GitError::CommitLoad {
290					cause: git2::Error::new(
291						ErrorCode::NotFound,
292						ErrorClass::Invalid,
293						"the requested type does not match the type in the ODB",
294					),
295				}
296			);
297		});
298	}
299
300	#[test]
301	fn find_reference() {
302		with_temp_repository(|repository| {
303			assert_ok!(repository.find_reference("refs/heads/main"));
304		});
305	}
306
307	#[test]
308	fn find_reference_error() {
309		with_temp_repository(|repository| {
310			assert_err_eq!(
311				repository.find_reference("refs/heads/invalid"),
312				GitError::ReferenceNotFound {
313					cause: git2::Error::new(
314						ErrorCode::NotFound,
315						ErrorClass::Reference,
316						"reference 'refs/heads/invalid' not found",
317					),
318				}
319			);
320		});
321	}
322
323	#[test]
324	fn find_commit() {
325		with_temp_repository(|repository| {
326			assert_ok!(repository.find_commit("refs/heads/main"));
327		});
328	}
329
330	#[test]
331	fn find_commit_error() {
332		with_temp_repository(|repository| {
333			assert_err_eq!(
334				repository.find_commit("refs/heads/invalid"),
335				GitError::ReferenceNotFound {
336					cause: git2::Error::new(
337						ErrorCode::NotFound,
338						ErrorClass::Reference,
339						"reference 'refs/heads/invalid' not found",
340					),
341				}
342			);
343		});
344	}
345
346	#[test]
347	fn fmt() {
348		with_temp_bare_repository(|repository| {
349			let formatted = format!("{repository:?}");
350			let path = repository.repo_path().canonicalize().unwrap();
351			assert_eq!(
352				formatted,
353				format!("Repository {{ [path]: \"{}/\" }}", path.to_str().unwrap())
354			);
355		});
356	}
357}