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