1use std::collections::{HashMap, HashSet};
2use std::io;
3use std::path::{Path, PathBuf};
4use std::result::Result as StdResult;
5use std::sync::LazyLock;
6
7use git2::{
8 BranchType, Commit, DescribeOptions, Oid, Repository as GitRepository, Sort, TreeWalkMode,
9 Worktree,
10};
11use glob::Pattern;
12use indexmap::IndexMap;
13use regex::Regex;
14use url::Url;
15
16use crate::commit::CommitStatistics;
17use crate::config::Remote;
18use crate::error::{Error, Result};
19use crate::tag::Tag;
20
21static TAG_SIGNATURE_REGEX: LazyLock<Regex> = LazyLock::new(|| {
23 Regex::new(
24 r"(?s)-----BEGIN (PGP|SSH|SIGNED) (SIGNATURE|MESSAGE)-----(.*?)-----END (PGP|SSH|SIGNED) (SIGNATURE|MESSAGE)-----"
26 )
27 .expect("valid git tag signature regex")
28});
29
30const CHANGED_FILES_CACHE: &str = "changed_files_cache";
32
33const GIT_BLAME_IGNORE_REVS_FILE: &str = ".git-blame-ignore-revs";
37
38pub struct Repository {
42 inner: GitRepository,
43 path: PathBuf,
45 changed_files_cache_path: PathBuf,
47}
48
49pub struct SubmoduleRange {
51 pub repository: Repository,
53 pub range: String,
56}
57
58impl Repository {
59 fn open(path: PathBuf, search_parents: bool) -> Result<Self> {
64 if !path.exists() {
65 return Err(Error::IoError(io::Error::new(
66 io::ErrorKind::NotFound,
67 format!("repository path not found: {}", path.display()),
68 )));
69 }
70
71 let inner = GitRepository::open(&path)
72 .or_else(|err| {
73 let mut current = Some(path.as_path());
75 while let Some(dir) = current {
76 let jujutsu_path = dir.join(".jj/repo/store/git");
77 if jujutsu_path.exists() {
78 return GitRepository::open_bare(&jujutsu_path);
79 }
80 if !search_parents {
82 break;
83 }
84 current = dir.parent();
85 }
86 Err(err)
87 })
88 .or_else(|err| {
90 if search_parents {
91 GitRepository::discover(&path)
92 } else {
93 Err(err)
94 }
95 })?;
96
97 let changed_files_cache_path = inner
98 .path()
99 .join(env!("CARGO_PKG_NAME"))
100 .join(CHANGED_FILES_CACHE);
101
102 Ok(Self {
103 inner,
104 path,
105 changed_files_cache_path,
106 })
107 }
108
109 pub fn discover(path: PathBuf) -> Result<Self> {
116 Self::open(path, true)
117 }
118
119 pub fn init(path: PathBuf) -> Result<Self> {
125 Self::open(path, false)
126 }
127
128 pub fn root_path(&self) -> Result<PathBuf> {
130 let mut path = if self.inner.is_worktree() {
131 let worktree = Worktree::open_from_repository(&self.inner)?;
132 worktree.path().to_path_buf()
133 } else {
134 self.inner.path().to_path_buf()
135 };
136 if path.ends_with(".git") {
137 path.pop();
138 }
139 Ok(path)
140 }
141
142 #[must_use]
147 pub fn path(&self) -> &PathBuf {
148 &self.path
149 }
150
151 fn set_commit_range(
156 revwalk: &mut git2::Revwalk<'_>,
157 range: Option<&str>,
158 ) -> StdResult<(), git2::Error> {
159 if let Some(range) = range {
160 if range.contains("..") {
161 revwalk.push_range(range)?;
162 } else {
163 revwalk.push(Oid::from_str(range)?)?;
164 }
165 } else {
166 revwalk.push_head()?;
167 }
168 Ok(())
169 }
170
171 pub fn commits(
175 &self,
176 range: Option<&str>,
177 include_path: Option<Vec<Pattern>>,
178 exclude_path: Option<Vec<Pattern>>,
179 topo_order_commits: bool,
180 ) -> Result<Vec<Commit<'_>>> {
181 let mut revwalk = self.inner.revwalk()?;
182 if topo_order_commits {
183 revwalk.set_sorting(Sort::TOPOLOGICAL)?;
184 } else {
185 revwalk.set_sorting(Sort::TIME)?;
186 }
187
188 Self::set_commit_range(&mut revwalk, range).map_err(|e| {
189 Error::SetCommitRangeError(range.map_or_else(|| "?".to_string(), String::from), e)
190 })?;
191 let mut commits: Vec<Commit> = revwalk
192 .filter_map(StdResult::ok)
193 .filter_map(|id| self.inner.find_commit(id).ok())
194 .collect();
195 if include_path.is_some() || exclude_path.is_some() {
196 let include_patterns = include_path.map(|patterns| {
197 patterns
198 .into_iter()
199 .map(Self::normalize_pattern)
200 .collect::<Vec<_>>()
201 });
202 let exclude_patterns = exclude_path.map(|patterns| {
203 patterns
204 .into_iter()
205 .map(Self::normalize_pattern)
206 .collect::<Vec<_>>()
207 });
208 commits.retain(|commit| {
209 self.should_retain_commit(
210 commit,
211 include_patterns.as_ref(),
212 exclude_patterns.as_ref(),
213 )
214 });
215 }
216 Ok(commits)
217 }
218
219 pub fn filter_git_blame_ignore_revs(&self, commits: &mut Vec<Commit<'_>>) {
226 let Ok(root) = self.root_path() else {
227 return;
228 };
229 let Ok(contents) = std::fs::read_to_string(root.join(GIT_BLAME_IGNORE_REVS_FILE)) else {
230 return;
231 };
232 let ignored_ids: Vec<&str> = contents
233 .lines()
234 .map(str::trim)
235 .filter(|line| !line.is_empty() && !line.starts_with('#'))
236 .collect();
237 if ignored_ids.is_empty() {
238 return;
239 }
240 commits.retain(|commit| {
241 let id = commit.id().to_string();
242 if ignored_ids.iter().any(|ignored| id.starts_with(ignored)) {
243 return false;
244 }
245 let changed_files = self.commit_changed_files(commit);
246 !(changed_files.len() == 1 && changed_files[0] == Path::new(GIT_BLAME_IGNORE_REVS_FILE))
247 });
248 }
249
250 pub fn commit_statistics(&self, commit: &Commit<'_>) -> Result<CommitStatistics> {
257 let current_tree = commit.tree()?;
258 let previous_tree = commit
259 .parent(0)
260 .ok()
261 .map(|parent| parent.tree())
262 .transpose()?;
263 let diff =
264 self.inner
265 .diff_tree_to_tree(previous_tree.as_ref(), Some(¤t_tree), None)?;
266 let stats = diff.stats()?;
267 Ok(CommitStatistics {
268 files_changed: stats.files_changed(),
269 additions: stats.insertions(),
270 deletions: stats.deletions(),
271 })
272 }
273
274 pub fn submodules_range(
286 &self,
287 old_commit: Option<&Commit<'_>>,
288 new_commit: &Commit<'_>,
289 ) -> Result<Vec<SubmoduleRange>> {
290 let old_tree = old_commit.and_then(|commit| commit.tree().ok());
291 let new_tree = new_commit.tree().ok();
292 let diff = self
293 .inner
294 .diff_tree_to_tree(old_tree.as_ref(), new_tree.as_ref(), None)?;
295 let before_and_after_deltas = diff.deltas().filter_map(|delta| {
297 let old_file_id = delta.old_file().id();
298 let new_file_id = delta.new_file().id();
299 let range = if old_file_id == new_file_id || new_file_id.is_zero() {
300 None
302 } else if old_file_id.is_zero() {
303 Some(new_file_id.to_string())
305 } else {
306 Some(format!("{old_file_id}..{new_file_id}"))
308 };
309 tracing::trace!("Release commit range for submodules: {range:?}");
310 delta.new_file().path().and_then(Path::to_str).zip(range)
311 });
312 let submodule_range = before_and_after_deltas.filter_map(|(path, range)| {
315 let repository = self
320 .inner
321 .find_submodule(path)
322 .ok()
323 .and_then(|submodule| Self::discover(submodule.path().into()).ok());
324 repository.map(|repository| SubmoduleRange { repository, range })
325 });
326 Ok(submodule_range.collect())
327 }
328
329 fn normalize_pattern(pattern: Pattern) -> Pattern {
334 let star_added = if pattern.as_str().ends_with('/') {
337 Pattern::new(&format!("{pattern}**")).expect("failed to add '**' to the end of glob")
338 } else {
339 pattern
340 };
341 match star_added.as_str().strip_prefix("./") {
342 Some(stripped) => {
343 Pattern::new(stripped).expect("failed to remove leading ./ from glob")
344 }
345 None => star_added,
346 }
347 }
348
349 fn should_retain_commit(
354 &self,
355 commit: &Commit,
356 include_patterns: Option<&Vec<Pattern>>,
357 exclude_patterns: Option<&Vec<Pattern>>,
358 ) -> bool {
359 let changed_files = self.commit_changed_files(commit);
360 match (include_patterns, exclude_patterns) {
361 (Some(include_pattern), Some(exclude_pattern)) => {
362 changed_files.iter().any(|path| {
365 include_pattern
366 .iter()
367 .any(|pattern| pattern.matches_path(path)) &&
368 !exclude_pattern
369 .iter()
370 .any(|pattern| pattern.matches_path(path))
371 })
372 }
373 (Some(include_pattern), None) => {
374 changed_files.iter().any(|path| {
377 include_pattern
378 .iter()
379 .any(|pattern| pattern.matches_path(path))
380 })
381 }
382 (None, Some(exclude_pattern)) => {
383 changed_files.iter().any(|path| {
386 !exclude_pattern
387 .iter()
388 .any(|pattern| pattern.matches_path(path))
389 })
390 }
391 (None, None) => true,
392 }
393 }
394
395 fn commit_changed_files(&self, commit: &Commit) -> Vec<PathBuf> {
404 let cache_key = format!("commit_id:{}", commit.id());
406
407 {
409 if let Ok(result) = cacache::read_sync(&self.changed_files_cache_path, &cache_key) &&
410 let Ok((files, _)) =
411 bincode::decode_from_slice(&result, bincode::config::standard())
412 {
413 return files;
414 }
415 }
416
417 let result = self.commit_changed_files_no_cache(commit);
419 match bincode::encode_to_vec(
420 self.commit_changed_files_no_cache(commit),
421 bincode::config::standard(),
422 ) {
423 Ok(v) => {
424 if let Err(e) = cacache::write_sync_with_algo(
425 cacache::Algorithm::Xxh3,
426 &self.changed_files_cache_path,
427 cache_key,
428 v,
429 ) {
430 #[allow(clippy::unnecessary_debug_formatting)]
431 {
432 tracing::error!("Failed to set cache for repo {:?}: {e}", self.path);
433 }
434 }
435 }
436 Err(e) => {
437 #[allow(clippy::unnecessary_debug_formatting)]
438 {
439 tracing::error!("Failed to serialize cache for repo {:?}: {e}", self.path);
440 }
441 }
442 }
443
444 result
445 }
446
447 fn commit_changed_files_no_cache(&self, commit: &Commit) -> Vec<PathBuf> {
451 let mut changed_files = Vec::new();
452 if let Ok(prev_commit) = commit.parent(0) {
453 if let Ok(diff) = self.inner.diff_tree_to_tree(
458 commit.tree().ok().as_ref(),
459 prev_commit.tree().ok().as_ref(),
460 None,
461 ) {
462 changed_files.extend(
463 diff.deltas()
464 .filter_map(|delta| delta.new_file().path().map(PathBuf::from)),
465 );
466 }
467 } else {
468 if let Ok(tree) = commit.tree() {
471 tree.walk(TreeWalkMode::PreOrder, |dir, entry| {
472 if entry.kind().expect("failed to get entry kind") != git2::ObjectType::Blob {
473 return 0;
474 }
475 let name = entry.name().expect("failed to get entry name");
476 let entry_path = if dir == "," {
477 name.to_string()
478 } else {
479 format!("{dir}/{name}")
480 };
481 changed_files.push(entry_path.into());
482 0
483 })
484 .expect("failed to get the changed files of the first commit");
485 }
486 }
487 changed_files
488 }
489
490 #[must_use]
494 pub fn current_tag(&self) -> Option<Tag> {
495 self.inner
496 .describe(DescribeOptions::new().describe_tags())
497 .ok()
498 .and_then(|describe| {
499 describe
500 .format(None)
501 .ok()
502 .map(|name| self.resolve_tag(&name))
503 })
504 }
505
506 #[must_use]
510 pub fn resolve_tag(&self, name: &str) -> Tag {
511 match self
512 .inner
513 .resolve_reference_from_short_name(name)
514 .and_then(|r| r.peel_to_tag())
515 {
516 Ok(tag) => Tag {
517 name: tag.name().unwrap_or_default().to_owned(),
518 message: tag
519 .message()
520 .unwrap_or_default()
521 .map(|msg| TAG_SIGNATURE_REGEX.replace(msg, "").trim().to_owned()),
522 },
523 _ => Tag {
524 name: name.to_owned(),
525 message: None,
526 },
527 }
528 }
529
530 #[must_use]
532 pub fn find_commit(&self, id: &str) -> Option<Commit<'_>> {
533 if let Ok(oid) = Oid::from_str(id) &&
534 let Ok(commit) = self.inner.find_commit(oid)
535 {
536 return Some(commit);
537 }
538 None
539 }
540
541 fn should_include_tag(&self, head_commit: &Commit, tag_commit: &Commit) -> Result<bool> {
548 Ok(self
549 .inner
550 .graph_descendant_of(head_commit.id(), tag_commit.id())? ||
551 head_commit.id() == tag_commit.id())
552 }
553
554 pub fn tags(
558 &self,
559 pattern: &Option<Regex>,
560 topo_order: bool,
561 use_branch_tags: bool,
562 ) -> Result<IndexMap<String, Tag>> {
563 let mut tags: Vec<(Commit, Tag)> = Vec::new();
564 let tag_names = self.inner.tag_names(None)?;
565 let head_commit = self.inner.head()?.peel_to_commit()?;
566 for name in tag_names
567 .iter()
568 .flatten()
569 .flatten()
570 .filter(|tag_name| pattern.as_ref().is_none_or(|pat| pat.is_match(tag_name)))
571 .map(String::from)
572 {
573 let obj = self.inner.revparse_single(&name)?;
574 if let Ok(commit) = obj.clone().into_commit() {
575 if use_branch_tags && !self.should_include_tag(&head_commit, &commit)? {
576 continue;
577 }
578
579 tags.push((commit, Tag {
580 name,
581 message: None,
582 }));
583 } else if let Some(tag) = obj.as_tag() {
584 if let Some(commit) = obj
586 .peel(git2::ObjectType::Commit)
587 .ok()
588 .and_then(|o| o.into_commit().ok())
589 {
590 if use_branch_tags && !self.should_include_tag(&head_commit, &commit)? {
591 continue;
592 }
593 tags.push((commit, Tag {
594 name: tag.name().map(String::from).unwrap_or(name),
595 message: tag
596 .message()
597 .ok()
598 .flatten()
599 .map(|msg| TAG_SIGNATURE_REGEX.replace(msg, "").trim().to_owned()),
600 }));
601 }
602 }
603 }
604 if !topo_order {
605 tags.sort_by_key(|a| a.0.time().seconds());
606 }
607 Ok(tags
608 .into_iter()
609 .map(|(a, b)| (a.id().to_string(), b))
610 .collect())
611 }
612
613 pub fn commit_tag_ownership(
630 &self,
631 tags: &IndexMap<String, Tag>,
632 boundary_ids: &HashSet<Oid>,
633 ) -> Result<HashMap<Oid, String>> {
634 let mut ownership = HashMap::new();
635 let tag_ids: Vec<(Oid, &String)> = tags
637 .keys()
638 .filter_map(|id| Oid::from_str(id).ok().map(|oid| (oid, id)))
639 .filter(|(oid, _)| boundary_ids.contains(oid))
640 .collect();
641 for (index, (tag_oid, tag_id)) in tag_ids.iter().enumerate() {
642 let mut revwalk = self.inner.revwalk()?;
643 revwalk.push(*tag_oid)?;
644 for (prev_oid, _) in &tag_ids[..index] {
647 let _ = revwalk.hide(*prev_oid);
649 }
650 for oid in revwalk.filter_map(StdResult::ok) {
651 if boundary_ids.contains(&oid) {
652 ownership.entry(oid).or_insert_with(|| (*tag_id).clone());
653 if ownership.len() == boundary_ids.len() {
654 return Ok(ownership);
655 }
656 }
657 }
658 }
659 Ok(ownership)
660 }
661
662 pub fn upstream_remote(&self) -> Result<Remote> {
671 for branch in self.inner.branches(Some(BranchType::Local))? {
672 let branch = branch?.0;
673 if branch.is_head() {
674 let upstream = &self.inner.branch_upstream_remote(&format!(
675 "refs/heads/{}",
676 branch.name()?.ok_or_else(|| Error::RepoError(String::from(
677 "branch name is not valid"
678 )))?
679 ))?;
680 let upstream_name = upstream.as_str().map_err(|err| {
681 Error::RepoError(format!("name of the upstream remote is not valid: {err}"))
682 })?;
683 let origin = &self.inner.find_remote(upstream_name)?;
684 let url = origin
685 .url()
686 .map_err(|err| {
687 Error::RepoError(format!("failed to get the remote URL: {err}"))
688 })?
689 .to_string();
690 tracing::trace!("Upstream URL: {url}");
691 return find_remote(&url);
692 }
693 }
694 Err(Error::RepoError(String::from(
695 "no remotes configured or HEAD is detached",
696 )))
697 }
698}
699
700fn find_remote(url: &str) -> Result<Remote> {
701 url_path_segments(url).or_else(|err| {
702 if url.contains('@') && url.contains(':') && url.contains('/') {
703 ssh_path_segments(url)
704 } else {
705 Err(err)
706 }
707 })
708}
709
710fn url_path_segments(url: &str) -> Result<Remote> {
718 let parsed_url = Url::parse(url.strip_suffix(".git").unwrap_or(url))?;
719 let segments: Vec<&str> = parsed_url
720 .path_segments()
721 .ok_or_else(|| Error::RepoError(String::from("failed to get URL segments")))?
722 .rev()
723 .collect();
724 let [repo, owner, ..] = &segments[..] else {
725 return Err(Error::RepoError(String::from(
726 "failed to get the owner and repo",
727 )));
728 };
729 Ok(Remote {
730 owner: (*owner).to_string(),
731 repo: (*repo).to_string(),
732 token: None,
733 is_custom: false,
734 api_url: None,
735 http_timeout: std::time::Duration::from_secs(30),
736 native_tls: None,
737 })
738}
739
740fn ssh_path_segments(url: &str) -> Result<Remote> {
746 let [_, owner_repo, ..] = url
747 .strip_suffix(".git")
748 .unwrap_or(url)
749 .split(':')
750 .collect::<Vec<_>>()[..]
751 else {
752 return Err(Error::RepoError(String::from(
753 "failed to get the owner and repo from ssh remote (:)",
754 )));
755 };
756 let [owner, repo] = owner_repo.split('/').collect::<Vec<_>>()[..] else {
757 return Err(Error::RepoError(String::from(
758 "failed to get the owner and repo from ssh remote (/)",
759 )));
760 };
761 Ok(Remote {
762 owner: owner.to_string(),
763 repo: repo.to_string(),
764 token: None,
765 is_custom: false,
766 api_url: None,
767 http_timeout: std::time::Duration::from_secs(30),
768 native_tls: None,
769 })
770}
771
772#[cfg(test)]
773mod test {
774 use std::process::Command;
775 use std::{env, fs, io, str};
776
777 use temp_dir::TempDir;
778
779 use super::*;
780 use crate::commit::Commit as AppCommit;
781
782 fn get_last_commit_hash() -> Result<String> {
783 Ok(str::from_utf8(
784 Command::new("git")
785 .args(["log", "--pretty=format:'%H'", "-n", "1"])
786 .output()?
787 .stdout
788 .as_ref(),
789 )?
790 .trim_matches('\'')
791 .to_string())
792 }
793
794 fn get_root_commit_hash() -> Result<String> {
795 Ok(str::from_utf8(
796 Command::new("git")
797 .args(["rev-list", "--max-parents=0", "HEAD"])
798 .output()?
799 .stdout
800 .as_ref(),
801 )?
802 .trim_ascii_end()
803 .to_string())
804 }
805
806 fn get_last_tag() -> Result<String> {
807 Ok(str::from_utf8(
808 Command::new("git")
809 .args(["describe", "--abbrev=0"])
810 .output()?
811 .stdout
812 .as_ref(),
813 )?
814 .trim()
815 .to_string())
816 }
817
818 fn get_repository() -> Result<Repository> {
819 Repository::discover(
820 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
821 .parent()
822 .expect("parent directory not found")
823 .to_path_buf(),
824 )
825 }
826
827 #[test]
828 fn http_url_repo_owner() -> Result<()> {
829 let url = "https://hostname.com/bob/magic.git";
830 let remote = find_remote(url)?;
831 assert_eq!(remote.owner, "bob", "match owner");
832 assert_eq!(remote.repo, "magic", "match repo");
833 Ok(())
834 }
835
836 #[test]
837 fn ssh_url_repo_owner() -> Result<()> {
838 let url = "git@hostname.com:bob/magic.git";
839 let remote = find_remote(url)?;
840 assert_eq!(remote.owner, "bob", "match owner");
841 assert_eq!(remote.repo, "magic", "match repo");
842 Ok(())
843 }
844
845 #[test]
846 fn get_latest_commit() -> Result<()> {
847 let repository = get_repository()?;
848 let commits = repository.commits(None, None, None, false)?;
849 let last_commit = AppCommit::from(&commits.first().expect("no commits found").clone());
850 assert_eq!(get_last_commit_hash()?, last_commit.id);
851 Ok(())
852 }
853
854 #[test]
855 fn commit_search() -> Result<()> {
856 let repository = get_repository()?;
857 assert!(
858 repository
859 .find_commit("e936ed571533ea6c41a1dd2b1a29d085c8dbada5")
860 .is_some()
861 );
862 Ok(())
863 }
864
865 #[test]
866 fn get_latest_tag() -> Result<()> {
867 let repository = get_repository()?;
868 let tags = repository.tags(&None, false, false)?;
869 let latest = tags.last().expect("no tags found").1.name.clone();
870 assert_eq!(get_last_tag()?, latest);
871
872 let current = repository.current_tag().expect("a current tag").name;
873 assert!(current.contains(&latest));
874 Ok(())
875 }
876
877 #[test]
878 fn git_tags() -> Result<()> {
879 let repository = get_repository()?;
880 let tags = repository.tags(&None, true, false)?;
881 assert_eq!(
882 tags.get("2b8b4d3535f29231e05c3572e919634b9af907b6")
883 .expect("the commit hash does not exist in the repository (tag v0.1.0)")
884 .name,
885 "v0.1.0"
886 );
887 assert_eq!(
888 tags.get("4ddef08debfff48117586296e49d5caa0800d1b5")
889 .expect("the commit hash does not exist in the repository (tag v0.1.0-beta.4)")
890 .name,
891 "v0.1.0-beta.4"
892 );
893 let tags = repository.tags(
894 &Some(Regex::new("^v[0-9]+\\.[0-9]+\\.[0-9]$").expect("the regex is not valid")),
895 true,
896 false,
897 )?;
898 assert_eq!(
899 tags.get("2b8b4d3535f29231e05c3572e919634b9af907b6")
900 .expect("the commit hash does not exist in the repository (tag v0.1.0)")
901 .name,
902 "v0.1.0"
903 );
904 assert!(!tags.contains_key("4ddef08debfff48117586296e49d5caa0800d1b5"));
905 Ok(())
906 }
907
908 #[test]
909 fn git_nested_tags() -> Result<()> {
910 let (repo, temp_dir) = create_temp_repo();
911 let path = temp_dir.path();
912
913 let commit = create_commit_with_files(&repo, vec![("initial.txt", "initial content")]);
914
915 Command::new("git")
916 .args(["tag", "-a", "v1.0.0-staging", "--no-sign", "-m", "s"])
917 .current_dir(path)
918 .output()?;
919
920 Command::new("git")
922 .args([
923 "tag",
924 "-a",
925 "v1.0.0-stable",
926 "--no-sign",
927 "-m",
928 "s",
929 "v1.0.0-staging",
930 ])
931 .current_dir(path)
932 .output()?;
933
934 let tags = repo.tags(&Some(Regex::new("v1.0.0-stable")?), false, false)?;
935 assert_eq!(
936 tags.get(&commit.id().to_string())
937 .expect("nested tag should resolve to commit")
938 .name,
939 "v1.0.0-stable"
940 );
941 Ok(())
942 }
943
944 #[test]
945 fn git_upstream_remote() -> Result<()> {
946 let repository = get_repository()?;
947 let remote = repository.upstream_remote()?;
948 assert_eq!(
949 Remote {
950 owner: remote.owner.clone(),
951 repo: String::from("git-cliff"),
952 token: None,
953 is_custom: false,
954 api_url: remote.api_url.clone(),
955 http_timeout: std::time::Duration::from_secs(30),
956 native_tls: None,
957 },
958 remote
959 );
960 Ok(())
961 }
962
963 #[test]
964 fn resolves_existing_tag_with_name_and_message() -> Result<()> {
965 let repository = get_repository()?;
966 let tag = repository.resolve_tag("v0.2.3");
967 assert_eq!(tag.name, "v0.2.3");
968 assert_eq!(
969 tag.message,
970 Some(
971 "Release v0.2.3\n\nBug Fixes\n- Fetch the dependencies before copying the file to \
972 embed (9e29c95)"
973 .to_string()
974 )
975 );
976
977 Ok(())
978 }
979
980 #[test]
981 fn resolves_tag_when_no_tags_exist() -> Result<()> {
982 let repository = get_repository()?;
983 let tag = repository.resolve_tag("nonexistent-tag");
984 assert_eq!(tag.name, "nonexistent-tag");
985 assert_eq!(tag.message, None);
986 Ok(())
987 }
988
989 #[test]
990 fn includes_root_commit() -> Result<()> {
991 let repository = get_repository()?;
992 let range = Some("eea3914c7ab07472841aa85c36d11bdb2589a234");
994 let commits = repository.commits(range, None, None, false)?;
995 let root_commit = AppCommit::from(&commits.last().expect("no commits found").clone());
996 assert_eq!(get_root_commit_hash()?, root_commit.id);
997 Ok(())
998 }
999
1000 fn create_temp_repo() -> (Repository, TempDir) {
1001 let temp_dir = TempDir::with_prefix("git-cliff-").expect("failed to create temp dir");
1002
1003 let output = Command::new("git")
1004 .args(["init"])
1005 .current_dir(temp_dir.path())
1006 .output()
1007 .expect("failed to execute git init");
1008 assert!(output.status.success(), "git init failed {output:?}");
1009
1010 let repo =
1011 Repository::discover(temp_dir.path().to_path_buf()).expect("failed to init repo");
1012 let output = Command::new("git")
1013 .args(["config", "user.email", "test@gmail.com"])
1014 .current_dir(temp_dir.path())
1015 .output()
1016 .expect("failed to execute git config user.email");
1017 assert!(
1018 output.status.success(),
1019 "git config user.email failed {output:?}",
1020 );
1021
1022 let output = Command::new("git")
1023 .args(["config", "user.name", "test"])
1024 .current_dir(temp_dir.path())
1025 .output()
1026 .expect("failed to execute git config user.name");
1027 assert!(
1028 output.status.success(),
1029 "git config user.name failed {output:?}",
1030 );
1031
1032 (repo, temp_dir)
1033 }
1034
1035 #[test]
1036 fn repository_path_not_found() {
1037 let path = PathBuf::from("/this/path/should/not/exist/123456789");
1038 let result = Repository::discover(path.clone());
1039 assert!(result.is_err());
1040 match result {
1041 Err(Error::IoError(err)) => {
1042 assert_eq!(err.kind(), io::ErrorKind::NotFound);
1043 assert!(err.to_string().contains("repository path not found"));
1044 }
1045 _ => panic!("expected IoError(NotFound)"),
1046 }
1047 }
1048
1049 #[test]
1050 fn discover_jujutsu_repo() {
1051 let (repo, _temp_dir) = create_temp_repo();
1052 let working_copy = repo.path;
1054
1055 std::process::Command::new("git")
1057 .args(["config", "core.bare", "true"])
1058 .current_dir(&working_copy)
1059 .status()
1060 .expect("failed to make git repo non-bare");
1061 let store = working_copy.join(".jj").join("repo").join("store");
1063 fs::create_dir_all(&store).expect("failed to create dir");
1064 fs::rename(working_copy.join(".git"), store.join("git")).expect("failed to move git repo");
1065
1066 let repo = Repository::discover(working_copy).expect("failed to init repo");
1068
1069 if repo.inner.path().starts_with("/private") {
1072 assert_eq!(
1073 repo.inner.path().strip_prefix("/private"),
1074 store.join("git").strip_prefix("/"),
1075 "open git repo in .jj/repo/store/"
1076 );
1077 } else {
1078 assert_eq!(
1079 repo.inner.path(),
1080 store.join("git"),
1081 "open git repo in .jj/repo/store/"
1082 );
1083 }
1084 }
1085
1086 #[test]
1087 fn propagate_error_if_no_repo_found() {
1088 let temp_dir = TempDir::with_prefix("git-cliff-").expect("failed to create temp dir");
1089
1090 let path = temp_dir.path().to_path_buf();
1091
1092 let result = Repository::discover(path.clone());
1093
1094 assert!(result.is_err());
1095 if let Err(error) = result {
1096 assert!(
1097 error.to_string().contains(
1098 format!("could not find repository at '{}'", path.display()).as_str()
1099 )
1100 );
1101 }
1102 }
1103
1104 #[test]
1105 fn repository_path_does_not_exist() {
1106 let path = PathBuf::from("/this/path/should/not/exist/123456789");
1107 let result = Repository::init(path.clone());
1108 assert!(result.is_err());
1109 match result {
1110 Err(Error::IoError(err)) => {
1111 assert_eq!(err.kind(), io::ErrorKind::NotFound);
1112 assert!(err.to_string().contains("repository path not found"));
1113 }
1114 _ => panic!("expected IoError(NotFound)"),
1115 }
1116 }
1117
1118 #[test]
1119 fn open_jujutsu_repo() {
1120 let (repo, _temp_dir) = create_temp_repo();
1121 let working_copy = repo.path;
1123
1124 Command::new("git")
1126 .args(["config", "core.bare", "true"])
1127 .current_dir(&working_copy)
1128 .status()
1129 .expect("failed to make git repo non-bare");
1130
1131 let store = working_copy.join(".jj").join("repo").join("store");
1133 fs::create_dir_all(&store).expect("failed to create dir");
1134 fs::rename(working_copy.join(".git"), store.join("git")).expect("failed to move git repo");
1135
1136 let repo = Repository::init(working_copy).expect("failed to init repo");
1138
1139 if repo.inner.path().starts_with("/private") {
1142 assert_eq!(
1143 repo.inner.path().strip_prefix("/private"),
1144 store.join("git").strip_prefix("/"),
1145 "open git repo in .jj/repo/store/"
1146 );
1147 } else {
1148 assert_eq!(
1149 repo.inner.path(),
1150 store.join("git"),
1151 "open git repo in .jj/repo/store/"
1152 );
1153 }
1154 }
1155
1156 #[test]
1157 fn propagate_error_if_no_repo_exist() {
1158 let temp_dir = TempDir::with_prefix("git-cliff-").expect("failed to create temp dir");
1159
1160 let path = temp_dir.path().to_path_buf();
1161
1162 let result = Repository::init(path.clone());
1163
1164 assert!(result.is_err());
1165 if let Err(error) = result {
1166 assert!(
1167 error.to_string().contains(
1168 format!("could not find repository at '{}'", path.display()).as_str()
1169 )
1170 );
1171 }
1172 }
1173
1174 fn create_commit_with_files<'a>(
1175 repo: &'a Repository,
1176 files: Vec<(&'a str, &'a str)>,
1177 ) -> Commit<'a> {
1178 for (path, content) in files {
1179 if let Some(parent) = repo.path.join(path).parent() {
1180 std::fs::create_dir_all(parent).expect("failed to create dir");
1181 }
1182 std::fs::write(repo.path.join(path), content).expect("failed to write file");
1183 }
1184
1185 let output = Command::new("git")
1186 .args(["add", "."])
1187 .current_dir(&repo.path)
1188 .output()
1189 .expect("failed to execute git add");
1190 assert!(output.status.success(), "git add failed {output:?}");
1191
1192 let output = Command::new("git")
1193 .args(["commit", "--no-gpg-sign", "-m", "test commit"])
1194 .current_dir(&repo.path)
1195 .output()
1196 .expect("failed to execute git commit");
1197 assert!(output.status.success(), "git commit failed {output:?}");
1198
1199 repo.inner
1200 .head()
1201 .and_then(|head| head.peel_to_commit())
1202 .expect("failed to get the last commit")
1203 }
1204
1205 #[test]
1206 fn filter_git_blame_ignore_revs_removes_listed_and_ignore_file_only_commits() {
1207 let (repo, _temp_dir) = create_temp_repo();
1208
1209 let normal_commit_1 = create_commit_with_files(&repo, vec![("file1.txt", "content1")]);
1210 let ignored_commit = create_commit_with_files(&repo, vec![("file2.txt", "content2")]);
1211 let normal_commit_2 = create_commit_with_files(&repo, vec![("file3.txt", "content3")]);
1212
1213 let ignore_file_contents = format!("# comment\n{}\n", ignored_commit.id());
1216 let ignore_file_commit = create_commit_with_files(&repo, vec![(
1217 ".git-blame-ignore-revs",
1218 ignore_file_contents.as_str(),
1219 )]);
1220
1221 let mut commits = repo
1222 .commits(None, None, None, false)
1223 .expect("failed to get commits");
1224 assert_eq!(commits.len(), 4, "sanity check before filtering");
1225
1226 repo.filter_git_blame_ignore_revs(&mut commits);
1227
1228 let remaining_ids: Vec<_> = commits.iter().map(git2::Commit::id).collect();
1229 assert!(remaining_ids.contains(&normal_commit_1.id()));
1230 assert!(remaining_ids.contains(&normal_commit_2.id()));
1231 assert!(
1232 !remaining_ids.contains(&ignored_commit.id()),
1233 "commit listed in .git-blame-ignore-revs should be filtered out"
1234 );
1235 assert!(
1236 !remaining_ids.contains(&ignore_file_commit.id()),
1237 "commit that only touches .git-blame-ignore-revs should be filtered out"
1238 );
1239 assert_eq!(commits.len(), 2);
1240 }
1241
1242 #[test]
1243 fn filter_git_blame_ignore_revs_is_a_no_op_without_the_file() {
1244 let (repo, _temp_dir) = create_temp_repo();
1245 create_commit_with_files(&repo, vec![("file1.txt", "content1")]);
1246
1247 let mut commits = repo
1248 .commits(None, None, None, false)
1249 .expect("failed to get commits");
1250 let before = commits.len();
1251
1252 repo.filter_git_blame_ignore_revs(&mut commits);
1253
1254 assert_eq!(
1255 commits.len(),
1256 before,
1257 "no .git-blame-ignore-revs file present"
1258 );
1259 }
1260
1261 #[test]
1262 fn test_normalize_pattern() {
1263 let normalize = |input: &str| {
1264 Repository::normalize_pattern(Pattern::new(input).expect("valid pattern"))
1265 .as_str()
1266 .to_string()
1267 };
1268
1269 assert_eq!(normalize("dir/"), "dir/**");
1270 assert_eq!(normalize("./dir/"), "dir/**");
1271 assert_eq!(normalize("./file.txt"), "file.txt");
1272 assert_eq!(normalize("dir/file.txt"), "dir/file.txt");
1273 }
1274
1275 #[test]
1276 fn test_should_retain_commit() {
1277 let (repo, _temp_dir) = create_temp_repo();
1278
1279 let new_pattern = |input: &str| {
1280 Repository::normalize_pattern(Pattern::new(input).expect("valid pattern"))
1281 };
1282
1283 let first_commit = create_commit_with_files(&repo, vec![
1284 ("initial.txt", "initial content"),
1285 ("dir/initial.txt", "initial content"),
1286 ]);
1287
1288 {
1289 let retain = repo.should_retain_commit(
1290 &first_commit,
1291 Some(vec![new_pattern("dir/")]).as_ref(),
1292 None,
1293 );
1294 assert!(retain, "include: dir/");
1295 }
1296
1297 let commit = create_commit_with_files(&repo, vec![
1298 ("file1.txt", "content1"),
1299 ("file2.txt", "content2"),
1300 ("dir/file3.txt", "content3"),
1301 ("dir/subdir/file4.txt", "content4"),
1302 ]);
1303
1304 {
1305 let retain = repo.should_retain_commit(&commit, None, None);
1306 assert!(retain, "no include/exclude patterns");
1307 }
1308
1309 {
1310 let retain =
1311 repo.should_retain_commit(&commit, Some(vec![new_pattern("./")]).as_ref(), None);
1312 assert!(retain, "include: ./");
1313 }
1314
1315 {
1316 let retain =
1317 repo.should_retain_commit(&commit, Some(vec![new_pattern("**")]).as_ref(), None);
1318 assert!(retain, "include: **");
1319 }
1320
1321 {
1322 let retain =
1323 repo.should_retain_commit(&commit, Some(vec![new_pattern("*")]).as_ref(), None);
1324 assert!(retain, "include: *");
1325 }
1326
1327 {
1328 let retain =
1329 repo.should_retain_commit(&commit, Some(vec![new_pattern("dir/")]).as_ref(), None);
1330 assert!(retain, "include: dir/");
1331 }
1332
1333 {
1334 let retain =
1335 repo.should_retain_commit(&commit, Some(vec![new_pattern("dir/*")]).as_ref(), None);
1336 assert!(retain, "include: dir/*");
1337 }
1338
1339 {
1340 let retain = repo.should_retain_commit(
1341 &commit,
1342 Some(vec![new_pattern("file1.txt")]).as_ref(),
1343 None,
1344 );
1345 assert!(retain, "include: file1.txt");
1346 }
1347
1348 {
1349 let retain = repo.should_retain_commit(
1350 &commit,
1351 None,
1352 Some(vec![new_pattern("file1.txt")]).as_ref(),
1353 );
1354 assert!(retain, "exclude: file1.txt");
1355 }
1356
1357 {
1358 let retain = repo.should_retain_commit(
1359 &commit,
1360 Some(vec![new_pattern("file1.txt")]).as_ref(),
1361 Some(vec![new_pattern("file2.txt")]).as_ref(),
1362 );
1363 assert!(retain, "include: file1.txt, exclude: file2.txt");
1364 }
1365
1366 {
1367 let retain = repo.should_retain_commit(
1368 &commit,
1369 None,
1370 Some(vec![new_pattern("**/*.txt")]).as_ref(),
1371 );
1372 assert!(!retain, "exclude: **/*.txt");
1373 }
1374 }
1375}