radicle_surf/
repo.rs

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