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
32static TAG_SIGNATURE_REGEX: Lazy<Regex> = lazy_regex!(
34 r"(?s)-----BEGIN (PGP|SSH|SIGNED) (SIGNATURE|MESSAGE)-----(.*?)-----END (PGP|SSH|SIGNED) (SIGNATURE|MESSAGE)-----"
36);
37
38const CHANGED_FILES_CACHE: &str = "changed_files_cache";
40
41pub struct Repository {
45 inner: GitRepository,
46 path: PathBuf,
48 changed_files_cache_path: PathBuf,
50}
51
52pub struct SubmoduleRange {
54 pub repository: Repository,
56 pub range: String,
59}
60
61impl Repository {
62 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 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 pub fn path(&self) -> &PathBuf {
110 &self.path
111 }
112
113 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 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 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 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 None
208 } else if old_file_id.is_zero() {
209 Some(new_file_id.to_string())
211 } else {
212 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 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 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 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 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 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 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 fn commit_changed_files(&self, commit: &Commit) -> Vec<PathBuf> {
303 let cache_key = format!("commit_id:{}", commit.id());
305
306 {
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 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 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 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 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 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 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 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 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 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 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
566fn 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
593fn 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 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 let working_copy = repo.path;
872
873 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 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 let repo = Repository::init(working_copy).expect("failed to init repo");
887
888 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}