Skip to main content

radicle_surf/
repo.rs

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