Skip to main content

git_cliff_core/
repo.rs

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
20/// Regex for replacing the signature part of a tag message.
21static TAG_SIGNATURE_REGEX: LazyLock<Regex> = LazyLock::new(|| {
22    Regex::new(
23        // https://git-scm.com/docs/gitformat-signature#_description
24        r"(?s)-----BEGIN (PGP|SSH|SIGNED) (SIGNATURE|MESSAGE)-----(.*?)-----END (PGP|SSH|SIGNED) (SIGNATURE|MESSAGE)-----"
25    )
26    .expect("valid git tag signature regex")
27});
28
29/// Name of the cache file for changed files.
30const CHANGED_FILES_CACHE: &str = "changed_files_cache";
31
32/// Wrapper for [`Repository`] type from git2.
33///
34/// [`Repository`]: GitRepository
35pub struct Repository {
36    inner: GitRepository,
37    /// Repository path.
38    path: PathBuf,
39    /// Cache path for the changed files of the commits.
40    changed_files_cache_path: PathBuf,
41}
42
43/// Range of commits in a submodule.
44pub struct SubmoduleRange {
45    /// Repository object to which this range belongs.
46    pub repository: Repository,
47    /// Commit range in "FIRST..LAST" or "LAST" format, where FIRST is
48    /// the first submodule commit and LAST is the last submodule commit.
49    pub range: String,
50}
51
52impl Repository {
53    /// Opens a repository from the given path.
54    ///
55    /// If `search_parents` is true, it will traverse up through parent
56    /// directories to find a repository.
57    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                // Optionally search for a Jujutsu repository layout
68                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                    // Only continue searching if enabled
75                    if !search_parents {
76                        break;
77                    }
78                    current = dir.parent();
79                }
80                Err(err)
81            })
82            // If still not found, try discover if traversal is enabled
83            .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    /// Discover a repository from the given path by traversing up through
104    /// parent directories.
105    ///
106    /// It first looks for a Git repository using [`GitRepository::discover`].
107    /// If no Git repository is found, it checks for a Jujutsu repository layout
108    /// (`.jj/repo/store/git`) in this directory and its parents.
109    pub fn discover(path: PathBuf) -> Result<Self> {
110        Self::open(path, true)
111    }
112
113    /// Attempts to open an already-existing repository at the given path.
114    ///
115    /// It tries to open the repository as a normal or bare Git repository located
116    /// exactly at `path`. If that fails, it falls back to checking for a Jujutsu
117    /// repository layout (`.jj/repo/store/git`) **only in the specified directory**.
118    pub fn init(path: PathBuf) -> Result<Self> {
119        Self::open(path, false)
120    }
121
122    /// Returns the path of the repository.
123    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    /// Returns the initial path of the repository.
137    ///
138    /// In case of a submodule this is the relative path to the toplevel
139    /// repository.
140    #[must_use]
141    pub fn path(&self) -> &PathBuf {
142        &self.path
143    }
144
145    /// Sets the range for the commit search.
146    ///
147    /// When a single SHA is provided as the range, start from the
148    /// root.
149    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    /// Parses and returns the commits.
166    ///
167    /// Sorts the commits by their time.
168    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    /// Returns diff statistics for a single commit.
214    ///
215    /// # Errors
216    ///
217    /// Returns an error if the commit tree, parent tree, diff, or diff
218    /// statistics cannot be read.
219    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(&current_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    /// Returns submodule repositories for a given commit range.
238    ///
239    /// For one or two given commits in this repository, a list of changed
240    /// submodules is calculated. If only one commit is given, then all
241    /// submodule commits up to the referenced commit will be included. This is
242    /// usually the case if a submodule is added to the repository.
243    ///
244    ///  For each submodule a [`SubmoduleRange`] object is created
245    ///
246    /// This can then be used to query the submodule's commits by using
247    /// [`Repository::commits`].
248    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        // iterate through all diffs and accumulate old/new commit ids
259        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                // no changes or submodule removed
264                None
265            } else if old_file_id.is_zero() {
266                // submodule added
267                Some(new_file_id.to_string())
268            } else {
269                // submodule updated
270                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        // iterate through all path diffs and find corresponding submodule if
276        // possible
277        let submodule_range = before_and_after_deltas.filter_map(|(path, range)| {
278            // NOTE:
279            // libgit2 recommends using `git_submodule_open`, whereas `git_repository_discover` is
280            // used here. Since it seems to be working fine for now, we don't think we
281            // should change this. Just leaving this message as a reminder.
282            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    /// Normalizes the glob pattern to match the git diff paths.
293    ///
294    /// It removes the leading `./` and adds `**` to the end if the pattern is a
295    /// directory.
296    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    /// Calculates whether the commit should be retained or not.
311    ///
312    /// This function is used to filter the commits based on the changed files,
313    /// and include/exclude patterns.
314    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                // check if the commit has any changed files that match any of the
324                // include patterns and none of the exclude patterns.
325                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                // check if the commit has any changed files that match the include
336                // patterns.
337                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                // check if the commit has at least one changed file that does not
345                // match all exclude patterns.
346                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    /// Returns the changed files of the commit.
357    ///
358    /// It uses a cache to speed up checks to store the changed files of the
359    /// commits under `./.git/git-cliff-core/changed_files_cache`. The speed-up
360    /// was measured to be around 260x for large repositories.
361    ///
362    /// If the cache is not found, it calculates the changed files and adds them
363    /// to the cache via [`Self::commit_changed_files_no_cache`].
364    fn commit_changed_files(&self, commit: &Commit) -> Vec<PathBuf> {
365        // Cache key is generated from the repository path and commit id
366        let cache_key = format!("commit_id:{}", commit.id());
367
368        // Check the cache first.
369        {
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        // If the cache is not found, calculate the result and set it to the cache.
380        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    /// Calculate the changed files of the commit.
410    ///
411    /// This function does not use the cache (directly calls git2).
412    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            // Compare the current commit with the previous commit to get the
416            // changed files.
417            // libgit2 does not provide a way to get the changed files directly, so
418            // the full diff is calculated here.
419            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 there is no parent, it is the first commit.
431            // So get all the files in the tree.
432            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    /// Returns the current tag.
453    ///
454    /// It is the same as running `git describe --tags`
455    #[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    /// Returns the tag object of the given name.
469    ///
470    /// If given name doesn't exist, it still returns `Tag` with the given name.
471    #[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    /// Returns the commit object of the given ID.
492    #[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    /// Decide whether to include tag.
503    ///
504    /// `head_commit` is the `latest` commit to generate changelog. It can be a
505    /// branch head or a detached head. `tag_commit` is a tagged commit. If the
506    /// commit is in the descendant graph of the `head_commit` or is the
507    /// `head_commit` itself, Changelog should include the tag.
508    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    /// Parses and returns a commit-tag map.
516    ///
517    /// It collects lightweight and annotated tags.
518    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    /// Returns the remote of the upstream repository.
571    ///
572    /// The strategy used here is the following:
573    ///
574    /// Find the branch that HEAD points to, and read the remote configured for
575    /// that branch returns the remote and the name of the local branch.
576    ///
577    /// Note: HEAD must not be detached.
578    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
616/// Returns the Remote from parsing the HTTPS format URL.
617///
618/// This function expects the URL to be in the following format:
619///
620/// ```text
621/// https://hostname/query/path.git
622/// ```
623fn 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
645/// Returns the Remote from parsing the SSH format URL.
646///
647/// This function expects the URL to be in the following format:
648///
649/// > git@hostname:owner/repo.git
650fn 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        // a close descendant of the root commit
860        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        // working copy is the directory that contains the .git directory:
920        let working_copy = repo.path;
921
922        // Make the Git repository bare and set HEAD
923        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        // Move the Git repo into jj
929        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        // Open repo from working copy, that contains the .jj directory
934        let repo = Repository::discover(working_copy).expect("failed to init repo");
935
936        // macOS canonical path for temp directories is in /private
937        // libgit2 forces the path to be canonical regardless of what we pass in
938        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        // working copy is the directory that contains the .git directory:
989        let working_copy = repo.path;
990
991        // Make the Git repository bare and set HEAD
992        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        // Move the Git repo into jj
999        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        // Open repo from working copy, that contains the .jj directory
1004        let repo = Repository::init(working_copy).expect("failed to init repo");
1005
1006        // macOS canonical path for temp directories is in /private
1007        // libgit2 forces the path to be canonical regardless of what we pass in
1008        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}