Skip to content

Commit

Permalink
surf: add submodule support
Browse files Browse the repository at this point in the history
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.

[submodules]: https://git-scm.com/book/en/v2/Git-Tools-Submodules

Signed-off-by: Fintan Halpenny <fintan.halpenny@gmail.com>
X-Clacks-Overhead: GNU Terry Pratchett
  • Loading branch information
FintanH committed Mar 4, 2024
1 parent e0b8789 commit 78c6a08
Show file tree
Hide file tree
Showing 9 changed files with 173 additions and 11 deletions.
3 changes: 3 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[submodule "git-platinum"]
path = radicle-surf/data/git-platinum
url = https://github.com/radicle-dev/git-platinum.git
4 changes: 4 additions & 0 deletions radicle-surf/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -52,6 +53,9 @@ version = "1"
features = ["serde_derive"]
optional = true

[dependencies.url]
version = "2.5"

[build-dependencies]
anyhow = "1.0"
flate2 = "1"
Expand Down
1 change: 1 addition & 0 deletions radicle-surf/data/git-platinum
Submodule git-platinum added at 27acd6
1 change: 1 addition & 0 deletions radicle-surf/examples/browsing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
}
}
}
Expand Down
133 changes: 129 additions & 4 deletions radicle-surf/src/fs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

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

#[derive(Debug, Error, PartialEq)]
pub enum File {
#[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.
Expand Down Expand Up @@ -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 {
Expand All @@ -211,33 +233,41 @@ 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(),
}
}

pub fn path(&self) -> PathBuf {
match self {
Entry::File(file) => file.path(),
Entry::Directory(directory) => directory.path(),
Entry::Submodule(submodule) => submodule.path(),
}
}

pub fn location(&self) -> &Path {
match self {
Entry::File(file) => file.location(),
Entry::Directory(directory) => directory.location(),
Entry::Submodule(submodule) => submodule.location(),
}
}

Expand All @@ -254,13 +284,18 @@ 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();

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.find_submodule(&name)?;
Ok(Self::Submodule(Submodule::new(name, path, submodule, id)?))
},
_ => Err(error::Directory::InvalidType(path, "tree or blob")),
}
}
Expand Down Expand Up @@ -354,7 +389,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);
Expand All @@ -365,6 +400,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);
Expand Down Expand Up @@ -397,7 +436,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.
Expand Down Expand Up @@ -447,6 +486,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),
})
}

Expand Down Expand Up @@ -479,6 +519,7 @@ impl Directory {
let acc = directory.traverse(repo, acc, f)?;
f(acc, entry)
},
Entry::Submodule(_) => f(acc, entry),
})
}
}
Expand All @@ -491,6 +532,90 @@ 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: git2::Submodule,
id: Oid,
) -> Result<Self, error::Submodule> {
let url = submodule
.opt_url_bytes()
.map(|bs| std::str::from_utf8(bs))
.transpose()
.map_err(|err| error::Submodule::Utf8 {
name: name.clone(),
err,
})?;
let url = url
.map(|s| {
Url::parse(s).map_err(|err| error::Submodule::ParseUrl {
name: name.clone(),
url: s.to_string(),
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 {
Expand Down
4 changes: 4 additions & 0 deletions radicle-surf/src/repo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,10 @@ impl Repository {
// Private API, ONLY add `pub(crate) fn` or `fn` in here. //
////////////////////////////////////////////////////////////
impl Repository {
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())
}
Expand Down
20 changes: 18 additions & 2 deletions radicle-surf/src/tree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ use serde::{
ser::{SerializeStruct as _, Serializer},
Serialize,
};
use url::Url;

use crate::{fs, Commit};

Expand Down Expand Up @@ -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 {
Expand All @@ -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,
}
}
Expand Down Expand Up @@ -177,6 +184,7 @@ impl Entry {
match self.entry {
EntryKind::Blob(id) => id,
EntryKind::Tree(id) => id,
EntryKind::Submodule { id, .. } => id,
}
}
}
Expand Down Expand Up @@ -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(),
},
}
}
}
Expand Down Expand Up @@ -241,16 +253,20 @@ 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(
"kind",
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()
Expand Down
1 change: 1 addition & 0 deletions radicle-surf/t/src/code_browsing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
}
},
)
Expand Down
17 changes: 12 additions & 5 deletions radicle-surf/t/src/submodule.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
#[cfg(not(feature = "gh-actions"))]
#[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 dir = repo
.root_dir(Branch::local(refname!("surf/submodule-support")))
.unwrap();
let platinum = dir
.find_entry(
&std::path::Path::new("radicle-surf/data/git-platinum"),
&repo,
)
.unwrap();
assert!(matches!(platinum, fs::Entry::Submodule(_)));
}

0 comments on commit 78c6a08

Please sign in to comment.