1use std::io;
2use std::path::{self, Path, PathBuf};
3use std::result::Result as StdResult;
4use std::sync::LazyLock;
5
6use git2::{
7 BranchType, Commit, DescribeOptions, Oid, Repository as GitRepository, Sort, TreeWalkMode,
8 Worktree,
9};
10use glob::Pattern;
11use indexmap::IndexMap;
12use regex::Regex;
13use url::Url;
14
15use crate::commit::CommitStatistics;
16use crate::config::Remote;
17use crate::error::{Error, Result};
18use crate::tag::Tag;
19
20static TAG_SIGNATURE_REGEX: LazyLock<Regex> = LazyLock::new(|| {
22 Regex::new(
23 r"(?s)-----BEGIN (PGP|SSH|SIGNED) (SIGNATURE|MESSAGE)-----(.*?)-----END (PGP|SSH|SIGNED) (SIGNATURE|MESSAGE)-----"
25 )
26 .expect("valid git tag signature regex")
27});
28
29const CHANGED_FILES_CACHE: &str = "changed_files_cache";
31
32pub struct Repository {
36 inner: GitRepository,
37 path: PathBuf,
39 changed_files_cache_path: PathBuf,
41}
42
43pub struct SubmoduleRange {
45 pub repository: Repository,
47 pub range: String,
50}
51
52impl Repository {
53 fn open(path: PathBuf, search_parents: bool) -> Result<Self> {
58 if !path.exists() {
59 return Err(Error::IoError(io::Error::new(
60 io::ErrorKind::NotFound,
61 format!("repository path not found: {}", path.display()),
62 )));
63 }
64
65 let inner = GitRepository::open(&path)
66 .or_else(|err| {
67 let mut current = Some(path.as_path());
69 while let Some(dir) = current {
70 let jujutsu_path = dir.join(".jj/repo/store/git");
71 if jujutsu_path.exists() {
72 return GitRepository::open_bare(&jujutsu_path);
73 }
74 if !search_parents {
76 break;
77 }
78 current = dir.parent();
79 }
80 Err(err)
81 })
82 .or_else(|err| {
84 if search_parents {
85 GitRepository::discover(&path)
86 } else {
87 Err(err)
88 }
89 })?;
90
91 let changed_files_cache_path = inner
92 .path()
93 .join(env!("CARGO_PKG_NAME"))
94 .join(CHANGED_FILES_CACHE);
95
96 Ok(Self {
97 inner,
98 path,
99 changed_files_cache_path,
100 })
101 }
102
103 pub fn discover(path: PathBuf) -> Result<Self> {
110 Self::open(path, true)
111 }
112
113 pub fn init(path: PathBuf) -> Result<Self> {
119 Self::open(path, false)
120 }
121
122 pub fn root_path(&self) -> Result<PathBuf> {
124 let mut path = if self.inner.is_worktree() {
125 let worktree = Worktree::open_from_repository(&self.inner)?;
126 worktree.path().to_path_buf()
127 } else {
128 self.inner.path().to_path_buf()
129 };
130 if path.ends_with(".git") {
131 path.pop();
132 }
133 Ok(path)
134 }
135
136 #[must_use]
141 pub fn path(&self) -> &PathBuf {
142 &self.path
143 }
144
145 fn set_commit_range(
150 revwalk: &mut git2::Revwalk<'_>,
151 range: Option<&str>,
152 ) -> StdResult<(), git2::Error> {
153 if let Some(range) = range {
154 if range.contains("..") {
155 revwalk.push_range(range)?;
156 } else {
157 revwalk.push(Oid::from_str(range)?)?;
158 }
159 } else {
160 revwalk.push_head()?;
161 }
162 Ok(())
163 }
164
165 pub fn commits(
169 &self,
170 range: Option<&str>,
171 include_path: Option<Vec<Pattern>>,
172 exclude_path: Option<Vec<Pattern>>,
173 topo_order_commits: bool,
174 ) -> Result<Vec<Commit<'_>>> {
175 let mut revwalk = self.inner.revwalk()?;
176 if topo_order_commits {
177 revwalk.set_sorting(Sort::TOPOLOGICAL)?;
178 } else {
179 revwalk.set_sorting(Sort::TIME)?;
180 }
181
182 Self::set_commit_range(&mut revwalk, range).map_err(|e| {
183 Error::SetCommitRangeError(range.map_or_else(|| "?".to_string(), String::from), e)
184 })?;
185 let mut commits: Vec<Commit> = revwalk
186 .filter_map(StdResult::ok)
187 .filter_map(|id| self.inner.find_commit(id).ok())
188 .collect();
189 if include_path.is_some() || exclude_path.is_some() {
190 let include_patterns = include_path.map(|patterns| {
191 patterns
192 .into_iter()
193 .map(Self::normalize_pattern)
194 .collect::<Vec<_>>()
195 });
196 let exclude_patterns = exclude_path.map(|patterns| {
197 patterns
198 .into_iter()
199 .map(Self::normalize_pattern)
200 .collect::<Vec<_>>()
201 });
202 commits.retain(|commit| {
203 self.should_retain_commit(
204 commit,
205 include_patterns.as_ref(),
206 exclude_patterns.as_ref(),
207 )
208 });
209 }
210 Ok(commits)
211 }
212
213 pub fn commit_statistics(&self, commit: &Commit<'_>) -> Result<CommitStatistics> {
220 let current_tree = commit.tree()?;
221 let previous_tree = commit
222 .parent(0)
223 .ok()
224 .map(|parent| parent.tree())
225 .transpose()?;
226 let diff =
227 self.inner
228 .diff_tree_to_tree(previous_tree.as_ref(), Some(¤t_tree), None)?;
229 let stats = diff.stats()?;
230 Ok(CommitStatistics {
231 files_changed: stats.files_changed(),
232 additions: stats.insertions(),
233 deletions: stats.deletions(),
234 })
235 }
236
237 pub fn submodules_range(
249 &self,
250 old_commit: Option<&Commit<'_>>,
251 new_commit: &Commit<'_>,
252 ) -> Result<Vec<SubmoduleRange>> {
253 let old_tree = old_commit.and_then(|commit| commit.tree().ok());
254 let new_tree = new_commit.tree().ok();
255 let diff = self
256 .inner
257 .diff_tree_to_tree(old_tree.as_ref(), new_tree.as_ref(), None)?;
258 let before_and_after_deltas = diff.deltas().filter_map(|delta| {
260 let old_file_id = delta.old_file().id();
261 let new_file_id = delta.new_file().id();
262 let range = if old_file_id == new_file_id || new_file_id.is_zero() {
263 None
265 } else if old_file_id.is_zero() {
266 Some(new_file_id.to_string())
268 } else {
269 Some(format!("{old_file_id}..{new_file_id}"))
271 };
272 tracing::trace!("Release commit range for submodules: {range:?}");
273 delta.new_file().path().and_then(Path::to_str).zip(range)
274 });
275 let submodule_range = before_and_after_deltas.filter_map(|(path, range)| {
278 let repository = self
283 .inner
284 .find_submodule(path)
285 .ok()
286 .and_then(|submodule| Self::discover(submodule.path().into()).ok());
287 repository.map(|repository| SubmoduleRange { repository, range })
288 });
289 Ok(submodule_range.collect())
290 }
291
292 fn normalize_pattern(pattern: Pattern) -> Pattern {
297 let star_added = if pattern.as_str().ends_with(path::MAIN_SEPARATOR) {
298 Pattern::new(&format!("{pattern}**")).expect("failed to add '**' to the end of glob")
299 } else {
300 pattern
301 };
302 match star_added.as_str().strip_prefix("./") {
303 Some(stripped) => {
304 Pattern::new(stripped).expect("failed to remove leading ./ from glob")
305 }
306 None => star_added,
307 }
308 }
309
310 fn should_retain_commit(
315 &self,
316 commit: &Commit,
317 include_patterns: Option<&Vec<Pattern>>,
318 exclude_patterns: Option<&Vec<Pattern>>,
319 ) -> bool {
320 let changed_files = self.commit_changed_files(commit);
321 match (include_patterns, exclude_patterns) {
322 (Some(include_pattern), Some(exclude_pattern)) => {
323 changed_files.iter().any(|path| {
326 include_pattern
327 .iter()
328 .any(|pattern| pattern.matches_path(path)) &&
329 !exclude_pattern
330 .iter()
331 .any(|pattern| pattern.matches_path(path))
332 })
333 }
334 (Some(include_pattern), None) => {
335 changed_files.iter().any(|path| {
338 include_pattern
339 .iter()
340 .any(|pattern| pattern.matches_path(path))
341 })
342 }
343 (None, Some(exclude_pattern)) => {
344 changed_files.iter().any(|path| {
347 !exclude_pattern
348 .iter()
349 .any(|pattern| pattern.matches_path(path))
350 })
351 }
352 (None, None) => true,
353 }
354 }
355
356 fn commit_changed_files(&self, commit: &Commit) -> Vec<PathBuf> {
365 let cache_key = format!("commit_id:{}", commit.id());
367
368 {
370 if let Ok(result) = cacache::read_sync(&self.changed_files_cache_path, &cache_key) {
371 if let Ok((files, _)) =
372 bincode::decode_from_slice(&result, bincode::config::standard())
373 {
374 return files;
375 }
376 }
377 }
378
379 let result = self.commit_changed_files_no_cache(commit);
381 match bincode::encode_to_vec(
382 self.commit_changed_files_no_cache(commit),
383 bincode::config::standard(),
384 ) {
385 Ok(v) => {
386 if let Err(e) = cacache::write_sync_with_algo(
387 cacache::Algorithm::Xxh3,
388 &self.changed_files_cache_path,
389 cache_key,
390 v,
391 ) {
392 #[allow(clippy::unnecessary_debug_formatting)]
393 {
394 tracing::error!("Failed to set cache for repo {:?}: {e}", self.path);
395 }
396 }
397 }
398 Err(e) => {
399 #[allow(clippy::unnecessary_debug_formatting)]
400 {
401 tracing::error!("Failed to serialize cache for repo {:?}: {e}", self.path);
402 }
403 }
404 }
405
406 result
407 }
408
409 fn commit_changed_files_no_cache(&self, commit: &Commit) -> Vec<PathBuf> {
413 let mut changed_files = Vec::new();
414 if let Ok(prev_commit) = commit.parent(0) {
415 if let Ok(diff) = self.inner.diff_tree_to_tree(
420 commit.tree().ok().as_ref(),
421 prev_commit.tree().ok().as_ref(),
422 None,
423 ) {
424 changed_files.extend(
425 diff.deltas()
426 .filter_map(|delta| delta.new_file().path().map(PathBuf::from)),
427 );
428 }
429 } else {
430 if let Ok(tree) = commit.tree() {
433 tree.walk(TreeWalkMode::PreOrder, |dir, entry| {
434 if entry.kind().expect("failed to get entry kind") != git2::ObjectType::Blob {
435 return 0;
436 }
437 let name = entry.name().expect("failed to get entry name");
438 let entry_path = if dir == "," {
439 name.to_string()
440 } else {
441 format!("{dir}/{name}")
442 };
443 changed_files.push(entry_path.into());
444 0
445 })
446 .expect("failed to get the changed files of the first commit");
447 }
448 }
449 changed_files
450 }
451
452 #[must_use]
456 pub fn current_tag(&self) -> Option<Tag> {
457 self.inner
458 .describe(DescribeOptions::new().describe_tags())
459 .ok()
460 .and_then(|describe| {
461 describe
462 .format(None)
463 .ok()
464 .map(|name| self.resolve_tag(&name))
465 })
466 }
467
468 #[must_use]
472 pub fn resolve_tag(&self, name: &str) -> Tag {
473 match self
474 .inner
475 .resolve_reference_from_short_name(name)
476 .and_then(|r| r.peel_to_tag())
477 {
478 Ok(tag) => Tag {
479 name: tag.name().unwrap_or_default().to_owned(),
480 message: tag
481 .message()
482 .map(|msg| TAG_SIGNATURE_REGEX.replace(msg, "").trim().to_owned()),
483 },
484 _ => Tag {
485 name: name.to_owned(),
486 message: None,
487 },
488 }
489 }
490
491 #[must_use]
493 pub fn find_commit(&self, id: &str) -> Option<Commit<'_>> {
494 if let Ok(oid) = Oid::from_str(id) {
495 if let Ok(commit) = self.inner.find_commit(oid) {
496 return Some(commit);
497 }
498 }
499 None
500 }
501
502 fn should_include_tag(&self, head_commit: &Commit, tag_commit: &Commit) -> Result<bool> {
509 Ok(self
510 .inner
511 .graph_descendant_of(head_commit.id(), tag_commit.id())? ||
512 head_commit.id() == tag_commit.id())
513 }
514
515 pub fn tags(
519 &self,
520 pattern: &Option<Regex>,
521 topo_order: bool,
522 use_branch_tags: bool,
523 ) -> Result<IndexMap<String, Tag>> {
524 let mut tags: Vec<(Commit, Tag)> = Vec::new();
525 let tag_names = self.inner.tag_names(None)?;
526 let head_commit = self.inner.head()?.peel_to_commit()?;
527 for name in tag_names
528 .iter()
529 .flatten()
530 .filter(|tag_name| pattern.as_ref().is_none_or(|pat| pat.is_match(tag_name)))
531 .map(String::from)
532 {
533 let obj = self.inner.revparse_single(&name)?;
534 if let Ok(commit) = obj.clone().into_commit() {
535 if use_branch_tags && !self.should_include_tag(&head_commit, &commit)? {
536 continue;
537 }
538
539 tags.push((commit, Tag {
540 name,
541 message: None,
542 }));
543 } else if let Some(tag) = obj.as_tag() {
544 if let Some(commit) = tag
545 .target()
546 .ok()
547 .and_then(|target| target.into_commit().ok())
548 {
549 if use_branch_tags && !self.should_include_tag(&head_commit, &commit)? {
550 continue;
551 }
552 tags.push((commit, Tag {
553 name: tag.name().map(String::from).unwrap_or(name),
554 message: tag
555 .message()
556 .map(|msg| TAG_SIGNATURE_REGEX.replace(msg, "").trim().to_owned()),
557 }));
558 }
559 }
560 }
561 if !topo_order {
562 tags.sort_by(|a, b| a.0.time().seconds().cmp(&b.0.time().seconds()));
563 }
564 Ok(tags
565 .into_iter()
566 .map(|(a, b)| (a.id().to_string(), b))
567 .collect())
568 }
569
570 pub fn upstream_remote(&self) -> Result<Remote> {
579 for branch in self.inner.branches(Some(BranchType::Local))? {
580 let branch = branch?.0;
581 if branch.is_head() {
582 let upstream = &self.inner.branch_upstream_remote(&format!(
583 "refs/heads/{}",
584 &branch.name()?.ok_or_else(|| Error::RepoError(String::from(
585 "branch name is not valid"
586 )))?
587 ))?;
588 let upstream_name = upstream.as_str().ok_or_else(|| {
589 Error::RepoError(String::from("name of the upstream remote is not valid"))
590 })?;
591 let origin = &self.inner.find_remote(upstream_name)?;
592 let url = origin
593 .url()
594 .ok_or_else(|| Error::RepoError(String::from("failed to get the remote URL")))?
595 .to_string();
596 tracing::trace!("Upstream URL: {url}");
597 return find_remote(&url);
598 }
599 }
600 Err(Error::RepoError(String::from(
601 "no remotes configured or HEAD is detached",
602 )))
603 }
604}
605
606fn find_remote(url: &str) -> Result<Remote> {
607 url_path_segments(url).or_else(|err| {
608 if url.contains('@') && url.contains(':') && url.contains('/') {
609 ssh_path_segments(url)
610 } else {
611 Err(err)
612 }
613 })
614}
615
616fn url_path_segments(url: &str) -> Result<Remote> {
624 let parsed_url = Url::parse(url.strip_suffix(".git").unwrap_or(url))?;
625 let segments: Vec<&str> = parsed_url
626 .path_segments()
627 .ok_or_else(|| Error::RepoError(String::from("failed to get URL segments")))?
628 .rev()
629 .collect();
630 let [repo, owner, ..] = &segments[..] else {
631 return Err(Error::RepoError(String::from(
632 "failed to get the owner and repo",
633 )));
634 };
635 Ok(Remote {
636 owner: (*owner).to_string(),
637 repo: (*repo).to_string(),
638 token: None,
639 is_custom: false,
640 api_url: None,
641 native_tls: None,
642 })
643}
644
645fn ssh_path_segments(url: &str) -> Result<Remote> {
651 let [_, owner_repo, ..] = url
652 .strip_suffix(".git")
653 .unwrap_or(url)
654 .split(':')
655 .collect::<Vec<_>>()[..]
656 else {
657 return Err(Error::RepoError(String::from(
658 "failed to get the owner and repo from ssh remote (:)",
659 )));
660 };
661 let [owner, repo] = owner_repo.split('/').collect::<Vec<_>>()[..] else {
662 return Err(Error::RepoError(String::from(
663 "failed to get the owner and repo from ssh remote (/)",
664 )));
665 };
666 Ok(Remote {
667 owner: owner.to_string(),
668 repo: repo.to_string(),
669 token: None,
670 is_custom: false,
671 api_url: None,
672 native_tls: None,
673 })
674}
675
676#[cfg(test)]
677mod test {
678 use std::process::Command;
679 use std::{env, fs, io, str};
680
681 use temp_dir::TempDir;
682
683 use super::*;
684 use crate::commit::Commit as AppCommit;
685
686 fn get_last_commit_hash() -> Result<String> {
687 Ok(str::from_utf8(
688 Command::new("git")
689 .args(["log", "--pretty=format:'%H'", "-n", "1"])
690 .output()?
691 .stdout
692 .as_ref(),
693 )?
694 .trim_matches('\'')
695 .to_string())
696 }
697
698 fn get_root_commit_hash() -> Result<String> {
699 Ok(str::from_utf8(
700 Command::new("git")
701 .args(["rev-list", "--max-parents=0", "HEAD"])
702 .output()?
703 .stdout
704 .as_ref(),
705 )?
706 .trim_ascii_end()
707 .to_string())
708 }
709
710 fn get_last_tag() -> Result<String> {
711 Ok(str::from_utf8(
712 Command::new("git")
713 .args(["describe", "--abbrev=0"])
714 .output()?
715 .stdout
716 .as_ref(),
717 )?
718 .trim()
719 .to_string())
720 }
721
722 fn get_repository() -> Result<Repository> {
723 Repository::discover(
724 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
725 .parent()
726 .expect("parent directory not found")
727 .to_path_buf(),
728 )
729 }
730
731 #[test]
732 fn http_url_repo_owner() -> Result<()> {
733 let url = "https://hostname.com/bob/magic.git";
734 let remote = find_remote(url)?;
735 assert_eq!(remote.owner, "bob", "match owner");
736 assert_eq!(remote.repo, "magic", "match repo");
737 Ok(())
738 }
739
740 #[test]
741 fn ssh_url_repo_owner() -> Result<()> {
742 let url = "git@hostname.com:bob/magic.git";
743 let remote = find_remote(url)?;
744 assert_eq!(remote.owner, "bob", "match owner");
745 assert_eq!(remote.repo, "magic", "match repo");
746 Ok(())
747 }
748
749 #[test]
750 fn get_latest_commit() -> Result<()> {
751 let repository = get_repository()?;
752 let commits = repository.commits(None, None, None, false)?;
753 let last_commit = AppCommit::from(&commits.first().expect("no commits found").clone());
754 assert_eq!(get_last_commit_hash()?, last_commit.id);
755 Ok(())
756 }
757
758 #[test]
759 fn commit_search() -> Result<()> {
760 let repository = get_repository()?;
761 assert!(
762 repository
763 .find_commit("e936ed571533ea6c41a1dd2b1a29d085c8dbada5")
764 .is_some()
765 );
766 Ok(())
767 }
768
769 #[test]
770 fn get_latest_tag() -> Result<()> {
771 let repository = get_repository()?;
772 let tags = repository.tags(&None, false, false)?;
773 let latest = tags.last().expect("no tags found").1.name.clone();
774 assert_eq!(get_last_tag()?, latest);
775
776 let current = repository.current_tag().expect("a current tag").name;
777 assert!(current.contains(&latest));
778 Ok(())
779 }
780
781 #[test]
782 fn git_tags() -> Result<()> {
783 let repository = get_repository()?;
784 let tags = repository.tags(&None, true, false)?;
785 assert_eq!(
786 tags.get("2b8b4d3535f29231e05c3572e919634b9af907b6")
787 .expect("the commit hash does not exist in the repository (tag v0.1.0)")
788 .name,
789 "v0.1.0"
790 );
791 assert_eq!(
792 tags.get("4ddef08debfff48117586296e49d5caa0800d1b5")
793 .expect("the commit hash does not exist in the repository (tag v0.1.0-beta.4)")
794 .name,
795 "v0.1.0-beta.4"
796 );
797 let tags = repository.tags(
798 &Some(Regex::new("^v[0-9]+\\.[0-9]+\\.[0-9]$").expect("the regex is not valid")),
799 true,
800 false,
801 )?;
802 assert_eq!(
803 tags.get("2b8b4d3535f29231e05c3572e919634b9af907b6")
804 .expect("the commit hash does not exist in the repository (tag v0.1.0)")
805 .name,
806 "v0.1.0"
807 );
808 assert!(!tags.contains_key("4ddef08debfff48117586296e49d5caa0800d1b5"));
809 Ok(())
810 }
811
812 #[test]
813 fn git_upstream_remote() -> Result<()> {
814 let repository = get_repository()?;
815 let remote = repository.upstream_remote()?;
816 assert_eq!(
817 Remote {
818 owner: remote.owner.clone(),
819 repo: String::from("git-cliff"),
820 token: None,
821 is_custom: false,
822 api_url: remote.api_url.clone(),
823 native_tls: None,
824 },
825 remote
826 );
827 Ok(())
828 }
829
830 #[test]
831 fn resolves_existing_tag_with_name_and_message() -> Result<()> {
832 let repository = get_repository()?;
833 let tag = repository.resolve_tag("v0.2.3");
834 assert_eq!(tag.name, "v0.2.3");
835 assert_eq!(
836 tag.message,
837 Some(
838 "Release v0.2.3\n\nBug Fixes\n- Fetch the dependencies before copying the file to \
839 embed (9e29c95)"
840 .to_string()
841 )
842 );
843
844 Ok(())
845 }
846
847 #[test]
848 fn resolves_tag_when_no_tags_exist() -> Result<()> {
849 let repository = get_repository()?;
850 let tag = repository.resolve_tag("nonexistent-tag");
851 assert_eq!(tag.name, "nonexistent-tag");
852 assert_eq!(tag.message, None);
853 Ok(())
854 }
855
856 #[test]
857 fn includes_root_commit() -> Result<()> {
858 let repository = get_repository()?;
859 let range = Some("eea3914c7ab07472841aa85c36d11bdb2589a234");
861 let commits = repository.commits(range, None, None, false)?;
862 let root_commit = AppCommit::from(&commits.last().expect("no commits found").clone());
863 assert_eq!(get_root_commit_hash()?, root_commit.id);
864 Ok(())
865 }
866
867 fn create_temp_repo() -> (Repository, TempDir) {
868 let temp_dir = TempDir::with_prefix("git-cliff-").expect("failed to create temp dir");
869
870 let output = Command::new("git")
871 .args(["init"])
872 .current_dir(temp_dir.path())
873 .output()
874 .expect("failed to execute git init");
875 assert!(output.status.success(), "git init failed {output:?}");
876
877 let repo =
878 Repository::discover(temp_dir.path().to_path_buf()).expect("failed to init repo");
879 let output = Command::new("git")
880 .args(["config", "user.email", "test@gmail.com"])
881 .current_dir(temp_dir.path())
882 .output()
883 .expect("failed to execute git config user.email");
884 assert!(
885 output.status.success(),
886 "git config user.email failed {output:?}",
887 );
888
889 let output = Command::new("git")
890 .args(["config", "user.name", "test"])
891 .current_dir(temp_dir.path())
892 .output()
893 .expect("failed to execute git config user.name");
894 assert!(
895 output.status.success(),
896 "git config user.name failed {output:?}",
897 );
898
899 (repo, temp_dir)
900 }
901
902 #[test]
903 fn repository_path_not_found() {
904 let path = PathBuf::from("/this/path/should/not/exist/123456789");
905 let result = Repository::discover(path.clone());
906 assert!(result.is_err());
907 match result {
908 Err(Error::IoError(err)) => {
909 assert_eq!(err.kind(), io::ErrorKind::NotFound);
910 assert!(err.to_string().contains("repository path not found"));
911 }
912 _ => panic!("expected IoError(NotFound)"),
913 }
914 }
915
916 #[test]
917 fn discover_jujutsu_repo() {
918 let (repo, _temp_dir) = create_temp_repo();
919 let working_copy = repo.path;
921
922 std::process::Command::new("git")
924 .args(["config", "core.bare", "true"])
925 .current_dir(&working_copy)
926 .status()
927 .expect("failed to make git repo non-bare");
928 let store = working_copy.join(".jj").join("repo").join("store");
930 fs::create_dir_all(&store).expect("failed to create dir");
931 fs::rename(working_copy.join(".git"), store.join("git")).expect("failed to move git repo");
932
933 let repo = Repository::discover(working_copy).expect("failed to init repo");
935
936 if repo.inner.path().starts_with("/private") {
939 assert_eq!(
940 repo.inner.path().strip_prefix("/private"),
941 store.join("git").strip_prefix("/"),
942 "open git repo in .jj/repo/store/"
943 );
944 } else {
945 assert_eq!(
946 repo.inner.path(),
947 store.join("git"),
948 "open git repo in .jj/repo/store/"
949 );
950 }
951 }
952
953 #[test]
954 fn propagate_error_if_no_repo_found() {
955 let temp_dir = TempDir::with_prefix("git-cliff-").expect("failed to create temp dir");
956
957 let path = temp_dir.path().to_path_buf();
958
959 let result = Repository::discover(path.clone());
960
961 assert!(result.is_err());
962 if let Err(error) = result {
963 assert!(
964 format!("{error:?}").contains(
965 format!("could not find repository at '{}'", path.display()).as_str()
966 )
967 );
968 }
969 }
970
971 #[test]
972 fn repository_path_does_not_exist() {
973 let path = PathBuf::from("/this/path/should/not/exist/123456789");
974 let result = Repository::init(path.clone());
975 assert!(result.is_err());
976 match result {
977 Err(Error::IoError(err)) => {
978 assert_eq!(err.kind(), io::ErrorKind::NotFound);
979 assert!(err.to_string().contains("repository path not found"));
980 }
981 _ => panic!("expected IoError(NotFound)"),
982 }
983 }
984
985 #[test]
986 fn open_jujutsu_repo() {
987 let (repo, _temp_dir) = create_temp_repo();
988 let working_copy = repo.path;
990
991 Command::new("git")
993 .args(["config", "core.bare", "true"])
994 .current_dir(&working_copy)
995 .status()
996 .expect("failed to make git repo non-bare");
997
998 let store = working_copy.join(".jj").join("repo").join("store");
1000 fs::create_dir_all(&store).expect("failed to create dir");
1001 fs::rename(working_copy.join(".git"), store.join("git")).expect("failed to move git repo");
1002
1003 let repo = Repository::init(working_copy).expect("failed to init repo");
1005
1006 if repo.inner.path().starts_with("/private") {
1009 assert_eq!(
1010 repo.inner.path().strip_prefix("/private"),
1011 store.join("git").strip_prefix("/"),
1012 "open git repo in .jj/repo/store/"
1013 );
1014 } else {
1015 assert_eq!(
1016 repo.inner.path(),
1017 store.join("git"),
1018 "open git repo in .jj/repo/store/"
1019 );
1020 }
1021 }
1022
1023 #[test]
1024 fn propagate_error_if_no_repo_exist() {
1025 let temp_dir = TempDir::with_prefix("git-cliff-").expect("failed to create temp dir");
1026
1027 let path = temp_dir.path().to_path_buf();
1028
1029 let result = Repository::init(path.clone());
1030
1031 assert!(result.is_err());
1032 if let Err(error) = result {
1033 assert!(
1034 format!("{error:?}").contains(
1035 format!("could not find repository at '{}'", path.display()).as_str()
1036 )
1037 );
1038 }
1039 }
1040
1041 fn create_commit_with_files<'a>(
1042 repo: &'a Repository,
1043 files: Vec<(&'a str, &'a str)>,
1044 ) -> Commit<'a> {
1045 for (path, content) in files {
1046 if let Some(parent) = repo.path.join(path).parent() {
1047 std::fs::create_dir_all(parent).expect("failed to create dir");
1048 }
1049 std::fs::write(repo.path.join(path), content).expect("failed to write file");
1050 }
1051
1052 let output = Command::new("git")
1053 .args(["add", "."])
1054 .current_dir(&repo.path)
1055 .output()
1056 .expect("failed to execute git add");
1057 assert!(output.status.success(), "git add failed {output:?}");
1058
1059 let output = Command::new("git")
1060 .args(["commit", "--no-gpg-sign", "-m", "test commit"])
1061 .current_dir(&repo.path)
1062 .output()
1063 .expect("failed to execute git commit");
1064 assert!(output.status.success(), "git commit failed {output:?}");
1065
1066 repo.inner
1067 .head()
1068 .and_then(|head| head.peel_to_commit())
1069 .expect("failed to get the last commit")
1070 }
1071
1072 #[test]
1073 fn test_should_retain_commit() {
1074 let (repo, _temp_dir) = create_temp_repo();
1075
1076 let new_pattern = |input: &str| {
1077 Repository::normalize_pattern(Pattern::new(input).expect("valid pattern"))
1078 };
1079
1080 let first_commit = create_commit_with_files(&repo, vec![
1081 ("initial.txt", "initial content"),
1082 ("dir/initial.txt", "initial content"),
1083 ]);
1084
1085 {
1086 let retain = repo.should_retain_commit(
1087 &first_commit,
1088 Some(vec![new_pattern("dir/")]).as_ref(),
1089 None,
1090 );
1091 assert!(retain, "include: dir/");
1092 }
1093
1094 let commit = create_commit_with_files(&repo, vec![
1095 ("file1.txt", "content1"),
1096 ("file2.txt", "content2"),
1097 ("dir/file3.txt", "content3"),
1098 ("dir/subdir/file4.txt", "content4"),
1099 ]);
1100
1101 {
1102 let retain = repo.should_retain_commit(&commit, None, None);
1103 assert!(retain, "no include/exclude patterns");
1104 }
1105
1106 {
1107 let retain =
1108 repo.should_retain_commit(&commit, Some(vec![new_pattern("./")]).as_ref(), None);
1109 assert!(retain, "include: ./");
1110 }
1111
1112 {
1113 let retain =
1114 repo.should_retain_commit(&commit, Some(vec![new_pattern("**")]).as_ref(), None);
1115 assert!(retain, "include: **");
1116 }
1117
1118 {
1119 let retain =
1120 repo.should_retain_commit(&commit, Some(vec![new_pattern("*")]).as_ref(), None);
1121 assert!(retain, "include: *");
1122 }
1123
1124 {
1125 let retain =
1126 repo.should_retain_commit(&commit, Some(vec![new_pattern("dir/")]).as_ref(), None);
1127 assert!(retain, "include: dir/");
1128 }
1129
1130 {
1131 let retain =
1132 repo.should_retain_commit(&commit, Some(vec![new_pattern("dir/*")]).as_ref(), None);
1133 assert!(retain, "include: dir/*");
1134 }
1135
1136 {
1137 let retain = repo.should_retain_commit(
1138 &commit,
1139 Some(vec![new_pattern("file1.txt")]).as_ref(),
1140 None,
1141 );
1142 assert!(retain, "include: file1.txt");
1143 }
1144
1145 {
1146 let retain = repo.should_retain_commit(
1147 &commit,
1148 None,
1149 Some(vec![new_pattern("file1.txt")]).as_ref(),
1150 );
1151 assert!(retain, "exclude: file1.txt");
1152 }
1153
1154 {
1155 let retain = repo.should_retain_commit(
1156 &commit,
1157 Some(vec![new_pattern("file1.txt")]).as_ref(),
1158 Some(vec![new_pattern("file2.txt")]).as_ref(),
1159 );
1160 assert!(retain, "include: file1.txt, exclude: file2.txt");
1161 }
1162
1163 {
1164 let retain = repo.should_retain_commit(
1165 &commit,
1166 None,
1167 Some(vec![new_pattern("**/*.txt")]).as_ref(),
1168 );
1169 assert!(!retain, "exclude: **/*.txt");
1170 }
1171 }
1172}