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