radicle_surf/
repo.rs

1// This file is part of radicle-surf
2// <https://github.com/radicle-dev/radicle-surf>
3//
4// Copyright (C) 2019-2020 The Radicle Team <dev@radicle.xyz>
5//
6// This program is free software: you can redistribute it and/or modify
7// it under the terms of the GNU General Public License version 3 or
8// later as published by the Free Software Foundation.
9//
10// This program is distributed in the hope that it will be useful,
11// but WITHOUT ANY WARRANTY; without even the implied warranty of
12// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13// GNU General Public License for more details.
14//
15// You should have received a copy of the GNU General Public License
16// along with this program. If not, see <https://www.gnu.org/licenses/>.
17
18use std::{
19    collections::BTreeSet,
20    convert::TryFrom,
21    path::{Path, PathBuf},
22    str,
23};
24
25use git_ext::{
26    ref_format::{refspec::QualifiedPattern, Qualified, RefStr, RefString},
27    Oid,
28};
29
30use crate::{
31    blob::{Blob, BlobRef},
32    diff::{Diff, FileDiff},
33    fs::{Directory, File, FileContent},
34    refs::{BranchNames, Branches, Categories, Namespaces, TagNames, Tags},
35    tree::{Entry, Tree},
36    Branch, Commit, Error, Glob, History, Namespace, Revision, Signature, Stats, Tag, ToCommit,
37};
38
39/// Enumeration of errors that can occur in repo operations.
40pub mod error {
41    use std::path::PathBuf;
42    use thiserror::Error;
43
44    #[derive(Debug, Error)]
45    #[non_exhaustive]
46    pub enum Repo {
47        #[error("path not found for: {0}")]
48        PathNotFound(PathBuf),
49    }
50}
51
52/// Represents the state associated with a Git repository.
53///
54/// Many other types in this crate are derived from methods in this struct.
55pub struct Repository {
56    /// Wrapper around the `git2`'s `git2::Repository` type.
57    /// This is to to limit the functionality that we can do
58    /// on the underlying object.
59    inner: git2::Repository,
60}
61
62////////////////////////////////////////////
63// Public API, ONLY add `pub fn` in here. //
64////////////////////////////////////////////
65impl Repository {
66    /// Open a git repository given its exact URI.
67    ///
68    /// # Errors
69    ///
70    /// * [`Error::Git`]
71    pub fn open(repo_uri: impl AsRef<std::path::Path>) -> Result<Self, Error> {
72        let repo = git2::Repository::open(repo_uri)?;
73        Ok(Self { inner: repo })
74    }
75
76    /// Attempt to open a git repository at or above `repo_uri` in the file
77    /// system.
78    pub fn discover(repo_uri: impl AsRef<std::path::Path>) -> Result<Self, Error> {
79        let repo = git2::Repository::discover(repo_uri)?;
80        Ok(Self { inner: repo })
81    }
82
83    /// What is the current namespace we're browsing in.
84    pub fn which_namespace(&self) -> Result<Option<Namespace>, Error> {
85        self.inner
86            .namespace_bytes()
87            .map(|ns| Namespace::try_from(ns).map_err(Error::from))
88            .transpose()
89    }
90
91    /// Switch to a `namespace`
92    pub fn switch_namespace(&self, namespace: &RefString) -> Result<(), Error> {
93        Ok(self.inner.set_namespace(namespace.as_str())?)
94    }
95
96    pub fn with_namespace<T, F>(&self, namespace: &RefString, f: F) -> Result<T, Error>
97    where
98        F: FnOnce() -> Result<T, Error>,
99    {
100        self.switch_namespace(namespace)?;
101        let res = f();
102        self.inner.remove_namespace()?;
103        res
104    }
105
106    /// Returns an iterator of branches that match `pattern`.
107    pub fn branches<G>(&self, pattern: G) -> Result<Branches, Error>
108    where
109        G: Into<Glob<Branch>>,
110    {
111        let pattern = pattern.into();
112        let mut branches = Branches::default();
113        for glob in pattern.globs() {
114            let namespaced = self.namespaced_pattern(glob)?;
115            let references = self.inner.references_glob(&namespaced)?;
116            branches.push(references);
117        }
118        Ok(branches)
119    }
120
121    /// Lists branch names with `filter`.
122    pub fn branch_names<G>(&self, filter: G) -> Result<BranchNames, Error>
123    where
124        G: Into<Glob<Branch>>,
125    {
126        Ok(self.branches(filter)?.names())
127    }
128
129    /// Returns an iterator of tags that match `pattern`.
130    pub fn tags(&self, pattern: &Glob<Tag>) -> Result<Tags, Error> {
131        let mut tags = Tags::default();
132        for glob in pattern.globs() {
133            let namespaced = self.namespaced_pattern(glob)?;
134            let references = self.inner.references_glob(&namespaced)?;
135            tags.push(references);
136        }
137        Ok(tags)
138    }
139
140    /// Lists tag names in the local RefScope.
141    pub fn tag_names(&self, filter: &Glob<Tag>) -> Result<TagNames, Error> {
142        Ok(self.tags(filter)?.names())
143    }
144
145    pub fn categories(&self, pattern: &Glob<Qualified<'_>>) -> Result<Categories, Error> {
146        let mut cats = Categories::default();
147        for glob in pattern.globs() {
148            let namespaced = self.namespaced_pattern(glob)?;
149            let references = self.inner.references_glob(&namespaced)?;
150            cats.push(references);
151        }
152        Ok(cats)
153    }
154
155    /// Returns an iterator of namespaces that match `pattern`.
156    pub fn namespaces(&self, pattern: &Glob<Namespace>) -> Result<Namespaces, Error> {
157        let mut set = BTreeSet::new();
158        for glob in pattern.globs() {
159            let new_set = self
160                .inner
161                .references_glob(glob)?
162                .map(|reference| {
163                    reference
164                        .map_err(Error::Git)
165                        .and_then(|r| Namespace::try_from(&r).map_err(Error::from))
166                })
167                .collect::<Result<BTreeSet<Namespace>, Error>>()?;
168            set.extend(new_set);
169        }
170        Ok(Namespaces::new(set))
171    }
172
173    /// Get the [`Diff`] between two commits.
174    pub fn diff(&self, from: impl Revision, to: impl Revision) -> Result<Diff, Error> {
175        let from_commit = self.find_commit(self.object_id(&from)?)?;
176        let to_commit = self.find_commit(self.object_id(&to)?)?;
177        self.diff_commits(None, Some(&from_commit), &to_commit)
178            .and_then(|diff| Diff::try_from(diff).map_err(Error::from))
179    }
180
181    /// Get the [`Diff`] of a `commit`.
182    ///
183    /// If the `commit` has a parent, then it the diff will be a
184    /// comparison between itself and that parent. Otherwise, the left
185    /// hand side of the diff will pass nothing.
186    pub fn diff_commit(&self, commit: impl ToCommit) -> Result<Diff, Error> {
187        let commit = commit
188            .to_commit(self)
189            .map_err(|err| Error::ToCommit(err.into()))?;
190        match commit.parents.first() {
191            Some(parent) => self.diff(*parent, commit.id),
192            None => self.initial_diff(commit.id),
193        }
194    }
195
196    /// Get the [`FileDiff`] between two revisions for a file at `path`.
197    ///
198    /// If `path` is only a directory name, not a file, returns
199    /// a [`FileDiff`] for any file under `path`.
200    pub fn diff_file<P: AsRef<Path>, R: Revision>(
201        &self,
202        path: &P,
203        from: R,
204        to: R,
205    ) -> Result<FileDiff, Error> {
206        let from_commit = self.find_commit(self.object_id(&from)?)?;
207        let to_commit = self.find_commit(self.object_id(&to)?)?;
208        let diff = self
209            .diff_commits(Some(path.as_ref()), Some(&from_commit), &to_commit)
210            .and_then(|diff| Diff::try_from(diff).map_err(Error::from))?;
211        let file_diff = diff
212            .into_files()
213            .pop()
214            .ok_or(error::Repo::PathNotFound(path.as_ref().to_path_buf()))?;
215        Ok(file_diff)
216    }
217
218    /// Parse an [`Oid`] from the given string.
219    pub fn oid(&self, oid: &str) -> Result<Oid, Error> {
220        Ok(self.inner.revparse_single(oid)?.id().into())
221    }
222
223    /// Returns a top level `Directory` without nested sub-directories.
224    ///
225    /// To visit inside any nested sub-directories, call `directory.get(&repo)`
226    /// on the sub-directory.
227    pub fn root_dir<C: ToCommit>(&self, commit: C) -> Result<Directory, Error> {
228        let commit = commit
229            .to_commit(self)
230            .map_err(|err| Error::ToCommit(err.into()))?;
231        let git2_commit = self.inner.find_commit((commit.id).into())?;
232        let tree = git2_commit.as_object().peel_to_tree()?;
233        Ok(Directory::root(tree.id().into()))
234    }
235
236    /// Returns a [`Directory`] for `path` in `commit`.
237    pub fn directory<C: ToCommit, P: AsRef<Path>>(
238        &self,
239        commit: C,
240        path: &P,
241    ) -> Result<Directory, Error> {
242        let root = self.root_dir(commit)?;
243        Ok(root.find_directory(path, self)?)
244    }
245
246    /// Returns a [`File`] for `path` in `commit`.
247    pub fn file<C: ToCommit, P: AsRef<Path>>(&self, commit: C, path: &P) -> Result<File, Error> {
248        let root = self.root_dir(commit)?;
249        Ok(root.find_file(path, self)?)
250    }
251
252    /// Returns a [`Tree`] for `path` in `commit`.
253    pub fn tree<C: ToCommit, P: AsRef<Path>>(&self, commit: C, path: &P) -> Result<Tree, Error> {
254        let commit = commit
255            .to_commit(self)
256            .map_err(|e| Error::ToCommit(e.into()))?;
257        let dir = self.directory(commit.id, path)?;
258        let mut entries = dir
259            .entries(self)?
260            .map(|en| {
261                let name = en.name().to_string();
262                let path = en.path();
263                let commit = self
264                    .last_commit(&path, commit.id)?
265                    .ok_or(error::Repo::PathNotFound(path))?;
266                Ok(Entry::new(name, en.into(), commit))
267            })
268            .collect::<Result<Vec<Entry>, Error>>()?;
269        entries.sort();
270
271        let last_commit = self
272            .last_commit(path, commit)?
273            .ok_or_else(|| error::Repo::PathNotFound(path.as_ref().to_path_buf()))?;
274        Ok(Tree::new(dir.id(), entries, last_commit))
275    }
276
277    /// Returns a [`Blob`] for `path` in `commit`.
278    pub fn blob<'a, C: ToCommit, P: AsRef<Path>>(
279        &'a self,
280        commit: C,
281        path: &P,
282    ) -> Result<Blob<BlobRef<'a>>, Error> {
283        let commit = commit
284            .to_commit(self)
285            .map_err(|e| Error::ToCommit(e.into()))?;
286        let file = self.file(commit.id, path)?;
287        let last_commit = self
288            .last_commit(path, commit)?
289            .ok_or_else(|| error::Repo::PathNotFound(path.as_ref().to_path_buf()))?;
290        let git2_blob = self.find_blob(file.id())?;
291        Ok(Blob::<BlobRef<'a>>::new(file.id(), git2_blob, last_commit))
292    }
293
294    pub fn blob_ref(&self, oid: Oid) -> Result<BlobRef<'_>, Error> {
295        Ok(BlobRef {
296            inner: self.find_blob(oid)?,
297        })
298    }
299
300    /// Returns the last commit, if exists, for a `path` in the history of
301    /// `rev`.
302    pub fn last_commit<P, C>(&self, path: &P, rev: C) -> Result<Option<Commit>, Error>
303    where
304        P: AsRef<Path>,
305        C: ToCommit,
306    {
307        let history = self.history(rev)?;
308        history.by_path(path).next().transpose()
309    }
310
311    /// Returns a commit for `rev`, if it exists.
312    pub fn commit<R: Revision>(&self, rev: R) -> Result<Commit, Error> {
313        rev.to_commit(self)
314    }
315
316    /// Gets the [`Stats`] of this repository starting from the
317    /// `HEAD` (see [`Repository::head`]) of the repository.
318    pub fn stats(&self) -> Result<Stats, Error> {
319        self.stats_from(&self.head()?)
320    }
321
322    /// Gets the [`Stats`] of this repository starting from the given
323    /// `rev`.
324    pub fn stats_from<R>(&self, rev: &R) -> Result<Stats, Error>
325    where
326        R: Revision,
327    {
328        let branches = self.branches(Glob::all_heads())?.count();
329        let mut history = self.history(rev)?;
330        let (commits, contributors) = history.try_fold(
331            (0, BTreeSet::new()),
332            |(commits, mut contributors), commit| {
333                let commit = commit?;
334                contributors.insert((commit.author.name, commit.author.email));
335                Ok::<_, Error>((commits + 1, contributors))
336            },
337        )?;
338        Ok(Stats {
339            branches,
340            commits,
341            contributors: contributors.len(),
342        })
343    }
344
345    // TODO(finto): I think this can be removed in favour of using
346    // `source::Blob::new`
347    /// Retrieves the file with `path` in this commit.
348    pub fn get_commit_file<P, R>(&self, rev: &R, path: &P) -> Result<FileContent, Error>
349    where
350        P: AsRef<Path>,
351        R: Revision,
352    {
353        let path = path.as_ref();
354        let id = self.object_id(rev)?;
355        let commit = self.find_commit(id)?;
356        let tree = commit.tree()?;
357        let entry = tree.get_path(path)?;
358        let object = entry.to_object(&self.inner)?;
359        let blob = object
360            .into_blob()
361            .map_err(|_| error::Repo::PathNotFound(path.to_path_buf()))?;
362        Ok(FileContent::new(blob))
363    }
364
365    /// Returns the [`Oid`] of the current `HEAD`.
366    pub fn head(&self) -> Result<Oid, Error> {
367        let head = self.inner.head()?;
368        let head_commit = head.peel_to_commit()?;
369        Ok(head_commit.id().into())
370    }
371
372    /// Extract the signature from a commit
373    ///
374    /// # Arguments
375    ///
376    /// `field` - the name of the header field containing the signature block;
377    ///           pass `None` to extract the default 'gpgsig'
378    pub fn extract_signature(
379        &self,
380        commit: impl ToCommit,
381        field: Option<&str>,
382    ) -> Result<Option<Signature>, Error> {
383        // Match is necessary here because according to the documentation for
384        // git_commit_extract_signature at
385        // https://libgit2.org/libgit2/#HEAD/group/commit/git_commit_extract_signature
386        // the return value for a commit without a signature will be GIT_ENOTFOUND
387        let commit = commit
388            .to_commit(self)
389            .map_err(|e| Error::ToCommit(e.into()))?;
390
391        match self.inner.extract_signature(&commit.id, field) {
392            Err(error) => {
393                if error.code() == git2::ErrorCode::NotFound {
394                    Ok(None)
395                } else {
396                    Err(error.into())
397                }
398            }
399            Ok(sig) => Ok(Some(Signature::from(sig.0))),
400        }
401    }
402
403    /// Returns the history with the `head` commit.
404    pub fn history<C: ToCommit>(&self, head: C) -> Result<History, Error> {
405        History::new(self, head)
406    }
407
408    /// Lists branches that are reachable from `rev`.
409    pub fn revision_branches(
410        &self,
411        rev: impl Revision,
412        glob: Glob<Branch>,
413    ) -> Result<Vec<Branch>, Error> {
414        let oid = self.object_id(&rev)?;
415        let mut contained_branches = vec![];
416        for branch in self.branches(glob)? {
417            let branch = branch?;
418            let namespaced = self.namespaced_refname(&branch.refname())?;
419            let reference = self.inner.find_reference(namespaced.as_str())?;
420            if self.reachable_from(&reference, &oid)? {
421                contained_branches.push(branch);
422            }
423        }
424
425        Ok(contained_branches)
426    }
427}
428
429////////////////////////////////////////////////////////////
430// Private API, ONLY add `pub(crate) fn` or `fn` in here. //
431////////////////////////////////////////////////////////////
432impl Repository {
433    pub(crate) fn is_bare(&self) -> bool {
434        self.inner.is_bare()
435    }
436
437    pub(crate) fn find_submodule(&self, name: &str) -> Result<git2::Submodule, git2::Error> {
438        self.inner.find_submodule(name)
439    }
440
441    pub(crate) fn find_blob(&self, oid: Oid) -> Result<git2::Blob<'_>, git2::Error> {
442        self.inner.find_blob(oid.into())
443    }
444
445    pub(crate) fn find_commit(&self, oid: Oid) -> Result<git2::Commit<'_>, git2::Error> {
446        self.inner.find_commit(oid.into())
447    }
448
449    pub(crate) fn find_tree(&self, oid: Oid) -> Result<git2::Tree<'_>, git2::Error> {
450        self.inner.find_tree(oid.into())
451    }
452
453    pub(crate) fn refname_to_id<R>(&self, name: &R) -> Result<Oid, git2::Error>
454    where
455        R: AsRef<RefStr>,
456    {
457        self.inner
458            .refname_to_id(name.as_ref().as_str())
459            .map(Oid::from)
460    }
461
462    pub(crate) fn revwalk(&self) -> Result<git2::Revwalk<'_>, git2::Error> {
463        self.inner.revwalk()
464    }
465
466    pub(super) fn object_id<R: Revision>(&self, r: &R) -> Result<Oid, Error> {
467        r.object_id(self).map_err(|err| Error::Revision(err.into()))
468    }
469
470    /// Get the [`Diff`] of a commit with no parents.
471    fn initial_diff<R: Revision>(&self, rev: R) -> Result<Diff, Error> {
472        let commit = self.find_commit(self.object_id(&rev)?)?;
473        self.diff_commits(None, None, &commit)
474            .and_then(|diff| Diff::try_from(diff).map_err(Error::from))
475    }
476
477    fn reachable_from(&self, reference: &git2::Reference, oid: &Oid) -> Result<bool, Error> {
478        let git2_oid = (*oid).into();
479        let other = reference.peel_to_commit()?.id();
480        let is_descendant = self.inner.graph_descendant_of(other, git2_oid)?;
481
482        Ok(other == git2_oid || is_descendant)
483    }
484
485    pub(crate) fn diff_commit_and_parents<P>(
486        &self,
487        path: &P,
488        commit: &git2::Commit,
489    ) -> Result<Option<PathBuf>, Error>
490    where
491        P: AsRef<Path>,
492    {
493        let mut parents = commit.parents();
494
495        let diff = self.diff_commits(Some(path.as_ref()), parents.next().as_ref(), commit)?;
496        if let Some(_delta) = diff.deltas().next() {
497            Ok(Some(path.as_ref().to_path_buf()))
498        } else {
499            Ok(None)
500        }
501    }
502
503    /// Create a diff with the difference between two tree objects.
504    ///
505    /// Defines some options and flags that are passed to git2.
506    ///
507    /// Note:
508    /// libgit2 optimizes around not loading the content when there's no content
509    /// callbacks configured. Be aware that binaries aren't detected as
510    /// expected.
511    ///
512    /// Reference: <https://github.com/libgit2/libgit2/issues/6637>
513    fn diff_commits(
514        &self,
515        path: Option<&Path>,
516        from: Option<&git2::Commit>,
517        to: &git2::Commit,
518    ) -> Result<git2::Diff, Error> {
519        let new_tree = to.tree()?;
520        let old_tree = from.map_or(Ok(None), |c| c.tree().map(Some))?;
521
522        let mut opts = git2::DiffOptions::new();
523        if let Some(path) = path {
524            opts.pathspec(path.to_string_lossy().to_string());
525            opts.skip_binary_check(false);
526        }
527
528        let mut diff =
529            self.inner
530                .diff_tree_to_tree(old_tree.as_ref(), Some(&new_tree), Some(&mut opts))?;
531
532        // Detect renames by default.
533        let mut find_opts = git2::DiffFindOptions::new();
534        find_opts.renames(true);
535        find_opts.copies(true);
536        diff.find_similar(Some(&mut find_opts))?;
537
538        Ok(diff)
539    }
540
541    /// Returns a full reference name with namespace(s) included.
542    pub(crate) fn namespaced_refname<'a>(
543        &'a self,
544        refname: &Qualified<'a>,
545    ) -> Result<Qualified<'a>, Error> {
546        let fullname = match self.which_namespace()? {
547            Some(namespace) => namespace.to_namespaced(refname).into_qualified(),
548            None => refname.clone(),
549        };
550        Ok(fullname)
551    }
552
553    /// Returns a full reference name with namespace(s) included.
554    fn namespaced_pattern<'a>(
555        &'a self,
556        refname: &QualifiedPattern<'a>,
557    ) -> Result<QualifiedPattern<'a>, Error> {
558        let fullname = match self.which_namespace()? {
559            Some(namespace) => namespace.to_namespaced_pattern(refname).into_qualified(),
560            None => refname.clone(),
561        };
562        Ok(fullname)
563    }
564}
565
566impl From<git2::Repository> for Repository {
567    fn from(repo: git2::Repository) -> Self {
568        Repository { inner: repo }
569    }
570}
571
572impl std::fmt::Debug for Repository {
573    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
574        write!(f, ".git")
575    }
576}