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
28static TAG_SIGNATURE_REGEX: Lazy<Regex> = lazy_regex!(
30 r"(?s)-----BEGIN (PGP|SSH|SIGNED) (SIGNATURE|MESSAGE)-----(.*?)-----END (PGP|SSH|SIGNED) (SIGNATURE|MESSAGE)-----"
32);
33
34const CHANGED_FILES_CACHE: &str = "changed_files_cache";
36
37pub struct Repository {
41 inner: GitRepository,
42 path: PathBuf,
44 changed_files_cache_path: PathBuf,
46}
47
48impl Repository {
49 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 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 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 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 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 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 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 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 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 fn commit_changed_files(&self, commit: &Commit) -> Vec<PathBuf> {
219 let cache_key = format!("commit_id:{}", commit.id());
221
222 {
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 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 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 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 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 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 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 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 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 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 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
482fn 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
509fn 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 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 let working_copy = repo.path;
786
787 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 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 let repo = Repository::init(working_copy).expect("failed to init repo");
801
802 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}