git_cliff_core/
repo.rs

1use crate::config::Remote;
2use crate::error::{
3	Error,
4	Result,
5};
6use crate::tag::Tag;
7use git2::{
8	BranchType,
9	Commit,
10	DescribeOptions,
11	Oid,
12	Repository as GitRepository,
13	Sort,
14	TreeWalkMode,
15	Worktree,
16};
17use glob::Pattern;
18use indexmap::IndexMap;
19use lazy_regex::{
20	Lazy,
21	Regex,
22	lazy_regex,
23};
24use std::io;
25use std::path::{
26	Path,
27	PathBuf,
28};
29use std::result::Result as StdResult;
30use url::Url;
31
32/// Regex for replacing the signature part of a tag message.
33static TAG_SIGNATURE_REGEX: Lazy<Regex> = lazy_regex!(
34	// https://git-scm.com/docs/gitformat-signature#_description
35	r"(?s)-----BEGIN (PGP|SSH|SIGNED) (SIGNATURE|MESSAGE)-----(.*?)-----END (PGP|SSH|SIGNED) (SIGNATURE|MESSAGE)-----"
36);
37
38/// Name of the cache file for changed files.
39const CHANGED_FILES_CACHE: &str = "changed_files_cache";
40
41/// Wrapper for [`Repository`] type from git2.
42///
43/// [`Repository`]: GitRepository
44pub struct Repository {
45	inner:                    GitRepository,
46	/// Repository path.
47	path:                     PathBuf,
48	/// Cache path for the changed files of the commits.
49	changed_files_cache_path: PathBuf,
50}
51
52/// Range of commits in a submodule.
53pub struct SubmoduleRange {
54	/// Repository object to which this range belongs.
55	pub repository: Repository,
56	/// Commit range in "<first_submodule_commit>..<last_submodule_commit>" or
57	/// "<last_submodule_commit>" format.
58	pub range:      String,
59}
60
61impl Repository {
62	/// Initializes (opens) the repository.
63	pub fn init(path: PathBuf) -> Result<Self> {
64		if path.exists() {
65			let inner = GitRepository::discover(&path).or_else(|err| {
66				let jujutsu_path =
67					path.join(".jj").join("repo").join("store").join("git");
68				if jujutsu_path.exists() {
69					GitRepository::open_bare(&jujutsu_path)
70				} else {
71					Err(err)
72				}
73			})?;
74			let changed_files_cache_path = inner
75				.path()
76				.join(env!("CARGO_PKG_NAME"))
77				.join(CHANGED_FILES_CACHE);
78			Ok(Self {
79				inner,
80				path,
81				changed_files_cache_path,
82			})
83		} else {
84			Err(Error::IoError(io::Error::new(
85				io::ErrorKind::NotFound,
86				"repository path not found",
87			)))
88		}
89	}
90
91	/// Returns the path of the repository.
92	pub fn root_path(&self) -> Result<PathBuf> {
93		let mut path = if self.inner.is_worktree() {
94			let worktree = Worktree::open_from_repository(&self.inner)?;
95			worktree.path().to_path_buf()
96		} else {
97			self.inner.path().to_path_buf()
98		};
99		if path.ends_with(".git") {
100			path.pop();
101		}
102		Ok(path)
103	}
104
105	/// Returns the initial path of the repository.
106	///
107	/// In case of a submodule this is the relative path to the toplevel
108	/// repository.
109	pub fn path(&self) -> &PathBuf {
110		&self.path
111	}
112
113	/// Sets the range for the commit search.
114	///
115	/// When a single SHA is provided as the range, start from the
116	/// root.
117	fn set_commit_range(
118		revwalk: &mut git2::Revwalk<'_>,
119		range: Option<&str>,
120	) -> StdResult<(), git2::Error> {
121		if let Some(range) = range {
122			if range.contains("..") {
123				revwalk.push_range(range)?;
124			} else {
125				revwalk.push(Oid::from_str(range)?)?;
126			}
127		} else {
128			revwalk.push_head()?;
129		}
130		Ok(())
131	}
132
133	/// Parses and returns the commits.
134	///
135	/// Sorts the commits by their time.
136	pub fn commits(
137		&self,
138		range: Option<&str>,
139		include_path: Option<Vec<Pattern>>,
140		exclude_path: Option<Vec<Pattern>>,
141		topo_order_commits: bool,
142	) -> Result<Vec<Commit>> {
143		let mut revwalk = self.inner.revwalk()?;
144		if topo_order_commits {
145			revwalk.set_sorting(Sort::TOPOLOGICAL)?;
146		} else {
147			revwalk.set_sorting(Sort::TIME)?;
148		}
149
150		Self::set_commit_range(&mut revwalk, range).map_err(|e| {
151			Error::SetCommitRangeError(
152				range.map(String::from).unwrap_or_else(|| "?".to_string()),
153				e,
154			)
155		})?;
156		let mut commits: Vec<Commit> = revwalk
157			.filter_map(|id| id.ok())
158			.filter_map(|id| self.inner.find_commit(id).ok())
159			.collect();
160		if include_path.is_some() || exclude_path.is_some() {
161			let include_patterns = include_path.map(|patterns| {
162				patterns.into_iter().map(Self::normalize_pattern).collect()
163			});
164			let exclude_patterns = exclude_path.map(|patterns| {
165				patterns.into_iter().map(Self::normalize_pattern).collect()
166			});
167			commits.retain(|commit| {
168				self.should_retain_commit(
169					commit,
170					&include_patterns,
171					&exclude_patterns,
172				)
173			});
174		}
175		Ok(commits)
176	}
177
178	/// Returns submodule repositories for a given commit range.
179	///
180	/// For one or two given commits in this repository, a list of changed
181	/// submodules is calculated. If only one commit is given, then all
182	/// submodule commits up to the referenced commit will be included. This is
183	/// usually the case if a submodule is added to the repository.
184	///
185	///  For each submodule a [`SubmoduleRange`] object is created
186	///
187	/// This can then be used to query the submodule's commits by using
188	/// [`Repository::commits`].
189	pub fn submodules_range(
190		&self,
191		old_commit: Option<Commit<'_>>,
192		new_commit: Commit<'_>,
193	) -> Result<Vec<SubmoduleRange>> {
194		let old_tree = old_commit.and_then(|commit| commit.tree().ok());
195		let new_tree = new_commit.tree().ok();
196		let diff = self.inner.diff_tree_to_tree(
197			old_tree.as_ref(),
198			new_tree.as_ref(),
199			None,
200		)?;
201		// iterate through all diffs and accumulate old/new commit ids
202		let before_and_after_deltas = diff.deltas().filter_map(|delta| {
203			let old_file_id = delta.old_file().id();
204			let new_file_id = delta.new_file().id();
205			let range = if old_file_id == new_file_id || new_file_id.is_zero() {
206				// no changes or submodule removed
207				None
208			} else if old_file_id.is_zero() {
209				// submodule added
210				Some(new_file_id.to_string())
211			} else {
212				// submodule updated
213				Some(format!("{}..{}", old_file_id, new_file_id))
214			};
215			trace!("Release commit range for submodules: {:?}", range);
216			delta.new_file().path().and_then(Path::to_str).zip(range)
217		});
218		// iterate through all path diffs and find corresponding submodule if
219		// possible
220		let submodule_range = before_and_after_deltas.filter_map(|(path, range)| {
221			let repository = self
222				.inner
223				.find_submodule(path)
224				.ok()
225				.and_then(|submodule| Self::init(submodule.path().into()).ok());
226			repository.map(|repository| SubmoduleRange { repository, range })
227		});
228		Ok(submodule_range.collect())
229	}
230
231	/// Normalizes the glob pattern to match the git diff paths.
232	///
233	/// It removes the leading `./` and adds `**` to the end if the pattern is a
234	/// directory.
235	fn normalize_pattern(pattern: Pattern) -> Pattern {
236		let star_added = match pattern.as_str().chars().last() {
237			Some('/' | '\\') => Pattern::new(&format!("{pattern}**"))
238				.expect("failed to add '**' to the end of glob"),
239			_ => pattern,
240		};
241		match star_added.as_str().strip_prefix("./") {
242			Some(stripped) => Pattern::new(stripped)
243				.expect("failed to remove leading ./ from glob"),
244			None => star_added,
245		}
246	}
247
248	/// Calculates whether the commit should be retained or not.
249	///
250	/// This function is used to filter the commits based on the changed files,
251	/// and include/exclude patterns.
252	fn should_retain_commit(
253		&self,
254		commit: &Commit,
255		include_patterns: &Option<Vec<Pattern>>,
256		exclude_patterns: &Option<Vec<Pattern>>,
257	) -> bool {
258		let changed_files = self.commit_changed_files(commit);
259		match (include_patterns, exclude_patterns) {
260			(Some(include_pattern), Some(exclude_pattern)) => {
261				// check if the commit has any changed files that match any of the
262				// include patterns and non of the exclude patterns.
263				changed_files.iter().any(|path| {
264					include_pattern
265						.iter()
266						.any(|pattern| pattern.matches_path(path)) &&
267						!exclude_pattern
268							.iter()
269							.any(|pattern| pattern.matches_path(path))
270				})
271			}
272			(Some(include_pattern), None) => {
273				// check if the commit has any changed files that match the include
274				// patterns.
275				changed_files.iter().any(|path| {
276					include_pattern
277						.iter()
278						.any(|pattern| pattern.matches_path(path))
279				})
280			}
281			(None, Some(exclude_pattern)) => {
282				// check if the commit has at least one changed file that does not
283				// match all exclude patterns.
284				changed_files.iter().any(|path| {
285					!exclude_pattern
286						.iter()
287						.any(|pattern| pattern.matches_path(path))
288				})
289			}
290			(None, None) => true,
291		}
292	}
293
294	/// Returns the changed files of the commit.
295	///
296	/// It uses a cache to speed up checks to store the changed files of the
297	/// commits under `./.git/git-cliff-core/changed_files_cache`. The speed-up
298	/// was measured to be around 260x for large repositories.
299	///
300	/// If the cache is not found, it calculates the changed files and adds them
301	/// to the cache via [`Self::commit_changed_files_no_cache`].
302	fn commit_changed_files(&self, commit: &Commit) -> Vec<PathBuf> {
303		// Cache key is generated from the repository path and commit id
304		let cache_key = format!("commit_id:{}", commit.id());
305
306		// Check the cache first.
307		{
308			if let Ok(result) =
309				cacache::read_sync(&self.changed_files_cache_path, &cache_key)
310			{
311				if let Ok((files, _)) =
312					bincode::decode_from_slice(&result, bincode::config::standard())
313				{
314					return files;
315				}
316			}
317		}
318
319		// If the cache is not found, calculate the result and set it to the cache.
320		let result = self.commit_changed_files_no_cache(commit);
321		match bincode::encode_to_vec(
322			self.commit_changed_files_no_cache(commit),
323			bincode::config::standard(),
324		) {
325			Ok(v) => {
326				if let Err(e) = cacache::write_sync_with_algo(
327					cacache::Algorithm::Xxh3,
328					&self.changed_files_cache_path,
329					cache_key,
330					v,
331				) {
332					error!("Failed to set cache for repo {:?}: {e}", self.path);
333				}
334			}
335			Err(e) => {
336				error!("Failed to serialize cache for repo {:?}: {e}", self.path);
337			}
338		}
339
340		result
341	}
342
343	/// Calculate the changed files of the commit.
344	///
345	/// This function does not use the cache (directly calls git2).
346	fn commit_changed_files_no_cache(&self, commit: &Commit) -> Vec<PathBuf> {
347		let mut changed_files = Vec::new();
348		if let Ok(prev_commit) = commit.parent(0) {
349			// Compare the current commit with the previous commit to get the
350			// changed files.
351			// libgit2 does not provide a way to get the changed files directly, so
352			// the full diff is calculated here.
353			if let Ok(diff) = self.inner.diff_tree_to_tree(
354				commit.tree().ok().as_ref(),
355				prev_commit.tree().ok().as_ref(),
356				None,
357			) {
358				changed_files.extend(
359					diff.deltas().filter_map(|delta| {
360						delta.new_file().path().map(PathBuf::from)
361					}),
362				);
363			}
364		} else {
365			// If there is no parent, it is the first commit.
366			// So get all the files in the tree.
367			if let Ok(tree) = commit.tree() {
368				tree.walk(TreeWalkMode::PreOrder, |dir, entry| {
369					if entry.kind().expect("failed to get entry kind") !=
370						git2::ObjectType::Blob
371					{
372						return 0;
373					}
374					let name = entry.name().expect("failed to get entry name");
375					let entry_path = if dir == "," {
376						name.to_string()
377					} else {
378						format!("{dir}/{name}")
379					};
380					changed_files.push(entry_path.into());
381					0
382				})
383				.expect("failed to get the changed files of the first commit");
384			}
385		}
386		changed_files
387	}
388
389	/// Returns the current tag.
390	///
391	/// It is the same as running `git describe --tags`
392	pub fn current_tag(&self) -> Option<Tag> {
393		self.inner
394			.describe(DescribeOptions::new().describe_tags())
395			.ok()
396			.and_then(|describe| {
397				describe
398					.format(None)
399					.ok()
400					.map(|name| self.resolve_tag(&name))
401			})
402	}
403
404	/// Returns the tag object of the given name.
405	///
406	/// If given name doesn't exist, it still returns `Tag` with the given name.
407	pub fn resolve_tag(&self, name: &str) -> Tag {
408		match self
409			.inner
410			.resolve_reference_from_short_name(name)
411			.and_then(|r| r.peel_to_tag())
412		{
413			Ok(tag) => Tag {
414				name:    tag.name().unwrap_or_default().to_owned(),
415				message: tag.message().map(|msg| {
416					TAG_SIGNATURE_REGEX.replace(msg, "").trim().to_owned()
417				}),
418			},
419			_ => Tag {
420				name:    name.to_owned(),
421				message: None,
422			},
423		}
424	}
425
426	/// Returns the commit object of the given ID.
427	pub fn find_commit(&self, id: &str) -> Option<Commit> {
428		if let Ok(oid) = Oid::from_str(id) {
429			if let Ok(commit) = self.inner.find_commit(oid) {
430				return Some(commit);
431			}
432		}
433		None
434	}
435
436	/// Decide whether to include tag.
437	///
438	/// `head_commit` is the `latest` commit to generate changelog. It can be a
439	/// branch head or a detached head. `tag_commit` is a tagged commit. If the
440	/// commit is in the descendant graph of the `head_commit` or is the
441	/// `head_commit` itself, Changelog should include the tag.
442	fn should_include_tag(
443		&self,
444		head_commit: &Commit,
445		tag_commit: &Commit,
446	) -> Result<bool> {
447		Ok(self
448			.inner
449			.graph_descendant_of(head_commit.id(), tag_commit.id())? ||
450			head_commit.id() == tag_commit.id())
451	}
452
453	/// Parses and returns a commit-tag map.
454	///
455	/// It collects lightweight and annotated tags.
456	pub fn tags(
457		&self,
458		pattern: &Option<Regex>,
459		topo_order: bool,
460		use_branch_tags: bool,
461	) -> Result<IndexMap<String, Tag>> {
462		let mut tags: Vec<(Commit, Tag)> = Vec::new();
463		let tag_names = self.inner.tag_names(None)?;
464		let head_commit = self.inner.head()?.peel_to_commit()?;
465		for name in tag_names
466			.iter()
467			.flatten()
468			.filter(|tag_name| {
469				pattern.as_ref().is_none_or(|pat| pat.is_match(tag_name))
470			})
471			.map(String::from)
472		{
473			let obj = self.inner.revparse_single(&name)?;
474			if let Ok(commit) = obj.clone().into_commit() {
475				if use_branch_tags &&
476					!self.should_include_tag(&head_commit, &commit)?
477				{
478					continue;
479				}
480
481				tags.push((commit, Tag {
482					name,
483					message: None,
484				}));
485			} else if let Some(tag) = obj.as_tag() {
486				if let Some(commit) = tag
487					.target()
488					.ok()
489					.and_then(|target| target.into_commit().ok())
490				{
491					if use_branch_tags &&
492						!self.should_include_tag(&head_commit, &commit)?
493					{
494						continue;
495					}
496					tags.push((commit, Tag {
497						name:    tag.name().map(String::from).unwrap_or(name),
498						message: tag.message().map(|msg| {
499							TAG_SIGNATURE_REGEX.replace(msg, "").trim().to_owned()
500						}),
501					}));
502				}
503			}
504		}
505		if !topo_order {
506			tags.sort_by(|a, b| a.0.time().seconds().cmp(&b.0.time().seconds()));
507		}
508		Ok(tags
509			.into_iter()
510			.map(|(a, b)| (a.id().to_string(), b))
511			.collect())
512	}
513
514	/// Returns the remote of the upstream repository.
515	///
516	/// The strategy used here is the following:
517	///
518	/// Find the branch that HEAD points to, and read the remote configured for
519	/// that branch returns the remote and the name of the local branch.
520	///
521	/// Note: HEAD must not be detached.
522	pub fn upstream_remote(&self) -> Result<Remote> {
523		for branch in self.inner.branches(Some(BranchType::Local))? {
524			let branch = branch?.0;
525			if branch.is_head() {
526				let upstream = &self.inner.branch_upstream_remote(&format!(
527					"refs/heads/{}",
528					&branch.name()?.ok_or_else(|| Error::RepoError(
529						String::from("branch name is not valid")
530					))?
531				))?;
532				let upstream_name = upstream.as_str().ok_or_else(|| {
533					Error::RepoError(String::from(
534						"name of the upstream remote is not valid",
535					))
536				})?;
537				let origin = &self.inner.find_remote(upstream_name)?;
538				let url = origin
539					.url()
540					.ok_or_else(|| {
541						Error::RepoError(String::from(
542							"failed to get the remote URL",
543						))
544					})?
545					.to_string();
546				trace!("Upstream URL: {url}");
547				return find_remote(&url);
548			}
549		}
550		Err(Error::RepoError(String::from(
551			"no remotes configured or HEAD is detached",
552		)))
553	}
554}
555
556fn find_remote(url: &str) -> Result<Remote> {
557	url_path_segments(url).or_else(|err| {
558		if url.contains("@") && url.contains(":") && url.contains("/") {
559			ssh_path_segments(url)
560		} else {
561			Err(err)
562		}
563	})
564}
565
566/// Returns the Remote from parsing the HTTPS format URL.
567///
568/// This function expects the URL to be in the following format:
569///
570/// > https://hostname/query/path.git
571fn url_path_segments(url: &str) -> Result<Remote> {
572	let parsed_url = Url::parse(url.strip_suffix(".git").unwrap_or(url))?;
573	let segments: Vec<&str> = parsed_url
574		.path_segments()
575		.ok_or_else(|| Error::RepoError(String::from("failed to get URL segments")))?
576		.rev()
577		.collect();
578	let [repo, owner, ..] = &segments[..] else {
579		return Err(Error::RepoError(String::from(
580			"failed to get the owner and repo",
581		)));
582	};
583	Ok(Remote {
584		owner:      owner.to_string(),
585		repo:       repo.to_string(),
586		token:      None,
587		is_custom:  false,
588		api_url:    None,
589		native_tls: None,
590	})
591}
592
593/// Returns the Remote from parsing the SSH format URL.
594///
595/// This function expects the URL to be in the following format:
596///
597/// > git@hostname:owner/repo.git
598fn ssh_path_segments(url: &str) -> Result<Remote> {
599	let [_, owner_repo, ..] = url
600		.strip_suffix(".git")
601		.unwrap_or(url)
602		.split(":")
603		.collect::<Vec<_>>()[..]
604	else {
605		return Err(Error::RepoError(String::from(
606			"failed to get the owner and repo from ssh remote (:)",
607		)));
608	};
609	let [owner, repo] = owner_repo.split("/").collect::<Vec<_>>()[..] else {
610		return Err(Error::RepoError(String::from(
611			"failed to get the owner and repo from ssh remote (/)",
612		)));
613	};
614	Ok(Remote {
615		owner:      owner.to_string(),
616		repo:       repo.to_string(),
617		token:      None,
618		is_custom:  false,
619		api_url:    None,
620		native_tls: None,
621	})
622}
623
624#[cfg(test)]
625mod test {
626	use super::*;
627	use crate::commit::Commit as AppCommit;
628	use std::process::Command;
629	use std::str;
630	use std::{
631		env,
632		fs,
633	};
634	use temp_dir::TempDir;
635
636	fn get_last_commit_hash() -> Result<String> {
637		Ok(str::from_utf8(
638			Command::new("git")
639				.args(["log", "--pretty=format:'%H'", "-n", "1"])
640				.output()?
641				.stdout
642				.as_ref(),
643		)?
644		.trim_matches('\'')
645		.to_string())
646	}
647
648	fn get_root_commit_hash() -> Result<String> {
649		Ok(str::from_utf8(
650			Command::new("git")
651				.args(["rev-list", "--max-parents=0", "HEAD"])
652				.output()?
653				.stdout
654				.as_ref(),
655		)?
656		.trim_ascii_end()
657		.to_string())
658	}
659
660	fn get_last_tag() -> Result<String> {
661		Ok(str::from_utf8(
662			Command::new("git")
663				.args(["describe", "--abbrev=0"])
664				.output()?
665				.stdout
666				.as_ref(),
667		)?
668		.trim()
669		.to_string())
670	}
671
672	fn get_repository() -> Result<Repository> {
673		Repository::init(
674			PathBuf::from(env!("CARGO_MANIFEST_DIR"))
675				.parent()
676				.expect("parent directory not found")
677				.to_path_buf(),
678		)
679	}
680
681	#[test]
682	fn http_url_repo_owner() -> Result<()> {
683		let url = "https://hostname.com/bob/magic.git";
684		let remote = find_remote(url)?;
685		assert_eq!(remote.owner, "bob", "match owner");
686		assert_eq!(remote.repo, "magic", "match repo");
687		Ok(())
688	}
689
690	#[test]
691	fn ssh_url_repo_owner() -> Result<()> {
692		let url = "git@hostname.com:bob/magic.git";
693		let remote = find_remote(url)?;
694		assert_eq!(remote.owner, "bob", "match owner");
695		assert_eq!(remote.repo, "magic", "match repo");
696		Ok(())
697	}
698
699	#[test]
700	fn get_latest_commit() -> Result<()> {
701		let repository = get_repository()?;
702		let commits = repository.commits(None, None, None, false)?;
703		let last_commit =
704			AppCommit::from(&commits.first().expect("no commits found").clone());
705		assert_eq!(get_last_commit_hash()?, last_commit.id);
706		Ok(())
707	}
708
709	#[test]
710	fn commit_search() -> Result<()> {
711		let repository = get_repository()?;
712		assert!(
713			repository
714				.find_commit("e936ed571533ea6c41a1dd2b1a29d085c8dbada5")
715				.is_some()
716		);
717		Ok(())
718	}
719
720	#[test]
721	fn get_latest_tag() -> Result<()> {
722		let repository = get_repository()?;
723		let tags = repository.tags(&None, false, false)?;
724		let latest = tags.last().expect("no tags found").1.name.clone();
725		assert_eq!(get_last_tag()?, latest);
726
727		let current = repository.current_tag().expect("a current tag").name;
728		assert!(current.contains(&latest));
729		Ok(())
730	}
731
732	#[test]
733	fn git_tags() -> Result<()> {
734		let repository = get_repository()?;
735		let tags = repository.tags(&None, true, false)?;
736		assert_eq!(
737			tags.get("2b8b4d3535f29231e05c3572e919634b9af907b6")
738				.expect(
739					"the commit hash does not exist in the repository (tag v0.1.0)"
740				)
741				.name,
742			"v0.1.0"
743		);
744		assert_eq!(
745			tags.get("4ddef08debfff48117586296e49d5caa0800d1b5")
746				.expect(
747					"the commit hash does not exist in the repository (tag \
748					 v0.1.0-beta.4)"
749				)
750				.name,
751			"v0.1.0-beta.4"
752		);
753		let tags = repository.tags(
754			&Some(
755				Regex::new("^v[0-9]+\\.[0-9]+\\.[0-9]$")
756					.expect("the regex is not valid"),
757			),
758			true,
759			false,
760		)?;
761		assert_eq!(
762			tags.get("2b8b4d3535f29231e05c3572e919634b9af907b6")
763				.expect(
764					"the commit hash does not exist in the repository (tag v0.1.0)"
765				)
766				.name,
767			"v0.1.0"
768		);
769		assert!(!tags.contains_key("4ddef08debfff48117586296e49d5caa0800d1b5"));
770		Ok(())
771	}
772
773	#[test]
774	fn git_upstream_remote() -> Result<()> {
775		let repository = get_repository()?;
776		let remote = repository.upstream_remote()?;
777		assert_eq!(
778			Remote {
779				owner:      remote.owner.clone(),
780				repo:       String::from("git-cliff"),
781				token:      None,
782				is_custom:  false,
783				api_url:    remote.api_url.clone(),
784				native_tls: None,
785			},
786			remote
787		);
788		Ok(())
789	}
790
791	#[test]
792	fn resolves_existing_tag_with_name_and_message() -> Result<()> {
793		let repository = get_repository()?;
794		let tag = repository.resolve_tag("v0.2.3");
795		assert_eq!(tag.name, "v0.2.3");
796		assert_eq!(
797			tag.message,
798			Some(
799				"Release v0.2.3\n\nBug Fixes\n- Fetch the dependencies before \
800				 copying the file to embed (9e29c95)"
801					.to_string()
802			)
803		);
804
805		Ok(())
806	}
807
808	#[test]
809	fn resolves_tag_when_no_tags_exist() -> Result<()> {
810		let repository = get_repository()?;
811		let tag = repository.resolve_tag("nonexistent-tag");
812		assert_eq!(tag.name, "nonexistent-tag");
813		assert_eq!(tag.message, None);
814		Ok(())
815	}
816
817	#[test]
818	fn includes_root_commit() -> Result<()> {
819		let repository = get_repository()?;
820		// a close descendant of the root commit
821		let range = Some("eea3914c7ab07472841aa85c36d11bdb2589a234");
822		let commits = repository.commits(range, None, None, false)?;
823		let root_commit =
824			AppCommit::from(&commits.last().expect("no commits found").clone());
825		assert_eq!(get_root_commit_hash()?, root_commit.id);
826		Ok(())
827	}
828
829	fn create_temp_repo() -> (Repository, TempDir) {
830		let temp_dir =
831			TempDir::with_prefix("git-cliff-").expect("failed to create temp dir");
832
833		let output = Command::new("git")
834			.args(["init"])
835			.current_dir(temp_dir.path())
836			.output()
837			.expect("failed to execute git init");
838		assert!(output.status.success(), "git init failed {:?}", output);
839
840		let repo = Repository::init(temp_dir.path().to_path_buf())
841			.expect("failed to init repo");
842		let output = Command::new("git")
843			.args(["config", "user.email", "test@gmail.com"])
844			.current_dir(temp_dir.path())
845			.output()
846			.expect("failed to execute git config user.email");
847		assert!(
848			output.status.success(),
849			"git config user.email failed {:?}",
850			output
851		);
852
853		let output = Command::new("git")
854			.args(["config", "user.name", "test"])
855			.current_dir(temp_dir.path())
856			.output()
857			.expect("failed to execute git config user.name");
858		assert!(
859			output.status.success(),
860			"git config user.name failed {:?}",
861			output
862		);
863
864		(repo, temp_dir)
865	}
866
867	#[test]
868	fn open_jujutsu_repo() {
869		let (repo, _temp_dir) = create_temp_repo();
870		// working copy is the directory that contains the .git directory:
871		let working_copy = repo.path;
872
873		// Make the Git repository bare and set HEAD
874		std::process::Command::new("git")
875			.args(["config", "core.bare", "true"])
876			.current_dir(&working_copy)
877			.status()
878			.expect("failed to make git repo non-bare");
879		// Move the Git repo into jj
880		let store = working_copy.join(".jj").join("repo").join("store");
881		fs::create_dir_all(&store).expect("failed to create dir");
882		fs::rename(working_copy.join(".git"), store.join("git"))
883			.expect("failed to move git repo");
884
885		// Open repo from working copy, that contains the .jj directory
886		let repo = Repository::init(working_copy).expect("failed to init repo");
887
888		// macOS canonical path for temp directories is in /private
889		// libgit2 forces the path to be canonical regardless of what we pass in
890		if repo.inner.path().starts_with("/private") {
891			assert_eq!(
892				repo.inner.path().strip_prefix("/private"),
893				store.join("git").strip_prefix("/"),
894				"open git repo in .jj/repo/store/"
895			);
896		} else {
897			assert_eq!(
898				repo.inner.path(),
899				store.join("git"),
900				"open git repo in .jj/repo/store/"
901			);
902		}
903	}
904
905	#[test]
906	fn propagate_error_if_no_repo_found() {
907		let temp_dir =
908			TempDir::with_prefix("git-cliff-").expect("failed to create temp dir");
909
910		let path = temp_dir.path().to_path_buf();
911
912		let result = Repository::init(path.clone());
913
914		assert!(result.is_err());
915		if let Err(error) = result {
916			assert!(
917				format!("{error:?}").contains(
918					format!("could not find repository at '{}'", path.display())
919						.as_str()
920				)
921			)
922		}
923	}
924
925	fn create_commit_with_files<'a>(
926		repo: &'a Repository,
927		files: Vec<(&'a str, &'a str)>,
928	) -> Commit<'a> {
929		for (path, content) in files {
930			if let Some(parent) = repo.path.join(path).parent() {
931				std::fs::create_dir_all(parent).expect("failed to create dir");
932			}
933			std::fs::write(repo.path.join(path), content)
934				.expect("failed to write file");
935		}
936
937		let output = Command::new("git")
938			.args(["add", "."])
939			.current_dir(&repo.path)
940			.output()
941			.expect("failed to execute git add");
942		assert!(output.status.success(), "git add failed {:?}", output);
943
944		let output = Command::new("git")
945			.args(["commit", "--no-gpg-sign", "-m", "test commit"])
946			.current_dir(&repo.path)
947			.output()
948			.expect("failed to execute git commit");
949		assert!(output.status.success(), "git commit failed {:?}", output);
950
951		repo.inner
952			.head()
953			.and_then(|head| head.peel_to_commit())
954			.expect("failed to get the last commit")
955	}
956
957	#[test]
958	fn test_should_retain_commit() {
959		let (repo, _temp_dir) = create_temp_repo();
960
961		let new_pattern = |input: &str| {
962			Repository::normalize_pattern(
963				Pattern::new(input).expect("valid pattern"),
964			)
965		};
966
967		let first_commit = create_commit_with_files(&repo, vec![
968			("initial.txt", "initial content"),
969			("dir/initial.txt", "initial content"),
970		]);
971
972		{
973			let retain = repo.should_retain_commit(
974				&first_commit,
975				&Some(vec![new_pattern("dir/")]),
976				&None,
977			);
978			assert!(retain, "include: dir/");
979		}
980
981		let commit = create_commit_with_files(&repo, vec![
982			("file1.txt", "content1"),
983			("file2.txt", "content2"),
984			("dir/file3.txt", "content3"),
985			("dir/subdir/file4.txt", "content4"),
986		]);
987
988		{
989			let retain = repo.should_retain_commit(&commit, &None, &None);
990			assert!(retain, "no include/exclude patterns");
991		}
992
993		{
994			let retain = repo.should_retain_commit(
995				&commit,
996				&Some(vec![new_pattern("./")]),
997				&None,
998			);
999			assert!(retain, "include: ./");
1000		}
1001
1002		{
1003			let retain = repo.should_retain_commit(
1004				&commit,
1005				&Some(vec![new_pattern("**")]),
1006				&None,
1007			);
1008			assert!(retain, "include: **");
1009		}
1010
1011		{
1012			let retain = repo.should_retain_commit(
1013				&commit,
1014				&Some(vec![new_pattern("*")]),
1015				&None,
1016			);
1017			assert!(retain, "include: *");
1018		}
1019
1020		{
1021			let retain = repo.should_retain_commit(
1022				&commit,
1023				&Some(vec![new_pattern("dir/")]),
1024				&None,
1025			);
1026			assert!(retain, "include: dir/");
1027		}
1028
1029		{
1030			let retain = repo.should_retain_commit(
1031				&commit,
1032				&Some(vec![new_pattern("dir/*")]),
1033				&None,
1034			);
1035			assert!(retain, "include: dir/*");
1036		}
1037
1038		{
1039			let retain = repo.should_retain_commit(
1040				&commit,
1041				&Some(vec![new_pattern("file1.txt")]),
1042				&None,
1043			);
1044			assert!(retain, "include: file1.txt");
1045		}
1046
1047		{
1048			let retain = repo.should_retain_commit(
1049				&commit,
1050				&None,
1051				&Some(vec![new_pattern("file1.txt")]),
1052			);
1053			assert!(retain, "exclude: file1.txt");
1054		}
1055
1056		{
1057			let retain = repo.should_retain_commit(
1058				&commit,
1059				&Some(vec![new_pattern("file1.txt")]),
1060				&Some(vec![new_pattern("file2.txt")]),
1061			);
1062			assert!(retain, "include: file1.txt, exclude: file2.txt");
1063		}
1064
1065		{
1066			let retain = repo.should_retain_commit(
1067				&commit,
1068				&None,
1069				&Some(vec![new_pattern("**/*.txt")]),
1070			);
1071			assert!(!retain, "exclude: **/*.txt");
1072		}
1073	}
1074}