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