git_cliff_core/
repo.rs

1use std::io;
2use std::path::{Path, PathBuf};
3use std::result::Result as StdResult;
4
5use git2::{
6    BranchType, Commit, DescribeOptions, Oid, Repository as GitRepository, Sort, TreeWalkMode,
7    Worktree,
8};
9use glob::Pattern;
10use indexmap::IndexMap;
11use lazy_regex::{Lazy, Regex, lazy_regex};
12use url::Url;
13
14use crate::config::Remote;
15use crate::error::{Error, Result};
16use crate::tag::Tag;
17
18/// Regex for replacing the signature part of a tag message.
19static TAG_SIGNATURE_REGEX: Lazy<Regex> = lazy_regex!(
20    // https://git-scm.com/docs/gitformat-signature#_description
21    r"(?s)-----BEGIN (PGP|SSH|SIGNED) (SIGNATURE|MESSAGE)-----(.*?)-----END (PGP|SSH|SIGNED) (SIGNATURE|MESSAGE)-----"
22);
23
24/// Name of the cache file for changed files.
25const CHANGED_FILES_CACHE: &str = "changed_files_cache";
26
27/// Wrapper for [`Repository`] type from git2.
28///
29/// [`Repository`]: GitRepository
30pub struct Repository {
31    inner: GitRepository,
32    /// Repository path.
33    path: PathBuf,
34    /// Cache path for the changed files of the commits.
35    changed_files_cache_path: PathBuf,
36}
37
38/// Range of commits in a submodule.
39pub struct SubmoduleRange {
40    /// Repository object to which this range belongs.
41    pub repository: Repository,
42    /// Commit range in "<first_submodule_commit>..<last_submodule_commit>" or
43    /// "<last_submodule_commit>" format.
44    pub range: String,
45}
46
47impl Repository {
48    /// Initializes (opens) the repository.
49    pub fn init(path: PathBuf) -> Result<Self> {
50        if path.exists() {
51            let inner = GitRepository::discover(&path).or_else(|err| {
52                let jujutsu_path = path.join(".jj").join("repo").join("store").join("git");
53                if jujutsu_path.exists() {
54                    GitRepository::open_bare(&jujutsu_path)
55                } else {
56                    Err(err)
57                }
58            })?;
59            let changed_files_cache_path = inner
60                .path()
61                .join(env!("CARGO_PKG_NAME"))
62                .join(CHANGED_FILES_CACHE);
63            Ok(Self {
64                inner,
65                path,
66                changed_files_cache_path,
67            })
68        } else {
69            Err(Error::IoError(io::Error::new(
70                io::ErrorKind::NotFound,
71                "repository path not found",
72            )))
73        }
74    }
75
76    /// Returns the path of the repository.
77    pub fn root_path(&self) -> Result<PathBuf> {
78        let mut path = if self.inner.is_worktree() {
79            let worktree = Worktree::open_from_repository(&self.inner)?;
80            worktree.path().to_path_buf()
81        } else {
82            self.inner.path().to_path_buf()
83        };
84        if path.ends_with(".git") {
85            path.pop();
86        }
87        Ok(path)
88    }
89
90    /// Returns the initial path of the repository.
91    ///
92    /// In case of a submodule this is the relative path to the toplevel
93    /// repository.
94    pub fn path(&self) -> &PathBuf {
95        &self.path
96    }
97
98    /// Sets the range for the commit search.
99    ///
100    /// When a single SHA is provided as the range, start from the
101    /// root.
102    fn set_commit_range(
103        revwalk: &mut git2::Revwalk<'_>,
104        range: Option<&str>,
105    ) -> StdResult<(), git2::Error> {
106        if let Some(range) = range {
107            if range.contains("..") {
108                revwalk.push_range(range)?;
109            } else {
110                revwalk.push(Oid::from_str(range)?)?;
111            }
112        } else {
113            revwalk.push_head()?;
114        }
115        Ok(())
116    }
117
118    /// Parses and returns the commits.
119    ///
120    /// Sorts the commits by their time.
121    pub fn commits(
122        &self,
123        range: Option<&str>,
124        include_path: Option<Vec<Pattern>>,
125        exclude_path: Option<Vec<Pattern>>,
126        topo_order_commits: bool,
127    ) -> Result<Vec<Commit<'_>>> {
128        let mut revwalk = self.inner.revwalk()?;
129        if topo_order_commits {
130            revwalk.set_sorting(Sort::TOPOLOGICAL)?;
131        } else {
132            revwalk.set_sorting(Sort::TIME)?;
133        }
134
135        Self::set_commit_range(&mut revwalk, range).map_err(|e| {
136            Error::SetCommitRangeError(
137                range.map(String::from).unwrap_or_else(|| "?".to_string()),
138                e,
139            )
140        })?;
141        let mut commits: Vec<Commit> = revwalk
142            .filter_map(|id| id.ok())
143            .filter_map(|id| self.inner.find_commit(id).ok())
144            .collect();
145        if include_path.is_some() || exclude_path.is_some() {
146            let include_patterns = include_path
147                .map(|patterns| patterns.into_iter().map(Self::normalize_pattern).collect());
148            let exclude_patterns = exclude_path
149                .map(|patterns| patterns.into_iter().map(Self::normalize_pattern).collect());
150            commits.retain(|commit| {
151                self.should_retain_commit(commit, &include_patterns, &exclude_patterns)
152            });
153        }
154        Ok(commits)
155    }
156
157    /// Returns submodule repositories for a given commit range.
158    ///
159    /// For one or two given commits in this repository, a list of changed
160    /// submodules is calculated. If only one commit is given, then all
161    /// submodule commits up to the referenced commit will be included. This is
162    /// usually the case if a submodule is added to the repository.
163    ///
164    ///  For each submodule a [`SubmoduleRange`] object is created
165    ///
166    /// This can then be used to query the submodule's commits by using
167    /// [`Repository::commits`].
168    pub fn submodules_range(
169        &self,
170        old_commit: Option<Commit<'_>>,
171        new_commit: Commit<'_>,
172    ) -> Result<Vec<SubmoduleRange>> {
173        let old_tree = old_commit.and_then(|commit| commit.tree().ok());
174        let new_tree = new_commit.tree().ok();
175        let diff = self
176            .inner
177            .diff_tree_to_tree(old_tree.as_ref(), new_tree.as_ref(), None)?;
178        // iterate through all diffs and accumulate old/new commit ids
179        let before_and_after_deltas = diff.deltas().filter_map(|delta| {
180            let old_file_id = delta.old_file().id();
181            let new_file_id = delta.new_file().id();
182            let range = if old_file_id == new_file_id || new_file_id.is_zero() {
183                // no changes or submodule removed
184                None
185            } else if old_file_id.is_zero() {
186                // submodule added
187                Some(new_file_id.to_string())
188            } else {
189                // submodule updated
190                Some(format!("{}..{}", old_file_id, new_file_id))
191            };
192            trace!("Release commit range for submodules: {:?}", range);
193            delta.new_file().path().and_then(Path::to_str).zip(range)
194        });
195        // iterate through all path diffs and find corresponding submodule if
196        // possible
197        let submodule_range = before_and_after_deltas.filter_map(|(path, range)| {
198            let repository = self
199                .inner
200                .find_submodule(path)
201                .ok()
202                .and_then(|submodule| Self::init(submodule.path().into()).ok());
203            repository.map(|repository| SubmoduleRange { repository, range })
204        });
205        Ok(submodule_range.collect())
206    }
207
208    /// Normalizes the glob pattern to match the git diff paths.
209    ///
210    /// It removes the leading `./` and adds `**` to the end if the pattern is a
211    /// directory.
212    fn normalize_pattern(pattern: Pattern) -> Pattern {
213        let star_added = match pattern.as_str().chars().last() {
214            Some('/' | '\\') => Pattern::new(&format!("{pattern}**"))
215                .expect("failed to add '**' to the end of glob"),
216            _ => pattern,
217        };
218        match star_added.as_str().strip_prefix("./") {
219            Some(stripped) => {
220                Pattern::new(stripped).expect("failed to remove leading ./ from glob")
221            }
222            None => star_added,
223        }
224    }
225
226    /// Calculates whether the commit should be retained or not.
227    ///
228    /// This function is used to filter the commits based on the changed files,
229    /// and include/exclude patterns.
230    fn should_retain_commit(
231        &self,
232        commit: &Commit,
233        include_patterns: &Option<Vec<Pattern>>,
234        exclude_patterns: &Option<Vec<Pattern>>,
235    ) -> bool {
236        let changed_files = self.commit_changed_files(commit);
237        match (include_patterns, exclude_patterns) {
238            (Some(include_pattern), Some(exclude_pattern)) => {
239                // check if the commit has any changed files that match any of the
240                // include patterns and none of the exclude patterns.
241                changed_files.iter().any(|path| {
242                    include_pattern
243                        .iter()
244                        .any(|pattern| pattern.matches_path(path)) &&
245                        !exclude_pattern
246                            .iter()
247                            .any(|pattern| pattern.matches_path(path))
248                })
249            }
250            (Some(include_pattern), None) => {
251                // check if the commit has any changed files that match the include
252                // patterns.
253                changed_files.iter().any(|path| {
254                    include_pattern
255                        .iter()
256                        .any(|pattern| pattern.matches_path(path))
257                })
258            }
259            (None, Some(exclude_pattern)) => {
260                // check if the commit has at least one changed file that does not
261                // match all exclude patterns.
262                changed_files.iter().any(|path| {
263                    !exclude_pattern
264                        .iter()
265                        .any(|pattern| pattern.matches_path(path))
266                })
267            }
268            (None, None) => true,
269        }
270    }
271
272    /// Returns the changed files of the commit.
273    ///
274    /// It uses a cache to speed up checks to store the changed files of the
275    /// commits under `./.git/git-cliff-core/changed_files_cache`. The speed-up
276    /// was measured to be around 260x for large repositories.
277    ///
278    /// If the cache is not found, it calculates the changed files and adds them
279    /// to the cache via [`Self::commit_changed_files_no_cache`].
280    fn commit_changed_files(&self, commit: &Commit) -> Vec<PathBuf> {
281        // Cache key is generated from the repository path and commit id
282        let cache_key = format!("commit_id:{}", commit.id());
283
284        // Check the cache first.
285        {
286            if let Ok(result) = cacache::read_sync(&self.changed_files_cache_path, &cache_key) {
287                if let Ok((files, _)) =
288                    bincode::decode_from_slice(&result, bincode::config::standard())
289                {
290                    return files;
291                }
292            }
293        }
294
295        // If the cache is not found, calculate the result and set it to the cache.
296        let result = self.commit_changed_files_no_cache(commit);
297        match bincode::encode_to_vec(
298            self.commit_changed_files_no_cache(commit),
299            bincode::config::standard(),
300        ) {
301            Ok(v) => {
302                if let Err(e) = cacache::write_sync_with_algo(
303                    cacache::Algorithm::Xxh3,
304                    &self.changed_files_cache_path,
305                    cache_key,
306                    v,
307                ) {
308                    error!("Failed to set cache for repo {:?}: {e}", self.path);
309                }
310            }
311            Err(e) => {
312                error!("Failed to serialize cache for repo {:?}: {e}", self.path);
313            }
314        }
315
316        result
317    }
318
319    /// Calculate the changed files of the commit.
320    ///
321    /// This function does not use the cache (directly calls git2).
322    fn commit_changed_files_no_cache(&self, commit: &Commit) -> Vec<PathBuf> {
323        let mut changed_files = Vec::new();
324        if let Ok(prev_commit) = commit.parent(0) {
325            // Compare the current commit with the previous commit to get the
326            // changed files.
327            // libgit2 does not provide a way to get the changed files directly, so
328            // the full diff is calculated here.
329            if let Ok(diff) = self.inner.diff_tree_to_tree(
330                commit.tree().ok().as_ref(),
331                prev_commit.tree().ok().as_ref(),
332                None,
333            ) {
334                changed_files.extend(
335                    diff.deltas()
336                        .filter_map(|delta| delta.new_file().path().map(PathBuf::from)),
337                );
338            }
339        } else {
340            // If there is no parent, it is the first commit.
341            // So get all the files in the tree.
342            if let Ok(tree) = commit.tree() {
343                tree.walk(TreeWalkMode::PreOrder, |dir, entry| {
344                    if entry.kind().expect("failed to get entry kind") != git2::ObjectType::Blob {
345                        return 0;
346                    }
347                    let name = entry.name().expect("failed to get entry name");
348                    let entry_path = if dir == "," {
349                        name.to_string()
350                    } else {
351                        format!("{dir}/{name}")
352                    };
353                    changed_files.push(entry_path.into());
354                    0
355                })
356                .expect("failed to get the changed files of the first commit");
357            }
358        }
359        changed_files
360    }
361
362    /// Returns the current tag.
363    ///
364    /// It is the same as running `git describe --tags`
365    pub fn current_tag(&self) -> Option<Tag> {
366        self.inner
367            .describe(DescribeOptions::new().describe_tags())
368            .ok()
369            .and_then(|describe| {
370                describe
371                    .format(None)
372                    .ok()
373                    .map(|name| self.resolve_tag(&name))
374            })
375    }
376
377    /// Returns the tag object of the given name.
378    ///
379    /// If given name doesn't exist, it still returns `Tag` with the given name.
380    pub fn resolve_tag(&self, name: &str) -> Tag {
381        match self
382            .inner
383            .resolve_reference_from_short_name(name)
384            .and_then(|r| r.peel_to_tag())
385        {
386            Ok(tag) => Tag {
387                name: tag.name().unwrap_or_default().to_owned(),
388                message: tag
389                    .message()
390                    .map(|msg| TAG_SIGNATURE_REGEX.replace(msg, "").trim().to_owned()),
391            },
392            _ => Tag {
393                name: name.to_owned(),
394                message: None,
395            },
396        }
397    }
398
399    /// Returns the commit object of the given ID.
400    pub fn find_commit(&self, id: &str) -> Option<Commit<'_>> {
401        if let Ok(oid) = Oid::from_str(id) {
402            if let Ok(commit) = self.inner.find_commit(oid) {
403                return Some(commit);
404            }
405        }
406        None
407    }
408
409    /// Decide whether to include tag.
410    ///
411    /// `head_commit` is the `latest` commit to generate changelog. It can be a
412    /// branch head or a detached head. `tag_commit` is a tagged commit. If the
413    /// commit is in the descendant graph of the `head_commit` or is the
414    /// `head_commit` itself, Changelog should include the tag.
415    fn should_include_tag(&self, head_commit: &Commit, tag_commit: &Commit) -> Result<bool> {
416        Ok(self
417            .inner
418            .graph_descendant_of(head_commit.id(), tag_commit.id())? ||
419            head_commit.id() == tag_commit.id())
420    }
421
422    /// Parses and returns a commit-tag map.
423    ///
424    /// It collects lightweight and annotated tags.
425    pub fn tags(
426        &self,
427        pattern: &Option<Regex>,
428        topo_order: bool,
429        use_branch_tags: bool,
430    ) -> Result<IndexMap<String, Tag>> {
431        let mut tags: Vec<(Commit, Tag)> = Vec::new();
432        let tag_names = self.inner.tag_names(None)?;
433        let head_commit = self.inner.head()?.peel_to_commit()?;
434        for name in tag_names
435            .iter()
436            .flatten()
437            .filter(|tag_name| pattern.as_ref().is_none_or(|pat| pat.is_match(tag_name)))
438            .map(String::from)
439        {
440            let obj = self.inner.revparse_single(&name)?;
441            if let Ok(commit) = obj.clone().into_commit() {
442                if use_branch_tags && !self.should_include_tag(&head_commit, &commit)? {
443                    continue;
444                }
445
446                tags.push((commit, Tag {
447                    name,
448                    message: None,
449                }));
450            } else if let Some(tag) = obj.as_tag() {
451                if let Some(commit) = tag
452                    .target()
453                    .ok()
454                    .and_then(|target| target.into_commit().ok())
455                {
456                    if use_branch_tags && !self.should_include_tag(&head_commit, &commit)? {
457                        continue;
458                    }
459                    tags.push((commit, Tag {
460                        name: tag.name().map(String::from).unwrap_or(name),
461                        message: tag
462                            .message()
463                            .map(|msg| TAG_SIGNATURE_REGEX.replace(msg, "").trim().to_owned()),
464                    }));
465                }
466            }
467        }
468        if !topo_order {
469            tags.sort_by(|a, b| a.0.time().seconds().cmp(&b.0.time().seconds()));
470        }
471        Ok(tags
472            .into_iter()
473            .map(|(a, b)| (a.id().to_string(), b))
474            .collect())
475    }
476
477    /// Returns the remote of the upstream repository.
478    ///
479    /// The strategy used here is the following:
480    ///
481    /// Find the branch that HEAD points to, and read the remote configured for
482    /// that branch returns the remote and the name of the local branch.
483    ///
484    /// Note: HEAD must not be detached.
485    pub fn upstream_remote(&self) -> Result<Remote> {
486        for branch in self.inner.branches(Some(BranchType::Local))? {
487            let branch = branch?.0;
488            if branch.is_head() {
489                let upstream = &self.inner.branch_upstream_remote(&format!(
490                    "refs/heads/{}",
491                    &branch.name()?.ok_or_else(|| Error::RepoError(String::from(
492                        "branch name is not valid"
493                    )))?
494                ))?;
495                let upstream_name = upstream.as_str().ok_or_else(|| {
496                    Error::RepoError(String::from("name of the upstream remote is not valid"))
497                })?;
498                let origin = &self.inner.find_remote(upstream_name)?;
499                let url = origin
500                    .url()
501                    .ok_or_else(|| Error::RepoError(String::from("failed to get the remote URL")))?
502                    .to_string();
503                trace!("Upstream URL: {url}");
504                return find_remote(&url);
505            }
506        }
507        Err(Error::RepoError(String::from(
508            "no remotes configured or HEAD is detached",
509        )))
510    }
511}
512
513fn find_remote(url: &str) -> Result<Remote> {
514    url_path_segments(url).or_else(|err| {
515        if url.contains("@") && url.contains(":") && url.contains("/") {
516            ssh_path_segments(url)
517        } else {
518            Err(err)
519        }
520    })
521}
522
523/// Returns the Remote from parsing the HTTPS format URL.
524///
525/// This function expects the URL to be in the following format:
526///
527/// > https://hostname/query/path.git
528fn url_path_segments(url: &str) -> Result<Remote> {
529    let parsed_url = Url::parse(url.strip_suffix(".git").unwrap_or(url))?;
530    let segments: Vec<&str> = parsed_url
531        .path_segments()
532        .ok_or_else(|| Error::RepoError(String::from("failed to get URL segments")))?
533        .rev()
534        .collect();
535    let [repo, owner, ..] = &segments[..] else {
536        return Err(Error::RepoError(String::from(
537            "failed to get the owner and repo",
538        )));
539    };
540    Ok(Remote {
541        owner: owner.to_string(),
542        repo: repo.to_string(),
543        token: None,
544        is_custom: false,
545        api_url: None,
546        native_tls: None,
547    })
548}
549
550/// Returns the Remote from parsing the SSH format URL.
551///
552/// This function expects the URL to be in the following format:
553///
554/// > git@hostname:owner/repo.git
555fn ssh_path_segments(url: &str) -> Result<Remote> {
556    let [_, owner_repo, ..] = url
557        .strip_suffix(".git")
558        .unwrap_or(url)
559        .split(":")
560        .collect::<Vec<_>>()[..]
561    else {
562        return Err(Error::RepoError(String::from(
563            "failed to get the owner and repo from ssh remote (:)",
564        )));
565    };
566    let [owner, repo] = owner_repo.split("/").collect::<Vec<_>>()[..] else {
567        return Err(Error::RepoError(String::from(
568            "failed to get the owner and repo from ssh remote (/)",
569        )));
570    };
571    Ok(Remote {
572        owner: owner.to_string(),
573        repo: repo.to_string(),
574        token: None,
575        is_custom: false,
576        api_url: None,
577        native_tls: None,
578    })
579}
580
581#[cfg(test)]
582mod test {
583    use std::process::Command;
584    use std::{env, fs, str};
585
586    use temp_dir::TempDir;
587
588    use super::*;
589    use crate::commit::Commit as AppCommit;
590
591    fn get_last_commit_hash() -> Result<String> {
592        Ok(str::from_utf8(
593            Command::new("git")
594                .args(["log", "--pretty=format:'%H'", "-n", "1"])
595                .output()?
596                .stdout
597                .as_ref(),
598        )?
599        .trim_matches('\'')
600        .to_string())
601    }
602
603    fn get_root_commit_hash() -> Result<String> {
604        Ok(str::from_utf8(
605            Command::new("git")
606                .args(["rev-list", "--max-parents=0", "HEAD"])
607                .output()?
608                .stdout
609                .as_ref(),
610        )?
611        .trim_ascii_end()
612        .to_string())
613    }
614
615    fn get_last_tag() -> Result<String> {
616        Ok(str::from_utf8(
617            Command::new("git")
618                .args(["describe", "--abbrev=0"])
619                .output()?
620                .stdout
621                .as_ref(),
622        )?
623        .trim()
624        .to_string())
625    }
626
627    fn get_repository() -> Result<Repository> {
628        Repository::init(
629            PathBuf::from(env!("CARGO_MANIFEST_DIR"))
630                .parent()
631                .expect("parent directory not found")
632                .to_path_buf(),
633        )
634    }
635
636    #[test]
637    fn http_url_repo_owner() -> Result<()> {
638        let url = "https://hostname.com/bob/magic.git";
639        let remote = find_remote(url)?;
640        assert_eq!(remote.owner, "bob", "match owner");
641        assert_eq!(remote.repo, "magic", "match repo");
642        Ok(())
643    }
644
645    #[test]
646    fn ssh_url_repo_owner() -> Result<()> {
647        let url = "git@hostname.com:bob/magic.git";
648        let remote = find_remote(url)?;
649        assert_eq!(remote.owner, "bob", "match owner");
650        assert_eq!(remote.repo, "magic", "match repo");
651        Ok(())
652    }
653
654    #[test]
655    fn get_latest_commit() -> Result<()> {
656        let repository = get_repository()?;
657        let commits = repository.commits(None, None, None, false)?;
658        let last_commit = AppCommit::from(&commits.first().expect("no commits found").clone());
659        assert_eq!(get_last_commit_hash()?, last_commit.id);
660        Ok(())
661    }
662
663    #[test]
664    fn commit_search() -> Result<()> {
665        let repository = get_repository()?;
666        assert!(
667            repository
668                .find_commit("e936ed571533ea6c41a1dd2b1a29d085c8dbada5")
669                .is_some()
670        );
671        Ok(())
672    }
673
674    #[test]
675    fn get_latest_tag() -> Result<()> {
676        let repository = get_repository()?;
677        let tags = repository.tags(&None, false, false)?;
678        let latest = tags.last().expect("no tags found").1.name.clone();
679        assert_eq!(get_last_tag()?, latest);
680
681        let current = repository.current_tag().expect("a current tag").name;
682        assert!(current.contains(&latest));
683        Ok(())
684    }
685
686    #[test]
687    fn git_tags() -> Result<()> {
688        let repository = get_repository()?;
689        let tags = repository.tags(&None, true, false)?;
690        assert_eq!(
691            tags.get("2b8b4d3535f29231e05c3572e919634b9af907b6")
692                .expect("the commit hash does not exist in the repository (tag v0.1.0)")
693                .name,
694            "v0.1.0"
695        );
696        assert_eq!(
697            tags.get("4ddef08debfff48117586296e49d5caa0800d1b5")
698                .expect("the commit hash does not exist in the repository (tag v0.1.0-beta.4)")
699                .name,
700            "v0.1.0-beta.4"
701        );
702        let tags = repository.tags(
703            &Some(Regex::new("^v[0-9]+\\.[0-9]+\\.[0-9]$").expect("the regex is not valid")),
704            true,
705            false,
706        )?;
707        assert_eq!(
708            tags.get("2b8b4d3535f29231e05c3572e919634b9af907b6")
709                .expect("the commit hash does not exist in the repository (tag v0.1.0)")
710                .name,
711            "v0.1.0"
712        );
713        assert!(!tags.contains_key("4ddef08debfff48117586296e49d5caa0800d1b5"));
714        Ok(())
715    }
716
717    #[test]
718    fn git_upstream_remote() -> Result<()> {
719        let repository = get_repository()?;
720        let remote = repository.upstream_remote()?;
721        assert_eq!(
722            Remote {
723                owner: remote.owner.clone(),
724                repo: String::from("git-cliff"),
725                token: None,
726                is_custom: false,
727                api_url: remote.api_url.clone(),
728                native_tls: None,
729            },
730            remote
731        );
732        Ok(())
733    }
734
735    #[test]
736    fn resolves_existing_tag_with_name_and_message() -> Result<()> {
737        let repository = get_repository()?;
738        let tag = repository.resolve_tag("v0.2.3");
739        assert_eq!(tag.name, "v0.2.3");
740        assert_eq!(
741            tag.message,
742            Some(
743                "Release v0.2.3\n\nBug Fixes\n- Fetch the dependencies before copying the file to \
744                 embed (9e29c95)"
745                    .to_string()
746            )
747        );
748
749        Ok(())
750    }
751
752    #[test]
753    fn resolves_tag_when_no_tags_exist() -> Result<()> {
754        let repository = get_repository()?;
755        let tag = repository.resolve_tag("nonexistent-tag");
756        assert_eq!(tag.name, "nonexistent-tag");
757        assert_eq!(tag.message, None);
758        Ok(())
759    }
760
761    #[test]
762    fn includes_root_commit() -> Result<()> {
763        let repository = get_repository()?;
764        // a close descendant of the root commit
765        let range = Some("eea3914c7ab07472841aa85c36d11bdb2589a234");
766        let commits = repository.commits(range, None, None, false)?;
767        let root_commit = AppCommit::from(&commits.last().expect("no commits found").clone());
768        assert_eq!(get_root_commit_hash()?, root_commit.id);
769        Ok(())
770    }
771
772    fn create_temp_repo() -> (Repository, TempDir) {
773        let temp_dir = TempDir::with_prefix("git-cliff-").expect("failed to create temp dir");
774
775        let output = Command::new("git")
776            .args(["init"])
777            .current_dir(temp_dir.path())
778            .output()
779            .expect("failed to execute git init");
780        assert!(output.status.success(), "git init failed {:?}", output);
781
782        let repo = Repository::init(temp_dir.path().to_path_buf()).expect("failed to init repo");
783        let output = Command::new("git")
784            .args(["config", "user.email", "test@gmail.com"])
785            .current_dir(temp_dir.path())
786            .output()
787            .expect("failed to execute git config user.email");
788        assert!(
789            output.status.success(),
790            "git config user.email failed {:?}",
791            output
792        );
793
794        let output = Command::new("git")
795            .args(["config", "user.name", "test"])
796            .current_dir(temp_dir.path())
797            .output()
798            .expect("failed to execute git config user.name");
799        assert!(
800            output.status.success(),
801            "git config user.name failed {:?}",
802            output
803        );
804
805        (repo, temp_dir)
806    }
807
808    #[test]
809    fn open_jujutsu_repo() {
810        let (repo, _temp_dir) = create_temp_repo();
811        // working copy is the directory that contains the .git directory:
812        let working_copy = repo.path;
813
814        // Make the Git repository bare and set HEAD
815        std::process::Command::new("git")
816            .args(["config", "core.bare", "true"])
817            .current_dir(&working_copy)
818            .status()
819            .expect("failed to make git repo non-bare");
820        // Move the Git repo into jj
821        let store = working_copy.join(".jj").join("repo").join("store");
822        fs::create_dir_all(&store).expect("failed to create dir");
823        fs::rename(working_copy.join(".git"), store.join("git")).expect("failed to move git repo");
824
825        // Open repo from working copy, that contains the .jj directory
826        let repo = Repository::init(working_copy).expect("failed to init repo");
827
828        // macOS canonical path for temp directories is in /private
829        // libgit2 forces the path to be canonical regardless of what we pass in
830        if repo.inner.path().starts_with("/private") {
831            assert_eq!(
832                repo.inner.path().strip_prefix("/private"),
833                store.join("git").strip_prefix("/"),
834                "open git repo in .jj/repo/store/"
835            );
836        } else {
837            assert_eq!(
838                repo.inner.path(),
839                store.join("git"),
840                "open git repo in .jj/repo/store/"
841            );
842        }
843    }
844
845    #[test]
846    fn propagate_error_if_no_repo_found() {
847        let temp_dir = TempDir::with_prefix("git-cliff-").expect("failed to create temp dir");
848
849        let path = temp_dir.path().to_path_buf();
850
851        let result = Repository::init(path.clone());
852
853        assert!(result.is_err());
854        if let Err(error) = result {
855            assert!(
856                format!("{error:?}").contains(
857                    format!("could not find repository at '{}'", path.display()).as_str()
858                )
859            )
860        }
861    }
862
863    fn create_commit_with_files<'a>(
864        repo: &'a Repository,
865        files: Vec<(&'a str, &'a str)>,
866    ) -> Commit<'a> {
867        for (path, content) in files {
868            if let Some(parent) = repo.path.join(path).parent() {
869                std::fs::create_dir_all(parent).expect("failed to create dir");
870            }
871            std::fs::write(repo.path.join(path), content).expect("failed to write file");
872        }
873
874        let output = Command::new("git")
875            .args(["add", "."])
876            .current_dir(&repo.path)
877            .output()
878            .expect("failed to execute git add");
879        assert!(output.status.success(), "git add failed {:?}", output);
880
881        let output = Command::new("git")
882            .args(["commit", "--no-gpg-sign", "-m", "test commit"])
883            .current_dir(&repo.path)
884            .output()
885            .expect("failed to execute git commit");
886        assert!(output.status.success(), "git commit failed {:?}", output);
887
888        repo.inner
889            .head()
890            .and_then(|head| head.peel_to_commit())
891            .expect("failed to get the last commit")
892    }
893
894    #[test]
895    fn test_should_retain_commit() {
896        let (repo, _temp_dir) = create_temp_repo();
897
898        let new_pattern = |input: &str| {
899            Repository::normalize_pattern(Pattern::new(input).expect("valid pattern"))
900        };
901
902        let first_commit = create_commit_with_files(&repo, vec![
903            ("initial.txt", "initial content"),
904            ("dir/initial.txt", "initial content"),
905        ]);
906
907        {
908            let retain =
909                repo.should_retain_commit(&first_commit, &Some(vec![new_pattern("dir/")]), &None);
910            assert!(retain, "include: dir/");
911        }
912
913        let commit = create_commit_with_files(&repo, vec![
914            ("file1.txt", "content1"),
915            ("file2.txt", "content2"),
916            ("dir/file3.txt", "content3"),
917            ("dir/subdir/file4.txt", "content4"),
918        ]);
919
920        {
921            let retain = repo.should_retain_commit(&commit, &None, &None);
922            assert!(retain, "no include/exclude patterns");
923        }
924
925        {
926            let retain = repo.should_retain_commit(&commit, &Some(vec![new_pattern("./")]), &None);
927            assert!(retain, "include: ./");
928        }
929
930        {
931            let retain = repo.should_retain_commit(&commit, &Some(vec![new_pattern("**")]), &None);
932            assert!(retain, "include: **");
933        }
934
935        {
936            let retain = repo.should_retain_commit(&commit, &Some(vec![new_pattern("*")]), &None);
937            assert!(retain, "include: *");
938        }
939
940        {
941            let retain =
942                repo.should_retain_commit(&commit, &Some(vec![new_pattern("dir/")]), &None);
943            assert!(retain, "include: dir/");
944        }
945
946        {
947            let retain =
948                repo.should_retain_commit(&commit, &Some(vec![new_pattern("dir/*")]), &None);
949            assert!(retain, "include: dir/*");
950        }
951
952        {
953            let retain =
954                repo.should_retain_commit(&commit, &Some(vec![new_pattern("file1.txt")]), &None);
955            assert!(retain, "include: file1.txt");
956        }
957
958        {
959            let retain =
960                repo.should_retain_commit(&commit, &None, &Some(vec![new_pattern("file1.txt")]));
961            assert!(retain, "exclude: file1.txt");
962        }
963
964        {
965            let retain = repo.should_retain_commit(
966                &commit,
967                &Some(vec![new_pattern("file1.txt")]),
968                &Some(vec![new_pattern("file2.txt")]),
969            );
970            assert!(retain, "include: file1.txt, exclude: file2.txt");
971        }
972
973        {
974            let retain =
975                repo.should_retain_commit(&commit, &None, &Some(vec![new_pattern("**/*.txt")]));
976            assert!(!retain, "exclude: **/*.txt");
977        }
978    }
979}