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 if let Ok((files, _)) =
411 bincode::decode_from_slice(&result, bincode::config::standard())
412 {
413 return files;
414 }
415 }
416 }
417
418 let result = self.commit_changed_files_no_cache(commit);
420 match bincode::encode_to_vec(
421 self.commit_changed_files_no_cache(commit),
422 bincode::config::standard(),
423 ) {
424 Ok(v) => {
425 if let Err(e) = cacache::write_sync_with_algo(
426 cacache::Algorithm::Xxh3,
427 &self.changed_files_cache_path,
428 cache_key,
429 v,
430 ) {
431 #[allow(clippy::unnecessary_debug_formatting)]
432 {
433 tracing::error!("Failed to set cache for repo {:?}: {e}", self.path);
434 }
435 }
436 }
437 Err(e) => {
438 #[allow(clippy::unnecessary_debug_formatting)]
439 {
440 tracing::error!("Failed to serialize cache for repo {:?}: {e}", self.path);
441 }
442 }
443 }
444
445 result
446 }
447
448 fn commit_changed_files_no_cache(&self, commit: &Commit) -> Vec<PathBuf> {
452 let mut changed_files = Vec::new();
453 if let Ok(prev_commit) = commit.parent(0) {
454 if let Ok(diff) = self.inner.diff_tree_to_tree(
459 commit.tree().ok().as_ref(),
460 prev_commit.tree().ok().as_ref(),
461 None,
462 ) {
463 changed_files.extend(
464 diff.deltas()
465 .filter_map(|delta| delta.new_file().path().map(PathBuf::from)),
466 );
467 }
468 } else {
469 if let Ok(tree) = commit.tree() {
472 tree.walk(TreeWalkMode::PreOrder, |dir, entry| {
473 if entry.kind().expect("failed to get entry kind") != git2::ObjectType::Blob {
474 return 0;
475 }
476 let name = entry.name().expect("failed to get entry name");
477 let entry_path = if dir == "," {
478 name.to_string()
479 } else {
480 format!("{dir}/{name}")
481 };
482 changed_files.push(entry_path.into());
483 0
484 })
485 .expect("failed to get the changed files of the first commit");
486 }
487 }
488 changed_files
489 }
490
491 #[must_use]
495 pub fn current_tag(&self) -> Option<Tag> {
496 self.inner
497 .describe(DescribeOptions::new().describe_tags())
498 .ok()
499 .and_then(|describe| {
500 describe
501 .format(None)
502 .ok()
503 .map(|name| self.resolve_tag(&name))
504 })
505 }
506
507 #[must_use]
511 pub fn resolve_tag(&self, name: &str) -> Tag {
512 match self
513 .inner
514 .resolve_reference_from_short_name(name)
515 .and_then(|r| r.peel_to_tag())
516 {
517 Ok(tag) => Tag {
518 name: tag.name().unwrap_or_default().to_owned(),
519 message: tag
520 .message()
521 .unwrap_or_default()
522 .map(|msg| TAG_SIGNATURE_REGEX.replace(msg, "").trim().to_owned()),
523 },
524 _ => Tag {
525 name: name.to_owned(),
526 message: None,
527 },
528 }
529 }
530
531 #[must_use]
533 pub fn find_commit(&self, id: &str) -> Option<Commit<'_>> {
534 if let Ok(oid) = Oid::from_str(id) {
535 if let Ok(commit) = self.inner.find_commit(oid) {
536 return Some(commit);
537 }
538 }
539 None
540 }
541
542 fn should_include_tag(&self, head_commit: &Commit, tag_commit: &Commit) -> Result<bool> {
549 Ok(self
550 .inner
551 .graph_descendant_of(head_commit.id(), tag_commit.id())? ||
552 head_commit.id() == tag_commit.id())
553 }
554
555 pub fn tags(
559 &self,
560 pattern: &Option<Regex>,
561 topo_order: bool,
562 use_branch_tags: bool,
563 ) -> Result<IndexMap<String, Tag>> {
564 let mut tags: Vec<(Commit, Tag)> = Vec::new();
565 let tag_names = self.inner.tag_names(None)?;
566 let head_commit = self.inner.head()?.peel_to_commit()?;
567 for name in tag_names
568 .iter()
569 .flatten()
570 .flatten()
571 .filter(|tag_name| pattern.as_ref().is_none_or(|pat| pat.is_match(tag_name)))
572 .map(String::from)
573 {
574 let obj = self.inner.revparse_single(&name)?;
575 if let Ok(commit) = obj.clone().into_commit() {
576 if use_branch_tags && !self.should_include_tag(&head_commit, &commit)? {
577 continue;
578 }
579
580 tags.push((commit, Tag {
581 name,
582 message: None,
583 }));
584 } else if let Some(tag) = obj.as_tag() {
585 if let Some(commit) = obj
587 .peel(git2::ObjectType::Commit)
588 .ok()
589 .and_then(|o| o.into_commit().ok())
590 {
591 if use_branch_tags && !self.should_include_tag(&head_commit, &commit)? {
592 continue;
593 }
594 tags.push((commit, Tag {
595 name: tag.name().map(String::from).unwrap_or(name),
596 message: tag
597 .message()
598 .ok()
599 .flatten()
600 .map(|msg| TAG_SIGNATURE_REGEX.replace(msg, "").trim().to_owned()),
601 }));
602 }
603 }
604 }
605 if !topo_order {
606 tags.sort_by_key(|a| a.0.time().seconds());
607 }
608 Ok(tags
609 .into_iter()
610 .map(|(a, b)| (a.id().to_string(), b))
611 .collect())
612 }
613
614 pub fn commit_tag_ownership(
631 &self,
632 tags: &IndexMap<String, Tag>,
633 boundary_ids: &HashSet<Oid>,
634 ) -> Result<HashMap<Oid, String>> {
635 let mut ownership = HashMap::new();
636 let tag_ids: Vec<(Oid, &String)> = tags
638 .keys()
639 .filter_map(|id| Oid::from_str(id).ok().map(|oid| (oid, id)))
640 .filter(|(oid, _)| boundary_ids.contains(oid))
641 .collect();
642 for (index, (tag_oid, tag_id)) in tag_ids.iter().enumerate() {
643 let mut revwalk = self.inner.revwalk()?;
644 revwalk.push(*tag_oid)?;
645 for (prev_oid, _) in &tag_ids[..index] {
648 let _ = revwalk.hide(*prev_oid);
650 }
651 for oid in revwalk.filter_map(StdResult::ok) {
652 if boundary_ids.contains(&oid) {
653 ownership.entry(oid).or_insert_with(|| (*tag_id).clone());
654 if ownership.len() == boundary_ids.len() {
655 return Ok(ownership);
656 }
657 }
658 }
659 }
660 Ok(ownership)
661 }
662
663 pub fn upstream_remote(&self) -> Result<Remote> {
672 for branch in self.inner.branches(Some(BranchType::Local))? {
673 let branch = branch?.0;
674 if branch.is_head() {
675 let upstream = &self.inner.branch_upstream_remote(&format!(
676 "refs/heads/{}",
677 branch.name()?.ok_or_else(|| Error::RepoError(String::from(
678 "branch name is not valid"
679 )))?
680 ))?;
681 let upstream_name = upstream.as_str().map_err(|err| {
682 Error::RepoError(format!("name of the upstream remote is not valid: {err}"))
683 })?;
684 let origin = &self.inner.find_remote(upstream_name)?;
685 let url = origin
686 .url()
687 .map_err(|err| {
688 Error::RepoError(format!("failed to get the remote URL: {err}"))
689 })?
690 .to_string();
691 tracing::trace!("Upstream URL: {url}");
692 return find_remote(&url);
693 }
694 }
695 Err(Error::RepoError(String::from(
696 "no remotes configured or HEAD is detached",
697 )))
698 }
699}
700
701fn find_remote(url: &str) -> Result<Remote> {
702 url_path_segments(url).or_else(|err| {
703 if url.contains('@') && url.contains(':') && url.contains('/') {
704 ssh_path_segments(url)
705 } else {
706 Err(err)
707 }
708 })
709}
710
711fn url_path_segments(url: &str) -> Result<Remote> {
719 let parsed_url = Url::parse(url.strip_suffix(".git").unwrap_or(url))?;
720 let segments: Vec<&str> = parsed_url
721 .path_segments()
722 .ok_or_else(|| Error::RepoError(String::from("failed to get URL segments")))?
723 .rev()
724 .collect();
725 let [repo, owner, ..] = &segments[..] else {
726 return Err(Error::RepoError(String::from(
727 "failed to get the owner and repo",
728 )));
729 };
730 Ok(Remote {
731 owner: (*owner).to_string(),
732 repo: (*repo).to_string(),
733 token: None,
734 is_custom: false,
735 api_url: None,
736 http_timeout: std::time::Duration::from_secs(30),
737 native_tls: None,
738 })
739}
740
741fn ssh_path_segments(url: &str) -> Result<Remote> {
747 let [_, owner_repo, ..] = url
748 .strip_suffix(".git")
749 .unwrap_or(url)
750 .split(':')
751 .collect::<Vec<_>>()[..]
752 else {
753 return Err(Error::RepoError(String::from(
754 "failed to get the owner and repo from ssh remote (:)",
755 )));
756 };
757 let [owner, repo] = owner_repo.split('/').collect::<Vec<_>>()[..] else {
758 return Err(Error::RepoError(String::from(
759 "failed to get the owner and repo from ssh remote (/)",
760 )));
761 };
762 Ok(Remote {
763 owner: owner.to_string(),
764 repo: repo.to_string(),
765 token: None,
766 is_custom: false,
767 api_url: None,
768 http_timeout: std::time::Duration::from_secs(30),
769 native_tls: None,
770 })
771}
772
773#[cfg(test)]
774mod test {
775 use std::process::Command;
776 use std::{env, fs, io, str};
777
778 use temp_dir::TempDir;
779
780 use super::*;
781 use crate::commit::Commit as AppCommit;
782
783 fn get_last_commit_hash() -> Result<String> {
784 Ok(str::from_utf8(
785 Command::new("git")
786 .args(["log", "--pretty=format:'%H'", "-n", "1"])
787 .output()?
788 .stdout
789 .as_ref(),
790 )?
791 .trim_matches('\'')
792 .to_string())
793 }
794
795 fn get_root_commit_hash() -> Result<String> {
796 Ok(str::from_utf8(
797 Command::new("git")
798 .args(["rev-list", "--max-parents=0", "HEAD"])
799 .output()?
800 .stdout
801 .as_ref(),
802 )?
803 .trim_ascii_end()
804 .to_string())
805 }
806
807 fn get_last_tag() -> Result<String> {
808 Ok(str::from_utf8(
809 Command::new("git")
810 .args(["describe", "--abbrev=0"])
811 .output()?
812 .stdout
813 .as_ref(),
814 )?
815 .trim()
816 .to_string())
817 }
818
819 fn get_repository() -> Result<Repository> {
820 Repository::discover(
821 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
822 .parent()
823 .expect("parent directory not found")
824 .to_path_buf(),
825 )
826 }
827
828 #[test]
829 fn http_url_repo_owner() -> Result<()> {
830 let url = "https://hostname.com/bob/magic.git";
831 let remote = find_remote(url)?;
832 assert_eq!(remote.owner, "bob", "match owner");
833 assert_eq!(remote.repo, "magic", "match repo");
834 Ok(())
835 }
836
837 #[test]
838 fn ssh_url_repo_owner() -> Result<()> {
839 let url = "git@hostname.com:bob/magic.git";
840 let remote = find_remote(url)?;
841 assert_eq!(remote.owner, "bob", "match owner");
842 assert_eq!(remote.repo, "magic", "match repo");
843 Ok(())
844 }
845
846 #[test]
847 fn get_latest_commit() -> Result<()> {
848 let repository = get_repository()?;
849 let commits = repository.commits(None, None, None, false)?;
850 let last_commit = AppCommit::from(&commits.first().expect("no commits found").clone());
851 assert_eq!(get_last_commit_hash()?, last_commit.id);
852 Ok(())
853 }
854
855 #[test]
856 fn commit_search() -> Result<()> {
857 let repository = get_repository()?;
858 assert!(
859 repository
860 .find_commit("e936ed571533ea6c41a1dd2b1a29d085c8dbada5")
861 .is_some()
862 );
863 Ok(())
864 }
865
866 #[test]
867 fn get_latest_tag() -> Result<()> {
868 let repository = get_repository()?;
869 let tags = repository.tags(&None, false, false)?;
870 let latest = tags.last().expect("no tags found").1.name.clone();
871 assert_eq!(get_last_tag()?, latest);
872
873 let current = repository.current_tag().expect("a current tag").name;
874 assert!(current.contains(&latest));
875 Ok(())
876 }
877
878 #[test]
879 fn git_tags() -> Result<()> {
880 let repository = get_repository()?;
881 let tags = repository.tags(&None, true, false)?;
882 assert_eq!(
883 tags.get("2b8b4d3535f29231e05c3572e919634b9af907b6")
884 .expect("the commit hash does not exist in the repository (tag v0.1.0)")
885 .name,
886 "v0.1.0"
887 );
888 assert_eq!(
889 tags.get("4ddef08debfff48117586296e49d5caa0800d1b5")
890 .expect("the commit hash does not exist in the repository (tag v0.1.0-beta.4)")
891 .name,
892 "v0.1.0-beta.4"
893 );
894 let tags = repository.tags(
895 &Some(Regex::new("^v[0-9]+\\.[0-9]+\\.[0-9]$").expect("the regex is not valid")),
896 true,
897 false,
898 )?;
899 assert_eq!(
900 tags.get("2b8b4d3535f29231e05c3572e919634b9af907b6")
901 .expect("the commit hash does not exist in the repository (tag v0.1.0)")
902 .name,
903 "v0.1.0"
904 );
905 assert!(!tags.contains_key("4ddef08debfff48117586296e49d5caa0800d1b5"));
906 Ok(())
907 }
908
909 #[test]
910 fn git_nested_tags() -> Result<()> {
911 let (repo, temp_dir) = create_temp_repo();
912 let path = temp_dir.path();
913
914 let commit = create_commit_with_files(&repo, vec![("initial.txt", "initial content")]);
915
916 Command::new("git")
917 .args(["tag", "-a", "v1.0.0-staging", "-m", "s"])
918 .current_dir(path)
919 .output()?;
920
921 Command::new("git")
923 .args(["tag", "-a", "v1.0.0-stable", "-m", "s", "v1.0.0-staging"])
924 .current_dir(path)
925 .output()?;
926
927 let tags = repo.tags(&Some(Regex::new("v1.0.0-stable")?), false, false)?;
928 assert_eq!(
929 tags.get(&commit.id().to_string())
930 .expect("nested tag should resolve to commit")
931 .name,
932 "v1.0.0-stable"
933 );
934 Ok(())
935 }
936
937 #[test]
938 fn git_upstream_remote() -> Result<()> {
939 let repository = get_repository()?;
940 let remote = repository.upstream_remote()?;
941 assert_eq!(
942 Remote {
943 owner: remote.owner.clone(),
944 repo: String::from("git-cliff"),
945 token: None,
946 is_custom: false,
947 api_url: remote.api_url.clone(),
948 http_timeout: std::time::Duration::from_secs(30),
949 native_tls: None,
950 },
951 remote
952 );
953 Ok(())
954 }
955
956 #[test]
957 fn resolves_existing_tag_with_name_and_message() -> Result<()> {
958 let repository = get_repository()?;
959 let tag = repository.resolve_tag("v0.2.3");
960 assert_eq!(tag.name, "v0.2.3");
961 assert_eq!(
962 tag.message,
963 Some(
964 "Release v0.2.3\n\nBug Fixes\n- Fetch the dependencies before copying the file to \
965 embed (9e29c95)"
966 .to_string()
967 )
968 );
969
970 Ok(())
971 }
972
973 #[test]
974 fn resolves_tag_when_no_tags_exist() -> Result<()> {
975 let repository = get_repository()?;
976 let tag = repository.resolve_tag("nonexistent-tag");
977 assert_eq!(tag.name, "nonexistent-tag");
978 assert_eq!(tag.message, None);
979 Ok(())
980 }
981
982 #[test]
983 fn includes_root_commit() -> Result<()> {
984 let repository = get_repository()?;
985 let range = Some("eea3914c7ab07472841aa85c36d11bdb2589a234");
987 let commits = repository.commits(range, None, None, false)?;
988 let root_commit = AppCommit::from(&commits.last().expect("no commits found").clone());
989 assert_eq!(get_root_commit_hash()?, root_commit.id);
990 Ok(())
991 }
992
993 fn create_temp_repo() -> (Repository, TempDir) {
994 let temp_dir = TempDir::with_prefix("git-cliff-").expect("failed to create temp dir");
995
996 let output = Command::new("git")
997 .args(["init"])
998 .current_dir(temp_dir.path())
999 .output()
1000 .expect("failed to execute git init");
1001 assert!(output.status.success(), "git init failed {output:?}");
1002
1003 let repo =
1004 Repository::discover(temp_dir.path().to_path_buf()).expect("failed to init repo");
1005 let output = Command::new("git")
1006 .args(["config", "user.email", "test@gmail.com"])
1007 .current_dir(temp_dir.path())
1008 .output()
1009 .expect("failed to execute git config user.email");
1010 assert!(
1011 output.status.success(),
1012 "git config user.email failed {output:?}",
1013 );
1014
1015 let output = Command::new("git")
1016 .args(["config", "user.name", "test"])
1017 .current_dir(temp_dir.path())
1018 .output()
1019 .expect("failed to execute git config user.name");
1020 assert!(
1021 output.status.success(),
1022 "git config user.name failed {output:?}",
1023 );
1024
1025 (repo, temp_dir)
1026 }
1027
1028 #[test]
1029 fn repository_path_not_found() {
1030 let path = PathBuf::from("/this/path/should/not/exist/123456789");
1031 let result = Repository::discover(path.clone());
1032 assert!(result.is_err());
1033 match result {
1034 Err(Error::IoError(err)) => {
1035 assert_eq!(err.kind(), io::ErrorKind::NotFound);
1036 assert!(err.to_string().contains("repository path not found"));
1037 }
1038 _ => panic!("expected IoError(NotFound)"),
1039 }
1040 }
1041
1042 #[test]
1043 fn discover_jujutsu_repo() {
1044 let (repo, _temp_dir) = create_temp_repo();
1045 let working_copy = repo.path;
1047
1048 std::process::Command::new("git")
1050 .args(["config", "core.bare", "true"])
1051 .current_dir(&working_copy)
1052 .status()
1053 .expect("failed to make git repo non-bare");
1054 let store = working_copy.join(".jj").join("repo").join("store");
1056 fs::create_dir_all(&store).expect("failed to create dir");
1057 fs::rename(working_copy.join(".git"), store.join("git")).expect("failed to move git repo");
1058
1059 let repo = Repository::discover(working_copy).expect("failed to init repo");
1061
1062 if repo.inner.path().starts_with("/private") {
1065 assert_eq!(
1066 repo.inner.path().strip_prefix("/private"),
1067 store.join("git").strip_prefix("/"),
1068 "open git repo in .jj/repo/store/"
1069 );
1070 } else {
1071 assert_eq!(
1072 repo.inner.path(),
1073 store.join("git"),
1074 "open git repo in .jj/repo/store/"
1075 );
1076 }
1077 }
1078
1079 #[test]
1080 fn propagate_error_if_no_repo_found() {
1081 let temp_dir = TempDir::with_prefix("git-cliff-").expect("failed to create temp dir");
1082
1083 let path = temp_dir.path().to_path_buf();
1084
1085 let result = Repository::discover(path.clone());
1086
1087 assert!(result.is_err());
1088 if let Err(error) = result {
1089 assert!(
1090 error.to_string().contains(
1091 format!("could not find repository at '{}'", path.display()).as_str()
1092 )
1093 );
1094 }
1095 }
1096
1097 #[test]
1098 fn repository_path_does_not_exist() {
1099 let path = PathBuf::from("/this/path/should/not/exist/123456789");
1100 let result = Repository::init(path.clone());
1101 assert!(result.is_err());
1102 match result {
1103 Err(Error::IoError(err)) => {
1104 assert_eq!(err.kind(), io::ErrorKind::NotFound);
1105 assert!(err.to_string().contains("repository path not found"));
1106 }
1107 _ => panic!("expected IoError(NotFound)"),
1108 }
1109 }
1110
1111 #[test]
1112 fn open_jujutsu_repo() {
1113 let (repo, _temp_dir) = create_temp_repo();
1114 let working_copy = repo.path;
1116
1117 Command::new("git")
1119 .args(["config", "core.bare", "true"])
1120 .current_dir(&working_copy)
1121 .status()
1122 .expect("failed to make git repo non-bare");
1123
1124 let store = working_copy.join(".jj").join("repo").join("store");
1126 fs::create_dir_all(&store).expect("failed to create dir");
1127 fs::rename(working_copy.join(".git"), store.join("git")).expect("failed to move git repo");
1128
1129 let repo = Repository::init(working_copy).expect("failed to init repo");
1131
1132 if repo.inner.path().starts_with("/private") {
1135 assert_eq!(
1136 repo.inner.path().strip_prefix("/private"),
1137 store.join("git").strip_prefix("/"),
1138 "open git repo in .jj/repo/store/"
1139 );
1140 } else {
1141 assert_eq!(
1142 repo.inner.path(),
1143 store.join("git"),
1144 "open git repo in .jj/repo/store/"
1145 );
1146 }
1147 }
1148
1149 #[test]
1150 fn propagate_error_if_no_repo_exist() {
1151 let temp_dir = TempDir::with_prefix("git-cliff-").expect("failed to create temp dir");
1152
1153 let path = temp_dir.path().to_path_buf();
1154
1155 let result = Repository::init(path.clone());
1156
1157 assert!(result.is_err());
1158 if let Err(error) = result {
1159 assert!(
1160 error.to_string().contains(
1161 format!("could not find repository at '{}'", path.display()).as_str()
1162 )
1163 );
1164 }
1165 }
1166
1167 fn create_commit_with_files<'a>(
1168 repo: &'a Repository,
1169 files: Vec<(&'a str, &'a str)>,
1170 ) -> Commit<'a> {
1171 for (path, content) in files {
1172 if let Some(parent) = repo.path.join(path).parent() {
1173 std::fs::create_dir_all(parent).expect("failed to create dir");
1174 }
1175 std::fs::write(repo.path.join(path), content).expect("failed to write file");
1176 }
1177
1178 let output = Command::new("git")
1179 .args(["add", "."])
1180 .current_dir(&repo.path)
1181 .output()
1182 .expect("failed to execute git add");
1183 assert!(output.status.success(), "git add failed {output:?}");
1184
1185 let output = Command::new("git")
1186 .args(["commit", "--no-gpg-sign", "-m", "test commit"])
1187 .current_dir(&repo.path)
1188 .output()
1189 .expect("failed to execute git commit");
1190 assert!(output.status.success(), "git commit failed {output:?}");
1191
1192 repo.inner
1193 .head()
1194 .and_then(|head| head.peel_to_commit())
1195 .expect("failed to get the last commit")
1196 }
1197
1198 #[test]
1199 fn filter_git_blame_ignore_revs_removes_listed_and_ignore_file_only_commits() {
1200 let (repo, _temp_dir) = create_temp_repo();
1201
1202 let normal_commit_1 = create_commit_with_files(&repo, vec![("file1.txt", "content1")]);
1203 let ignored_commit = create_commit_with_files(&repo, vec![("file2.txt", "content2")]);
1204 let normal_commit_2 = create_commit_with_files(&repo, vec![("file3.txt", "content3")]);
1205
1206 let ignore_file_contents = format!("# comment\n{}\n", ignored_commit.id());
1209 let ignore_file_commit = create_commit_with_files(&repo, vec![(
1210 ".git-blame-ignore-revs",
1211 ignore_file_contents.as_str(),
1212 )]);
1213
1214 let mut commits = repo
1215 .commits(None, None, None, false)
1216 .expect("failed to get commits");
1217 assert_eq!(commits.len(), 4, "sanity check before filtering");
1218
1219 repo.filter_git_blame_ignore_revs(&mut commits);
1220
1221 let remaining_ids: Vec<_> = commits.iter().map(git2::Commit::id).collect();
1222 assert!(remaining_ids.contains(&normal_commit_1.id()));
1223 assert!(remaining_ids.contains(&normal_commit_2.id()));
1224 assert!(
1225 !remaining_ids.contains(&ignored_commit.id()),
1226 "commit listed in .git-blame-ignore-revs should be filtered out"
1227 );
1228 assert!(
1229 !remaining_ids.contains(&ignore_file_commit.id()),
1230 "commit that only touches .git-blame-ignore-revs should be filtered out"
1231 );
1232 assert_eq!(commits.len(), 2);
1233 }
1234
1235 #[test]
1236 fn filter_git_blame_ignore_revs_is_a_no_op_without_the_file() {
1237 let (repo, _temp_dir) = create_temp_repo();
1238 create_commit_with_files(&repo, vec![("file1.txt", "content1")]);
1239
1240 let mut commits = repo
1241 .commits(None, None, None, false)
1242 .expect("failed to get commits");
1243 let before = commits.len();
1244
1245 repo.filter_git_blame_ignore_revs(&mut commits);
1246
1247 assert_eq!(
1248 commits.len(),
1249 before,
1250 "no .git-blame-ignore-revs file present"
1251 );
1252 }
1253
1254 #[test]
1255 fn test_normalize_pattern() {
1256 let normalize = |input: &str| {
1257 Repository::normalize_pattern(Pattern::new(input).expect("valid pattern"))
1258 .as_str()
1259 .to_string()
1260 };
1261
1262 assert_eq!(normalize("dir/"), "dir/**");
1263 assert_eq!(normalize("./dir/"), "dir/**");
1264 assert_eq!(normalize("./file.txt"), "file.txt");
1265 assert_eq!(normalize("dir/file.txt"), "dir/file.txt");
1266 }
1267
1268 #[test]
1269 fn test_should_retain_commit() {
1270 let (repo, _temp_dir) = create_temp_repo();
1271
1272 let new_pattern = |input: &str| {
1273 Repository::normalize_pattern(Pattern::new(input).expect("valid pattern"))
1274 };
1275
1276 let first_commit = create_commit_with_files(&repo, vec![
1277 ("initial.txt", "initial content"),
1278 ("dir/initial.txt", "initial content"),
1279 ]);
1280
1281 {
1282 let retain = repo.should_retain_commit(
1283 &first_commit,
1284 Some(vec![new_pattern("dir/")]).as_ref(),
1285 None,
1286 );
1287 assert!(retain, "include: dir/");
1288 }
1289
1290 let commit = create_commit_with_files(&repo, vec![
1291 ("file1.txt", "content1"),
1292 ("file2.txt", "content2"),
1293 ("dir/file3.txt", "content3"),
1294 ("dir/subdir/file4.txt", "content4"),
1295 ]);
1296
1297 {
1298 let retain = repo.should_retain_commit(&commit, None, None);
1299 assert!(retain, "no include/exclude patterns");
1300 }
1301
1302 {
1303 let retain =
1304 repo.should_retain_commit(&commit, Some(vec![new_pattern("./")]).as_ref(), None);
1305 assert!(retain, "include: ./");
1306 }
1307
1308 {
1309 let retain =
1310 repo.should_retain_commit(&commit, Some(vec![new_pattern("**")]).as_ref(), None);
1311 assert!(retain, "include: **");
1312 }
1313
1314 {
1315 let retain =
1316 repo.should_retain_commit(&commit, Some(vec![new_pattern("*")]).as_ref(), None);
1317 assert!(retain, "include: *");
1318 }
1319
1320 {
1321 let retain =
1322 repo.should_retain_commit(&commit, Some(vec![new_pattern("dir/")]).as_ref(), None);
1323 assert!(retain, "include: dir/");
1324 }
1325
1326 {
1327 let retain =
1328 repo.should_retain_commit(&commit, Some(vec![new_pattern("dir/*")]).as_ref(), None);
1329 assert!(retain, "include: dir/*");
1330 }
1331
1332 {
1333 let retain = repo.should_retain_commit(
1334 &commit,
1335 Some(vec![new_pattern("file1.txt")]).as_ref(),
1336 None,
1337 );
1338 assert!(retain, "include: file1.txt");
1339 }
1340
1341 {
1342 let retain = repo.should_retain_commit(
1343 &commit,
1344 None,
1345 Some(vec![new_pattern("file1.txt")]).as_ref(),
1346 );
1347 assert!(retain, "exclude: file1.txt");
1348 }
1349
1350 {
1351 let retain = repo.should_retain_commit(
1352 &commit,
1353 Some(vec![new_pattern("file1.txt")]).as_ref(),
1354 Some(vec![new_pattern("file2.txt")]).as_ref(),
1355 );
1356 assert!(retain, "include: file1.txt, exclude: file2.txt");
1357 }
1358
1359 {
1360 let retain = repo.should_retain_commit(
1361 &commit,
1362 None,
1363 Some(vec![new_pattern("**/*.txt")]).as_ref(),
1364 );
1365 assert!(!retain, "exclude: **/*.txt");
1366 }
1367 }
1368}