Radish alpha
r
rad:z6cFWeWpnZNHh9rUW8phgA3b5yGt
Git libraries for Radicle
Radicle
Git
surf: add submodule support
Merged fintohaps opened 2 years ago

Git submodules are encoded as commits when walking a Git tree. To allow the support of them in radicle-surf, the tree entry is checked for this case. The entry will be treated such that it is has a name, prefix, Oid, and the submodule’s URL.

It’s important to note that the Oid is not usable within the context of the repository that is being browsed, since they are entirely separate.

Instead, the URL should be used in conjunction with the Oid for browsing purposes outside of radicle-surf. However, in a future iteration it may be considered to be able to browse the local contents of the submodule, if possible.

12 files changed +193 -20 e0b8789b 2391c25e
added .gitmodules
@@ -0,0 +1,3 @@
+
[submodule "git-platinum"]
+
	path = radicle-surf/data/git-platinum
+
	url = https://github.com/radicle-dev/git-platinum.git
modified nix/sources.json
@@ -5,10 +5,10 @@
        "homepage": "https://github.com/nmattia/niv",
        "owner": "nmattia",
        "repo": "niv",
-
        "rev": "723f0eeb969a730db3c30f977c2b66b9dce9fe4a",
-
        "sha256": "0016l7230gd2kdh0g2w573r9a2krqb7x4ifcjhhsn4h1bwap7qr0",
+
        "rev": "d67c25f29716fd2087e71352783fcce194303a9a",
+
        "sha256": "1813r42sz4pmv1syn38s281lmg2l7h779q4r33nn5azm7wy45yrh",
        "type": "tarball",
-
        "url": "https://github.com/nmattia/niv/archive/723f0eeb969a730db3c30f977c2b66b9dce9fe4a.tar.gz",
+
        "url": "https://github.com/nmattia/niv/archive/d67c25f29716fd2087e71352783fcce194303a9a.tar.gz",
        "url_template": "https://github.com/<owner>/<repo>/archive/<rev>.tar.gz"
    },
    "nixpkgs": {
@@ -29,10 +29,10 @@
        "homepage": "",
        "owner": "oxalica",
        "repo": "rust-overlay",
-
        "rev": "7c94410d52d4e8bd72803fc1fe6c51fe179edaf5",
-
        "sha256": "1m4awxwvmwmv61fpl9jwfg2ka241hwl37rqffw6lpqb8rj3safff",
+
        "rev": "e86c0fb5d3a22a5f30d7f64ecad88643fe26449d",
+
        "sha256": "138pmz1ypw5j34bywfaffxkffsgfmd0s6889bdc69356cp11zvlm",
        "type": "tarball",
-
        "url": "https://github.com/oxalica/rust-overlay/archive/7c94410d52d4e8bd72803fc1fe6c51fe179edaf5.tar.gz",
+
        "url": "https://github.com/oxalica/rust-overlay/archive/e86c0fb5d3a22a5f30d7f64ecad88643fe26449d.tar.gz",
        "url_template": "https://github.com/<owner>/<repo>/archive/<rev>.tar.gz"
    }
}
modified radicle-surf/Cargo.toml
@@ -26,6 +26,7 @@ doctest = false
# to ignore the test on CI.
gh-actions = []
minicbor = ["radicle-git-ext/minicbor"]
+
serde = ["dep:serde", "url/serde"]

[dependencies]
base64 = "0.13"
@@ -52,6 +53,9 @@ version = "1"
features = ["serde_derive"]
optional = true

+
[dependencies.url]
+
version = "2.5"
+

[build-dependencies]
anyhow = "1.0"
flate2 = "1"
added radicle-surf/data/git-platinum
@@ -0,0 +1 @@
+
Subproject commit 27acd68c7504755aa11023300890bb85bbd69d45
modified radicle-surf/examples/browsing.rs
@@ -55,6 +55,7 @@ fn print_directory(d: &Directory, repo: &Repository, indent_level: usize) {
        match entry {
            fs::Entry::File(f) => println!("    {}{}", &indent, f.name()),
            fs::Entry::Directory(d) => print_directory(&d, repo, indent_level + 1),
+
            fs::Entry::Submodule(s) => println!("    {}{}", &indent, s.name()),
        }
    }
}
modified radicle-surf/src/fs.rs
@@ -30,6 +30,7 @@ use std::{
use git2::Blob;
use radicle_git_ext::{is_not_found_err, Oid};
use radicle_std_ext::result::ResultExt as _;
+
use url::Url;

use crate::{Repository, Revision};

@@ -52,6 +53,8 @@ pub mod error {
        Utf8Error,
        #[error("the path {0} not found")]
        PathNotFound(PathBuf),
+
        #[error(transparent)]
+
        Submodule(#[from] Submodule),
    }

    #[derive(Debug, Error, PartialEq)]
@@ -59,6 +62,23 @@ pub mod error {
        #[error(transparent)]
        Git(#[from] git2::Error),
    }
+

+
    #[derive(Debug, Error, PartialEq)]
+
    pub enum Submodule {
+
        #[error("URL is invalid utf-8 for submodule '{name}': {err}")]
+
        Utf8 {
+
            name: String,
+
            #[source]
+
            err: std::str::Utf8Error,
+
        },
+
        #[error("failed to parse URL '{url}' for submodule '{name}': {err}")]
+
        ParseUrl {
+
            name: String,
+
            url: String,
+
            #[source]
+
            err: url::ParseError,
+
        },
+
    }
}

/// A `File` in a git repository.
@@ -198,6 +218,8 @@ pub enum Entry {
    File(File),
    /// A sub-directory of a [`Directory`].
    Directory(Directory),
+
    /// An entry points to a submodule.
+
    Submodule(Submodule),
}

impl PartialOrd for Entry {
@@ -211,19 +233,25 @@ impl Ord for Entry {
        match (self, other) {
            (Entry::File(x), Entry::File(y)) => x.name().cmp(y.name()),
            (Entry::File(_), Entry::Directory(_)) => Ordering::Less,
+
            (Entry::File(_), Entry::Submodule(_)) => Ordering::Less,
            (Entry::Directory(_), Entry::File(_)) => Ordering::Greater,
+
            (Entry::Submodule(_), Entry::File(_)) => Ordering::Less,
            (Entry::Directory(x), Entry::Directory(y)) => x.name().cmp(y.name()),
+
            (Entry::Directory(x), Entry::Submodule(y)) => x.name().cmp(y.name()),
+
            (Entry::Submodule(x), Entry::Directory(y)) => x.name().cmp(y.name()),
+
            (Entry::Submodule(x), Entry::Submodule(y)) => x.name().cmp(y.name()),
        }
    }
}

impl Entry {
-
    /// Get a label for the `Entriess`, either the name of the [`File`]
-
    /// or the name of the [`Directory`].
+
    /// Get a label for the `Entriess`, either the name of the [`File`],
+
    /// the name of the [`Directory`], or the name of the [`Submodule`].
    pub fn name(&self) -> &String {
        match self {
            Entry::File(file) => &file.name,
            Entry::Directory(directory) => directory.name(),
+
            Entry::Submodule(submodule) => submodule.name(),
        }
    }

@@ -231,6 +259,7 @@ impl Entry {
        match self {
            Entry::File(file) => file.path(),
            Entry::Directory(directory) => directory.path(),
+
            Entry::Submodule(submodule) => submodule.path(),
        }
    }

@@ -238,6 +267,7 @@ impl Entry {
        match self {
            Entry::File(file) => file.location(),
            Entry::Directory(directory) => directory.location(),
+
            Entry::Submodule(submodule) => submodule.location(),
        }
    }

@@ -254,6 +284,7 @@ impl Entry {
    pub(crate) fn from_entry(
        entry: &git2::TreeEntry,
        path: PathBuf,
+
        repo: &Repository,
    ) -> Result<Self, error::Directory> {
        let name = entry.name().ok_or(error::Directory::Utf8Error)?.to_string();
        let id = entry.id().into();
@@ -261,6 +292,12 @@ impl Entry {
        match entry.kind() {
            Some(git2::ObjectType::Tree) => Ok(Self::Directory(Directory::new(name, path, id))),
            Some(git2::ObjectType::Blob) => Ok(Self::File(File::new(name, path, id))),
+
            Some(git2::ObjectType::Commit) => {
+
                let submodule = (!repo.is_bare())
+
                    .then(|| repo.find_submodule(&name))
+
                    .transpose()?;
+
                Ok(Self::Submodule(Submodule::new(name, path, submodule, id)?))
+
            },
            _ => Err(error::Directory::InvalidType(path, "tree or blob")),
        }
    }
@@ -354,7 +391,7 @@ impl Directory {
        // Walks only the first level of entries. And `_entry_path` is always
        // empty for the first level.
        tree.walk(git2::TreeWalkMode::PreOrder, |_entry_path, entry| {
-
            match Entry::from_entry(entry, path.clone()) {
+
            match Entry::from_entry(entry, path.clone(), repo) {
                Ok(entry) => match entry {
                    Entry::File(_) => {
                        entries.insert(entry.name().clone(), entry);
@@ -365,6 +402,10 @@ impl Directory {
                        // Skip nested directories
                        git2::TreeWalkResult::Skip
                    },
+
                    Entry::Submodule(_) => {
+
                        entries.insert(entry.name().clone(), entry);
+
                        git2::TreeWalkResult::Ok
+
                    },
                },
                Err(err) => {
                    error = Some(err);
@@ -397,7 +438,7 @@ impl Directory {
            .ok_or_else(|| error::Directory::InvalidPath(path.to_path_buf()))?;
        let root_path = self.path().join(parent);

-
        Entry::from_entry(&entry, root_path)
+
        Entry::from_entry(&entry, root_path, repo)
    }

    /// Find the `Oid`, for a [`File`], found at `path`, if it exists.
@@ -447,6 +488,7 @@ impl Directory {
        self.traverse(repo, 0, &mut |size, entry| match entry {
            Entry::File(file) => Ok(size + file.content(repo)?.size()),
            Entry::Directory(dir) => Ok(size + dir.size(repo)?),
+
            Entry::Submodule(_) => Ok(size),
        })
    }

@@ -479,6 +521,7 @@ impl Directory {
                    let acc = directory.traverse(repo, acc, f)?;
                    f(acc, entry)
                },
+
                Entry::Submodule(_) => f(acc, entry),
            })
    }
}
@@ -491,6 +534,93 @@ impl Revision for Directory {
    }
}

+
/// A representation of a Git [submodule] when encountered in a Git
+
/// repository.
+
///
+
/// [submodule]: https://git-scm.com/book/en/v2/Git-Tools-Submodules
+
#[derive(Debug, Clone, PartialEq, Eq)]
+
pub struct Submodule {
+
    name: String,
+
    prefix: PathBuf,
+
    id: Oid,
+
    url: Option<Url>,
+
}
+

+
impl Submodule {
+
    /// Construct a new `Submodule`.
+
    ///
+
    /// The `path` must be the prefix location of the directory, and
+
    /// so should not end in `name`.
+
    ///
+
    /// The `id` is the commit pointer that Git provides when listing
+
    /// a submodule.
+
    pub fn new(
+
        name: String,
+
        prefix: PathBuf,
+
        submodule: Option<git2::Submodule>,
+
        id: Oid,
+
    ) -> Result<Self, error::Submodule> {
+
        let url = submodule
+
            .and_then(|module| {
+
                module
+
                    .opt_url_bytes()
+
                    .map(|bs| std::str::from_utf8(bs).map(|url| url.to_string()))
+
            })
+
            .transpose()
+
            .map_err(|err| error::Submodule::Utf8 {
+
                name: name.clone(),
+
                err,
+
            })?;
+
        let url = url
+
            .map(|url| {
+
                Url::parse(&url).map_err(|err| error::Submodule::ParseUrl {
+
                    name: name.clone(),
+
                    url,
+
                    err,
+
                })
+
            })
+
            .transpose()?;
+
        Ok(Self {
+
            name,
+
            prefix,
+
            id,
+
            url,
+
        })
+
    }
+

+
    /// The name of this `Submodule`.
+
    pub fn name(&self) -> &String {
+
        &self.name
+
    }
+

+
    /// Return the [`Path`] where this `Submodule` is located, relative to the
+
    /// git repository root.
+
    pub fn location(&self) -> &Path {
+
        &self.prefix
+
    }
+

+
    /// Return the exact path for this `Submodule`, including the
+
    /// `name` of the submodule itself.
+
    ///
+
    /// The path is relative to the git repository root.
+
    pub fn path(&self) -> PathBuf {
+
        self.prefix.join(escaped_name(&self.name))
+
    }
+

+
    /// The object identifier of this `Submodule`.
+
    ///
+
    /// Note that this does not exist in the parent `Repository`. A
+
    /// new `Repository` should be opened for the submodule.
+
    pub fn id(&self) -> Oid {
+
        self.id
+
    }
+

+
    /// The URL for the submodule, if it is defined.
+
    pub fn url(&self) -> &Option<Url> {
+
        &self.url
+
    }
+
}
+

/// When we need to escape "\" (represented as `\\`) for `PathBuf`
/// so that it can be processed correctly.
fn escaped_name(name: &str) -> String {
modified radicle-surf/src/repo.rs
@@ -440,6 +440,14 @@ impl Repository {
// Private API, ONLY add `pub(crate) fn` or `fn` in here. //
////////////////////////////////////////////////////////////
impl Repository {
+
    pub(crate) fn is_bare(&self) -> bool {
+
        self.inner.is_bare()
+
    }
+

+
    pub(crate) fn find_submodule(&self, name: &str) -> Result<git2::Submodule, git2::Error> {
+
        self.inner.find_submodule(name)
+
    }
+

    pub(crate) fn find_blob(&self, oid: Oid) -> Result<git2::Blob<'_>, git2::Error> {
        self.inner.find_blob(oid.into())
    }
modified radicle-surf/src/stats.rs
@@ -15,8 +15,6 @@
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.

-
pub use radicle_git_ext::Oid;
-

#[cfg(feature = "serde")]
use serde::Serialize;

modified radicle-surf/src/tree.rs
@@ -26,6 +26,7 @@ use serde::{
    ser::{SerializeStruct as _, Serializer},
    Serialize,
};
+
use url::Url;

use crate::{fs, Commit};

@@ -108,10 +109,11 @@ impl Serialize for Tree {
    }
}

-
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EntryKind {
    Tree(Oid),
    Blob(Oid),
+
    Submodule { id: Oid, url: Option<Url> },
}

impl PartialOrd for EntryKind {
@@ -123,9 +125,14 @@ impl PartialOrd for EntryKind {
impl Ord for EntryKind {
    fn cmp(&self, other: &Self) -> Ordering {
        match (self, other) {
+
            (EntryKind::Submodule { .. }, EntryKind::Submodule { .. }) => Ordering::Equal,
+
            (EntryKind::Submodule { .. }, EntryKind::Tree(_)) => Ordering::Equal,
+
            (EntryKind::Tree(_), EntryKind::Submodule { .. }) => Ordering::Equal,
            (EntryKind::Tree(_), EntryKind::Tree(_)) => Ordering::Equal,
            (EntryKind::Tree(_), EntryKind::Blob(_)) => Ordering::Less,
            (EntryKind::Blob(_), EntryKind::Tree(_)) => Ordering::Greater,
+
            (EntryKind::Submodule { .. }, EntryKind::Blob(_)) => Ordering::Less,
+
            (EntryKind::Blob(_), EntryKind::Submodule { .. }) => Ordering::Greater,
            (EntryKind::Blob(_), EntryKind::Blob(_)) => Ordering::Equal,
        }
    }
@@ -177,6 +184,7 @@ impl Entry {
        match self.entry {
            EntryKind::Blob(id) => id,
            EntryKind::Tree(id) => id,
+
            EntryKind::Submodule { id, .. } => id,
        }
    }
}
@@ -209,6 +217,10 @@ impl From<fs::Entry> for EntryKind {
        match entry {
            fs::Entry::File(f) => EntryKind::Blob(f.id()),
            fs::Entry::Directory(d) => EntryKind::Tree(d.id()),
+
            fs::Entry::Submodule(u) => EntryKind::Submodule {
+
                id: u.id(),
+
                url: u.url().clone(),
+
            },
        }
    }
}
@@ -241,7 +253,7 @@ impl Serialize for Entry {
    where
        S: Serializer,
    {
-
        const FIELDS: usize = 4;
+
        const FIELDS: usize = 5;
        let mut state = serializer.serialize_struct("TreeEntry", FIELDS)?;
        state.serialize_field("name", &self.name)?;
        state.serialize_field(
@@ -249,8 +261,12 @@ impl Serialize for Entry {
            match self.entry {
                EntryKind::Blob(_) => "blob",
                EntryKind::Tree(_) => "tree",
+
                EntryKind::Submodule { .. } => "submodule",
            },
        )?;
+
        if let EntryKind::Submodule { url: Some(url), .. } = &self.entry {
+
            state.serialize_field("url", url)?;
+
        };
        state.serialize_field("oid", &self.object_id())?;
        state.serialize_field("lastCommit", &self.commit)?;
        state.end()
modified radicle-surf/t/src/code_browsing.rs
@@ -31,6 +31,7 @@ fn iterate_root_dir_recursive() {
                match entry {
                    fs::Entry::File(_) => Ok((count + 1, indent_level)),
                    fs::Entry::Directory(_) => Ok((count + 1, indent_level + 1)),
+
                    fs::Entry::Submodule(_) => Ok((count + 1, indent_level)),
                }
            },
        )
modified radicle-surf/t/src/diff.rs
@@ -129,7 +129,7 @@ fn test_diff() -> Result<(), Error> {
    let repo = Repository::open(GIT_PLATINUM)?;
    let oid = "80bacafba303bf0cdf6142921f430ff265f25095";
    let commit = repo.commit(oid).unwrap();
-
    let parent_oid = commit.parents.get(0).unwrap();
+
    let parent_oid = commit.parents.first().unwrap();
    let diff = repo.diff(*parent_oid, oid)?;

    let expected_files = vec![FileDiff::Modified(Modified {
modified radicle-surf/t/src/submodule.rs
@@ -1,10 +1,21 @@
-
#[cfg(not(feature = "gh-actions"))]
+
use std::path::Path;
+

+
use radicle_surf::tree::EntryKind;
+

#[test]
-
// An issue with submodules, see: https://github.com/radicle-dev/radicle-surf/issues/54
-
fn test_submodule_failure() {
+
fn test_submodule() {
    use radicle_git_ext::ref_format::refname;
-
    use radicle_surf::{Branch, Repository};
+
    use radicle_surf::{fs, Branch, Repository};

    let repo = Repository::discover(".").unwrap();
-
    repo.root_dir(Branch::local(refname!("main"))).unwrap();
+
    let branch = Branch::local(refname!("surf/submodule-support"));
+
    let dir = repo.root_dir(&branch).unwrap();
+
    let platinum = dir
+
        .find_entry(&Path::new("radicle-surf/data/git-platinum"), &repo)
+
        .unwrap();
+
    assert!(matches!(&platinum, fs::Entry::Submodule(module) if module.url().is_some()));
+

+
    let surf = repo.tree(&branch, &Path::new("radicle-surf/data")).unwrap();
+
    let kind = EntryKind::from(platinum);
+
    assert!(surf.entries().iter().any(|e| e.entry() == &kind));
}