surf: add submodule support

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.

N.b. getting the submodule and its URL is not supported for a bare
repository, since it will complain about not having a working tree.

[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
This commit is contained in:
Fintan Halpenny
2024-03-06 15:59:57 +00:00
parent d5bf8e7f68
commit 2391c25e95
9 changed files with 186 additions and 11 deletions
+3
View File
@@ -0,0 +1,3 @@
[submodule "git-platinum"]
path = radicle-surf/data/git-platinum
url = https://github.com/radicle-dev/git-platinum.git
+4
View File
@@ -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"
+1
View File
@@ -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()),
}
}
}
+134 -4
View File
@@ -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 {
+8
View File
@@ -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())
}
+18 -2
View File
@@ -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()
+1
View File
@@ -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)),
}
},
)
+16 -5
View File
@@ -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));
}