Skip to main content

git_cliff_core/
repo.rs

1use std::collections::{HashMap, HashSet};
2use std::io;
3use std::path::{Path, PathBuf};
4use std::result::Result as StdResult;
5use std::sync::LazyLock;
6
7use git2::{
8    BranchType, Commit, DescribeOptions, Oid, Repository as GitRepository, Sort, TreeWalkMode,
9    Worktree,
10};
11use glob::Pattern;
12use indexmap::IndexMap;
13use regex::Regex;
14use url::Url;
15
16use crate::commit::CommitStatistics;
17use crate::config::Remote;
18use crate::error::{Error, Result};
19use crate::tag::Tag;
20
21/// Regex for replacing the signature part of a tag message.
22static TAG_SIGNATURE_REGEX: LazyLock<Regex> = LazyLock::new(|| {
23    Regex::new(
24        // https://git-scm.com/docs/gitformat-signature#_description
25        r"(?s)-----BEGIN (PGP|SSH|SIGNED) (SIGNATURE|MESSAGE)-----(.*?)-----END (PGP|SSH|SIGNED) (SIGNATURE|MESSAGE)-----"
26    )
27    .expect("valid git tag signature regex")
28});
29
30/// Name of the cache file for changed files.
31const CHANGED_FILES_CACHE: &str = "changed_files_cache";
32
33/// Name of the file listing commit hashes for `git blame` to ignore.
34///
35/// <https://git-scm.com/docs/git-blame#Documentation/git-blame.txt---ignore-revs-fileltfilegt>
36const GIT_BLAME_IGNORE_REVS_FILE: &str = ".git-blame-ignore-revs";
37
38/// Wrapper for [`Repository`] type from git2.
39///
40/// [`Repository`]: GitRepository
41pub struct Repository {
42    inner: GitRepository,
43    /// Repository path.
44    path: PathBuf,
45    /// Cache path for the changed files of the commits.
46    changed_files_cache_path: PathBuf,
47}
48
49/// Range of commits in a submodule.
50pub struct SubmoduleRange {
51    /// Repository object to which this range belongs.
52    pub repository: Repository,
53    /// Commit range in "FIRST..LAST" or "LAST" format, where FIRST is
54    /// the first submodule commit and LAST is the last submodule commit.
55    pub range: String,
56}
57
58impl Repository {
59    /// Opens a repository from the given path.
60    ///
61    /// If `search_parents` is true, it will traverse up through parent
62    /// directories to find a repository.
63    fn open(path: PathBuf, search_parents: bool) -> Result<Self> {
64        if !path.exists() {
65            return Err(Error::IoError(io::Error::new(
66                io::ErrorKind::NotFound,
67                format!("repository path not found: {}", path.display()),
68            )));
69        }
70
71        let inner = GitRepository::open(&path)
72            .or_else(|err| {
73                // Optionally search for a Jujutsu repository layout
74                let mut current = Some(path.as_path());
75                while let Some(dir) = current {
76                    let jujutsu_path = dir.join(".jj/repo/store/git");
77                    if jujutsu_path.exists() {
78                        return GitRepository::open_bare(&jujutsu_path);
79                    }
80                    // Only continue searching if enabled
81                    if !search_parents {
82                        break;
83                    }
84                    current = dir.parent();
85                }
86                Err(err)
87            })
88            // If still not found, try discover if traversal is enabled
89            .or_else(|err| {
90                if search_parents {
91                    GitRepository::discover(&path)
92                } else {
93                    Err(err)
94                }
95            })?;
96
97        let changed_files_cache_path = inner
98            .path()
99            .join(env!("CARGO_PKG_NAME"))
100            .join(CHANGED_FILES_CACHE);
101
102        Ok(Self {
103            inner,
104            path,
105            changed_files_cache_path,
106        })
107    }
108
109    /// Discover a repository from the given path by traversing up through
110    /// parent directories.
111    ///
112    /// It first looks for a Git repository using [`GitRepository::discover`].
113    /// If no Git repository is found, it checks for a Jujutsu repository layout
114    /// (`.jj/repo/store/git`) in this directory and its parents.
115    pub fn discover(path: PathBuf) -> Result<Self> {
116        Self::open(path, true)
117    }
118
119    /// Attempts to open an already-existing repository at the given path.
120    ///
121    /// It tries to open the repository as a normal or bare Git repository located
122    /// exactly at `path`. If that fails, it falls back to checking for a Jujutsu
123    /// repository layout (`.jj/repo/store/git`) **only in the specified directory**.
124    pub fn init(path: PathBuf) -> Result<Self> {
125        Self::open(path, false)
126    }
127
128    /// Returns the path of the repository.
129    pub fn root_path(&self) -> Result<PathBuf> {
130        let mut path = if self.inner.is_worktree() {
131            let worktree = Worktree::open_from_repository(&self.inner)?;
132            worktree.path().to_path_buf()
133        } else {
134            self.inner.path().to_path_buf()
135        };
136        if path.ends_with(".git") {
137            path.pop();
138        }
139        Ok(path)
140    }
141
142    /// Returns the initial path of the repository.
143    ///
144    /// In case of a submodule this is the relative path to the toplevel
145    /// repository.
146    #[must_use]
147    pub fn path(&self) -> &PathBuf {
148        &self.path
149    }
150
151    /// Sets the range for the commit search.
152    ///
153    /// When a single SHA is provided as the range, start from the
154    /// root.
155    fn set_commit_range(
156        revwalk: &mut git2::Revwalk<'_>,
157        range: Option<&str>,
158    ) -> StdResult<(), git2::Error> {
159        if let Some(range) = range {
160            if range.contains("..") {
161                revwalk.push_range(range)?;
162            } else {
163                revwalk.push(Oid::from_str(range)?)?;
164            }
165        } else {
166            revwalk.push_head()?;
167        }
168        Ok(())
169    }
170
171    /// Parses and returns the commits.
172    ///
173    /// Sorts the commits by their time.
174    pub fn commits(
175        &self,
176        range: Option<&str>,
177        include_path: Option<Vec<Pattern>>,
178        exclude_path: Option<Vec<Pattern>>,
179        topo_order_commits: bool,
180    ) -> Result<Vec<Commit<'_>>> {
181        let mut revwalk = self.inner.revwalk()?;
182        if topo_order_commits {
183            revwalk.set_sorting(Sort::TOPOLOGICAL)?;
184        } else {
185            revwalk.set_sorting(Sort::TIME)?;
186        }
187
188        Self::set_commit_range(&mut revwalk, range).map_err(|e| {
189            Error::SetCommitRangeError(range.map_or_else(|| "?".to_string(), String::from), e)
190        })?;
191        let mut commits: Vec<Commit> = revwalk
192            .filter_map(StdResult::ok)
193            .filter_map(|id| self.inner.find_commit(id).ok())
194            .collect();
195        if include_path.is_some() || exclude_path.is_some() {
196            let include_patterns = include_path.map(|patterns| {
197                patterns
198                    .into_iter()
199                    .map(Self::normalize_pattern)
200                    .collect::<Vec<_>>()
201            });
202            let exclude_patterns = exclude_path.map(|patterns| {
203                patterns
204                    .into_iter()
205                    .map(Self::normalize_pattern)
206                    .collect::<Vec<_>>()
207            });
208            commits.retain(|commit| {
209                self.should_retain_commit(
210                    commit,
211                    include_patterns.as_ref(),
212                    exclude_patterns.as_ref(),
213                )
214            });
215        }
216        Ok(commits)
217    }
218
219    /// Filters out commits listed in the repository's `.git-blame-ignore-revs`
220    /// file, as well as commits that only modify that file.
221    ///
222    /// Mirrors the file `git blame --ignore-revs-file` reads: one commit
223    /// hash per line, blank lines and `#`-comments ignored. Hashes may be
224    /// abbreviated. Does nothing if the file does not exist.
225    pub fn filter_git_blame_ignore_revs(&self, commits: &mut Vec<Commit<'_>>) {
226        let Ok(root) = self.root_path() else {
227            return;
228        };
229        let Ok(contents) = std::fs::read_to_string(root.join(GIT_BLAME_IGNORE_REVS_FILE)) else {
230            return;
231        };
232        let ignored_ids: Vec<&str> = contents
233            .lines()
234            .map(str::trim)
235            .filter(|line| !line.is_empty() && !line.starts_with('#'))
236            .collect();
237        if ignored_ids.is_empty() {
238            return;
239        }
240        commits.retain(|commit| {
241            let id = commit.id().to_string();
242            if ignored_ids.iter().any(|ignored| id.starts_with(ignored)) {
243                return false;
244            }
245            let changed_files = self.commit_changed_files(commit);
246            !(changed_files.len() == 1 && changed_files[0] == Path::new(GIT_BLAME_IGNORE_REVS_FILE))
247        });
248    }
249
250    /// Returns diff statistics for a single commit.
251    ///
252    /// # Errors
253    ///
254    /// Returns an error if the commit tree, parent tree, diff, or diff
255    /// statistics cannot be read.
256    pub fn commit_statistics(&self, commit: &Commit<'_>) -> Result<CommitStatistics> {
257        let current_tree = commit.tree()?;
258        let previous_tree = commit
259            .parent(0)
260            .ok()
261            .map(|parent| parent.tree())
262            .transpose()?;
263        let diff =
264            self.inner
265                .diff_tree_to_tree(previous_tree.as_ref(), Some(&current_tree), None)?;
266        let stats = diff.stats()?;
267        Ok(CommitStatistics {
268            files_changed: stats.files_changed(),
269            additions: stats.insertions(),
270            deletions: stats.deletions(),
271        })
272    }
273
274    /// Returns submodule repositories for a given commit range.
275    ///
276    /// For one or two given commits in this repository, a list of changed
277    /// submodules is calculated. If only one commit is given, then all
278    /// submodule commits up to the referenced commit will be included. This is
279    /// usually the case if a submodule is added to the repository.
280    ///
281    ///  For each submodule a [`SubmoduleRange`] object is created
282    ///
283    /// This can then be used to query the submodule's commits by using
284    /// [`Repository::commits`].
285    pub fn submodules_range(
286        &self,
287        old_commit: Option<&Commit<'_>>,
288        new_commit: &Commit<'_>,
289    ) -> Result<Vec<SubmoduleRange>> {
290        let old_tree = old_commit.and_then(|commit| commit.tree().ok());
291        let new_tree = new_commit.tree().ok();
292        let diff = self
293            .inner
294            .diff_tree_to_tree(old_tree.as_ref(), new_tree.as_ref(), None)?;
295        // iterate through all diffs and accumulate old/new commit ids
296        let before_and_after_deltas = diff.deltas().filter_map(|delta| {
297            let old_file_id = delta.old_file().id();
298            let new_file_id = delta.new_file().id();
299            let range = if old_file_id == new_file_id || new_file_id.is_zero() {
300                // no changes or submodule removed
301                None
302            } else if old_file_id.is_zero() {
303                // submodule added
304                Some(new_file_id.to_string())
305            } else {
306                // submodule updated
307                Some(format!("{old_file_id}..{new_file_id}"))
308            };
309            tracing::trace!("Release commit range for submodules: {range:?}");
310            delta.new_file().path().and_then(Path::to_str).zip(range)
311        });
312        // iterate through all path diffs and find corresponding submodule if
313        // possible
314        let submodule_range = before_and_after_deltas.filter_map(|(path, range)| {
315            // NOTE:
316            // libgit2 recommends using `git_submodule_open`, whereas `git_repository_discover` is
317            // used here. Since it seems to be working fine for now, we don't think we
318            // should change this. Just leaving this message as a reminder.
319            let repository = self
320                .inner
321                .find_submodule(path)
322                .ok()
323                .and_then(|submodule| Self::discover(submodule.path().into()).ok());
324            repository.map(|repository| SubmoduleRange { repository, range })
325        });
326        Ok(submodule_range.collect())
327    }
328
329    /// Normalizes the glob pattern to match the git diff paths.
330    ///
331    /// It removes the leading `./` and adds `**` to the end if the pattern is a
332    /// directory.
333    fn normalize_pattern(pattern: Pattern) -> Pattern {
334        // glob patterns and git's diff paths always use '/', whatever the host
335        // OS, so this must not be `path::MAIN_SEPARATOR`
336        let star_added = if pattern.as_str().ends_with('/') {
337            Pattern::new(&format!("{pattern}**")).expect("failed to add '**' to the end of glob")
338        } else {
339            pattern
340        };
341        match star_added.as_str().strip_prefix("./") {
342            Some(stripped) => {
343                Pattern::new(stripped).expect("failed to remove leading ./ from glob")
344            }
345            None => star_added,
346        }
347    }
348
349    /// Calculates whether the commit should be retained or not.
350    ///
351    /// This function is used to filter the commits based on the changed files,
352    /// and include/exclude patterns.
353    fn should_retain_commit(
354        &self,
355        commit: &Commit,
356        include_patterns: Option<&Vec<Pattern>>,
357        exclude_patterns: Option<&Vec<Pattern>>,
358    ) -> bool {
359        let changed_files = self.commit_changed_files(commit);
360        match (include_patterns, exclude_patterns) {
361            (Some(include_pattern), Some(exclude_pattern)) => {
362                // check if the commit has any changed files that match any of the
363                // include patterns and none of the exclude patterns.
364                changed_files.iter().any(|path| {
365                    include_pattern
366                        .iter()
367                        .any(|pattern| pattern.matches_path(path)) &&
368                        !exclude_pattern
369                            .iter()
370                            .any(|pattern| pattern.matches_path(path))
371                })
372            }
373            (Some(include_pattern), None) => {
374                // check if the commit has any changed files that match the include
375                // patterns.
376                changed_files.iter().any(|path| {
377                    include_pattern
378                        .iter()
379                        .any(|pattern| pattern.matches_path(path))
380                })
381            }
382            (None, Some(exclude_pattern)) => {
383                // check if the commit has at least one changed file that does not
384                // match all exclude patterns.
385                changed_files.iter().any(|path| {
386                    !exclude_pattern
387                        .iter()
388                        .any(|pattern| pattern.matches_path(path))
389                })
390            }
391            (None, None) => true,
392        }
393    }
394
395    /// Returns the changed files of the commit.
396    ///
397    /// It uses a cache to speed up checks to store the changed files of the
398    /// commits under `./.git/git-cliff-core/changed_files_cache`. The speed-up
399    /// was measured to be around 260x for large repositories.
400    ///
401    /// If the cache is not found, it calculates the changed files and adds them
402    /// to the cache via [`Self::commit_changed_files_no_cache`].
403    fn commit_changed_files(&self, commit: &Commit) -> Vec<PathBuf> {
404        // Cache key is generated from the repository path and commit id
405        let cache_key = format!("commit_id:{}", commit.id());
406
407        // Check the cache first.
408        {
409            if let Ok(result) = cacache::read_sync(&self.changed_files_cache_path, &cache_key) &&
410                let Ok((files, _)) =
411                    bincode::decode_from_slice(&result, bincode::config::standard())
412            {
413                return files;
414            }
415        }
416
417        // If the cache is not found, calculate the result and set it to the cache.
418        let result = self.commit_changed_files_no_cache(commit);
419        match bincode::encode_to_vec(
420            self.commit_changed_files_no_cache(commit),
421            bincode::config::standard(),
422        ) {
423            Ok(v) => {
424                if let Err(e) = cacache::write_sync_with_algo(
425                    cacache::Algorithm::Xxh3,
426                    &self.changed_files_cache_path,
427                    cache_key,
428                    v,
429                ) {
430                    #[allow(clippy::unnecessary_debug_formatting)]
431                    {
432                        tracing::error!("Failed to set cache for repo {:?}: {e}", self.path);
433                    }
434                }
435            }
436            Err(e) => {
437                #[allow(clippy::unnecessary_debug_formatting)]
438                {
439                    tracing::error!("Failed to serialize cache for repo {:?}: {e}", self.path);
440                }
441            }
442        }
443
444        result
445    }
446
447    /// Calculate the changed files of the commit.
448    ///
449    /// This function does not use the cache (directly calls git2).
450    fn commit_changed_files_no_cache(&self, commit: &Commit) -> Vec<PathBuf> {
451        let mut changed_files = Vec::new();
452        if let Ok(prev_commit) = commit.parent(0) {
453            // Compare the current commit with the previous commit to get the
454            // changed files.
455            // libgit2 does not provide a way to get the changed files directly, so
456            // the full diff is calculated here.
457            if let Ok(diff) = self.inner.diff_tree_to_tree(
458                commit.tree().ok().as_ref(),
459                prev_commit.tree().ok().as_ref(),
460                None,
461            ) {
462                changed_files.extend(
463                    diff.deltas()
464                        .filter_map(|delta| delta.new_file().path().map(PathBuf::from)),
465                );
466            }
467        } else {
468            // If there is no parent, it is the first commit.
469            // So get all the files in the tree.
470            if let Ok(tree) = commit.tree() {
471                tree.walk(TreeWalkMode::PreOrder, |dir, entry| {
472                    if entry.kind().expect("failed to get entry kind") != git2::ObjectType::Blob {
473                        return 0;
474                    }
475                    let name = entry.name().expect("failed to get entry name");
476                    let entry_path = if dir == "," {
477                        name.to_string()
478                    } else {
479                        format!("{dir}/{name}")
480                    };
481                    changed_files.push(entry_path.into());
482                    0
483                })
484                .expect("failed to get the changed files of the first commit");
485            }
486        }
487        changed_files
488    }
489
490    /// Returns the current tag.
491    ///
492    /// It is the same as running `git describe --tags`
493    #[must_use]
494    pub fn current_tag(&self) -> Option<Tag> {
495        self.inner
496            .describe(DescribeOptions::new().describe_tags())
497            .ok()
498            .and_then(|describe| {
499                describe
500                    .format(None)
501                    .ok()
502                    .map(|name| self.resolve_tag(&name))
503            })
504    }
505
506    /// Returns the tag object of the given name.
507    ///
508    /// If given name doesn't exist, it still returns `Tag` with the given name.
509    #[must_use]
510    pub fn resolve_tag(&self, name: &str) -> Tag {
511        match self
512            .inner
513            .resolve_reference_from_short_name(name)
514            .and_then(|r| r.peel_to_tag())
515        {
516            Ok(tag) => Tag {
517                name: tag.name().unwrap_or_default().to_owned(),
518                message: tag
519                    .message()
520                    .unwrap_or_default()
521                    .map(|msg| TAG_SIGNATURE_REGEX.replace(msg, "").trim().to_owned()),
522            },
523            _ => Tag {
524                name: name.to_owned(),
525                message: None,
526            },
527        }
528    }
529
530    /// Returns the commit object of the given ID.
531    #[must_use]
532    pub fn find_commit(&self, id: &str) -> Option<Commit<'_>> {
533        if let Ok(oid) = Oid::from_str(id) &&
534            let Ok(commit) = self.inner.find_commit(oid)
535        {
536            return Some(commit);
537        }
538        None
539    }
540
541    /// Decide whether to include tag.
542    ///
543    /// `head_commit` is the `latest` commit to generate changelog. It can be a
544    /// branch head or a detached head. `tag_commit` is a tagged commit. If the
545    /// commit is in the descendant graph of the `head_commit` or is the
546    /// `head_commit` itself, Changelog should include the tag.
547    fn should_include_tag(&self, head_commit: &Commit, tag_commit: &Commit) -> Result<bool> {
548        Ok(self
549            .inner
550            .graph_descendant_of(head_commit.id(), tag_commit.id())? ||
551            head_commit.id() == tag_commit.id())
552    }
553
554    /// Parses and returns a commit-tag map.
555    ///
556    /// It collects lightweight and annotated tags.
557    pub fn tags(
558        &self,
559        pattern: &Option<Regex>,
560        topo_order: bool,
561        use_branch_tags: bool,
562    ) -> Result<IndexMap<String, Tag>> {
563        let mut tags: Vec<(Commit, Tag)> = Vec::new();
564        let tag_names = self.inner.tag_names(None)?;
565        let head_commit = self.inner.head()?.peel_to_commit()?;
566        for name in tag_names
567            .iter()
568            .flatten()
569            .flatten()
570            .filter(|tag_name| pattern.as_ref().is_none_or(|pat| pat.is_match(tag_name)))
571            .map(String::from)
572        {
573            let obj = self.inner.revparse_single(&name)?;
574            if let Ok(commit) = obj.clone().into_commit() {
575                if use_branch_tags && !self.should_include_tag(&head_commit, &commit)? {
576                    continue;
577                }
578
579                tags.push((commit, Tag {
580                    name,
581                    message: None,
582                }));
583            } else if let Some(tag) = obj.as_tag() {
584                // Use peel to resolve nested tags to the final commit
585                if let Some(commit) = obj
586                    .peel(git2::ObjectType::Commit)
587                    .ok()
588                    .and_then(|o| o.into_commit().ok())
589                {
590                    if use_branch_tags && !self.should_include_tag(&head_commit, &commit)? {
591                        continue;
592                    }
593                    tags.push((commit, Tag {
594                        name: tag.name().map(String::from).unwrap_or(name),
595                        message: tag
596                            .message()
597                            .ok()
598                            .flatten()
599                            .map(|msg| TAG_SIGNATURE_REGEX.replace(msg, "").trim().to_owned()),
600                    }));
601                }
602            }
603        }
604        if !topo_order {
605            tags.sort_by_key(|a| a.0.time().seconds());
606        }
607        Ok(tags
608            .into_iter()
609            .map(|(a, b)| (a.id().to_string(), b))
610            .collect())
611    }
612
613    /// Maps each commit id to the id of the tag that "owns" it.
614    ///
615    /// A commit is owned by the earliest tag (in `tags` order, which must be
616    /// oldest to newest) whose commit can reach it, i.e. the tag whose
617    /// `previous_tag..tag` range contains it. This assigns commits to releases
618    /// by graph reachability rather than by their position in the linearized
619    /// log, which can interleave diverged-then-merged branches
620    ///
621    /// Only tags whose commit id is in `boundary_ids` (the commits actually in
622    /// the walk) are considered. Commits not reachable from any such tag are
623    /// absent from the map and should be treated as unreleased.
624    ///
625    /// # Returns
626    ///
627    /// A map from each owned commit id to the commit id of its owning tag.
628    /// Commits that are not reachable from a considered tag are omitted.
629    pub fn commit_tag_ownership(
630        &self,
631        tags: &IndexMap<String, Tag>,
632        boundary_ids: &HashSet<Oid>,
633    ) -> Result<HashMap<Oid, String>> {
634        let mut ownership = HashMap::new();
635        // Only tags that are part of the walked history can act as boundaries.
636        let tag_ids: Vec<(Oid, &String)> = tags
637            .keys()
638            .filter_map(|id| Oid::from_str(id).ok().map(|oid| (oid, id)))
639            .filter(|(oid, _)| boundary_ids.contains(oid))
640            .collect();
641        for (index, (tag_oid, tag_id)) in tag_ids.iter().enumerate() {
642            let mut revwalk = self.inner.revwalk()?;
643            revwalk.push(*tag_oid)?;
644            // Hide all previous (older) tags so that this walk only yields the
645            // commits belonging to this tag's release range.
646            for (prev_oid, _) in &tag_ids[..index] {
647                // Ignore errors from hiding unrelated histories.
648                let _ = revwalk.hide(*prev_oid);
649            }
650            for oid in revwalk.filter_map(StdResult::ok) {
651                if boundary_ids.contains(&oid) {
652                    ownership.entry(oid).or_insert_with(|| (*tag_id).clone());
653                    if ownership.len() == boundary_ids.len() {
654                        return Ok(ownership);
655                    }
656                }
657            }
658        }
659        Ok(ownership)
660    }
661
662    /// Returns the remote of the upstream repository.
663    ///
664    /// The strategy used here is the following:
665    ///
666    /// Find the branch that HEAD points to, and read the remote configured for
667    /// that branch returns the remote and the name of the local branch.
668    ///
669    /// Note: HEAD must not be detached.
670    pub fn upstream_remote(&self) -> Result<Remote> {
671        for branch in self.inner.branches(Some(BranchType::Local))? {
672            let branch = branch?.0;
673            if branch.is_head() {
674                let upstream = &self.inner.branch_upstream_remote(&format!(
675                    "refs/heads/{}",
676                    branch.name()?.ok_or_else(|| Error::RepoError(String::from(
677                        "branch name is not valid"
678                    )))?
679                ))?;
680                let upstream_name = upstream.as_str().map_err(|err| {
681                    Error::RepoError(format!("name of the upstream remote is not valid: {err}"))
682                })?;
683                let origin = &self.inner.find_remote(upstream_name)?;
684                let url = origin
685                    .url()
686                    .map_err(|err| {
687                        Error::RepoError(format!("failed to get the remote URL: {err}"))
688                    })?
689                    .to_string();
690                tracing::trace!("Upstream URL: {url}");
691                return find_remote(&url);
692            }
693        }
694        Err(Error::RepoError(String::from(
695            "no remotes configured or HEAD is detached",
696        )))
697    }
698}
699
700fn find_remote(url: &str) -> Result<Remote> {
701    url_path_segments(url).or_else(|err| {
702        if url.contains('@') && url.contains(':') && url.contains('/') {
703            ssh_path_segments(url)
704        } else {
705            Err(err)
706        }
707    })
708}
709
710/// Returns the Remote from parsing the HTTPS format URL.
711///
712/// This function expects the URL to be in the following format:
713///
714/// ```text
715/// https://hostname/query/path.git
716/// ```
717fn url_path_segments(url: &str) -> Result<Remote> {
718    let parsed_url = Url::parse(url.strip_suffix(".git").unwrap_or(url))?;
719    let segments: Vec<&str> = parsed_url
720        .path_segments()
721        .ok_or_else(|| Error::RepoError(String::from("failed to get URL segments")))?
722        .rev()
723        .collect();
724    let [repo, owner, ..] = &segments[..] else {
725        return Err(Error::RepoError(String::from(
726            "failed to get the owner and repo",
727        )));
728    };
729    Ok(Remote {
730        owner: (*owner).to_string(),
731        repo: (*repo).to_string(),
732        token: None,
733        is_custom: false,
734        api_url: None,
735        http_timeout: std::time::Duration::from_secs(30),
736        native_tls: None,
737    })
738}
739
740/// Returns the Remote from parsing the SSH format URL.
741///
742/// This function expects the URL to be in the following format:
743///
744/// > git@hostname:owner/repo.git
745fn ssh_path_segments(url: &str) -> Result<Remote> {
746    let [_, owner_repo, ..] = url
747        .strip_suffix(".git")
748        .unwrap_or(url)
749        .split(':')
750        .collect::<Vec<_>>()[..]
751    else {
752        return Err(Error::RepoError(String::from(
753            "failed to get the owner and repo from ssh remote (:)",
754        )));
755    };
756    let [owner, repo] = owner_repo.split('/').collect::<Vec<_>>()[..] else {
757        return Err(Error::RepoError(String::from(
758            "failed to get the owner and repo from ssh remote (/)",
759        )));
760    };
761    Ok(Remote {
762        owner: owner.to_string(),
763        repo: repo.to_string(),
764        token: None,
765        is_custom: false,
766        api_url: None,
767        http_timeout: std::time::Duration::from_secs(30),
768        native_tls: None,
769    })
770}
771
772#[cfg(test)]
773mod test {
774    use std::process::Command;
775    use std::{env, fs, io, str};
776
777    use temp_dir::TempDir;
778
779    use super::*;
780    use crate::commit::Commit as AppCommit;
781
782    fn get_last_commit_hash() -> Result<String> {
783        Ok(str::from_utf8(
784            Command::new("git")
785                .args(["log", "--pretty=format:'%H'", "-n", "1"])
786                .output()?
787                .stdout
788                .as_ref(),
789        )?
790        .trim_matches('\'')
791        .to_string())
792    }
793
794    fn get_root_commit_hash() -> Result<String> {
795        Ok(str::from_utf8(
796            Command::new("git")
797                .args(["rev-list", "--max-parents=0", "HEAD"])
798                .output()?
799                .stdout
800                .as_ref(),
801        )?
802        .trim_ascii_end()
803        .to_string())
804    }
805
806    fn get_last_tag() -> Result<String> {
807        Ok(str::from_utf8(
808            Command::new("git")
809                .args(["describe", "--abbrev=0"])
810                .output()?
811                .stdout
812                .as_ref(),
813        )?
814        .trim()
815        .to_string())
816    }
817
818    fn get_repository() -> Result<Repository> {
819        Repository::discover(
820            PathBuf::from(env!("CARGO_MANIFEST_DIR"))
821                .parent()
822                .expect("parent directory not found")
823                .to_path_buf(),
824        )
825    }
826
827    #[test]
828    fn http_url_repo_owner() -> Result<()> {
829        let url = "https://hostname.com/bob/magic.git";
830        let remote = find_remote(url)?;
831        assert_eq!(remote.owner, "bob", "match owner");
832        assert_eq!(remote.repo, "magic", "match repo");
833        Ok(())
834    }
835
836    #[test]
837    fn ssh_url_repo_owner() -> Result<()> {
838        let url = "git@hostname.com:bob/magic.git";
839        let remote = find_remote(url)?;
840        assert_eq!(remote.owner, "bob", "match owner");
841        assert_eq!(remote.repo, "magic", "match repo");
842        Ok(())
843    }
844
845    #[test]
846    fn get_latest_commit() -> Result<()> {
847        let repository = get_repository()?;
848        let commits = repository.commits(None, None, None, false)?;
849        let last_commit = AppCommit::from(&commits.first().expect("no commits found").clone());
850        assert_eq!(get_last_commit_hash()?, last_commit.id);
851        Ok(())
852    }
853
854    #[test]
855    fn commit_search() -> Result<()> {
856        let repository = get_repository()?;
857        assert!(
858            repository
859                .find_commit("e936ed571533ea6c41a1dd2b1a29d085c8dbada5")
860                .is_some()
861        );
862        Ok(())
863    }
864
865    #[test]
866    fn get_latest_tag() -> Result<()> {
867        let repository = get_repository()?;
868        let tags = repository.tags(&None, false, false)?;
869        let latest = tags.last().expect("no tags found").1.name.clone();
870        assert_eq!(get_last_tag()?, latest);
871
872        let current = repository.current_tag().expect("a current tag").name;
873        assert!(current.contains(&latest));
874        Ok(())
875    }
876
877    #[test]
878    fn git_tags() -> Result<()> {
879        let repository = get_repository()?;
880        let tags = repository.tags(&None, true, false)?;
881        assert_eq!(
882            tags.get("2b8b4d3535f29231e05c3572e919634b9af907b6")
883                .expect("the commit hash does not exist in the repository (tag v0.1.0)")
884                .name,
885            "v0.1.0"
886        );
887        assert_eq!(
888            tags.get("4ddef08debfff48117586296e49d5caa0800d1b5")
889                .expect("the commit hash does not exist in the repository (tag v0.1.0-beta.4)")
890                .name,
891            "v0.1.0-beta.4"
892        );
893        let tags = repository.tags(
894            &Some(Regex::new("^v[0-9]+\\.[0-9]+\\.[0-9]$").expect("the regex is not valid")),
895            true,
896            false,
897        )?;
898        assert_eq!(
899            tags.get("2b8b4d3535f29231e05c3572e919634b9af907b6")
900                .expect("the commit hash does not exist in the repository (tag v0.1.0)")
901                .name,
902            "v0.1.0"
903        );
904        assert!(!tags.contains_key("4ddef08debfff48117586296e49d5caa0800d1b5"));
905        Ok(())
906    }
907
908    #[test]
909    fn git_nested_tags() -> Result<()> {
910        let (repo, temp_dir) = create_temp_repo();
911        let path = temp_dir.path();
912
913        let commit = create_commit_with_files(&repo, vec![("initial.txt", "initial content")]);
914
915        Command::new("git")
916            .args(["tag", "-a", "v1.0.0-staging", "--no-sign", "-m", "s"])
917            .current_dir(path)
918            .output()?;
919
920        // nested tag: v1.0.0-stable -> v1.0.0-staging -> commit
921        Command::new("git")
922            .args([
923                "tag",
924                "-a",
925                "v1.0.0-stable",
926                "--no-sign",
927                "-m",
928                "s",
929                "v1.0.0-staging",
930            ])
931            .current_dir(path)
932            .output()?;
933
934        let tags = repo.tags(&Some(Regex::new("v1.0.0-stable")?), false, false)?;
935        assert_eq!(
936            tags.get(&commit.id().to_string())
937                .expect("nested tag should resolve to commit")
938                .name,
939            "v1.0.0-stable"
940        );
941        Ok(())
942    }
943
944    #[test]
945    fn git_upstream_remote() -> Result<()> {
946        let repository = get_repository()?;
947        let remote = repository.upstream_remote()?;
948        assert_eq!(
949            Remote {
950                owner: remote.owner.clone(),
951                repo: String::from("git-cliff"),
952                token: None,
953                is_custom: false,
954                api_url: remote.api_url.clone(),
955                http_timeout: std::time::Duration::from_secs(30),
956                native_tls: None,
957            },
958            remote
959        );
960        Ok(())
961    }
962
963    #[test]
964    fn resolves_existing_tag_with_name_and_message() -> Result<()> {
965        let repository = get_repository()?;
966        let tag = repository.resolve_tag("v0.2.3");
967        assert_eq!(tag.name, "v0.2.3");
968        assert_eq!(
969            tag.message,
970            Some(
971                "Release v0.2.3\n\nBug Fixes\n- Fetch the dependencies before copying the file to \
972                 embed (9e29c95)"
973                    .to_string()
974            )
975        );
976
977        Ok(())
978    }
979
980    #[test]
981    fn resolves_tag_when_no_tags_exist() -> Result<()> {
982        let repository = get_repository()?;
983        let tag = repository.resolve_tag("nonexistent-tag");
984        assert_eq!(tag.name, "nonexistent-tag");
985        assert_eq!(tag.message, None);
986        Ok(())
987    }
988
989    #[test]
990    fn includes_root_commit() -> Result<()> {
991        let repository = get_repository()?;
992        // a close descendant of the root commit
993        let range = Some("eea3914c7ab07472841aa85c36d11bdb2589a234");
994        let commits = repository.commits(range, None, None, false)?;
995        let root_commit = AppCommit::from(&commits.last().expect("no commits found").clone());
996        assert_eq!(get_root_commit_hash()?, root_commit.id);
997        Ok(())
998    }
999
1000    fn create_temp_repo() -> (Repository, TempDir) {
1001        let temp_dir = TempDir::with_prefix("git-cliff-").expect("failed to create temp dir");
1002
1003        let output = Command::new("git")
1004            .args(["init"])
1005            .current_dir(temp_dir.path())
1006            .output()
1007            .expect("failed to execute git init");
1008        assert!(output.status.success(), "git init failed {output:?}");
1009
1010        let repo =
1011            Repository::discover(temp_dir.path().to_path_buf()).expect("failed to init repo");
1012        let output = Command::new("git")
1013            .args(["config", "user.email", "test@gmail.com"])
1014            .current_dir(temp_dir.path())
1015            .output()
1016            .expect("failed to execute git config user.email");
1017        assert!(
1018            output.status.success(),
1019            "git config user.email failed {output:?}",
1020        );
1021
1022        let output = Command::new("git")
1023            .args(["config", "user.name", "test"])
1024            .current_dir(temp_dir.path())
1025            .output()
1026            .expect("failed to execute git config user.name");
1027        assert!(
1028            output.status.success(),
1029            "git config user.name failed {output:?}",
1030        );
1031
1032        (repo, temp_dir)
1033    }
1034
1035    #[test]
1036    fn repository_path_not_found() {
1037        let path = PathBuf::from("/this/path/should/not/exist/123456789");
1038        let result = Repository::discover(path.clone());
1039        assert!(result.is_err());
1040        match result {
1041            Err(Error::IoError(err)) => {
1042                assert_eq!(err.kind(), io::ErrorKind::NotFound);
1043                assert!(err.to_string().contains("repository path not found"));
1044            }
1045            _ => panic!("expected IoError(NotFound)"),
1046        }
1047    }
1048
1049    #[test]
1050    fn discover_jujutsu_repo() {
1051        let (repo, _temp_dir) = create_temp_repo();
1052        // working copy is the directory that contains the .git directory:
1053        let working_copy = repo.path;
1054
1055        // Make the Git repository bare and set HEAD
1056        std::process::Command::new("git")
1057            .args(["config", "core.bare", "true"])
1058            .current_dir(&working_copy)
1059            .status()
1060            .expect("failed to make git repo non-bare");
1061        // Move the Git repo into jj
1062        let store = working_copy.join(".jj").join("repo").join("store");
1063        fs::create_dir_all(&store).expect("failed to create dir");
1064        fs::rename(working_copy.join(".git"), store.join("git")).expect("failed to move git repo");
1065
1066        // Open repo from working copy, that contains the .jj directory
1067        let repo = Repository::discover(working_copy).expect("failed to init repo");
1068
1069        // macOS canonical path for temp directories is in /private
1070        // libgit2 forces the path to be canonical regardless of what we pass in
1071        if repo.inner.path().starts_with("/private") {
1072            assert_eq!(
1073                repo.inner.path().strip_prefix("/private"),
1074                store.join("git").strip_prefix("/"),
1075                "open git repo in .jj/repo/store/"
1076            );
1077        } else {
1078            assert_eq!(
1079                repo.inner.path(),
1080                store.join("git"),
1081                "open git repo in .jj/repo/store/"
1082            );
1083        }
1084    }
1085
1086    #[test]
1087    fn propagate_error_if_no_repo_found() {
1088        let temp_dir = TempDir::with_prefix("git-cliff-").expect("failed to create temp dir");
1089
1090        let path = temp_dir.path().to_path_buf();
1091
1092        let result = Repository::discover(path.clone());
1093
1094        assert!(result.is_err());
1095        if let Err(error) = result {
1096            assert!(
1097                error.to_string().contains(
1098                    format!("could not find repository at '{}'", path.display()).as_str()
1099                )
1100            );
1101        }
1102    }
1103
1104    #[test]
1105    fn repository_path_does_not_exist() {
1106        let path = PathBuf::from("/this/path/should/not/exist/123456789");
1107        let result = Repository::init(path.clone());
1108        assert!(result.is_err());
1109        match result {
1110            Err(Error::IoError(err)) => {
1111                assert_eq!(err.kind(), io::ErrorKind::NotFound);
1112                assert!(err.to_string().contains("repository path not found"));
1113            }
1114            _ => panic!("expected IoError(NotFound)"),
1115        }
1116    }
1117
1118    #[test]
1119    fn open_jujutsu_repo() {
1120        let (repo, _temp_dir) = create_temp_repo();
1121        // working copy is the directory that contains the .git directory:
1122        let working_copy = repo.path;
1123
1124        // Make the Git repository bare and set HEAD
1125        Command::new("git")
1126            .args(["config", "core.bare", "true"])
1127            .current_dir(&working_copy)
1128            .status()
1129            .expect("failed to make git repo non-bare");
1130
1131        // Move the Git repo into jj
1132        let store = working_copy.join(".jj").join("repo").join("store");
1133        fs::create_dir_all(&store).expect("failed to create dir");
1134        fs::rename(working_copy.join(".git"), store.join("git")).expect("failed to move git repo");
1135
1136        // Open repo from working copy, that contains the .jj directory
1137        let repo = Repository::init(working_copy).expect("failed to init repo");
1138
1139        // macOS canonical path for temp directories is in /private
1140        // libgit2 forces the path to be canonical regardless of what we pass in
1141        if repo.inner.path().starts_with("/private") {
1142            assert_eq!(
1143                repo.inner.path().strip_prefix("/private"),
1144                store.join("git").strip_prefix("/"),
1145                "open git repo in .jj/repo/store/"
1146            );
1147        } else {
1148            assert_eq!(
1149                repo.inner.path(),
1150                store.join("git"),
1151                "open git repo in .jj/repo/store/"
1152            );
1153        }
1154    }
1155
1156    #[test]
1157    fn propagate_error_if_no_repo_exist() {
1158        let temp_dir = TempDir::with_prefix("git-cliff-").expect("failed to create temp dir");
1159
1160        let path = temp_dir.path().to_path_buf();
1161
1162        let result = Repository::init(path.clone());
1163
1164        assert!(result.is_err());
1165        if let Err(error) = result {
1166            assert!(
1167                error.to_string().contains(
1168                    format!("could not find repository at '{}'", path.display()).as_str()
1169                )
1170            );
1171        }
1172    }
1173
1174    fn create_commit_with_files<'a>(
1175        repo: &'a Repository,
1176        files: Vec<(&'a str, &'a str)>,
1177    ) -> Commit<'a> {
1178        for (path, content) in files {
1179            if let Some(parent) = repo.path.join(path).parent() {
1180                std::fs::create_dir_all(parent).expect("failed to create dir");
1181            }
1182            std::fs::write(repo.path.join(path), content).expect("failed to write file");
1183        }
1184
1185        let output = Command::new("git")
1186            .args(["add", "."])
1187            .current_dir(&repo.path)
1188            .output()
1189            .expect("failed to execute git add");
1190        assert!(output.status.success(), "git add failed {output:?}");
1191
1192        let output = Command::new("git")
1193            .args(["commit", "--no-gpg-sign", "-m", "test commit"])
1194            .current_dir(&repo.path)
1195            .output()
1196            .expect("failed to execute git commit");
1197        assert!(output.status.success(), "git commit failed {output:?}");
1198
1199        repo.inner
1200            .head()
1201            .and_then(|head| head.peel_to_commit())
1202            .expect("failed to get the last commit")
1203    }
1204
1205    #[test]
1206    fn filter_git_blame_ignore_revs_removes_listed_and_ignore_file_only_commits() {
1207        let (repo, _temp_dir) = create_temp_repo();
1208
1209        let normal_commit_1 = create_commit_with_files(&repo, vec![("file1.txt", "content1")]);
1210        let ignored_commit = create_commit_with_files(&repo, vec![("file2.txt", "content2")]);
1211        let normal_commit_2 = create_commit_with_files(&repo, vec![("file3.txt", "content3")]);
1212
1213        // A commit that only adds/updates the ignore file itself should also
1214        // be filtered out, regardless of whether it lists itself.
1215        let ignore_file_contents = format!("# comment\n{}\n", ignored_commit.id());
1216        let ignore_file_commit = create_commit_with_files(&repo, vec![(
1217            ".git-blame-ignore-revs",
1218            ignore_file_contents.as_str(),
1219        )]);
1220
1221        let mut commits = repo
1222            .commits(None, None, None, false)
1223            .expect("failed to get commits");
1224        assert_eq!(commits.len(), 4, "sanity check before filtering");
1225
1226        repo.filter_git_blame_ignore_revs(&mut commits);
1227
1228        let remaining_ids: Vec<_> = commits.iter().map(git2::Commit::id).collect();
1229        assert!(remaining_ids.contains(&normal_commit_1.id()));
1230        assert!(remaining_ids.contains(&normal_commit_2.id()));
1231        assert!(
1232            !remaining_ids.contains(&ignored_commit.id()),
1233            "commit listed in .git-blame-ignore-revs should be filtered out"
1234        );
1235        assert!(
1236            !remaining_ids.contains(&ignore_file_commit.id()),
1237            "commit that only touches .git-blame-ignore-revs should be filtered out"
1238        );
1239        assert_eq!(commits.len(), 2);
1240    }
1241
1242    #[test]
1243    fn filter_git_blame_ignore_revs_is_a_no_op_without_the_file() {
1244        let (repo, _temp_dir) = create_temp_repo();
1245        create_commit_with_files(&repo, vec![("file1.txt", "content1")]);
1246
1247        let mut commits = repo
1248            .commits(None, None, None, false)
1249            .expect("failed to get commits");
1250        let before = commits.len();
1251
1252        repo.filter_git_blame_ignore_revs(&mut commits);
1253
1254        assert_eq!(
1255            commits.len(),
1256            before,
1257            "no .git-blame-ignore-revs file present"
1258        );
1259    }
1260
1261    #[test]
1262    fn test_normalize_pattern() {
1263        let normalize = |input: &str| {
1264            Repository::normalize_pattern(Pattern::new(input).expect("valid pattern"))
1265                .as_str()
1266                .to_string()
1267        };
1268
1269        assert_eq!(normalize("dir/"), "dir/**");
1270        assert_eq!(normalize("./dir/"), "dir/**");
1271        assert_eq!(normalize("./file.txt"), "file.txt");
1272        assert_eq!(normalize("dir/file.txt"), "dir/file.txt");
1273    }
1274
1275    #[test]
1276    fn test_should_retain_commit() {
1277        let (repo, _temp_dir) = create_temp_repo();
1278
1279        let new_pattern = |input: &str| {
1280            Repository::normalize_pattern(Pattern::new(input).expect("valid pattern"))
1281        };
1282
1283        let first_commit = create_commit_with_files(&repo, vec![
1284            ("initial.txt", "initial content"),
1285            ("dir/initial.txt", "initial content"),
1286        ]);
1287
1288        {
1289            let retain = repo.should_retain_commit(
1290                &first_commit,
1291                Some(vec![new_pattern("dir/")]).as_ref(),
1292                None,
1293            );
1294            assert!(retain, "include: dir/");
1295        }
1296
1297        let commit = create_commit_with_files(&repo, vec![
1298            ("file1.txt", "content1"),
1299            ("file2.txt", "content2"),
1300            ("dir/file3.txt", "content3"),
1301            ("dir/subdir/file4.txt", "content4"),
1302        ]);
1303
1304        {
1305            let retain = repo.should_retain_commit(&commit, None, None);
1306            assert!(retain, "no include/exclude patterns");
1307        }
1308
1309        {
1310            let retain =
1311                repo.should_retain_commit(&commit, Some(vec![new_pattern("./")]).as_ref(), None);
1312            assert!(retain, "include: ./");
1313        }
1314
1315        {
1316            let retain =
1317                repo.should_retain_commit(&commit, Some(vec![new_pattern("**")]).as_ref(), None);
1318            assert!(retain, "include: **");
1319        }
1320
1321        {
1322            let retain =
1323                repo.should_retain_commit(&commit, Some(vec![new_pattern("*")]).as_ref(), None);
1324            assert!(retain, "include: *");
1325        }
1326
1327        {
1328            let retain =
1329                repo.should_retain_commit(&commit, Some(vec![new_pattern("dir/")]).as_ref(), None);
1330            assert!(retain, "include: dir/");
1331        }
1332
1333        {
1334            let retain =
1335                repo.should_retain_commit(&commit, Some(vec![new_pattern("dir/*")]).as_ref(), None);
1336            assert!(retain, "include: dir/*");
1337        }
1338
1339        {
1340            let retain = repo.should_retain_commit(
1341                &commit,
1342                Some(vec![new_pattern("file1.txt")]).as_ref(),
1343                None,
1344            );
1345            assert!(retain, "include: file1.txt");
1346        }
1347
1348        {
1349            let retain = repo.should_retain_commit(
1350                &commit,
1351                None,
1352                Some(vec![new_pattern("file1.txt")]).as_ref(),
1353            );
1354            assert!(retain, "exclude: file1.txt");
1355        }
1356
1357        {
1358            let retain = repo.should_retain_commit(
1359                &commit,
1360                Some(vec![new_pattern("file1.txt")]).as_ref(),
1361                Some(vec![new_pattern("file2.txt")]).as_ref(),
1362            );
1363            assert!(retain, "include: file1.txt, exclude: file2.txt");
1364        }
1365
1366        {
1367            let retain = repo.should_retain_commit(
1368                &commit,
1369                None,
1370                Some(vec![new_pattern("**/*.txt")]).as_ref(),
1371            );
1372            assert!(!retain, "exclude: **/*.txt");
1373        }
1374    }
1375}