Radish alpha
h
rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5
Radicle Heartwood Protocol & Stack
Radicle
Git
Adopt `radicle-surf` from `radicle-git` workspace
Draft lorenz opened 3 months ago
56 files changed +7691 -102 02318f19 aebddc98
modified Cargo.lock
@@ -1129,16 +1129,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f"

[[package]]
-
name = "git-ref-format"
-
version = "0.6.0"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "ed6913a77cee9e231cab93577c9a5eea84a1344ab39294d91dc075b3c24499d0"
-
dependencies = [
-
 "git-ref-format-core",
-
 "git-ref-format-macro",
-
]
-

-
[[package]]
name = "git-ref-format-core"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1150,18 +1140,6 @@ dependencies = [
]

[[package]]
-
name = "git-ref-format-macro"
-
version = "0.6.0"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "4e730f09c82961c28f5465b83da0aa5c2716156ce57da33a1fa51bbd560aa5f7"
-
dependencies = [
-
 "git-ref-format-core",
-
 "proc-macro-error2",
-
 "quote",
-
 "syn 2.0.106",
-
]
-

-
[[package]]
name = "git2"
version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2691,28 +2669,6 @@ dependencies = [
]

[[package]]
-
name = "proc-macro-error-attr2"
-
version = "2.0.0"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5"
-
dependencies = [
-
 "proc-macro2",
-
 "quote",
-
]
-

-
[[package]]
-
name = "proc-macro-error2"
-
version = "2.0.1"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802"
-
dependencies = [
-
 "proc-macro-error-attr2",
-
 "proc-macro2",
-
 "quote",
-
 "syn 2.0.106",
-
]
-

-
[[package]]
name = "proc-macro2"
version = "1.0.101"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2996,23 +2952,10 @@ dependencies = [
]

[[package]]
-
name = "radicle-git-ext"
-
version = "0.11.0"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "71a5fbca2ee3fc61a6b467e0b85da7c092421afc2538feb0023ad6792d6e39d0"
-
dependencies = [
-
 "git-ref-format",
-
 "git2",
-
 "percent-encoding",
-
 "radicle-std-ext",
-
 "serde",
-
 "thiserror 1.0.69",
-
]
-

-
[[package]]
name = "radicle-git-metadata"
version = "0.1.0"
dependencies = [
+
 "git2",
 "thiserror 2.0.17",
]

@@ -3149,16 +3092,8 @@ dependencies = [
]

[[package]]
-
name = "radicle-std-ext"
-
version = "0.2.0"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "fb935931bdd2a2966f3b584f3031d9d54ec0713ddbc563a0193d54e62a88ec73"
-

-
[[package]]
name = "radicle-surf"
version = "0.26.0"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "4c814514d0bf56fbec811099eaa14da1349639b04b8317746c9cd9e6b0f02196"
dependencies = [
 "anyhow",
 "base64 0.21.7",
@@ -3166,9 +3101,15 @@ dependencies = [
 "git2",
 "log",
 "nonempty",
-
 "radicle-git-ext",
-
 "radicle-std-ext",
+
 "pretty_assertions",
+
 "proptest",
+
 "radicle-git-metadata",
+
 "radicle-git-ref-format",
+
 "radicle-oid",
+
 "serde",
+
 "serde_json",
 "tar",
+
 "tempfile",
 "thiserror 1.0.69",
 "url",
]
@@ -4550,6 +4491,7 @@ dependencies = [
 "form_urlencoded",
 "idna",
 "percent-encoding",
+
 "serde",
]

[[package]]
modified Cargo.toml
@@ -57,6 +57,7 @@ radicle-oid = { version = "0.1.0", path = "crates/radicle-oid", default-features
radicle-protocol = { version = "0.4", path = "crates/radicle-protocol" }
radicle-signals = { version = "0.11", path = "crates/radicle-signals" }
radicle-ssh = { version = "0.10", path = "crates/radicle-ssh", default-features = false }
+
radicle-surf = { version = "0.26.0", path = "crates/radicle-surf", default-features = false }
radicle-systemd = { version = "0.11", path = "crates/radicle-systemd" }
radicle-term = { version = "0.16", path = "crates/radicle-term" }
schemars = { version = "1.0.4", default-features = false }
@@ -71,12 +72,8 @@ thiserror = { version = "2", default-features = false }
winpipe = "0.1.1"
zeroize = "1.5.7"

-
# Crates from the "radicle-git" workspace. These should be synced manually.
-
# When updating, start from `radicle-surf`:
-
# `radicle-surf` → `radicle-git-ext` → `git-ref-format` → `git-ref-format-core`
-
# Also note that `radicle-surf → git2` so try to also sync with `git2`.
+
# Crates from the "radicle-git" workspace.
git-ref-format-core = { version = "0.6.0", default-features = false }
-
radicle-surf = "0.26.0"

[workspace.lints]
clippy.type_complexity = "allow"
modified crates/radicle-cli/src/commands/patch/review/builder.rs
@@ -196,28 +196,25 @@ impl ReviewItem {

    fn paths(&self) -> (Option<(&Path, Oid)>, Option<(&Path, Oid)>) {
        match self {
-
            Self::FileAdded { path, new, .. } => (None, Some((path, Oid::from(*new.oid)))),
-
            Self::FileDeleted { path, old, .. } => (Some((path, Oid::from(*old.oid))), None),
+
            Self::FileAdded { path, new, .. } => (None, Some((path, new.oid))),
+
            Self::FileDeleted { path, old, .. } => (Some((path, old.oid)), None),
            Self::FileMoved { moved } => (
-
                Some((&moved.old_path, Oid::from(*moved.old.oid))),
-
                Some((&moved.new_path, Oid::from(*moved.new.oid))),
+
                Some((&moved.old_path, moved.old.oid)),
+
                Some((&moved.new_path, moved.new.oid)),
            ),
            Self::FileCopied { copied } => (
-
                Some((&copied.old_path, Oid::from(*copied.old.oid))),
-
                Some((&copied.new_path, Oid::from(*copied.new.oid))),
-
            ),
-
            Self::FileModified { path, old, new, .. } => (
-
                Some((path, Oid::from(*old.oid))),
-
                Some((path, Oid::from(*new.oid))),
-
            ),
-
            Self::FileEofChanged { path, old, new, .. } => (
-
                Some((path, Oid::from(*old.oid))),
-
                Some((path, Oid::from(*new.oid))),
-
            ),
-
            Self::FileModeChanged { path, old, new, .. } => (
-
                Some((path, Oid::from(*old.oid))),
-
                Some((path, Oid::from(*new.oid))),
+
                Some((&copied.old_path, copied.old.oid)),
+
                Some((&copied.new_path, copied.new.oid)),
            ),
+
            Self::FileModified { path, old, new, .. } => {
+
                (Some((path, old.oid)), Some((path, new.oid)))
+
            }
+
            Self::FileEofChanged { path, old, new, .. } => {
+
                (Some((path, old.oid)), Some((path, new.oid)))
+
            }
+
            Self::FileModeChanged { path, old, new, .. } => {
+
                (Some((path, old.oid)), Some((path, new.oid)))
+
            }
        }
    }

modified crates/radicle-cli/src/git/pretty_diff.rs
@@ -338,7 +338,7 @@ impl ToPretty for Added {
        repo: &R,
    ) -> Self::Output {
        let old = None;
-
        let new = Some((self.path.as_path(), Oid::from(*self.new.oid)));
+
        let new = Some((self.path.as_path(), self.new.oid));

        pretty_modification(header, &self.diff, old, new, repo, hi)
    }
@@ -354,7 +354,7 @@ impl ToPretty for Deleted {
        header: &Self::Context,
        repo: &R,
    ) -> Self::Output {
-
        let old = Some((self.path.as_path(), Oid::from(*self.old.oid)));
+
        let old = Some((self.path.as_path(), self.old.oid));
        let new = None;

        pretty_modification(header, &self.diff, old, new, repo, hi)
@@ -371,8 +371,8 @@ impl ToPretty for Modified {
        header: &Self::Context,
        repo: &R,
    ) -> Self::Output {
-
        let old = Some((self.path.as_path(), Oid::from(*self.old.oid)));
-
        let new = Some((self.path.as_path(), Oid::from(*self.new.oid)));
+
        let old = Some((self.path.as_path(), self.old.oid));
+
        let new = Some((self.path.as_path(), self.new.oid));

        pretty_modification(header, &self.diff, old, new, repo, hi)
    }
modified crates/radicle-cli/src/git/unified_diff.rs
@@ -306,8 +306,8 @@ impl Encode for FileHeader {
                if old.mode == new.mode {
                    w.meta(format!(
                        "index {}..{} {:o}",
-
                        term::format::oid(*old.oid),
-
                        term::format::oid(*new.oid),
+
                        term::format::oid(old.oid),
+
                        term::format::oid(new.oid),
                        u32::from(old.mode.clone()),
                    ))?;
                } else {
@@ -315,8 +315,8 @@ impl Encode for FileHeader {
                    w.meta(format!("new mode {:o}", u32::from(new.mode.clone())))?;
                    w.meta(format!(
                        "index {}..{}",
-
                        term::format::oid(*old.oid),
-
                        term::format::oid(*new.oid)
+
                        term::format::oid(old.oid),
+
                        term::format::oid(new.oid)
                    ))?;
                }

@@ -334,7 +334,7 @@ impl Encode for FileHeader {
                w.meta(format!(
                    "index {}..{}",
                    term::format::oid(git::Oid::sha1_zero()),
-
                    term::format::oid(*new.oid),
+
                    term::format::oid(new.oid),
                ))?;

                w.meta("--- /dev/null")?;
@@ -354,7 +354,7 @@ impl Encode for FileHeader {
                ))?;
                w.meta(format!(
                    "index {}..{}",
-
                    term::format::oid(*old.oid),
+
                    term::format::oid(old.oid),
                    term::format::oid(git::Oid::sha1_zero())
                ))?;

modified crates/radicle-git-metadata/Cargo.toml
@@ -10,4 +10,5 @@ keywords = ["radicle", "git", "metadata"]
rust-version.workspace = true

[dependencies]
-
thiserror = { workspace = true, default-features = true }

\ No newline at end of file
+
thiserror = { workspace = true, default-features = true }
+
git2 = { workspace = true, optional = true }

\ No newline at end of file
modified crates/radicle-git-metadata/src/author.rs
@@ -123,3 +123,16 @@ impl FromStr for Author {
        })
    }
}
+

+
#[cfg(feature = "git2")]
+
impl TryFrom<&Author> for git2::Signature<'_> {
+
    type Error = git2::Error;
+

+
    fn try_from(author: &Author) -> Result<Self, Self::Error> {
+
        git2::Signature::new(
+
            &author.name,
+
            &author.email,
+
            &git2::Time::new(author.time.seconds(), author.time.offset()),
+
        )
+
    }
+
}
added crates/radicle-surf/CHANGELOG.md
@@ -0,0 +1,23 @@
+
# CHANGELOG
+

+
## Version 0.25.0
+

+
* Update to `radicle-git-ext-0.10.0` [6422fd5](https://app.radicle.xyz/nodes/seed.radicle.xyz/rad:z6cFWeWpnZNHh9rUW8phgA3b5yGt/commits/6422fd580b1c9c96ba40620197e29d7b9fbe2824)
+

+
## Version 0.9.0
+

+
This release consists of a major rewrite of this crate. Its API is overall
+
simplified and is not compatible with the previous version (v0.8.0). The main
+
changes include:
+

+
- `Browser` is removed. Its methods are implemented directly with `Repository`.
+
- Git will be the only supported VCS. Any extension points for other VCSes were
+
removed.
+
- `Ref` and `RefScope` are removed. Reuse the `git-ref-format` crate and a new
+
`Glob` type for the refspec patterns.
+
- Added support of `Tree` and `Blob` that correspond to their definitions in
+
Git.
+
- Added two new traits `Revision` and `ToCommit` that make methods flexible and
+
still simple to use.
+

+
For more details, please check out the crate's documentation.
added crates/radicle-surf/Cargo.toml
@@ -0,0 +1,50 @@
+
[package]
+
name = "radicle-surf"
+
description = "A code surfing library for Git repositories"
+
version = "0.26.0"
+
homepage.workspace = true
+
repository.workspace = true
+
edition.workspace = true
+
license.workspace = true
+
rust-version.workspace = true
+

+
include = [
+
    "**/*.rs",
+
    "Cargo.toml",
+
    "data/git-platinum.tgz",
+
]
+

+
[features]
+
# NOTE: testing `test_submodule_failure` on GH actions
+
# is painful since it uses this specific repo and expects
+
# certain branches to be setup. So we use this feature flag
+
# to ignore the test on CI.
+
gh-actions = []
+
minicbor = []
+
serde = ["dep:serde", "url/serde"]
+

+
[dependencies]
+
base64 = "0.21"
+
log = "0.4"
+
nonempty = "0.9"
+
thiserror = "1.0"
+
url = "2.5.4"
+

+
serde = { workspace = true, optional = true, features = ["derive"] }
+
git2 = { workspace = true, features = ["vendored-libgit2"] }
+

+
radicle-oid = { workspace = true, features = ["git2", "sha1"] }
+
radicle-git-ref-format = { workspace = true, features = ["macro"] }
+
radicle-git-metadata = { workspace = true, features = ["git2"] }
+

+
[dev-dependencies]
+
pretty_assertions = "1.3.0"
+
proptest = "1"
+
serde_json = "1"
+
url = "2.5"
+
tempfile = { workspace = true }
+

+
[build-dependencies]
+
anyhow = "1.0"
+
flate2 = "1.1"
+
tar = "0.4"
added crates/radicle-surf/DEVELOPMENT.md
@@ -0,0 +1,78 @@
+

+
# Radicle Surfing 🏄
+

+
Thanks for wanting to contribute to `radicle-surf`!
+

+
## Building & Testing 🏗️
+

+
We try to make development as seamless as possible so we can get down to the real work. We supply
+
the toolchain via the `rust-toolchain` file, and the formatting rules `.rustmt.toml` file.
+

+
For the [Nix](https://nixos.org/) inclined there is a `default.nix` file to get all the necessary
+
dependencies and it also uses the `rust-toolchain` file to pin to that version of Rust.
+

+
You can build the project the usual way:
+
```
+
cargo build
+
```
+

+
To run all the tests:
+
```
+
cargo test
+
```
+

+
For the full list of checks that get executed in CI you can checkout the [ci/run](./ci/run) script.
+

+
If any of this _isn't_ working, then let's work through it together and get it Working on Your
+
Machine™.
+

+
## Structure 🏛️
+

+
The design of `radicle-surf` is to have an in-memory representation of a project's directory which
+
can be generated by a VCS's backend. The directory system is modeled under `file_system`, the VCS
+
functionality is naturally under `vcs`, and `diff` logic is held under `diff`.
+

+
```
+
src/
+
├── diff
+
├── file_system
+
└── vcs
+
```
+

+
## Testing & Documentation 📚
+

+
We ensure that the crate is well documented. `cargo clippy` will argue with you anytime a public
+
facing piece of the library is undocumented. We should always provide an explanation of what
+
something is or does, and also provide examples to allow our users to get up and running as quick
+
and easy as possible.
+

+
When writing documentation we should try provide one or two examples (if they make sense). This
+
provides us with some simple unit tests as well as something our users can copy and paste for ease
+
of development.
+

+
If more tests are needed then we should add them under `mod tests` in the relevant module. We strive
+
to find properties of our programs so that we can use tools like `proptest` to extensively prove our
+
programs are correct. As well as this, we add unit tests to ensure the examples in our heads are
+
correct, and testing out the ergonomics of our API first-hand.
+

+
## CI files 🤖
+

+
Our CI infrastructure runs on Buildkite. The build process is run for every commit which is pushed
+
to GitHub.
+

+
All relevant configuration can be found here:
+

+
```
+
radicle-surf/.buildkite/
+
├── docker
+
│   ├── build
+
│   │   └── Dockerfile
+
│   └── rust-nightly
+
│       └── Dockerfile
+
└── pipeline.yaml
+
```
+

+
## Releases 📅
+

+
TODO: Once we get the API into a good shape we will keep track of releases via a `CHANGELOG.md` and
+
tag the releases via `git tag`.
added crates/radicle-surf/README.md
@@ -0,0 +1,22 @@
+
# radicle-surf
+

+
A code surfing library for Git repositories 🏄‍♀️🏄‍♂️
+

+
Welcome to `radicle-surf`!
+

+
`radicle-surf` is a library to describe a Git repository as a file system. It
+
aims to provide an easy-to-use API to browse a repository via the concept of
+
files and directories for any given revision. It also allows the user to diff
+
any two different revisions.
+

+
One of the use cases would be to create a web GUI for interacting with a Git
+
repository (thinking GitHub, GitLab or similar systems).
+

+
## Contributing
+

+
To get started on contributing you can check out our [developing guide](../DEVELOPMENT.md), and also
+
our [LICENSE](../LICENSE) file.
+

+
## The Community
+

+
Join our community discussions at [radicle.community](https://radicle.community)!
added crates/radicle-surf/build.rs
@@ -0,0 +1,64 @@
+
use std::{
+
    env, fs,
+
    fs::File,
+
    io,
+
    path::{Path, PathBuf},
+
};
+

+
use anyhow::Context as _;
+
use flate2::read::GzDecoder;
+
use tar::Archive;
+

+
enum Command {
+
    Build(PathBuf),
+
    Publish(PathBuf),
+
}
+

+
impl Command {
+
    fn new() -> io::Result<Self> {
+
        let current = env::current_dir()?;
+
        Ok(if current.ends_with("radicle-surf") {
+
            Self::Build(current)
+
        } else {
+
            Self::Publish(PathBuf::from(
+
                env::var("OUT_DIR").map_err(io::Error::other)?,
+
            ))
+
        })
+
    }
+

+
    fn target(&self) -> PathBuf {
+
        match self {
+
            Self::Build(path) => path.join("data"),
+
            Self::Publish(path) => path.join("data"),
+
        }
+
    }
+
}
+

+
fn main() {
+
    let _target = Command::new()
+
        .expect("could not determine the cargo command")
+
        .target();
+
    // let git_platinum_tarball = "./data/git-platinum.tgz";
+

+
    // unpack(git_platinum_tarball, target).expect("Failed to unpack git-platinum");
+

+
    // println!("cargo:rerun-if-changed={git_platinum_tarball}");
+
}
+

+
#[allow(dead_code)]
+
fn unpack(archive_path: impl AsRef<Path>, target: impl AsRef<Path>) -> anyhow::Result<()> {
+
    let content = target.as_ref().join("git-platinum");
+
    if content.exists() {
+
        fs::remove_dir_all(content).context("attempting to remove git-platinum")?;
+
    }
+
    let archive_path = archive_path.as_ref();
+
    let tar_gz = File::open(archive_path).context(format!(
+
        "attempting to open file: {}",
+
        archive_path.display()
+
    ))?;
+
    let tar = GzDecoder::new(tar_gz);
+
    let mut archive = Archive::new(tar);
+
    archive.unpack(target).context("attempting to unpack")?;
+

+
    Ok(())
+
}
added crates/radicle-surf/docs/denotational-design.md
@@ -0,0 +1,265 @@
+
# Design Documentation
+

+
In this document we will describe the design of `radicle-surf`. The design of the system will rely
+
heavily on [denotational design](todo) and use Haskell syntax (because types are easy to reason about, I'm sorry).
+

+
`radicle-surf` is a system to describe a file-system in a VCS world. We have the concept of files and directories,
+
but these objects can change over time while people iterate on them. Thus, it is a file-system within history and
+
we, the user, are viewing the file-system at a particular snapshot. Alongside this, we will wish to take two snapshots
+
and view their differences.
+

+
The stream of consciousness that gave birth to this document started with thinking how the user would interact with
+
the system, identifying the key components. This is captured in [User Flow](#user-flow). From there we found nouns that
+
represent objects in our system and verbs that represent functions over those objects. This iteratively informed us as
+
to what other actions we would need to supply. We would occasionally look at [GitHub](todo) and [Pijul Nest](todo) for
+
inspiration, since we would like to imitate the features that they supply, and we ultimately want use one or both of
+
these for our backends.
+

+
## User Flow
+

+
For the user flow we imagined what it would be like if the user was using a [REPL](todo) to interact with `radicle-surf`.
+
The general concept was that the user would enter the repository, build a view of the directory structure, and then
+
interact with the directories and files from there (called `browse`).
+
```haskell
+
repl :: IO ()
+
repl = do
+
  repo <- getRepo
+
  history <- getHistory label repo -- head is SHA1, tail is rest
+
  directory <- buildDirectory history
+

+
  forever browse directory
+
```
+

+
But then we thought about what happens when we are in `browse` but we would like to change the history and see that
+
file or directory at a different snapshot. This was captured in the pseudo-code below:
+
```haskell
+
  src_foo_bar <- find...
+
  history' <- historyOf src_foo_bar
+
```
+

+
This information was enough for us to begin the [denotational design](#denotational-design) below.
+

+
## Denotational Design
+

+
```haskell
+
-- A Label is a name for a directory or a file
+
type Label
+
μ Label = Text
+

+
-- A Directory captures its own Label followed by 1 or more
+
-- artefacts which can either be sub-directories or files.
+
--
+
-- An example of "foo/bar.hs" structure:
+
--  foo
+
--  |-- bar.hs
+
--
+
-- Would look like:
+
-- @("foo", Right ("bar.hs", "module Banana ...") :| [])@
+
type Directory
+
μ Directory = (Label, NonEmpty (Either Directory File))
+

+
-- DirectoryContents can either be the special IsRepo object,
+
-- a Directory, or a File.
+
type DirectoryContents
+
μ DirectoryContents = IsRepo | Directory | File
+

+
-- Opaque representation of repository state directories (e.g. `.git`, `.pijul`)
+
-- Those are not browsable, but have to be present at the repo root 'Directory'.
+
type IsRepo
+

+
-- A Directory captures its own Label followed by 1 or more DirectoryContents
+
--
+
-- An example of "foo/bar.hs" structure:
+
--  foo
+
--  |-- bar.hs
+
--
+
-- Would look like:
+
-- @("~", IsRepo :| [Directory ("foo", File ("bar.hs", "module Banana ..") :| [])]
+
-- where IsRepo is the implicit root of the repository.
+
type Directory
+
μ Directory = (Label, NonEmpty DirectoryContents)
+

+
-- A File is its Label and its contents
+
type File
+
μ File = (Label, ByteString)
+

+
-- An enumeration of what file-system artefact we're looking at.
+
-- Useful for listing a directory and denoting what the label is
+
-- corresponding to.
+
type SystemType
+
μ SystemType
+
  = IsFile
+
  | IsDirectory
+

+
-- A Change is an enumeration of how a file has changed.
+
-- This is simply used for getting the difference between two
+
-- directories.
+
type Change
+

+
-- Constructors of Change - think GADT
+
AddLineToFile :: NonEmpty Label -> Location -> ByteString -> Change
+
RemoveLineFromFile :: NonEmpty Label -> Location -> Change
+
MoveFile :: NonEmpty Label -> NonEmpty Label -> Change
+
CreateFile :: NonEmpty Label -> Change
+
DeleteFile :: NonEmpty Label -> Change
+

+
-- A Diff is a set of Changes that were made
+
type Diff
+
μ Diff = [Change]
+

+
-- History is an ordered set of @a@s. The reason for it being
+
-- polymorphic is that it allows us to choose what set artefact we
+
-- want to carry around.
+
--
+
-- For example:
+
--  * In `git` this would be a `Commit`.
+
--  * In `pijul` it would be a `Patch`.
+
type History a
+
μ History = NonEmpty a
+

+
-- A Repo is a collection of multiple histories.
+
-- This would essentially boil down to branches and tags.
+
type Repo
+
μ Repo a = [History a]
+

+
-- A Snapshot is a way of converting a History into a Directory.
+
-- In other words it gives us a snapshot of the history in the form of a directory.
+
type Snapshot a
+
μ Snapshot a = History a -> Directory
+

+
-- For example, we have a `git` snapshot or a `pjul` snapshot.
+
type Commit
+
type GitSnapshot   = Snapshot Commit
+

+
type Patch
+
type PijulSnapshot = Snapshot Patch
+

+
-- This is piece de resistance of the design! It turns out,
+
-- everything is just a Monad after all.
+
--
+
-- Our code Browser is a stateful computation of what History
+
-- we are currently working with and how to get a Snapshot of it.
+
type Browser a b
+
μ type Browser a b = ReaderT (Snapshot a) (State (History a) b)
+

+
-- A function that will retrieve a repository given an
+
-- identifier. In our case the identifier is opaque to the system.
+
getRepo :: Repo -> Repo
+

+
-- Find a particular History in the Repo. Again, how these things
+
-- are equated and found is opaque, but we can think of them as
+
-- branch or tag labels.
+
getHistory :: Eq a => History a -> Repo a -> Maybe (History a)
+
μ getHistory history repo =
+
  find history (μ repo)
+

+
-- Find if a particular artefact occurs in 0 or more histories.
+
findInHistories :: a -> [History a] -> [History a]
+
μ findInHistories a histories =
+
  filterMaybe (findInHistory a) histories
+

+
-- Find a particular artefact is in a history.
+
findInHistory :: Eq a => a -> History a -> Maybe a
+
μ findInHistory a history = find (== a) (μ history)
+

+
-- A special Label that guarantees a starting point, i.e. ~
+
rootLabel :: Label
+
μ rootLabel = "~"
+

+
emptyRepoRoot :: Directory
+
μ emptyRepoRoot = (rootLabel, IsRepo :| [])
+

+
-- Get the difference between two directory views.
+
diff :: Directory -> Directory -> Diff
+

+
-- List the current file or directories in a given Directory view.
+
listDirectory :: Directory -> [Label, SystemType]
+
μ listDirectory directory = foldMap toLabel $ snd (μ directory)
+
  where
+
    toLabel content = case content of
+
      File      (label, _) -> [(label, IsFile)]
+
      Directory (label, _) -> [(label, IsDirectory)]
+
      IsRepo               -> []
+

+
fileName :: File -> Label
+
μ fileName file = fst (μ file)
+

+
findFile :: NonEmpty Label -> Directory -> Maybe File
+
μ findFile (label :| labels) (Directory (label', contents)) =
+
  if label == label' then go labels contents else Nothing
+
  where
+
    findFileWithLabel :: Foldable f => Label -> f DirectoryContents -> Maybe File
+
    findFileWithLabel label = find (\artefact -> case content of
+
      File (fileLabel, _) -> fileLabel == label
+
      Directory _         -> False
+
      IsRepo              -> False)
+

+
    go :: [Label] -> NonEmpty DirectoryContents -> Just File
+
    go []             _        = Nothing
+
    go [label]        contents = findMaybe (fileWithLabel label) contents
+
    go (label:labels) contents = (go labels . snd) <$> find ((label ==) . fst) onlyDirectories contents
+

+
onlyDirectories :: Foldable f => f DirectoryContents -> [Directory]
+
μ onlyDirectories = fmapMaybe (\content -> case content of
+
  d@(Directory _) -> Just d
+
  File _          -> Nothing
+
  IsRepo          -> Nothing) . toList
+

+
getSubDirectories :: Directory -> [Directory]
+
μ getSubDirectories directory = foldMap f $ snd (μ directory)
+
  where
+
    f :: DirectoryContents -> [Directory]
+
    f = \case
+
      d@(Directory _) -> [d]
+
      File _          -> []
+
      IsRepo          -> []
+

+
-- Definition elided
+
findDirectory :: NonEmpty Label -> Directory -> Maybe Directory
+

+
-- Definition elided
+
fuzzyFind :: Label -> [Directory]
+

+
-- A Git Snapshot is grabbing the HEAD commit of your History
+
-- and turning it into a Directory
+
gitSnapshot :: Snapshot Commit
+
μ gitSnapshot commits = second (\root -> root <> getDirectoryPtr $ Nel.head commits) emptyRepoRoot
+

+
-- Opaque and defined by the backend
+
getDirectoryPtr :: Commit -> Directory
+

+
-- A Pijul history is semantically applying the patches in a
+
-- topological order and achieving the Directory view.
+
pijulHistory :: Snapshot Patch
+
μ pijulHistory = foldl pijulMagic emptyRepoRoot
+

+
-- Opaque and defined by the backend
+
pijulMagic :: Patch -> Directory -> Directory
+

+
-- Get the current History we are working with.
+
getHistory :: Browser a (History a)
+
μ getHistory = get
+

+
setHistory :: History a -> Browser a ()
+
μ setHistory = put
+

+
-- Get the current Directory in the Browser
+
getDirectory :: Browser a Directory
+
μ getDirectory = do
+
  hist <- get
+
  applySnapshot <- ask
+
  pure $ applySnapshot hist
+

+
-- We modify the history by changing the internal history state.
+
switchHistory :: (History a -> History a) -> Browser a b
+
μ switchHistory f = modify f
+

+
-- | Find the suffix of a History.
+
findSuffix :: Eq a => a -> History a -> Maybe (History a)
+
μ findSuffix a = nonEmpty . Nel.dropWhile (/= a)
+

+
-- View the history up to a given point by supplying a function to modify
+
-- the state. If this operation fails, then the default value is used.
+
viewAt :: (History a -> Maybe (History a)) -> History a -> Browser a b
+
μ viewAt f def = switchHistory (fromMaybe def . f)
+
```
added crates/radicle-surf/docs/refactor-design.md
@@ -0,0 +1,341 @@
+
# An updated design for radicle-surf
+

+
This is a design blueprint for the new `radicle-git/radicle-surf` crate. The
+
actual design details and implementation are described and updated in its
+
documentation comments, viewable via `cargo doc`.
+

+
## Introduction
+

+
In September 2022, we have ported the [`radicle-surf` crate](https://github.com/radicle-dev/radicle-surf)
+
from its own github repo to be part of the [`radicle-git` repo](https://github.com/radicle-dev/radicle-git).
+
We are taking this opportunity to refactor its design as well. Intuitively,
+
`radicle-surf` provides an API so that one can use it to create a GitHub-like
+
UI for a git repo:
+

+
1. Code browsing: given a specific commit/ref, browse files and directories.
+
2. Diff between two revisions that resolve into two commits.
+
3. Retrieve the history of commits with a given head, and optionally a file.
+
4. List refs and retrieve their metadata: Branches, Tags, Remotes,
+
Notes and user-defined "categories", where a category is: `refs/<category>/<...>`.
+

+
## Motivation
+

+
The `radicle-surf` crate aims to provide a safe and easy-to-use API that
+
supports the features listed in [Introduction](#introduction). Based on the
+
existing API, the main goals of the refactoring are:
+

+
- API review: identify the issues with the current API.
+
- Updated API: propose an updated API that reuses parts of the existing API.
+
- Address open issues in the original `radicle-surf` repo.
+
- Be `git` specific. (i.e. no need to support other VCS systems)
+
- Remove `git2` from the public API. The use of `git2` should be an
+
implementation detail.
+

+
## API review
+

+
In this section, we review some core types in the current API and propose
+
changes to them. The main theme is to make the API simpler and easier to use.
+

+
### Remove the `Browser` type
+

+
The type [`Browser`](https://github.com/radicle-dev/radicle-surf/blob/b85d2183d786e5fa447aab9d2f420a32f1061bfa/surf/src/vcs.rs#L145) is awkward:
+

+
- it is not a source of truth of any information. For example, `list_branches`
+
method is just a wrapper of `Repository::list_branches`.
+
- it takes in `History`, but really operates at the `Snapshot` level.
+
- it is mutable but its state mutations are not used much.
+

+
Can we just remove `Browser` and implement its functionalities using other
+
types?
+

+
- For iteratoring the history, use `History`.
+
- For generating `Directory`, use `Repository` directly given a `Rev`.
+
- For accessing `Branch`, `Tag` or `Commit`, use `Repository`.
+

+
### Remove the `Snapshot` type
+

+
A [`Snapshot`](https://github.com/radicle-dev/radicle-surf/blob/b85d2183d786e5fa447aab9d2f420a32f1061bfa/surf/src/vcs.rs#L140) should be really
+
just a tree (or `Directory`) of a `Commit` in git. Currently it is a function
+
that returns a `Directory`. As we are moving to be git specific, we don't need
+
to have this generic function to create a snapshot across different VCS systems.
+

+
The snapshot function can be easily implement as a method of `RepositoryRef`.
+

+
### Simplify `Directory` and remove the `Tree` and `Forest` types
+

+
The [`Directory`](https://github.com/radicle-dev/radicle-surf/blob/b85d2183d786e5fa447aab9d2f420a32f1061bfa/surf/src/file_system/directory.rs#L144)
+
type represents the file system view of a snapshot. Its field
+
`sub_directories` is defined a `Forest` based on `Tree`. The types are
+
over-engineered from such a simple concept. We could refactor `Directory` to
+
use `DirectoryContents` for its items and not to use `Tree` or `Forest` at all.
+

+
We also found the `list_directory()` method duplicates with `iter()` method.
+
Hence `list_directory()` is removed, together with `SystemType` type.
+

+
### Remove `Vcs` trait
+

+
The `Vcs` trait was introduced to support different version control backends,
+
for example both Git and Pijul, and potentially others. However, since this
+
port is part of `radicle-git` repo, we are only supporting Git going forward.
+
We no longer need another layer of indirection defined by `Vcs` trait.
+

+
## The new API
+

+
With the changes proposed in the previous section, we describe what the new API
+
would look like and how they meet the requirements.
+

+
### Basic types
+

+
#### Repository
+

+
`Repository` is kept as the entry point of the API, even though its methods
+
would change due to changes in other types. Also, we would like to consolidate
+
[`Repository`](https://github.com/radicle-dev/radicle-surf/blob/b85d2183d786e5fa447aab9d2f420a32f1061bfa/surf/src/vcs/git/repo.rs#L53) and
+
 [`RepositoryRef`](https://github.com/radicle-dev/radicle-surf/blob/b85d2183d786e5fa447aab9d2f420a32f1061bfa/surf/src/vcs/git/repo.rs#L63) to
+
 simplify the API.
+

+
#### Revision and Commit
+

+
In Git, `Revision` commonly resolves into a `Commit` but could refer to other
+
objects for example a `Blob`. Hence we need to keep both concepts in the API.
+
Currently we have multiple types to identify a `Commit` or `Revision`.
+

+
- Commit
+
- Oid
+
- Rev
+

+
The relations between them are: all `Rev` and `Commit` can resolve into `Oid`,
+
and in most cases `Rev` can resolve into `Commit`.
+

+
On one hand, `Oid` is the ultimate unique identifier but it is more machine-
+
friendly than human-friendly. On the other hand, `Revision` is most human-
+
friendly and better suited in the API interface. A conversion from `Revision`
+
to `Oid` will be useful.
+

+
For the places where `Commit` is required, we should explicitly ask for
+
`Commit` instead of `Revision`.
+

+
In conclusion, we define two new traits to support the use of `Revision` and
+
`Commit`:
+

+
```Rust
+
pub trait Revision {
+
    /// Resolves a revision into an object id in `repo`.
+
    fn object_id(&self, repo: &RepositoryRef) -> Result<Oid, Error>;
+
}
+

+
pub trait ToCommit {
+
    /// Converts to a commit in `repo`.
+
    fn to_commit(self, repo: &RepositoryRef) -> Result<Commit, Error>;
+
}
+
```
+

+
These two traits will be implemented for most common representations of
+
`Revision` and `Commit`, for example `&str`, refs like `Branch`, `Tag`, etc.
+
Our API will use these traits where we expect a `Revision` or a `Commit`.
+

+
#### History
+

+
The current `History` is generic over VCS types and also retrieves the full list
+
of commits when the history is created. The VCS part can be removed and the
+
history can lazy-load the list of commits by implementing `Iterator` to support
+
 potentially very long histories.
+

+
We can also store the head commit with the history so that it's easy to get
+
the start point and it helps to identify the history.
+

+
To support getting the history of a file, we provide methods to modify a
+
`History` to filter by a file path.
+

+
The new `History` type would look like this:
+

+
```Rust
+
pub struct History<'a> {
+
    repo: RepositoryRef<'a>,
+
    head: Commit,
+
    revwalk: git2::Revwalk<'a>,
+
    filter_by: Option<FilterBy>,
+
}
+

+
enum FilterBy {
+
    File { path: file_system::Path },
+
}
+
```
+

+
For the methods provided by `History`, please see section [Retrieve the history](#retrieve-the-history) below.
+

+
#### Commit
+

+
`Commit` is a central concept in Git. In `radicle-surf` we define `Commit` type
+
to represent its metadata:
+

+
```Rust
+
pub struct Commit {
+
    /// Object Id
+
    pub id: Oid,
+
    /// The author of the commit.
+
    pub author: Author,
+
    /// The actor who committed this commit.
+
    pub committer: Author,
+
    /// The long form message of the commit.
+
    pub message: String,
+
    /// The summary message of the commit.
+
    pub summary: String,
+
    /// The parents of this commit.
+
    pub parents: Vec<Oid>,
+
}
+
```
+

+
To get the file system snapshot of the commit, the user should use
+
`root_dir` method described in [Code browsing](#code-browsing) section.
+

+
To get the diff of the commit, the user should use `diff_commit` method
+
described in [Diffs](#diffs) section. Note that we might move that method to
+
`Commit` type itself.
+

+
### Code browsing
+

+
The user should be able to browse the files and directories for any given
+
commit. The core API is:
+

+
- Create a root Directory:
+
```Rust
+
impl RepositoryRef {
+
    pub fn root_dir<C: ToCommit>(&self, commit: C) -> Result<Directory, Error>;
+
}
+
```
+

+
- Browse a Directory's contents:
+
```Rust
+
impl Directory {
+
    pub fn contents(&self) -> impl Iterator<Item = &DirectoryContents>;
+
}
+
```
+
where `DirectoryContents` supports both files and sub-directories:
+
```Rust
+
pub enum DirectoryContents {
+
    /// The `File` variant contains the file's name and the [`File`] itself.
+
    File {
+
        /// The name of the file.
+
        name: Label,
+
        /// The file data.
+
        file: File,
+
    },
+
    /// The `Directory` variant contains a sub-directory to the current one.
+
    Directory(Directory),
+
}
+
```
+

+
### Diffs
+

+
The user would be able to create a diff between any two revisions. In the first
+
implementation, these revisions have to resolve into commits. But in future,
+
the revisions could refer to other objects, e.g. files (blobs).
+

+
The core API is:
+

+
```Rust
+
impl RepositoryRef {
+
    /// Returns the diff between two revisions.
+
    pub fn diff<R: Revision>(&self, from: R, to: R) -> Result<Diff, Error>;
+
}
+
```
+

+
To obtain the diff of a particular commit, we will have:
+
```Rust
+
impl RepositoryRef {
+
    pub fn diff_commit(&self, commit: impl ToCommit) -> Result<Diff, Error>;
+
}
+
```
+

+
### Retrieve the history
+

+
The user would be able to get the list of previous commits reachable from a
+
particular commit.
+

+
To create a `History` from a repo with a given head:
+
```Rust
+
impl RepositoryRef {
+
    pub fn history<C: ToCommit>(&self, head: C) -> Result<History, Error>;
+
}
+
```
+

+
To access the `History`, the user simply iterates `History` as it implements the
+
`Iterator` trait that produces `Result<Commit, Error>`.
+

+
`History` provides a method to filter based on a path and other helper methods:
+

+
```Rust
+
impl<'a> History<'a> {
+
    /// Returns the head commit of a history.
+
    pub fn head(&self) -> &Commit;
+

+
    // Modifies a history with a filter by `path`.
+
    // This is to support getting the history of a file.
+
    pub fn by_path(mut self, path: file_system::Path) -> Self;
+
```
+

+
- Alternative design:
+

+
One potential downside of define `History` as an iterator is that:
+
`history.next()` takes a mutable history object. A different design is to use
+
`History` as immutable object that produces an iterator on-demand:
+

+
```Rust
+
pub struct History<'a> {
+
    repo: RepositoryRef<'a>,
+
    head: Commit,
+
}
+

+
impl<'a> History<'a> {
+
    /// This method creates a new `RevWalk` internally and return an
+
    /// iterator for all commits in a history.
+
    pub fn iter(&self) -> impl Iterator<Item = Commit>;
+
}
+
```
+

+
In this design, `History` does not keep `RevWalk` in its state. It will create
+
a new one when `iter()` is called. I like the immutable interface of this design
+
but did not implement it in the current code mainly because the libgit2 doc says
+
[creating a new `RevWalk` is relatively expensive](https://libgit2.org/libgit2/#HEAD/group/revwalk/git_revwalk_new).
+

+
### List refs and retrieve their metadata
+

+
Git refs are simple names that point to objects using object IDs. In this new
+
design, we no longer group different refs into a single enum. Instead, each kind
+
of ref would be their own type, e.g. `Tag`, `Branch`, `Namespace`, etc.
+

+
To retrieve the refs, the user would call the `<ref_type>s()` method of a
+
repo, e.g. `branches()`, `tags()`. The result is an iterator of available refs,
+
e.g. `Branches`.
+

+
```Rust
+
impl RepositoryRef {
+
    /// Lists branch names with `filter`.
+
    pub fn branches<G>(&self, pattern: G) -> Result<Branches, Error>
+
    where
+
        G: Into<Glob<Branch>>
+
}
+
```
+

+
### Git Tree and Blob
+

+
In Git, a `Blob` object represents the content of a file, and a `Tree` object
+
represents the content of a listing (in a directory). A `Tree` or a `Blob` does
+
not have its own name because there could be different names (paths) pointing
+
to the same `Tree` or `Blob`. In contrast, a `Directory` or a `File` has names
+
included and possible other attributes.
+

+
In `radicle-surf` API, we would expose `Tree` and `Blob` as defined in Git, i.e.
+
as the content only, with some extra helper methods, for example `commit()` that
+
returns the commit that created the object.
+

+
The `Repository` provides methods to retrieve `Tree` or `Blob` for a given path.
+

+
## Summary
+

+
This design document was created as a guideline for refactoring the
+
`radicle-surf` crate when we move it into the `radicle-git` repo. As the code
+
evolves, this document would not be matching the actual code exactly. However
+
we can still come back to this document to learn about the history of the
+
design and update it when needed.
added crates/radicle-surf/examples/browsing.rs
@@ -0,0 +1,49 @@
+
//! An example of browsing a git repo using `radicle-surf`.
+
//!
+
//! How to run:
+
//!
+
//!     cargo run --example browsing <git_repo_path>
+
//!
+
//! This program browses the given repo and prints out the files and
+
//! the directories in a tree-like structure.
+

+
use radicle_surf::{
+
    fs::{self, Directory},
+
    Repository,
+
};
+
use std::{env, time::Instant};
+

+
fn main() {
+
    let repo_path = match env::args().nth(1) {
+
        Some(path) => path,
+
        None => {
+
            print_usage();
+
            return;
+
        }
+
    };
+
    let repo = Repository::discover(repo_path).unwrap();
+
    let now = Instant::now();
+
    let head = repo.head().unwrap();
+
    let root = repo.root_dir(head).unwrap();
+
    print_directory(&root, &repo, 0);
+

+
    let elapsed_millis = now.elapsed().as_millis();
+
    println!("browse with print: {elapsed_millis} ms");
+
}
+

+
fn print_directory(d: &Directory, repo: &Repository, indent_level: usize) {
+
    let indent = " ".repeat(indent_level * 4);
+
    println!("{}{}/", &indent, d.name());
+
    for entry in d.entries(repo).unwrap() {
+
        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()),
+
        }
+
    }
+
}
+

+
fn print_usage() {
+
    println!("Usage:");
+
    println!("cargo run --example browsing <repo_path>");
+
}
added crates/radicle-surf/examples/diff.rs
@@ -0,0 +1,102 @@
+
extern crate radicle_surf;
+

+
use std::{env::Args, str::FromStr, time::Instant};
+

+
use radicle_oid::Oid;
+
use radicle_surf::{diff::Diff, Repository};
+

+
fn main() {
+
    let options = get_options_or_exit();
+
    let repo = init_repository_or_exit(&options.path_to_repo);
+
    let head_oid = match options.head_revision {
+
        HeadRevision::Head => repo.head().unwrap(),
+
        HeadRevision::Commit(id) => Oid::from_str(&id).unwrap(),
+
    };
+
    let base_oid = Oid::from_str(&options.base_revision).unwrap();
+
    let now = Instant::now();
+
    let elapsed_nanos = now.elapsed().as_nanos();
+
    let diff = repo.diff(base_oid, head_oid).unwrap();
+
    print_diff_summary(&diff, elapsed_nanos);
+
}
+

+
fn get_options_or_exit() -> Options {
+
    match Options::parse(std::env::args()) {
+
        Ok(options) => options,
+
        Err(message) => {
+
            println!("{message}");
+
            std::process::exit(1);
+
        }
+
    }
+
}
+

+
fn init_repository_or_exit(path_to_repo: &str) -> Repository {
+
    match Repository::open(path_to_repo) {
+
        Ok(repo) => repo,
+
        Err(e) => {
+
            println!("Failed to create repository: {e:?}");
+
            std::process::exit(1);
+
        }
+
    }
+
}
+

+
fn print_diff_summary(diff: &Diff, elapsed_nanos: u128) {
+
    diff.added().for_each(|created| {
+
        println!("+++ {:?}", created.path);
+
    });
+
    diff.deleted().for_each(|deleted| {
+
        println!("--- {:?}", deleted.path);
+
    });
+
    diff.modified().for_each(|modified| {
+
        println!("mod {:?}", modified.path);
+
    });
+

+
    println!(
+
        "created {} / deleted {} / modified {} / total {}",
+
        diff.added().count(),
+
        diff.deleted().count(),
+
        diff.modified().count(),
+
        diff.added().count() + diff.deleted().count() + diff.modified().count()
+
    );
+
    println!("diff took {elapsed_nanos} nanos ");
+
}
+

+
struct Options {
+
    path_to_repo: String,
+
    base_revision: String,
+
    head_revision: HeadRevision,
+
}
+

+
enum HeadRevision {
+
    Head,
+
    Commit(String),
+
}
+

+
impl Options {
+
    fn parse(args: Args) -> Result<Self, String> {
+
        let args: Vec<String> = args.collect();
+
        if args.len() != 4 {
+
            return Err(format!(
+
                "Usage: {} <path-to-repo> <base-revision> <head-revision>\n\
+
                \tpath-to-repo: Path to the directory containing .git subdirectory\n\
+
                \tbase-revision: Git commit ID of the base revision (one that will be considered less recent)\n\
+
                \thead-revision: Git commit ID of the head revision (one that will be considered more recent) or 'HEAD' to use current git HEAD\n",
+
                args[0]));
+
        }
+

+
        let path_to_repo = args[1].clone();
+
        let base_revision = args[2].clone();
+
        let head_revision = {
+
            if args[3].eq_ignore_ascii_case("HEAD") {
+
                HeadRevision::Head
+
            } else {
+
                HeadRevision::Commit(args[3].clone())
+
            }
+
        };
+

+
        Ok(Options {
+
            path_to_repo,
+
            base_revision,
+
            head_revision,
+
        })
+
    }
+
}
added crates/radicle-surf/scripts/update-git-platinum.sh
@@ -0,0 +1,43 @@
+
#!/usr/bin/env bash
+
set -euo pipefail
+

+
# Verify that the script is run from project root.
+
BASE=$(basename "$(pwd)")
+

+
if [ "${BASE}" != "radicle-surf" ]
+
then
+
   echo "ERROR: this script should be run from the root of radicle-surf"
+
   exit 1
+
fi
+

+
TARBALL_PATH=data/git-platinum.tgz
+
WORKDIR=.workdir
+
PLATINUM_REPO="$WORKDIR/git-platinum"
+

+
# Create the workdir if needed.
+
mkdir -p $WORKDIR
+

+
# This is here in case the last script run failed and it never cleaned up.
+
rm -rf "$PLATINUM_REPO"
+

+
# Clone an up-to-date version of git-platinum.
+
git clone https://github.com/radicle-dev/git-platinum.git "$PLATINUM_REPO"
+
git -C "$PLATINUM_REPO" checkout empty-branch
+
git -C "$PLATINUM_REPO" checkout diff-test
+
git -C "$PLATINUM_REPO" checkout dev
+

+
# Add the necessary refs.
+
input="./data/mock-branches.txt"
+
while IFS= read -r line
+
do
+
    IFS=, read -ra pair <<< "$line"
+
    echo "Creating branch ${pair[0]}"
+
    git -C "$PLATINUM_REPO" update-ref "${pair[0]}" "${pair[1]}"
+
done < "$input"
+

+
# Update the archive.
+
tar -czf $WORKDIR/git-platinum.tgz -C $WORKDIR git-platinum
+
mv $WORKDIR/git-platinum.tgz $TARBALL_PATH
+

+
# Clean up.
+
rm -rf "$PLATINUM_REPO"
added crates/radicle-surf/src/blob.rs
@@ -0,0 +1,157 @@
+
//! Represents git object type 'blob', i.e. actual file contents.
+
//! See git [doc](https://git-scm.com/book/en/v2/Git-Internals-Git-Objects) for more details.
+

+
use std::ops::Deref;
+

+
use radicle_oid::Oid;
+

+
#[cfg(feature = "serde")]
+
use serde::{
+
    ser::{SerializeStruct as _, Serializer},
+
    Serialize,
+
};
+

+
use crate::Commit;
+

+
/// Represents a git blob object.
+
///
+
/// The type parameter `T` can be fulfilled by [`BlobRef`] or a
+
/// [`Vec`] of bytes.
+
pub struct Blob<T> {
+
    id: Oid,
+
    is_binary: bool,
+
    commit: Commit,
+
    content: T,
+
}
+

+
impl<T> Blob<T> {
+
    pub fn object_id(&self) -> Oid {
+
        self.id
+
    }
+

+
    pub fn is_binary(&self) -> bool {
+
        self.is_binary
+
    }
+

+
    /// Returns the commit that created this blob.
+
    pub fn commit(&self) -> &Commit {
+
        &self.commit
+
    }
+

+
    pub fn content(&self) -> &[u8]
+
    where
+
        T: AsRef<[u8]>,
+
    {
+
        self.content.as_ref()
+
    }
+

+
    pub fn size(&self) -> usize
+
    where
+
        T: AsRef<[u8]>,
+
    {
+
        self.content.as_ref().len()
+
    }
+
}
+

+
impl<'a> Blob<BlobRef<'a>> {
+
    /// Returns the [`Blob`] wrapping around an underlying [`git2::Blob`].
+
    pub(crate) fn new(id: Oid, git2_blob: git2::Blob<'a>, commit: Commit) -> Self {
+
        let is_binary = git2_blob.is_binary();
+
        let content = BlobRef { inner: git2_blob };
+
        Self {
+
            id,
+
            is_binary,
+
            content,
+
            commit,
+
        }
+
    }
+

+
    /// Converts into a `Blob` with owned content bytes.
+
    pub fn to_owned(&self) -> Blob<Vec<u8>> {
+
        Blob {
+
            id: self.id,
+
            content: self.content.to_vec(),
+
            commit: self.commit.clone(),
+
            is_binary: self.is_binary,
+
        }
+
    }
+
}
+

+
/// Represents a blob with borrowed content bytes.
+
pub struct BlobRef<'a> {
+
    pub(crate) inner: git2::Blob<'a>,
+
}
+

+
impl BlobRef<'_> {
+
    pub fn id(&self) -> Oid {
+
        self.inner.id().into()
+
    }
+
}
+

+
impl AsRef<[u8]> for BlobRef<'_> {
+
    fn as_ref(&self) -> &[u8] {
+
        self.inner.content()
+
    }
+
}
+

+
impl Deref for BlobRef<'_> {
+
    type Target = [u8];
+

+
    fn deref(&self) -> &Self::Target {
+
        self.inner.content()
+
    }
+
}
+

+
#[cfg(feature = "serde")]
+
impl<T> Serialize for Blob<T>
+
where
+
    T: AsRef<[u8]>,
+
{
+
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+
    where
+
        S: Serializer,
+
    {
+
        use base64::Engine as _;
+

+
        const FIELDS: usize = 4;
+
        let mut state = serializer.serialize_struct("Blob", FIELDS)?;
+
        state.serialize_field("id", &self.id)?;
+
        state.serialize_field("binary", &self.is_binary())?;
+

+
        let bytes = self.content.as_ref();
+
        match std::str::from_utf8(bytes) {
+
            Ok(s) => state.serialize_field("content", s)?,
+
            Err(_) => {
+
                let encoded = base64::prelude::BASE64_STANDARD.encode(bytes);
+
                state.serialize_field("content", &encoded)?
+
            }
+
        };
+
        state.serialize_field("lastCommit", &self.commit)?;
+
        state.end()
+
    }
+
}
+

+
#[cfg(feature = "serde")]
+
impl Serialize for BlobRef<'_> {
+
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+
    where
+
        S: Serializer,
+
    {
+
        use base64::Engine as _;
+

+
        const FIELDS: usize = 3;
+
        let mut state = serializer.serialize_struct("BlobRef", FIELDS)?;
+
        state.serialize_field("id", &self.id())?;
+
        state.serialize_field("binary", &self.inner.is_binary())?;
+

+
        let bytes = self.as_ref();
+
        match std::str::from_utf8(bytes) {
+
            Ok(s) => state.serialize_field("content", s)?,
+
            Err(_) => {
+
                let encoded = base64::prelude::BASE64_STANDARD.encode(bytes);
+
                state.serialize_field("content", &encoded)?
+
            }
+
        };
+
        state.end()
+
    }
+
}
added crates/radicle-surf/src/branch.rs
@@ -0,0 +1,336 @@
+
use std::{
+
    convert::TryFrom,
+
    str::{self, FromStr},
+
};
+

+
use radicle_git_ref_format::{lit, name::component, Component, Qualified, RefStr, RefString};
+

+
use crate::refs::refstr_join;
+

+
/// A `Branch` represents any git branch. It can be [`Local`] or [`Remote`].
+
///
+
/// Note that if a `Branch` is created from a [`git2::Reference`] then
+
/// any `refs/namespaces` will be stripped.
+
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
+
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+
pub enum Branch {
+
    Local(Local),
+
    Remote(Remote),
+
}
+

+
impl Branch {
+
    /// Construct a [`Local`] branch.
+
    pub fn local<R>(name: R) -> Self
+
    where
+
        R: AsRef<RefStr>,
+
    {
+
        Self::Local(Local::new(name))
+
    }
+

+
    /// Construct a [`Remote`] branch.
+
    /// The `remote` is the remote name of the reference name while
+
    /// the `name` is the suffix, i.e. `refs/remotes/<remote>/<name>`.
+
    pub fn remote<R>(remote: Component<'_>, name: R) -> Self
+
    where
+
        R: AsRef<RefStr>,
+
    {
+
        Self::Remote(Remote::new(remote, name))
+
    }
+

+
    /// Return the short `Branch` refname,
+
    /// e.g. `fix/ref-format`.
+
    pub fn short_name(&self) -> &RefString {
+
        match self {
+
            Branch::Local(local) => local.short_name(),
+
            Branch::Remote(remote) => remote.short_name(),
+
        }
+
    }
+

+
    /// Give back the fully qualified `Branch` refname,
+
    /// e.g. `refs/remotes/origin/fix/ref-format`,
+
    /// `refs/heads/fix/ref-format`.
+
    pub fn refname<'a>(&'a self) -> Qualified<'a> {
+
        match self {
+
            Branch::Local(local) => local.refname(),
+
            Branch::Remote(remote) => remote.refname(),
+
        }
+
    }
+
}
+

+
impl TryFrom<&git2::Reference<'_>> for Branch {
+
    type Error = error::Branch;
+

+
    fn try_from(reference: &git2::Reference<'_>) -> Result<Self, Self::Error> {
+
        let name = str::from_utf8(reference.name_bytes())?;
+
        Self::from_str(name)
+
    }
+
}
+

+
impl TryFrom<&str> for Branch {
+
    type Error = error::Branch;
+

+
    fn try_from(name: &str) -> Result<Self, Self::Error> {
+
        Self::from_str(name)
+
    }
+
}
+

+
impl FromStr for Branch {
+
    type Err = error::Branch;
+

+
    fn from_str(name: &str) -> Result<Self, Self::Err> {
+
        let name = RefStr::try_from_str(name)?;
+
        let name = match name.to_namespaced() {
+
            None => name
+
                .qualified()
+
                .ok_or_else(|| error::Branch::NotQualified(name.to_string()))?,
+
            Some(name) => name.strip_namespace_recursive(),
+
        };
+

+
        let (_ref, category, c, cs) = name.non_empty_components();
+

+
        if category == component::HEADS {
+
            Ok(Self::Local(Local::new(refstr_join(c, cs))))
+
        } else if category == component::REMOTES {
+
            Ok(Self::Remote(Remote::new(c, cs.collect::<RefString>())))
+
        } else {
+
            Err(error::Branch::InvalidName(name.into()))
+
        }
+
    }
+
}
+

+
/// A `Local` represents a local branch, i.e. it is a reference under
+
/// `refs/heads`.
+
///
+
/// Note that if a `Local` is created from a [`git2::Reference`] then
+
/// any `refs/namespaces` will be stripped.
+
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
+
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+
pub struct Local {
+
    name: RefString,
+
}
+

+
impl Local {
+
    /// Construct a new `Local` with the given `name`.
+
    ///
+
    /// If the name is qualified with `refs/heads`, this will be
+
    /// shortened to the suffix. To get the `Qualified` name again,
+
    /// use [`Local::refname`].
+
    pub(crate) fn new<R>(name: R) -> Self
+
    where
+
        R: AsRef<RefStr>,
+
    {
+
        match name.as_ref().qualified() {
+
            None => Self {
+
                name: name.as_ref().to_ref_string(),
+
            },
+
            Some(qualified) => {
+
                let (_refs, heads, c, cs) = qualified.non_empty_components();
+
                if heads == component::HEADS {
+
                    Self {
+
                        name: refstr_join(c, cs),
+
                    }
+
                } else {
+
                    Self {
+
                        name: name.as_ref().to_ref_string(),
+
                    }
+
                }
+
            }
+
        }
+
    }
+

+
    /// Return the short `Local` refname,
+
    /// e.g. `fix/ref-format`.
+
    pub fn short_name(&self) -> &RefString {
+
        &self.name
+
    }
+

+
    /// Return the fully qualified `Local` refname,
+
    /// e.g. `refs/heads/fix/ref-format`.
+
    pub fn refname<'a>(&'a self) -> Qualified<'a> {
+
        lit::refs_heads(&self.name).into()
+
    }
+
}
+

+
impl TryFrom<&git2::Reference<'_>> for Local {
+
    type Error = error::Local;
+

+
    fn try_from(reference: &git2::Reference) -> Result<Self, Self::Error> {
+
        let name = str::from_utf8(reference.name_bytes())?;
+
        Self::from_str(name)
+
    }
+
}
+

+
impl TryFrom<&str> for Local {
+
    type Error = error::Local;
+

+
    fn try_from(name: &str) -> Result<Self, Self::Error> {
+
        Self::from_str(name)
+
    }
+
}
+

+
impl FromStr for Local {
+
    type Err = error::Local;
+

+
    fn from_str(name: &str) -> Result<Self, Self::Err> {
+
        let name = RefStr::try_from_str(name)?;
+
        let name = match name.to_namespaced() {
+
            None => name
+
                .qualified()
+
                .ok_or_else(|| error::Local::NotQualified(name.to_string()))?,
+
            Some(name) => name.strip_namespace_recursive(),
+
        };
+

+
        let (_ref, heads, c, cs) = name.non_empty_components();
+
        if heads == component::HEADS {
+
            Ok(Self::new(refstr_join(c, cs)))
+
        } else {
+
            Err(error::Local::NotHeads(name.into()))
+
        }
+
    }
+
}
+

+
/// A `Remote` represents a remote branch, i.e. it is a reference under
+
/// `refs/remotes`.
+
///
+
/// Note that if a `Remote` is created from a [`git2::Reference`] then
+
/// any `refs/namespaces` will be stripped.
+
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
+
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+
pub struct Remote {
+
    remote: RefString,
+
    name: RefString,
+
}
+

+
impl Remote {
+
    /// Construct a new `Remote` with the given `name` and `remote`.
+
    ///
+
    /// ## Note
+
    /// `name` is expected to be in short form, i.e. not begin with
+
    /// `refs`.
+
    ///
+
    /// If you are creating a `Remote` with a name that begins with
+
    /// `refs/remotes`, use [`Remote::from_refs_remotes`] instead.
+
    ///
+
    /// To get the `Qualified` name, use [`Remote::refname`].
+
    pub(crate) fn new<R>(remote: Component, name: R) -> Self
+
    where
+
        R: AsRef<RefStr>,
+
    {
+
        Self {
+
            name: name.as_ref().to_ref_string(),
+
            remote: remote.to_ref_string(),
+
        }
+
    }
+

+
    /// Parse the `name` from the form `refs/remotes/<remote>/<rest>`.
+
    ///
+
    /// If the `name` is not of this form, then `None` is returned.
+
    pub fn from_refs_remotes<R>(name: R) -> Option<Self>
+
    where
+
        R: AsRef<RefStr>,
+
    {
+
        let qualified = name.as_ref().qualified()?;
+
        let (_refs, remotes, remote, cs) = qualified.non_empty_components();
+
        (remotes == component::REMOTES).then_some(Self {
+
            name: cs.collect(),
+
            remote: remote.to_ref_string(),
+
        })
+
    }
+

+
    /// Return the short `Remote` refname,
+
    /// e.g. `fix/ref-format`.
+
    pub fn short_name(&self) -> &RefString {
+
        &self.name
+
    }
+

+
    /// Return the remote of the `Remote`'s refname,
+
    /// e.g. `origin`.
+
    pub fn remote(&self) -> &RefString {
+
        &self.remote
+
    }
+

+
    /// Give back the fully qualified `Remote` refname,
+
    /// e.g. `refs/remotes/origin/fix/ref-format`.
+
    pub fn refname<'a>(&'a self) -> Qualified<'a> {
+
        lit::refs_remotes(self.remote.join(&self.name)).into()
+
    }
+
}
+

+
impl TryFrom<&git2::Reference<'_>> for Remote {
+
    type Error = error::Remote;
+

+
    fn try_from(reference: &git2::Reference) -> Result<Self, Self::Error> {
+
        let name = str::from_utf8(reference.name_bytes())?;
+
        Self::from_str(name)
+
    }
+
}
+

+
impl TryFrom<&str> for Remote {
+
    type Error = error::Remote;
+

+
    fn try_from(name: &str) -> Result<Self, Self::Error> {
+
        Self::from_str(name)
+
    }
+
}
+

+
impl FromStr for Remote {
+
    type Err = error::Remote;
+

+
    fn from_str(name: &str) -> Result<Self, Self::Err> {
+
        let name = RefStr::try_from_str(name)?;
+
        let name = match name.to_namespaced() {
+
            None => name
+
                .qualified()
+
                .ok_or_else(|| error::Remote::NotQualified(name.to_string()))?,
+
            Some(name) => name.strip_namespace_recursive(),
+
        };
+

+
        let (_ref, remotes, remote, cs) = name.non_empty_components();
+
        if remotes == component::REMOTES {
+
            Ok(Self::new(remote, cs.collect::<RefString>()))
+
        } else {
+
            Err(error::Remote::NotRemotes(name.into()))
+
        }
+
    }
+
}
+

+
pub mod error {
+
    use radicle_git_ref_format::{self, RefString};
+
    use thiserror::Error;
+

+
    #[derive(Debug, Error)]
+
    pub enum Branch {
+
        #[error("the refname '{0}' did not begin with 'refs/heads' or 'refs/remotes'")]
+
        InvalidName(RefString),
+
        #[error("the refname '{0}' did not begin with 'refs/heads' or 'refs/remotes'")]
+
        NotQualified(String),
+
        #[error(transparent)]
+
        RefFormat(#[from] radicle_git_ref_format::Error),
+
        #[error(transparent)]
+
        Utf8(#[from] std::str::Utf8Error),
+
    }
+

+
    #[derive(Debug, Error)]
+
    pub enum Local {
+
        #[error("the refname '{0}' did not begin with 'refs/heads'")]
+
        NotHeads(RefString),
+
        #[error("the refname '{0}' did not begin with 'refs/heads'")]
+
        NotQualified(String),
+
        #[error(transparent)]
+
        RefFormat(#[from] radicle_git_ref_format::Error),
+
        #[error(transparent)]
+
        Utf8(#[from] std::str::Utf8Error),
+
    }
+

+
    #[derive(Debug, Error)]
+
    pub enum Remote {
+
        #[error("the refname '{0}' did not begin with 'refs/remotes'")]
+
        NotQualified(String),
+
        #[error("the refname '{0}' did not begin with 'refs/remotes'")]
+
        NotRemotes(RefString),
+
        #[error(transparent)]
+
        RefFormat(#[from] radicle_git_ref_format::Error),
+
        #[error(transparent)]
+
        Utf8(#[from] std::str::Utf8Error),
+
    }
+
}
added crates/radicle-surf/src/commit.rs
@@ -0,0 +1,190 @@
+
use std::{convert::TryFrom, str};
+

+
use radicle_oid::Oid;
+
use thiserror::Error;
+

+
#[cfg(feature = "serde")]
+
use serde::{ser::SerializeStruct, Deserialize, Deserializer, Serialize, Serializer};
+

+
#[derive(Debug, Error)]
+
pub enum Error {
+
    /// When trying to get the summary for a [`git2::Commit`] some action
+
    /// failed.
+
    #[error("an error occurred trying to get a commit's summary")]
+
    MissingSummary,
+
    #[error(transparent)]
+
    Utf8Error(#[from] str::Utf8Error),
+
}
+

+
/// Represents the authorship of actions in a git repo.
+
#[cfg_attr(feature = "serde", derive(Deserialize, Serialize))]
+
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord)]
+
pub struct Author {
+
    /// Name of the author.
+
    pub name: String,
+
    /// Email of the author.
+
    pub email: String,
+
    /// Time the action was taken, e.g. time of commit.
+
    #[cfg_attr(
+
        feature = "serde",
+
        serde(
+
            serialize_with = "serialize_time",
+
            deserialize_with = "deserialize_time"
+
        )
+
    )]
+
    pub time: Time,
+
}
+

+
/// Time used in the authorship of an action in a git repo.
+
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
+
pub struct Time {
+
    inner: git2::Time,
+
}
+

+
impl From<git2::Time> for Time {
+
    fn from(inner: git2::Time) -> Self {
+
        Self { inner }
+
    }
+
}
+

+
impl Time {
+
    pub fn new(epoch_seconds: i64, offset_minutes: i32) -> Self {
+
        git2::Time::new(epoch_seconds, offset_minutes).into()
+
    }
+

+
    /// Returns the seconds since UNIX epoch.
+
    pub fn seconds(&self) -> i64 {
+
        self.inner.seconds()
+
    }
+

+
    /// Returns the timezone offset in minutes.
+
    pub fn offset_minutes(&self) -> i32 {
+
        self.inner.offset_minutes()
+
    }
+
}
+

+
#[cfg(feature = "serde")]
+
fn deserialize_time<'de, D>(deserializer: D) -> Result<Time, D::Error>
+
where
+
    D: Deserializer<'de>,
+
{
+
    let seconds: i64 = Deserialize::deserialize(deserializer)?;
+
    Ok(Time::new(seconds, 0))
+
}
+

+
#[cfg(feature = "serde")]
+
fn serialize_time<S>(t: &Time, serializer: S) -> Result<S::Ok, S::Error>
+
where
+
    S: Serializer,
+
{
+
    serializer.serialize_i64(t.seconds())
+
}
+

+
impl std::fmt::Debug for Author {
+
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+
        use std::cmp::Ordering;
+
        let time = match self.time.offset_minutes().cmp(&0) {
+
            Ordering::Equal => format!("{}", self.time.seconds()),
+
            Ordering::Greater => format!("{}+{}", self.time.seconds(), self.time.offset_minutes()),
+
            Ordering::Less => format!("{}{}", self.time.seconds(), self.time.offset_minutes()),
+
        };
+
        f.debug_struct("Author")
+
            .field("name", &self.name)
+
            .field("email", &self.email)
+
            .field("time", &time)
+
            .finish()
+
    }
+
}
+

+
impl TryFrom<git2::Signature<'_>> for Author {
+
    type Error = str::Utf8Error;
+

+
    fn try_from(signature: git2::Signature) -> Result<Self, Self::Error> {
+
        let name = str::from_utf8(signature.name_bytes())?.into();
+
        let email = str::from_utf8(signature.email_bytes())?.into();
+
        let time = signature.when().into();
+

+
        Ok(Author { name, email, time })
+
    }
+
}
+

+
/// `Commit` is the metadata of a [Git commit][git-commit].
+
///
+
/// [git-commit]: https://git-scm.com/book/en/v2/Git-Internals-Git-Objects
+
#[cfg_attr(feature = "serde", derive(Deserialize))]
+
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
+
pub struct Commit {
+
    /// Object Id
+
    pub id: Oid,
+
    /// The author of the commit.
+
    pub author: Author,
+
    /// The actor who committed this commit.
+
    pub committer: Author,
+
    /// The long form message of the commit.
+
    pub message: String,
+
    /// The summary message of the commit.
+
    pub summary: String,
+
    /// The parents of this commit.
+
    pub parents: Vec<Oid>,
+
}
+

+
impl Commit {
+
    /// Returns the commit description text. This is the text after the one-line
+
    /// summary.
+
    #[must_use]
+
    pub fn description(&self) -> &str {
+
        self.message
+
            .strip_prefix(&self.summary)
+
            .unwrap_or(&self.message)
+
            .trim()
+
    }
+
}
+

+
#[cfg(feature = "serde")]
+
impl Serialize for Commit {
+
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+
    where
+
        S: Serializer,
+
    {
+
        let mut state = serializer.serialize_struct("Commit", 7)?;
+
        state.serialize_field("id", &self.id.to_string())?;
+
        state.serialize_field("author", &self.author)?;
+
        state.serialize_field("committer", &self.committer)?;
+
        state.serialize_field("summary", &self.summary)?;
+
        state.serialize_field("message", &self.message)?;
+
        state.serialize_field("description", &self.description())?;
+
        state.serialize_field(
+
            "parents",
+
            &self
+
                .parents
+
                .iter()
+
                .map(|oid| oid.to_string())
+
                .collect::<Vec<String>>(),
+
        )?;
+
        state.end()
+
    }
+
}
+

+
impl TryFrom<git2::Commit<'_>> for Commit {
+
    type Error = Error;
+

+
    fn try_from(commit: git2::Commit) -> Result<Self, Self::Error> {
+
        let id = commit.id().into();
+
        let author = Author::try_from(commit.author())?;
+
        let committer = Author::try_from(commit.committer())?;
+
        let message_raw = commit.message_bytes();
+
        let message = str::from_utf8(message_raw)?.into();
+
        let summary_raw = commit.summary_bytes().ok_or(Error::MissingSummary)?;
+
        let summary = str::from_utf8(summary_raw)?.into();
+
        let parents = commit.parent_ids().map(|oid| oid.into()).collect();
+

+
        Ok(Commit {
+
            id,
+
            author,
+
            committer,
+
            message,
+
            summary,
+
            parents,
+
        })
+
    }
+
}
added crates/radicle-surf/src/diff.rs
@@ -0,0 +1,612 @@
+
//! Types that represent diff(s) in a Git repo.
+

+
use std::{
+
    borrow::Cow,
+
    ops::Range,
+
    path::{Path, PathBuf},
+
    string::FromUtf8Error,
+
};
+

+
#[cfg(feature = "serde")]
+
use serde::{ser, ser::SerializeStruct, Serialize, Serializer};
+

+
use radicle_oid::Oid;
+

+
pub mod git;
+

+
/// The serializable representation of a `git diff`.
+
///
+
/// A [`Diff`] can be retrieved by the following functions:
+
///    * [`crate::Repository::diff`]
+
///    * [`crate::Repository::diff_commit`]
+
#[cfg_attr(feature = "serde", derive(Serialize))]
+
#[derive(Clone, Debug, Default, PartialEq, Eq)]
+
pub struct Diff {
+
    files: Vec<FileDiff>,
+
    stats: Stats,
+
}
+

+
impl Diff {
+
    /// Creates an empty diff.
+
    pub(crate) fn new() -> Self {
+
        Diff::default()
+
    }
+

+
    /// Returns an iterator of the file in the diff.
+
    pub fn files(&self) -> impl Iterator<Item = &FileDiff> {
+
        self.files.iter()
+
    }
+

+
    /// Returns owned files in the diff.
+
    pub fn into_files(self) -> Vec<FileDiff> {
+
        self.files
+
    }
+

+
    pub fn added(&self) -> impl Iterator<Item = &Added> {
+
        self.files().filter_map(|x| match x {
+
            FileDiff::Added(a) => Some(a),
+
            _ => None,
+
        })
+
    }
+

+
    pub fn deleted(&self) -> impl Iterator<Item = &Deleted> {
+
        self.files().filter_map(|x| match x {
+
            FileDiff::Deleted(a) => Some(a),
+
            _ => None,
+
        })
+
    }
+

+
    pub fn moved(&self) -> impl Iterator<Item = &Moved> {
+
        self.files().filter_map(|x| match x {
+
            FileDiff::Moved(a) => Some(a),
+
            _ => None,
+
        })
+
    }
+

+
    pub fn modified(&self) -> impl Iterator<Item = &Modified> {
+
        self.files().filter_map(|x| match x {
+
            FileDiff::Modified(a) => Some(a),
+
            _ => None,
+
        })
+
    }
+

+
    pub fn copied(&self) -> impl Iterator<Item = &Copied> {
+
        self.files().filter_map(|x| match x {
+
            FileDiff::Copied(a) => Some(a),
+
            _ => None,
+
        })
+
    }
+

+
    pub fn stats(&self) -> &Stats {
+
        &self.stats
+
    }
+

+
    fn update_stats(&mut self, diff: &DiffContent) {
+
        self.stats.files_changed += 1;
+
        if let DiffContent::Plain { hunks, .. } = diff {
+
            for h in hunks.iter() {
+
                for l in &h.lines {
+
                    match l {
+
                        Modification::Addition(_) => self.stats.insertions += 1,
+
                        Modification::Deletion(_) => self.stats.deletions += 1,
+
                        _ => (),
+
                    }
+
                }
+
            }
+
        }
+
    }
+

+
    pub fn insert_modified(
+
        &mut self,
+
        path: PathBuf,
+
        diff: DiffContent,
+
        old: DiffFile,
+
        new: DiffFile,
+
    ) {
+
        self.update_stats(&diff);
+
        let diff = FileDiff::Modified(Modified {
+
            path,
+
            diff,
+
            old,
+
            new,
+
        });
+
        self.files.push(diff);
+
    }
+

+
    pub fn insert_moved(
+
        &mut self,
+
        old_path: PathBuf,
+
        new_path: PathBuf,
+
        old: DiffFile,
+
        new: DiffFile,
+
        content: DiffContent,
+
    ) {
+
        self.update_stats(&DiffContent::Empty);
+
        let diff = FileDiff::Moved(Moved {
+
            old_path,
+
            new_path,
+
            old,
+
            new,
+
            diff: content,
+
        });
+
        self.files.push(diff);
+
    }
+

+
    pub fn insert_copied(
+
        &mut self,
+
        old_path: PathBuf,
+
        new_path: PathBuf,
+
        old: DiffFile,
+
        new: DiffFile,
+
        content: DiffContent,
+
    ) {
+
        self.update_stats(&DiffContent::Empty);
+
        let diff = FileDiff::Copied(Copied {
+
            old_path,
+
            new_path,
+
            old,
+
            new,
+
            diff: content,
+
        });
+
        self.files.push(diff);
+
    }
+

+
    pub fn insert_added(&mut self, path: PathBuf, diff: DiffContent, new: DiffFile) {
+
        self.update_stats(&diff);
+
        let diff = FileDiff::Added(Added { path, diff, new });
+
        self.files.push(diff);
+
    }
+

+
    pub fn insert_deleted(&mut self, path: PathBuf, diff: DiffContent, old: DiffFile) {
+
        self.update_stats(&diff);
+
        let diff = FileDiff::Deleted(Deleted { path, diff, old });
+
        self.files.push(diff);
+
    }
+
}
+

+
/// A file that was added within a [`Diff`].
+
#[cfg_attr(feature = "serde", derive(Serialize))]
+
#[derive(Clone, Debug, PartialEq, Eq)]
+
pub struct Added {
+
    /// The path to this file, relative to the repository root.
+
    pub path: PathBuf,
+
    pub diff: DiffContent,
+
    pub new: DiffFile,
+
}
+

+
/// A file that was deleted within a [`Diff`].
+
#[cfg_attr(feature = "serde", derive(Serialize))]
+
#[derive(Clone, Debug, PartialEq, Eq)]
+
pub struct Deleted {
+
    /// The path to this file, relative to the repository root.
+
    pub path: PathBuf,
+
    pub diff: DiffContent,
+
    pub old: DiffFile,
+
}
+

+
/// A file that was moved within a [`Diff`].
+
#[derive(Clone, Debug, PartialEq, Eq)]
+
pub struct Moved {
+
    /// The old path to this file, relative to the repository root.
+
    pub old_path: PathBuf,
+
    pub old: DiffFile,
+
    /// The new path to this file, relative to the repository root.
+
    pub new_path: PathBuf,
+
    pub new: DiffFile,
+
    pub diff: DiffContent,
+
}
+

+
#[cfg(feature = "serde")]
+
impl Serialize for Moved {
+
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+
    where
+
        S: Serializer,
+
    {
+
        if self.old == self.new {
+
            let mut state = serializer.serialize_struct("Moved", 3)?;
+
            state.serialize_field("oldPath", &self.old_path)?;
+
            state.serialize_field("newPath", &self.new_path)?;
+
            state.serialize_field("current", &self.new)?;
+
            state.end()
+
        } else {
+
            let mut state = serializer.serialize_struct("Moved", 5)?;
+
            state.serialize_field("oldPath", &self.old_path)?;
+
            state.serialize_field("newPath", &self.new_path)?;
+
            state.serialize_field("old", &self.old)?;
+
            state.serialize_field("new", &self.new)?;
+
            state.serialize_field("diff", &self.diff)?;
+
            state.end()
+
        }
+
    }
+
}
+

+
/// A file that was copied within a [`Diff`].
+
#[derive(Clone, Debug, PartialEq, Eq)]
+
pub struct Copied {
+
    /// The old path to this file, relative to the repository root.
+
    pub old_path: PathBuf,
+
    /// The new path to this file, relative to the repository root.
+
    pub new_path: PathBuf,
+
    pub old: DiffFile,
+
    pub new: DiffFile,
+
    pub diff: DiffContent,
+
}
+

+
#[cfg(feature = "serde")]
+
impl Serialize for Copied {
+
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+
    where
+
        S: Serializer,
+
    {
+
        if self.old == self.new {
+
            let mut state = serializer.serialize_struct("Copied", 3)?;
+
            state.serialize_field("oldPath", &self.old_path)?;
+
            state.serialize_field("newPath", &self.new_path)?;
+
            state.serialize_field("current", &self.new)?;
+
            state.end()
+
        } else {
+
            let mut state = serializer.serialize_struct("Copied", 5)?;
+
            state.serialize_field("oldPath", &self.old_path)?;
+
            state.serialize_field("newPath", &self.new_path)?;
+
            state.serialize_field("old", &self.old)?;
+
            state.serialize_field("new", &self.new)?;
+
            state.serialize_field("diff", &self.diff)?;
+
            state.end()
+
        }
+
    }
+
}
+

+
#[cfg_attr(feature = "serde", derive(Serialize), serde(rename_all = "camelCase"))]
+
#[derive(Clone, Debug, PartialEq, Eq)]
+
pub enum EofNewLine {
+
    OldMissing,
+
    NewMissing,
+
    BothMissing,
+
    NoneMissing,
+
}
+

+
impl Default for EofNewLine {
+
    fn default() -> Self {
+
        Self::NoneMissing
+
    }
+
}
+

+
/// A file that was modified within a [`Diff`].
+
#[cfg_attr(feature = "serde", derive(Serialize), serde(rename_all = "camelCase"))]
+
#[derive(Clone, Debug, PartialEq, Eq)]
+
pub struct Modified {
+
    pub path: PathBuf,
+
    pub diff: DiffContent,
+
    pub old: DiffFile,
+
    pub new: DiffFile,
+
}
+

+
/// The set of changes for a given file.
+
#[cfg_attr(
+
    feature = "serde",
+
    derive(Serialize),
+
    serde(tag = "type", rename_all = "camelCase")
+
)]
+
#[derive(Clone, Debug, PartialEq, Eq)]
+
pub enum DiffContent {
+
    /// The file is a binary file and so no set of changes can be provided.
+
    Binary,
+
    /// The set of changes, as [`Hunks`] for a plaintext file.
+
    #[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))]
+
    Plain {
+
        hunks: Hunks<Modification>,
+
        stats: FileStats,
+
        eof: EofNewLine,
+
    },
+
    Empty,
+
}
+

+
impl DiffContent {
+
    pub fn eof(&self) -> Option<EofNewLine> {
+
        match self {
+
            Self::Plain { eof, .. } => Some(eof.clone()),
+
            _ => None,
+
        }
+
    }
+

+
    pub fn stats(&self) -> Option<&FileStats> {
+
        match &self {
+
            DiffContent::Plain { stats, .. } => Some(stats),
+
            DiffContent::Empty => None,
+
            DiffContent::Binary => None,
+
        }
+
    }
+
}
+

+
/// File mode in a diff.
+
#[derive(Clone, Debug, PartialEq, Eq)]
+
#[cfg_attr(feature = "serde", derive(Serialize), serde(rename_all = "camelCase"))]
+
pub enum FileMode {
+
    /// For regular files.
+
    Blob,
+
    /// For regular files that are executable.
+
    BlobExecutable,
+
    /// For directories.
+
    Tree,
+
    /// For symbolic links.
+
    Link,
+
    /// Used for Git submodules.
+
    Commit,
+
}
+

+
impl From<FileMode> for u32 {
+
    fn from(m: FileMode) -> Self {
+
        git2::FileMode::from(m).into()
+
    }
+
}
+

+
impl From<FileMode> for i32 {
+
    fn from(m: FileMode) -> Self {
+
        git2::FileMode::from(m).into()
+
    }
+
}
+

+
/// A modified file.
+
#[derive(Clone, Debug, PartialEq, Eq)]
+
#[cfg_attr(feature = "serde", derive(Serialize), serde(rename_all = "camelCase"))]
+
pub struct DiffFile {
+
    /// File blob id.
+
    pub oid: Oid,
+
    /// File mode.
+
    pub mode: FileMode,
+
}
+

+
#[derive(Clone, Debug, PartialEq, Eq)]
+
#[cfg_attr(
+
    feature = "serde",
+
    derive(Serialize),
+
    serde(tag = "status", rename_all = "camelCase")
+
)]
+
pub enum FileDiff {
+
    Added(Added),
+
    Deleted(Deleted),
+
    Modified(Modified),
+
    Moved(Moved),
+
    Copied(Copied),
+
}
+

+
impl FileDiff {
+
    pub fn path(&self) -> &Path {
+
        match self {
+
            FileDiff::Added(x) => x.path.as_path(),
+
            FileDiff::Deleted(x) => x.path.as_path(),
+
            FileDiff::Modified(x) => x.path.as_path(),
+
            FileDiff::Moved(x) => x.new_path.as_path(),
+
            FileDiff::Copied(x) => x.new_path.as_path(),
+
        }
+
    }
+
}
+

+
/// Statistics describing a particular [`FileDiff`].
+
#[cfg_attr(feature = "serde", derive(Serialize), serde(rename_all = "camelCase"))]
+
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
+
pub struct FileStats {
+
    /// Get the total number of additions in a [`FileDiff`].
+
    pub additions: usize,
+
    /// Get the total number of deletions in a [`FileDiff`].
+
    pub deletions: usize,
+
}
+

+
/// Statistics describing a particular [`Diff`].
+
#[cfg_attr(feature = "serde", derive(Serialize), serde(rename_all = "camelCase"))]
+
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
+
pub struct Stats {
+
    /// Get the total number of files changed in a [`Diff`]
+
    pub files_changed: usize,
+
    /// Get the total number of insertions in a [`Diff`].
+
    pub insertions: usize,
+
    /// Get the total number of deletions in a [`Diff`].
+
    pub deletions: usize,
+
}
+

+
/// A set of changes across multiple lines.
+
///
+
/// The parameter `T` can be an [`Addition`], [`Deletion`], or
+
/// [`Modification`].
+
#[cfg_attr(feature = "serde", derive(Serialize), serde(rename_all = "camelCase"))]
+
#[derive(Clone, Debug, PartialEq, Eq)]
+
pub struct Hunk<T> {
+
    pub header: Line,
+
    pub lines: Vec<T>,
+
    /// Old line range.
+
    pub old: Range<u32>,
+
    /// New line range.
+
    pub new: Range<u32>,
+
}
+

+
/// A set of [`Hunk`] changes.
+
#[cfg_attr(feature = "serde", derive(Serialize))]
+
#[derive(Clone, Debug, PartialEq, Eq)]
+
pub struct Hunks<T>(pub Vec<Hunk<T>>);
+

+
impl<T> Default for Hunks<T> {
+
    fn default() -> Self {
+
        Self(Default::default())
+
    }
+
}
+

+
impl<T> Hunks<T> {
+
    pub fn iter(&self) -> impl Iterator<Item = &Hunk<T>> {
+
        self.0.iter()
+
    }
+
}
+

+
impl<T> From<Vec<Hunk<T>>> for Hunks<T> {
+
    fn from(hunks: Vec<Hunk<T>>) -> Self {
+
        Self(hunks)
+
    }
+
}
+

+
/// The content of a single line.
+
#[derive(Clone, Debug, PartialEq, Eq)]
+
pub struct Line(pub(crate) Vec<u8>);
+

+
impl Line {
+
    pub fn as_bytes(&self) -> &[u8] {
+
        self.0.as_slice()
+
    }
+

+
    pub fn from_utf8(self) -> Result<String, FromUtf8Error> {
+
        String::from_utf8(self.0)
+
    }
+

+
    pub fn from_utf8_lossy<'a>(&'a self) -> Cow<'a, str> {
+
        String::from_utf8_lossy(&self.0)
+
    }
+
}
+

+
impl From<Vec<u8>> for Line {
+
    fn from(v: Vec<u8>) -> Self {
+
        Self(v)
+
    }
+
}
+

+
impl From<String> for Line {
+
    fn from(s: String) -> Self {
+
        Self(s.into_bytes())
+
    }
+
}
+

+
#[cfg(feature = "serde")]
+
impl Serialize for Line {
+
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+
    where
+
        S: Serializer,
+
    {
+
        let s = std::str::from_utf8(&self.0).map_err(ser::Error::custom)?;
+

+
        serializer.serialize_str(s)
+
    }
+
}
+

+
/// Either the modification of a single [`Line`], or just contextual
+
/// information.
+
#[derive(Clone, Debug, PartialEq, Eq)]
+
pub enum Modification {
+
    /// A line is an addition in a file.
+
    Addition(Addition),
+

+
    /// A line is a deletion in a file.
+
    Deletion(Deletion),
+

+
    /// A contextual line in a file, i.e. there were no changes to the line.
+
    Context {
+
        line: Line,
+
        line_no_old: u32,
+
        line_no_new: u32,
+
    },
+
}
+

+
#[cfg(feature = "serde")]
+
impl Serialize for Modification {
+
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+
    where
+
        S: Serializer,
+
    {
+
        use serde::ser::SerializeMap as _;
+

+
        match self {
+
            Modification::Addition(addition) => {
+
                let mut map = serializer.serialize_map(Some(3))?;
+
                map.serialize_entry("line", &addition.line)?;
+
                map.serialize_entry("lineNo", &addition.line_no)?;
+
                map.serialize_entry("type", "addition")?;
+
                map.end()
+
            }
+
            Modification::Deletion(deletion) => {
+
                let mut map = serializer.serialize_map(Some(3))?;
+
                map.serialize_entry("line", &deletion.line)?;
+
                map.serialize_entry("lineNo", &deletion.line_no)?;
+
                map.serialize_entry("type", "deletion")?;
+
                map.end()
+
            }
+
            Modification::Context {
+
                line,
+
                line_no_old,
+
                line_no_new,
+
            } => {
+
                let mut map = serializer.serialize_map(Some(4))?;
+
                map.serialize_entry("line", line)?;
+
                map.serialize_entry("lineNoOld", line_no_old)?;
+
                map.serialize_entry("lineNoNew", line_no_new)?;
+
                map.serialize_entry("type", "context")?;
+
                map.end()
+
            }
+
        }
+
    }
+
}
+

+
/// A addition of a [`Line`] at the `line_no`.
+
#[derive(Clone, Debug, PartialEq, Eq)]
+
pub struct Addition {
+
    pub line: Line,
+
    pub line_no: u32,
+
}
+

+
#[cfg(feature = "serde")]
+
impl Serialize for Addition {
+
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+
    where
+
        S: Serializer,
+
    {
+
        use serde::ser::SerializeStruct as _;
+

+
        let mut s = serializer.serialize_struct("Addition", 3)?;
+
        s.serialize_field("line", &self.line)?;
+
        s.serialize_field("lineNo", &self.line_no)?;
+
        s.serialize_field("type", "addition")?;
+
        s.end()
+
    }
+
}
+

+
/// A deletion of a [`Line`] at the `line_no`.
+
#[derive(Clone, Debug, PartialEq, Eq)]
+
pub struct Deletion {
+
    pub line: Line,
+
    pub line_no: u32,
+
}
+

+
#[cfg(feature = "serde")]
+
impl Serialize for Deletion {
+
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+
    where
+
        S: Serializer,
+
    {
+
        use serde::ser::SerializeStruct as _;
+

+
        let mut s = serializer.serialize_struct("Deletion", 3)?;
+
        s.serialize_field("line", &self.line)?;
+
        s.serialize_field("lineNo", &self.line_no)?;
+
        s.serialize_field("type", "deletion")?;
+
        s.end()
+
    }
+
}
+

+
impl Modification {
+
    pub fn addition(line: impl Into<Line>, line_no: u32) -> Self {
+
        Self::Addition(Addition {
+
            line: line.into(),
+
            line_no,
+
        })
+
    }
+

+
    pub fn deletion(line: impl Into<Line>, line_no: u32) -> Self {
+
        Self::Deletion(Deletion {
+
            line: line.into(),
+
            line_no,
+
        })
+
    }
+

+
    pub fn context(line: impl Into<Line>, line_no_old: u32, line_no_new: u32) -> Self {
+
        Self::Context {
+
            line: line.into(),
+
            line_no_old,
+
            line_no_new,
+
        }
+
    }
+
}
added crates/radicle-surf/src/diff/git.rs
@@ -0,0 +1,377 @@
+
use std::convert::TryFrom;
+

+
use super::{
+
    Diff, DiffContent, DiffFile, EofNewLine, FileMode, FileStats, Hunk, Hunks, Line, Modification,
+
    Stats,
+
};
+

+
pub mod error {
+
    use std::path::PathBuf;
+

+
    use thiserror::Error;
+

+
    #[derive(Debug, Error)]
+
    #[non_exhaustive]
+
    pub enum Addition {
+
        #[error(transparent)]
+
        Git(#[from] git2::Error),
+
        #[error("the new line number was missing for an added line")]
+
        MissingNewLineNo,
+
    }
+

+
    #[derive(Debug, Error)]
+
    #[non_exhaustive]
+
    pub enum Deletion {
+
        #[error(transparent)]
+
        Git(#[from] git2::Error),
+
        #[error("the new line number was missing for an deleted line")]
+
        MissingOldLineNo,
+
    }
+

+
    #[derive(Debug, Error)]
+
    #[non_exhaustive]
+
    pub enum FileMode {
+
        #[error("unknown file mode `{0:?}`")]
+
        Unknown(git2::FileMode),
+
    }
+

+
    #[derive(Debug, Error)]
+
    #[non_exhaustive]
+
    pub enum Modification {
+
        /// A Git `DiffLine` is invalid.
+
        #[error(
+
            "invalid `git2::DiffLine` which contains no line numbers for either side of the diff"
+
        )]
+
        Invalid,
+
    }
+

+
    #[derive(Debug, Error)]
+
    #[non_exhaustive]
+
    pub enum Hunk {
+
        #[error(transparent)]
+
        Git(#[from] git2::Error),
+
        #[error(transparent)]
+
        Line(#[from] Modification),
+
    }
+

+
    /// A Git diff error.
+
    #[derive(Debug, Error)]
+
    #[non_exhaustive]
+
    pub enum Diff {
+
        #[error(transparent)]
+
        Addition(#[from] Addition),
+
        #[error(transparent)]
+
        Deletion(#[from] Deletion),
+
        /// A Git delta type isn't currently handled.
+
        #[error("git delta type is not handled")]
+
        DeltaUnhandled(git2::Delta),
+
        #[error(transparent)]
+
        Git(#[from] git2::Error),
+
        #[error(transparent)]
+
        FileMode(#[from] FileMode),
+
        #[error(transparent)]
+
        Hunk(#[from] Hunk),
+
        #[error(transparent)]
+
        Line(#[from] Modification),
+
        /// A patch is unavailable.
+
        #[error("couldn't retrieve patch for {0}")]
+
        PatchUnavailable(PathBuf),
+
        /// A The path of a file isn't available.
+
        #[error("couldn't retrieve file path")]
+
        PathUnavailable,
+
    }
+
}
+

+
impl TryFrom<git2::DiffFile<'_>> for DiffFile {
+
    type Error = error::FileMode;
+

+
    fn try_from(value: git2::DiffFile) -> Result<Self, Self::Error> {
+
        Ok(Self {
+
            mode: value.mode().try_into()?,
+
            oid: value.id().into(),
+
        })
+
    }
+
}
+

+
impl TryFrom<git2::FileMode> for FileMode {
+
    type Error = error::FileMode;
+

+
    fn try_from(value: git2::FileMode) -> Result<Self, Self::Error> {
+
        match value {
+
            git2::FileMode::Blob => Ok(Self::Blob),
+
            git2::FileMode::BlobExecutable => Ok(Self::BlobExecutable),
+
            git2::FileMode::Commit => Ok(Self::Commit),
+
            git2::FileMode::Tree => Ok(Self::Tree),
+
            git2::FileMode::Link => Ok(Self::Link),
+
            _ => Err(error::FileMode::Unknown(value)),
+
        }
+
    }
+
}
+

+
impl From<FileMode> for git2::FileMode {
+
    fn from(m: FileMode) -> Self {
+
        match m {
+
            FileMode::Blob => git2::FileMode::Blob,
+
            FileMode::BlobExecutable => git2::FileMode::BlobExecutable,
+
            FileMode::Tree => git2::FileMode::Tree,
+
            FileMode::Link => git2::FileMode::Link,
+
            FileMode::Commit => git2::FileMode::Commit,
+
        }
+
    }
+
}
+

+
impl TryFrom<git2::Patch<'_>> for DiffContent {
+
    type Error = error::Hunk;
+

+
    fn try_from(patch: git2::Patch) -> Result<Self, Self::Error> {
+
        let mut hunks = Vec::new();
+
        let mut old_missing_eof = false;
+
        let mut new_missing_eof = false;
+
        let mut additions = 0;
+
        let mut deletions = 0;
+

+
        for h in 0..patch.num_hunks() {
+
            let (hunk, hunk_lines) = patch.hunk(h)?;
+
            let header = Line(hunk.header().to_owned());
+
            let mut lines: Vec<Modification> = Vec::new();
+

+
            for l in 0..hunk_lines {
+
                let line = patch.line_in_hunk(h, l)?;
+
                match line.origin_value() {
+
                    git2::DiffLineType::ContextEOFNL => {
+
                        new_missing_eof = true;
+
                        old_missing_eof = true;
+
                        continue;
+
                    }
+
                    git2::DiffLineType::Addition => {
+
                        additions += 1;
+
                    }
+
                    git2::DiffLineType::Deletion => {
+
                        deletions += 1;
+
                    }
+
                    git2::DiffLineType::AddEOFNL => {
+
                        additions += 1;
+
                        old_missing_eof = true;
+
                        continue;
+
                    }
+
                    git2::DiffLineType::DeleteEOFNL => {
+
                        deletions += 1;
+
                        new_missing_eof = true;
+
                        continue;
+
                    }
+
                    _ => {}
+
                }
+
                let line = Modification::try_from(line)?;
+
                lines.push(line);
+
            }
+
            hunks.push(Hunk {
+
                header,
+
                lines,
+
                old: hunk.old_start()..hunk.old_start() + hunk.old_lines(),
+
                new: hunk.new_start()..hunk.new_start() + hunk.new_lines(),
+
            });
+
        }
+
        let eof = match (old_missing_eof, new_missing_eof) {
+
            (true, true) => EofNewLine::BothMissing,
+
            (true, false) => EofNewLine::OldMissing,
+
            (false, true) => EofNewLine::NewMissing,
+
            (false, false) => EofNewLine::NoneMissing,
+
        };
+
        Ok(DiffContent::Plain {
+
            hunks: Hunks(hunks),
+
            stats: FileStats {
+
                additions,
+
                deletions,
+
            },
+
            eof,
+
        })
+
    }
+
}
+

+
impl TryFrom<git2::DiffLine<'_>> for Modification {
+
    type Error = error::Modification;
+

+
    fn try_from(line: git2::DiffLine) -> Result<Self, Self::Error> {
+
        match (line.old_lineno(), line.new_lineno()) {
+
            (None, Some(n)) => Ok(Self::addition(line.content().to_owned(), n)),
+
            (Some(n), None) => Ok(Self::deletion(line.content().to_owned(), n)),
+
            (Some(l), Some(r)) => Ok(Self::context(line.content().to_owned(), l, r)),
+
            (None, None) => Err(error::Modification::Invalid),
+
        }
+
    }
+
}
+

+
impl From<git2::DiffStats> for Stats {
+
    fn from(stats: git2::DiffStats) -> Self {
+
        Self {
+
            files_changed: stats.files_changed(),
+
            insertions: stats.insertions(),
+
            deletions: stats.deletions(),
+
        }
+
    }
+
}
+

+
impl TryFrom<git2::Diff<'_>> for Diff {
+
    type Error = error::Diff;
+

+
    fn try_from(git_diff: git2::Diff) -> Result<Diff, Self::Error> {
+
        use git2::Delta;
+

+
        let mut diff = Diff::new();
+

+
        // This allows libgit2 to run the binary detection.
+
        // Reference: <https://github.com/libgit2/libgit2/issues/6637>
+
        git_diff.foreach(&mut |_, _| true, None, None, None)?;
+

+
        for (idx, delta) in git_diff.deltas().enumerate() {
+
            match delta.status() {
+
                Delta::Added => created(&mut diff, &git_diff, idx, &delta)?,
+
                Delta::Deleted => deleted(&mut diff, &git_diff, idx, &delta)?,
+
                Delta::Modified => modified(&mut diff, &git_diff, idx, &delta)?,
+
                Delta::Renamed => renamed(&mut diff, &git_diff, idx, &delta)?,
+
                Delta::Copied => copied(&mut diff, &git_diff, idx, &delta)?,
+
                status => {
+
                    return Err(error::Diff::DeltaUnhandled(status));
+
                }
+
            }
+
        }
+

+
        Ok(diff)
+
    }
+
}
+

+
fn created(
+
    diff: &mut Diff,
+
    git_diff: &git2::Diff<'_>,
+
    idx: usize,
+
    delta: &git2::DiffDelta<'_>,
+
) -> Result<(), error::Diff> {
+
    let diff_file = delta.new_file();
+
    let is_binary = diff_file.is_binary();
+
    let path = diff_file
+
        .path()
+
        .ok_or(error::Diff::PathUnavailable)?
+
        .to_path_buf();
+
    let new = DiffFile::try_from(diff_file)?;
+

+
    let patch = git2::Patch::from_diff(git_diff, idx)?;
+
    if is_binary {
+
        diff.insert_added(path, DiffContent::Binary, new);
+
    } else if let Some(patch) = patch {
+
        diff.insert_added(path, DiffContent::try_from(patch)?, new);
+
    } else {
+
        return Err(error::Diff::PatchUnavailable(path));
+
    }
+
    Ok(())
+
}
+

+
fn deleted(
+
    diff: &mut Diff,
+
    git_diff: &git2::Diff<'_>,
+
    idx: usize,
+
    delta: &git2::DiffDelta<'_>,
+
) -> Result<(), error::Diff> {
+
    let diff_file = delta.old_file();
+
    let is_binary = diff_file.is_binary();
+
    let path = diff_file
+
        .path()
+
        .ok_or(error::Diff::PathUnavailable)?
+
        .to_path_buf();
+
    let patch = git2::Patch::from_diff(git_diff, idx)?;
+
    let old = DiffFile::try_from(diff_file)?;
+

+
    if is_binary {
+
        diff.insert_deleted(path, DiffContent::Binary, old);
+
    } else if let Some(patch) = patch {
+
        diff.insert_deleted(path, DiffContent::try_from(patch)?, old);
+
    } else {
+
        return Err(error::Diff::PatchUnavailable(path));
+
    }
+
    Ok(())
+
}
+

+
fn modified(
+
    diff: &mut Diff,
+
    git_diff: &git2::Diff<'_>,
+
    idx: usize,
+
    delta: &git2::DiffDelta<'_>,
+
) -> Result<(), error::Diff> {
+
    let diff_file = delta.new_file();
+
    let path = diff_file
+
        .path()
+
        .ok_or(error::Diff::PathUnavailable)?
+
        .to_path_buf();
+
    let patch = git2::Patch::from_diff(git_diff, idx)?;
+
    let old = DiffFile::try_from(delta.old_file())?;
+
    let new = DiffFile::try_from(delta.new_file())?;
+

+
    if diff_file.is_binary() {
+
        diff.insert_modified(path, DiffContent::Binary, old, new);
+
        Ok(())
+
    } else if let Some(patch) = patch {
+
        diff.insert_modified(path, DiffContent::try_from(patch)?, old, new);
+
        Ok(())
+
    } else {
+
        Err(error::Diff::PatchUnavailable(path))
+
    }
+
}
+

+
fn renamed(
+
    diff: &mut Diff,
+
    git_diff: &git2::Diff<'_>,
+
    idx: usize,
+
    delta: &git2::DiffDelta<'_>,
+
) -> Result<(), error::Diff> {
+
    let old_path = delta
+
        .old_file()
+
        .path()
+
        .ok_or(error::Diff::PathUnavailable)?
+
        .to_path_buf();
+
    let new_path = delta
+
        .new_file()
+
        .path()
+
        .ok_or(error::Diff::PathUnavailable)?
+
        .to_path_buf();
+
    let patch = git2::Patch::from_diff(git_diff, idx)?;
+
    let old = DiffFile::try_from(delta.old_file())?;
+
    let new = DiffFile::try_from(delta.new_file())?;
+

+
    if delta.new_file().is_binary() {
+
        diff.insert_moved(old_path, new_path, old, new, DiffContent::Binary);
+
    } else if let Some(patch) = patch {
+
        diff.insert_moved(old_path, new_path, old, new, DiffContent::try_from(patch)?);
+
    } else {
+
        diff.insert_moved(old_path, new_path, old, new, DiffContent::Empty);
+
    }
+
    Ok(())
+
}
+

+
fn copied(
+
    diff: &mut Diff,
+
    git_diff: &git2::Diff<'_>,
+
    idx: usize,
+
    delta: &git2::DiffDelta<'_>,
+
) -> Result<(), error::Diff> {
+
    let old_path = delta
+
        .old_file()
+
        .path()
+
        .ok_or(error::Diff::PathUnavailable)?
+
        .to_path_buf();
+
    let new_path = delta
+
        .new_file()
+
        .path()
+
        .ok_or(error::Diff::PathUnavailable)?
+
        .to_path_buf();
+
    let patch = git2::Patch::from_diff(git_diff, idx)?;
+
    let old = DiffFile::try_from(delta.old_file())?;
+
    let new = DiffFile::try_from(delta.new_file())?;
+

+
    if delta.new_file().is_binary() {
+
        diff.insert_copied(old_path, new_path, old, new, DiffContent::Binary);
+
    } else if let Some(patch) = patch {
+
        diff.insert_copied(old_path, new_path, old, new, DiffContent::try_from(patch)?);
+
    } else {
+
        diff.insert_copied(old_path, new_path, old, new, DiffContent::Empty);
+
    }
+
    Ok(())
+
}
added crates/radicle-surf/src/error.rs
@@ -0,0 +1,41 @@
+
//! Definition for a crate level error type, which wraps up module level
+
//! error types transparently.
+

+
use crate::{commit, diff, fs, glob, namespace, refs, repo};
+
use thiserror::Error;
+

+
/// The crate level error type that wraps up module level error types.
+
#[derive(Debug, Error)]
+
#[non_exhaustive]
+
pub enum Error {
+
    #[error(transparent)]
+
    Branches(#[from] refs::error::Branch),
+
    #[error(transparent)]
+
    Categories(#[from] refs::error::Category),
+
    #[error(transparent)]
+
    Commit(#[from] commit::Error),
+
    #[error(transparent)]
+
    Diff(#[from] diff::git::error::Diff),
+
    #[error(transparent)]
+
    Directory(#[from] fs::error::Directory),
+
    #[error(transparent)]
+
    File(#[from] fs::error::File),
+
    #[error(transparent)]
+
    Git(#[from] git2::Error),
+
    #[error(transparent)]
+
    Glob(#[from] glob::Error),
+
    #[error(transparent)]
+
    Namespace(#[from] namespace::Error),
+
    #[error(transparent)]
+
    RefFormat(#[from] radicle_git_ref_format::Error),
+
    #[error(transparent)]
+
    Revision(Box<dyn std::error::Error + Send + Sync + 'static>),
+
    #[error(transparent)]
+
    ToCommit(Box<dyn std::error::Error + Send + Sync + 'static>),
+
    #[error(transparent)]
+
    Tags(#[from] refs::error::Tag),
+
    #[error(transparent)]
+
    Repo(#[from] repo::error::Repo),
+
    #[error(transparent)]
+
    Oid(#[from] radicle_oid::str::error::ParseOidError),
+
}
added crates/radicle-surf/src/ext.rs
@@ -0,0 +1,35 @@
+
pub(crate) trait ResultExt<T, E> {
+
    /// Calls `f` if the result is [`Err`], **and** the predicate `pred` on the
+
    /// error value returns true. Otherwise returns the [`Ok`] value of
+
    /// `self`. Note that `f` may change the error type, so as long as the
+
    /// target type can be converted from the original one.
+
    ///
+
    /// # Examples
+
    ///
+
    /// ```
+
    /// use std::io;
+
    /// use radicle_std_ext::result::ResultExt as _;
+
    ///
+
    /// let res = Err(io::Error::new(io::ErrorKind::Other, "crashbug"))
+
    ///     .or_matches::<io::Error, _, _>(|e| matches!(e.kind(), io::ErrorKind::Other), || Ok(()))
+
    ///     .unwrap();
+
    ///
+
    /// assert_eq!((), res)
+
    /// ```
+
    fn or_matches<E2, P, F>(self, pred: P, f: F) -> Result<T, E2>
+
    where
+
        E2: From<E>,
+
        P: FnOnce(&E) -> bool,
+
        F: FnOnce() -> Result<T, E2>;
+
}
+

+
impl<T, E> ResultExt<T, E> for Result<T, E> {
+
    fn or_matches<E2, P, F>(self, pred: P, f: F) -> Result<T, E2>
+
    where
+
        E2: From<E>,
+
        P: FnOnce(&E) -> bool,
+
        F: FnOnce() -> Result<T, E2>,
+
    {
+
        self.or_else(|e| if pred(&e) { f() } else { Err(e.into()) })
+
    }
+
}
added crates/radicle-surf/src/fs.rs
@@ -0,0 +1,611 @@
+
//! Definition for a file system consisting of `Directory` and `File`.
+
//!
+
//! A `Directory` is expected to be a non-empty tree of directories and files.
+
//! See [`Directory`] for more information.
+

+
use std::{
+
    cmp::Ordering,
+
    collections::BTreeMap,
+
    convert::{Infallible, Into as _},
+
    path::{Path, PathBuf},
+
};
+

+
use git2::Blob;
+
use radicle_oid::Oid;
+
use url::Url;
+

+
use crate::{ext::ResultExt as _, Repository, Revision};
+

+
pub mod error {
+
    use std::path::PathBuf;
+

+
    use thiserror::Error;
+

+
    #[derive(Debug, Error, PartialEq)]
+
    pub enum Directory {
+
        #[error(transparent)]
+
        Git(#[from] git2::Error),
+
        #[error(transparent)]
+
        File(#[from] File),
+
        #[error("the path {0} is not valid")]
+
        InvalidPath(PathBuf),
+
        #[error("the entry at '{0}' must be of type {1}")]
+
        InvalidType(PathBuf, &'static str),
+
        #[error("the entry name was not valid UTF-8")]
+
        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.
+
///
+
/// The representation is lightweight and contains the [`Oid`] that
+
/// points to the git blob which is this file.
+
///
+
/// The name of a file can be retrieved via [`File::name`].
+
///
+
/// The [`FileContent`] of a file can be retrieved via
+
/// [`File::content`].
+
#[derive(Clone, PartialEq, Eq, Debug)]
+
pub struct File {
+
    /// The name of the file.
+
    name: String,
+
    /// The relative path of the file, not including the `name`,
+
    /// in respect to the root of the git repository.
+
    prefix: PathBuf,
+
    /// The object identifier of the git blob of this file.
+
    id: Oid,
+
}
+

+
impl File {
+
    /// Construct a new `File`.
+
    ///
+
    /// The `path` must be the prefix location of the directory, and
+
    /// so should not end in `name`.
+
    ///
+
    /// The `id` must point to a git blob.
+
    pub(crate) fn new(name: String, prefix: PathBuf, id: Oid) -> Self {
+
        debug_assert!(
+
            !prefix.ends_with(&name),
+
            "prefix = {prefix:?}, name = {name}",
+
        );
+
        Self { name, prefix, id }
+
    }
+

+
    /// The name of this `File`.
+
    pub fn name(&self) -> &str {
+
        self.name.as_str()
+
    }
+

+
    /// The object identifier of this `File`.
+
    pub fn id(&self) -> Oid {
+
        self.id
+
    }
+

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

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

+
    /// Get the [`FileContent`] for this `File`.
+
    ///
+
    /// # Errors
+
    ///
+
    /// This function will fail if it could not find the `git` blob
+
    /// for the `Oid` of this `File`.
+
    pub fn content<'a>(&self, repo: &'a Repository) -> Result<FileContent<'a>, error::File> {
+
        let blob = repo.find_blob(self.id)?;
+
        Ok(FileContent { blob })
+
    }
+
}
+

+
/// The contents of a [`File`].
+
///
+
/// To construct a `FileContent` use [`File::content`].
+
pub struct FileContent<'a> {
+
    blob: Blob<'a>,
+
}
+

+
impl<'a> FileContent<'a> {
+
    /// Return the file contents as a byte slice.
+
    pub fn as_bytes(&self) -> &[u8] {
+
        self.blob.content()
+
    }
+

+
    /// Return the size of the file contents.
+
    pub fn size(&self) -> usize {
+
        self.blob.size()
+
    }
+

+
    /// Creates a `FileContent` using a blob.
+
    pub(crate) fn new(blob: Blob<'a>) -> Self {
+
        Self { blob }
+
    }
+
}
+

+
/// A representations of a [`Directory`]'s entries.
+
pub struct Entries {
+
    listing: BTreeMap<String, Entry>,
+
}
+

+
impl Entries {
+
    /// Return the name of each [`Entry`].
+
    pub fn names(&self) -> impl Iterator<Item = &String> {
+
        self.listing.keys()
+
    }
+

+
    /// Return each [`Entry`].
+
    pub fn entries(&self) -> impl Iterator<Item = &Entry> {
+
        self.listing.values()
+
    }
+

+
    /// Return each [`Entry`] and its name.
+
    pub fn iter(&self) -> impl Iterator<Item = (&String, &Entry)> {
+
        self.listing.iter()
+
    }
+
}
+

+
impl Iterator for Entries {
+
    type Item = Entry;
+

+
    fn next(&mut self) -> Option<Self::Item> {
+
        // Can be improved when `pop_first()` is stable for BTreeMap.
+
        let next_key = match self.listing.keys().next() {
+
            Some(k) => k.clone(),
+
            None => return None,
+
        };
+
        self.listing.remove(&next_key)
+
    }
+
}
+

+
/// An `Entry` is either a [`File`] entry or a [`Directory`] entry.
+
#[derive(Debug, Clone, PartialEq, Eq)]
+
pub enum Entry {
+
    /// A file entry within a [`Directory`].
+
    File(File),
+
    /// A sub-directory of a [`Directory`].
+
    Directory(Directory),
+
    /// An entry points to a submodule.
+
    Submodule(Submodule),
+
}
+

+
impl PartialOrd for Entry {
+
    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
+
        Some(self.cmp(other))
+
    }
+
}
+

+
impl Ord for Entry {
+
    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
+
        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 `Entries`, 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(),
+
        }
+
    }
+

+
    /// Returns `true` if the `Entry` is a file.
+
    pub fn is_file(&self) -> bool {
+
        matches!(self, Entry::File(_))
+
    }
+

+
    /// Returns `true` if the `Entry` is a directory.
+
    pub fn is_directory(&self) -> bool {
+
        matches!(self, Entry::Directory(_))
+
    }
+

+
    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.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")),
+
        }
+
    }
+
}
+

+
/// A `Directory` is the representation of a file system directory, for a given
+
/// [`git` tree][git-tree].
+
///
+
/// The name of a directory can be retrieved via [`File::name`].
+
///
+
/// The [`Entries`] of a directory can be retrieved via
+
/// [`Directory::entries`].
+
///
+
/// [git-tree]: https://git-scm.com/book/en/v2/Git-Internals-Git-Objects
+
#[derive(Debug, Clone, PartialEq, Eq)]
+
pub struct Directory {
+
    /// The name of the directory.
+
    name: String,
+
    /// The relative path of the directory, not including the `name`,
+
    /// in respect to the root of the git repository.
+
    prefix: PathBuf,
+
    /// The object identifier of the git tree of this directory.
+
    id: Oid,
+
}
+

+
const ROOT_DIR: &str = "";
+

+
impl Directory {
+
    /// Creates a directory given its `tree_id`.
+
    ///
+
    /// The `name` and `prefix` are both set to be empty.
+
    pub(crate) fn root(id: Oid) -> Self {
+
        Self::new(ROOT_DIR.to_string(), PathBuf::new(), id)
+
    }
+

+
    /// Creates a directory given its `name` and `id`.
+
    ///
+
    /// The `path` must be the prefix location of the directory, and
+
    /// so should not end in `name`.
+
    ///
+
    /// The `id` must point to a `git` tree.
+
    pub(crate) fn new(name: String, prefix: PathBuf, id: Oid) -> Self {
+
        debug_assert!(
+
            name.is_empty() || !prefix.ends_with(&name),
+
            "prefix = {prefix:?}, name = {name}",
+
        );
+
        Self { name, prefix, id }
+
    }
+

+
    /// Get the name of the current `Directory`.
+
    pub fn name(&self) -> &String {
+
        &self.name
+
    }
+

+
    /// The object identifier of this `[Directory]`.
+
    pub fn id(&self) -> Oid {
+
        self.id
+
    }
+

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

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

+
    /// Return the [`Entries`] for this `Directory`'s `Oid`.
+
    ///
+
    /// The resulting `Entries` will only resolve to this
+
    /// `Directory`'s entries. Any sub-directories will need to be
+
    /// resolved independently.
+
    ///
+
    /// # Errors
+
    ///
+
    /// This function will fail if it could not find the `git` tree
+
    /// for the `Oid`.
+
    pub fn entries(&self, repo: &Repository) -> Result<Entries, error::Directory> {
+
        let tree = repo.find_tree(self.id)?;
+

+
        let mut entries = BTreeMap::new();
+
        let mut error = None;
+
        let path = self.path();
+

+
        // 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(), repo) {
+
                Ok(entry) => match entry {
+
                    Entry::File(_) => {
+
                        entries.insert(entry.name().clone(), entry);
+
                        git2::TreeWalkResult::Ok
+
                    }
+
                    Entry::Directory(_) => {
+
                        entries.insert(entry.name().clone(), entry);
+
                        // Skip nested directories
+
                        git2::TreeWalkResult::Skip
+
                    }
+
                    Entry::Submodule(_) => {
+
                        entries.insert(entry.name().clone(), entry);
+
                        git2::TreeWalkResult::Ok
+
                    }
+
                },
+
                Err(err) => {
+
                    error = Some(err);
+
                    git2::TreeWalkResult::Abort
+
                }
+
            }
+
        })?;
+

+
        match error {
+
            Some(err) => Err(err),
+
            None => Ok(Entries { listing: entries }),
+
        }
+
    }
+

+
    /// Find the [`Entry`] found at a non-empty `path`, if it exists.
+
    pub fn find_entry<P>(&self, path: &P, repo: &Repository) -> Result<Entry, error::Directory>
+
    where
+
        P: AsRef<Path>,
+
    {
+
        // Search the path in git2 tree.
+
        let path = path.as_ref();
+
        let git2_tree = repo.find_tree(self.id)?;
+
        let entry = git2_tree
+
            .get_path(path)
+
            .or_matches::<error::Directory, _, _>(
+
                |err| err.code() == git2::ErrorCode::NotFound,
+
                || Err(error::Directory::PathNotFound(path.to_path_buf())),
+
            )?;
+
        let parent = path
+
            .parent()
+
            .ok_or_else(|| error::Directory::InvalidPath(path.to_path_buf()))?;
+
        let root_path = self.path().join(parent);
+

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

+
    /// Find the `Oid`, for a [`File`], found at `path`, if it exists.
+
    pub fn find_file<P>(&self, path: &P, repo: &Repository) -> Result<File, error::Directory>
+
    where
+
        P: AsRef<Path>,
+
    {
+
        match self.find_entry(path, repo)? {
+
            Entry::File(file) => Ok(file),
+
            _ => Err(error::Directory::InvalidType(
+
                path.as_ref().to_path_buf(),
+
                "file",
+
            )),
+
        }
+
    }
+

+
    /// Find the `Directory` found at `path`, if it exists.
+
    ///
+
    /// If `path` is `ROOT_DIR` (i.e. an empty path), returns self.
+
    pub fn find_directory<P>(&self, path: &P, repo: &Repository) -> Result<Self, error::Directory>
+
    where
+
        P: AsRef<Path>,
+
    {
+
        if path.as_ref() == Path::new(ROOT_DIR) {
+
            return Ok(self.clone());
+
        }
+

+
        match self.find_entry(path, repo)? {
+
            Entry::Directory(d) => Ok(d),
+
            _ => Err(error::Directory::InvalidType(
+
                path.as_ref().to_path_buf(),
+
                "directory",
+
            )),
+
        }
+
    }
+

+
    // TODO(fintan): This is going to be a bit trickier so going to leave it out for
+
    // now
+
    #[allow(dead_code)]
+
    fn fuzzy_find(_label: &Path) -> Vec<Self> {
+
        unimplemented!()
+
    }
+

+
    /// Get the total size, in bytes, of a `Directory`. The size is
+
    /// the sum of all files that can be reached from this `Directory`.
+
    pub fn size(&self, repo: &Repository) -> Result<usize, error::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),
+
        })
+
    }
+

+
    /// Traverse the entire `Directory` using the `initial`
+
    /// accumulator and the function `f`.
+
    ///
+
    /// For each [`Entry::Directory`] this will recursively call
+
    /// [`Directory::traverse`] and obtain its [`Entries`].
+
    ///
+
    /// `Error` is the error type of the fallible function.
+
    /// `B` is the type of the accumulator.
+
    /// `F` is the fallible function that takes the accumulator and
+
    /// the next [`Entry`], possibly providing the next accumulator
+
    /// value.
+
    pub fn traverse<Error, B, F>(
+
        &self,
+
        repo: &Repository,
+
        initial: B,
+
        f: &mut F,
+
    ) -> Result<B, Error>
+
    where
+
        Error: From<error::Directory>,
+
        F: FnMut(B, &Entry) -> Result<B, Error>,
+
    {
+
        self.entries(repo)?
+
            .entries()
+
            .try_fold(initial, |acc, entry| match entry {
+
                Entry::File(_) => f(acc, entry),
+
                Entry::Directory(directory) => {
+
                    let acc = directory.traverse(repo, acc, f)?;
+
                    f(acc, entry)
+
                }
+
                Entry::Submodule(_) => f(acc, entry),
+
            })
+
    }
+
}
+

+
impl Revision for Directory {
+
    type Error = Infallible;
+

+
    fn object_id(&self, _repo: &Repository) -> Result<Oid, Self::Error> {
+
        Ok(self.id)
+
    }
+
}
+

+
/// 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 {
+
    name.replace('\\', r"\\")
+
}
added crates/radicle-surf/src/glob.rs
@@ -0,0 +1,338 @@
+
use std::marker::PhantomData;
+

+
use radicle_git_ref_format::{
+
    self, pattern, refname,
+
    refspec::{PatternString, QualifiedPattern},
+
    Qualified, RefStr, RefString,
+
};
+
use thiserror::Error;
+

+
use crate::{Branch, Local, Namespace, Remote, Tag};
+

+
#[derive(Debug, Error)]
+
pub enum Error {
+
    #[error(transparent)]
+
    RefFormat(#[from] radicle_git_ref_format::Error),
+
}
+

+
/// A collection of globs for a git reference type.
+
#[derive(Clone, Debug)]
+
pub struct Glob<T> {
+
    globs: Vec<QualifiedPattern<'static>>,
+
    glob_type: PhantomData<T>, // To support different methods for different T.
+
}
+

+
impl<T> Default for Glob<T> {
+
    fn default() -> Self {
+
        Self {
+
            globs: Default::default(),
+
            glob_type: PhantomData,
+
        }
+
    }
+
}
+

+
impl<T> Glob<T> {
+
    /// Return the [`QualifiedPattern`] globs of this `Glob`.
+
    pub fn globs(&self) -> impl Iterator<Item = &QualifiedPattern<'static>> {
+
        self.globs.iter()
+
    }
+

+
    /// Combine two `Glob`s together by combining their glob lists together.
+
    ///
+
    /// Note that the `Glob`s must result in the same type,
+
    /// e.g. `Glob<Tag>` can only combine with `Glob<Tag>`,
+
    /// `Glob<Local>` can combine with `Glob<Remote>`, etc.
+
    pub fn and(mut self, other: impl Into<Self>) -> Self {
+
        self.globs.extend(other.into().globs);
+
        self
+
    }
+
}
+

+
impl Glob<Namespace> {
+
    /// Creates the `Glob` that matches all `refs/namespaces`.
+
    pub fn all_namespaces() -> Self {
+
        Self::namespaces(pattern!("*"))
+
    }
+

+
    /// Creates a `Glob` for `refs/namespaces`, starting with `glob`.
+
    pub fn namespaces(glob: PatternString) -> Self {
+
        let globs = vec![Self::qualify(glob)];
+
        Self {
+
            globs,
+
            glob_type: PhantomData,
+
        }
+
    }
+

+
    /// Adds a `refs/namespaces` pattern to this `Glob`.
+
    pub fn insert(mut self, glob: PatternString) -> Self {
+
        self.globs.push(Self::qualify(glob));
+
        self
+
    }
+

+
    fn qualify(glob: PatternString) -> QualifiedPattern<'static> {
+
        qualify(&refname!("refs/namespaces"), glob).expect("BUG: pattern is qualified")
+
    }
+
}
+

+
impl FromIterator<PatternString> for Glob<Namespace> {
+
    fn from_iter<T: IntoIterator<Item = PatternString>>(iter: T) -> Self {
+
        let globs = iter
+
            .into_iter()
+
            .map(|pat| {
+
                qualify(&refname!("refs/namespaces"), pat).expect("BUG: pattern is qualified")
+
            })
+
            .collect();
+

+
        Self {
+
            globs,
+
            glob_type: PhantomData,
+
        }
+
    }
+
}
+

+
impl Extend<PatternString> for Glob<Namespace> {
+
    fn extend<T: IntoIterator<Item = PatternString>>(&mut self, iter: T) {
+
        self.globs.extend(iter.into_iter().map(|pat| {
+
            qualify(&refname!("refs/namespaces"), pat).expect("BUG: pattern is qualified")
+
        }))
+
    }
+
}
+

+
impl Glob<Tag> {
+
    /// Creates a `Glob` that matches all `refs/tags`.
+
    pub fn all_tags() -> Self {
+
        Self::tags(pattern!("*"))
+
    }
+

+
    /// Creates a `Glob` for `refs/tags`, starting with `glob`.
+
    pub fn tags(glob: PatternString) -> Self {
+
        let globs = vec![Self::qualify(glob)];
+
        Self {
+
            globs,
+
            glob_type: PhantomData,
+
        }
+
    }
+

+
    /// Adds a `refs/tags` pattern to this `Glob`.
+
    pub fn insert(mut self, glob: PatternString) -> Self {
+
        self.globs.push(Self::qualify(glob));
+
        self
+
    }
+

+
    fn qualify(glob: PatternString) -> QualifiedPattern<'static> {
+
        qualify(&refname!("refs/tags"), glob).expect("BUG: pattern is qualified")
+
    }
+
}
+

+
impl FromIterator<PatternString> for Glob<Tag> {
+
    fn from_iter<T: IntoIterator<Item = PatternString>>(iter: T) -> Self {
+
        let globs = iter
+
            .into_iter()
+
            .map(|pat| qualify(&refname!("refs/tags"), pat).expect("BUG: pattern is qualified"))
+
            .collect();
+

+
        Self {
+
            globs,
+
            glob_type: PhantomData,
+
        }
+
    }
+
}
+

+
impl Extend<PatternString> for Glob<Tag> {
+
    fn extend<T: IntoIterator<Item = PatternString>>(&mut self, iter: T) {
+
        self.globs.extend(
+
            iter.into_iter()
+
                .map(|pat| qualify(&refname!("refs/tag"), pat).expect("BUG: pattern is qualified")),
+
        )
+
    }
+
}
+

+
impl Glob<Local> {
+
    /// Creates the `Glob` that matches all `refs/heads`.
+
    pub fn all_heads() -> Self {
+
        Self::heads(pattern!("*"))
+
    }
+

+
    /// Creates a `Glob` for `refs/heads`, starting with `glob`.
+
    pub fn heads(glob: PatternString) -> Self {
+
        let globs = vec![Self::qualify_heads(glob)];
+
        Self {
+
            globs,
+
            glob_type: PhantomData,
+
        }
+
    }
+

+
    /// Adds a `refs/heads` pattern to this `Glob`.
+
    pub fn insert(mut self, glob: PatternString) -> Self {
+
        self.globs.push(Self::qualify_heads(glob));
+
        self
+
    }
+

+
    /// When chaining `Glob<Local>` with `Glob<Remote>`, use
+
    /// `branches` to convert this `Glob<Local>` into a
+
    /// `Glob<Branch>`.
+
    ///
+
    /// # Example
+
    /// ```no_run
+
    /// Glob::heads(pattern!("features/*"))
+
    ///     .insert(pattern!("qa/*"))
+
    ///     .branches()
+
    ///     .and(Glob::remotes(pattern!("origin/features/*")))
+
    /// ```
+
    pub fn branches(self) -> Glob<Branch> {
+
        self.into()
+
    }
+

+
    fn qualify_heads(glob: PatternString) -> QualifiedPattern<'static> {
+
        qualify(&refname!("refs/heads"), glob).expect("BUG: pattern is qualified")
+
    }
+
}
+

+
impl FromIterator<PatternString> for Glob<Local> {
+
    fn from_iter<T: IntoIterator<Item = PatternString>>(iter: T) -> Self {
+
        let globs = iter
+
            .into_iter()
+
            .map(|pat| qualify(&refname!("refs/heads"), pat).expect("BUG: pattern is qualified"))
+
            .collect();
+

+
        Self {
+
            globs,
+
            glob_type: PhantomData,
+
        }
+
    }
+
}
+

+
impl Extend<PatternString> for Glob<Local> {
+
    fn extend<T: IntoIterator<Item = PatternString>>(&mut self, iter: T) {
+
        self.globs.extend(
+
            iter.into_iter().map(|pat| {
+
                qualify(&refname!("refs/heads"), pat).expect("BUG: pattern is qualified")
+
            }),
+
        )
+
    }
+
}
+

+
impl From<Glob<Local>> for Glob<Branch> {
+
    fn from(Glob { globs, .. }: Glob<Local>) -> Self {
+
        Self {
+
            globs,
+
            glob_type: PhantomData,
+
        }
+
    }
+
}
+

+
impl Glob<Remote> {
+
    /// Creates the `Glob` that matches all `refs/remotes`.
+
    pub fn all_remotes() -> Self {
+
        Self::remotes(pattern!("*"))
+
    }
+

+
    /// Creates a `Glob` for `refs/remotes`, starting with `glob`.
+
    pub fn remotes(glob: PatternString) -> Self {
+
        let globs = vec![Self::qualify_remotes(glob)];
+
        Self {
+
            globs,
+
            glob_type: PhantomData,
+
        }
+
    }
+

+
    /// Adds a `refs/remotes` pattern to this `Glob`.
+
    pub fn insert(mut self, glob: PatternString) -> Self {
+
        self.globs.push(Self::qualify_remotes(glob));
+
        self
+
    }
+

+
    /// When chaining `Glob<Remote>` with `Glob<Local>`, use
+
    /// `branches` to convert this `Glob<Remote>` into a
+
    /// `Glob<Branch>`.
+
    ///
+
    /// # Example
+
    /// ```no_run
+
    /// Glob::remotes(pattern!("origin/features/*"))
+
    ///     .insert(pattern!("origin/qa/*"))
+
    ///     .branches()
+
    ///     .and(Glob::heads(pattern!("features/*")))
+
    /// ```
+
    pub fn branches(self) -> Glob<Branch> {
+
        self.into()
+
    }
+

+
    fn qualify_remotes(glob: PatternString) -> QualifiedPattern<'static> {
+
        qualify(&refname!("refs/remotes"), glob).expect("BUG: pattern is qualified")
+
    }
+
}
+

+
impl FromIterator<PatternString> for Glob<Remote> {
+
    fn from_iter<T: IntoIterator<Item = PatternString>>(iter: T) -> Self {
+
        let globs = iter
+
            .into_iter()
+
            .map(|pat| qualify(&refname!("refs/remotes"), pat).expect("BUG: pattern is qualified"))
+
            .collect();
+

+
        Self {
+
            globs,
+
            glob_type: PhantomData,
+
        }
+
    }
+
}
+

+
impl Extend<PatternString> for Glob<Remote> {
+
    fn extend<T: IntoIterator<Item = PatternString>>(&mut self, iter: T) {
+
        self.globs.extend(
+
            iter.into_iter().map(|pat| {
+
                qualify(&refname!("refs/remotes"), pat).expect("BUG: pattern is qualified")
+
            }),
+
        )
+
    }
+
}
+

+
impl From<Glob<Remote>> for Glob<Branch> {
+
    fn from(Glob { globs, .. }: Glob<Remote>) -> Self {
+
        Self {
+
            globs,
+
            glob_type: PhantomData,
+
        }
+
    }
+
}
+

+
impl Glob<Qualified<'_>> {
+
    pub fn all_category<R: AsRef<RefStr>>(category: R) -> Self {
+
        Self {
+
            globs: vec![Self::qualify_category(category, pattern!("*"))],
+
            glob_type: PhantomData,
+
        }
+
    }
+

+
    /// Creates a `Glob` for `refs/<category>`, starting with `glob`.
+
    pub fn categories<R>(category: R, glob: PatternString) -> Self
+
    where
+
        R: AsRef<RefStr>,
+
    {
+
        let globs = vec![Self::qualify_category(category, glob)];
+
        Self {
+
            globs,
+
            glob_type: PhantomData,
+
        }
+
    }
+

+
    /// Adds a `refs/<category>` pattern to this `Glob`.
+
    pub fn insert<R>(mut self, category: R, glob: PatternString) -> Self
+
    where
+
        R: AsRef<RefStr>,
+
    {
+
        self.globs.push(Self::qualify_category(category, glob));
+
        self
+
    }
+

+
    fn qualify_category<R>(category: R, glob: PatternString) -> QualifiedPattern<'static>
+
    where
+
        R: AsRef<RefStr>,
+
    {
+
        let prefix = refname!("refs").and(category);
+
        qualify(&prefix, glob).expect("BUG: pattern is qualified")
+
    }
+
}
+

+
fn qualify(prefix: &RefString, glob: PatternString) -> Option<QualifiedPattern<'static>> {
+
    prefix.to_pattern(glob).qualified().map(|q| q.into_owned())
+
}
added crates/radicle-surf/src/history.rs
@@ -0,0 +1,98 @@
+
use std::{
+
    convert::TryFrom,
+
    path::{Path, PathBuf},
+
};
+

+
use crate::{Commit, Error, Repository, ToCommit};
+

+
/// An iterator that produces the history of commits for a given `head`.
+
///
+
/// The lifetime of this struct is attached to the underlying [`Repository`].
+
pub struct History<'a> {
+
    repo: &'a Repository,
+
    head: Commit,
+
    revwalk: git2::Revwalk<'a>,
+
    filter_by: Option<FilterBy>,
+
}
+

+
/// Internal implementation, subject to refactoring.
+
enum FilterBy {
+
    File { path: PathBuf },
+
}
+

+
impl<'a> History<'a> {
+
    /// Creates a new history starting from `head`, in `repo`.
+
    pub(crate) fn new<C: ToCommit>(repo: &'a Repository, head: C) -> Result<Self, Error> {
+
        let head = head
+
            .to_commit(repo)
+
            .map_err(|err| Error::ToCommit(err.into()))?;
+
        let mut revwalk = repo.revwalk()?;
+
        revwalk.push(head.id.into())?;
+
        let history = Self {
+
            repo,
+
            head,
+
            revwalk,
+
            filter_by: None,
+
        };
+
        Ok(history)
+
    }
+

+
    /// Returns the first commit (i.e. the head) in the history.
+
    pub fn head(&self) -> &Commit {
+
        &self.head
+
    }
+

+
    /// Returns a modified `History` filtered by `path`.
+
    ///
+
    /// Note that it is possible that a filtered History becomes empty,
+
    /// even though calling `.head()` still returns the original head.
+
    pub fn by_path<P>(mut self, path: &P) -> Self
+
    where
+
        P: AsRef<Path>,
+
    {
+
        self.filter_by = Some(FilterBy::File {
+
            path: path.as_ref().to_path_buf(),
+
        });
+
        self
+
    }
+
}
+

+
impl Iterator for History<'_> {
+
    type Item = Result<Commit, Error>;
+

+
    fn next(&mut self) -> Option<Self::Item> {
+
        // Loop through the commits with the optional filtering.
+
        while let Some(oid) = self.revwalk.next() {
+
            let found = oid
+
                .map_err(Error::Git)
+
                .and_then(|oid| {
+
                    let commit = self.repo.find_commit(oid.into())?;
+

+
                    // Handles the optional filter_by.
+
                    if let Some(FilterBy::File { path }) = &self.filter_by {
+
                        // Only check the commit diff if the path is not empty.
+
                        if !path.as_os_str().is_empty() {
+
                            let path_opt = self.repo.diff_commit_and_parents(path, &commit)?;
+
                            if path_opt.is_none() {
+
                                return Ok(None); // Filter out this commit.
+
                            }
+
                        }
+
                    }
+

+
                    let commit = Commit::try_from(commit)?;
+
                    Ok(Some(commit))
+
                })
+
                .transpose();
+
            if found.is_some() {
+
                return found;
+
            }
+
        }
+
        None
+
    }
+
}
+

+
impl std::fmt::Debug for History<'_> {
+
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+
        write!(f, "History of {}", self.head.id)
+
    }
+
}
added crates/radicle-surf/src/lib.rs
@@ -0,0 +1,65 @@
+
//! `radicle-surf` is a library to help users explore a Git repository with
+
//! ease. It supports browsing a repository via the concept of files and
+
//! directories, or via blobs and trees in a git fashion. With the additional
+
//! support of [`diff::Diff`] and [`History`], this library can be used to build
+
//! an intuitive UI for any Git repository.
+
//!
+
//! The main entry point of the library API is [`Repository`].
+
//!
+
//! Let's start surfing!
+
//!
+
//! ## Serialization with feature `serde`
+
//!
+
//! Many types in this crate support serialization using [`Serde`][serde]
+
//! through the `serde` feature flag for this crate.
+
//!
+
//! [serde]: https://crates.io/crates/serde
+

+
/// Re-exports.
+
pub use radicle_git_ref_format;
+

+
/// Represents an object id in Git. Re-exported from `radicle-git-ext`.
+
pub type Oid = radicle_oid::Oid;
+

+
pub mod blob;
+
pub mod diff;
+
pub mod fs;
+
pub mod tree;
+

+
/// Private modules with their public types.
+
mod repo;
+
pub use repo::Repository;
+

+
mod glob;
+
pub use glob::Glob;
+

+
mod history;
+
pub use history::History;
+

+
mod branch;
+
pub use branch::{Branch, Local, Remote};
+

+
mod tag;
+
pub use tag::Tag;
+

+
mod commit;
+
pub use commit::{Author, Commit, Time};
+

+
mod namespace;
+
pub use namespace::Namespace;
+

+
mod stats;
+
pub use stats::Stats;
+

+
mod revision;
+
pub use revision::{Revision, Signature, ToCommit};
+

+
mod refs;
+

+
mod error;
+
pub use error::Error;
+

+
mod ext;
+

+
#[cfg(test)]
+
pub mod test;
added crates/radicle-surf/src/namespace.rs
@@ -0,0 +1,169 @@
+
use std::{
+
    convert::TryFrom,
+
    fmt,
+
    str::{self, FromStr},
+
};
+

+
use nonempty::NonEmpty;
+
use radicle_git_ref_format::{
+
    self,
+
    refspec::{NamespacedPattern, PatternString, QualifiedPattern},
+
    Component, Namespaced, Qualified, RefStr, RefString,
+
};
+
use thiserror::Error;
+

+
#[derive(Debug, Error)]
+
pub enum Error {
+
    /// When parsing a namespace we may come across one that was an empty
+
    /// string.
+
    #[error("namespaces must not be empty")]
+
    EmptyNamespace,
+
    #[error(transparent)]
+
    RefFormat(#[from] radicle_git_ref_format::Error),
+
    #[error(transparent)]
+
    Utf8(#[from] str::Utf8Error),
+
}
+

+
/// A `Namespace` value allows us to switch the git namespace of
+
/// a repo.
+
///
+
/// A `Namespace` is one or more name components separated by `/`, e.g. `surf`,
+
/// `surf/git`.
+
///
+
/// For each `Namespace`, the reference name will add a single `refs/namespaces`
+
/// prefix, e.g. `refs/namespaces/surf`,
+
/// `refs/namespaces/surf/refs/namespaces/git`.
+
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
+
pub struct Namespace {
+
    // XXX: we rely on RefString being non-empty here, which
+
    // git-ref-format ensures that there's no way to construct one.
+
    pub(super) namespaces: RefString,
+
}
+

+
impl Namespace {
+
    /// Take a `Qualified` reference name and convert it to a `Namespaced` using
+
    /// this `Namespace`.
+
    ///
+
    /// # Example
+
    ///
+
    /// ```no_run
+
    /// let ns = "surf/git".parse::<Namespace>();
+
    /// let name = ns.to_namespaced(qualified!("refs/heads/main"));
+
    /// assert_eq!(
+
    ///     name.as_str(),
+
    ///     "refs/namespaces/surf/refs/namespaces/git/refs/heads/main"
+
    /// );
+
    /// ```
+
    pub(crate) fn to_namespaced<'a>(&self, name: &Qualified<'a>) -> Namespaced<'a> {
+
        let mut components = self.namespaces.components().rev();
+
        let mut namespaced = name.with_namespace(
+
            components
+
                .next()
+
                .expect("BUG: 'namespaces' cannot be empty"),
+
        );
+
        for ns in components {
+
            let qualified = namespaced.into_qualified();
+
            namespaced = qualified.with_namespace(ns);
+
        }
+
        namespaced
+
    }
+

+
    /// Take a `QualifiedPattern` reference name and convert it to a
+
    /// `NamespacedPattern` using this `Namespace`.
+
    ///
+
    /// # Example
+
    ///
+
    /// ```no_run
+
    /// let ns = "surf/git".parse::<Namespace>();
+
    /// let name = ns.to_namespaced(pattern!("refs/heads/*").to_qualified().unwrap());
+
    /// assert_eq!(
+
    ///     name.as_str(),
+
    ///     "refs/namespaces/surf/refs/namespaces/git/refs/heads/*"
+
    /// );
+
    /// ```
+
    pub(crate) fn to_namespaced_pattern<'a>(
+
        &self,
+
        pat: &QualifiedPattern<'a>,
+
    ) -> NamespacedPattern<'a> {
+
        let pattern = PatternString::from(self.namespaces.clone());
+
        let mut components = pattern.components().rev();
+
        let mut namespaced = pat
+
            .with_namespace(
+
                components
+
                    .next()
+
                    .expect("BUG: 'namespaces' cannot be empty"),
+
            )
+
            .expect("BUG: 'namespace' cannot have globs");
+
        for ns in components {
+
            let qualified = namespaced.into_qualified();
+
            namespaced = qualified
+
                .with_namespace(ns)
+
                .expect("BUG: 'namespaces' cannot have globs");
+
        }
+
        namespaced
+
    }
+
}
+

+
impl fmt::Display for Namespace {
+
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+
        write!(f, "{}", self.namespaces)
+
    }
+
}
+

+
impl<'a> From<NonEmpty<Component<'a>>> for Namespace {
+
    fn from(cs: NonEmpty<Component<'a>>) -> Self {
+
        Self {
+
            namespaces: cs.into_iter().collect::<RefString>(),
+
        }
+
    }
+
}
+

+
impl TryFrom<&str> for Namespace {
+
    type Error = Error;
+

+
    fn try_from(name: &str) -> Result<Self, Self::Error> {
+
        Self::from_str(name)
+
    }
+
}
+

+
impl TryFrom<&[u8]> for Namespace {
+
    type Error = Error;
+

+
    fn try_from(namespace: &[u8]) -> Result<Self, Self::Error> {
+
        str::from_utf8(namespace)
+
            .map_err(Error::from)
+
            .and_then(Self::from_str)
+
    }
+
}
+

+
impl FromStr for Namespace {
+
    type Err = Error;
+

+
    fn from_str(name: &str) -> Result<Self, Self::Err> {
+
        let namespaces = RefStr::try_from_str(name)?.to_ref_string();
+
        Ok(Self { namespaces })
+
    }
+
}
+

+
impl From<Namespaced<'_>> for Namespace {
+
    fn from(namespaced: Namespaced<'_>) -> Self {
+
        let mut namespaces = namespaced.namespace().to_ref_string();
+
        let mut qualified = namespaced.strip_namespace();
+
        while let Some(namespaced) = qualified.to_namespaced() {
+
            namespaces.push(namespaced.namespace());
+
            qualified = namespaced.strip_namespace();
+
        }
+
        Self { namespaces }
+
    }
+
}
+

+
impl TryFrom<&git2::Reference<'_>> for Namespace {
+
    type Error = Error;
+

+
    fn try_from(reference: &git2::Reference) -> Result<Self, Self::Error> {
+
        let name = RefStr::try_from_str(str::from_utf8(reference.name_bytes())?)?;
+
        name.to_namespaced()
+
            .ok_or(Error::EmptyNamespace)
+
            .map(Self::from)
+
    }
+
}
added crates/radicle-surf/src/refs.rs
@@ -0,0 +1,246 @@
+
// I think the following `Tags` and `Branches` would be merged
+
// using Generic associated types supported in Rust 1.65.0.
+

+
use std::{
+
    collections::{btree_set, BTreeSet},
+
    convert::TryFrom as _,
+
};
+

+
use radicle_git_ref_format::{self, lit, name::Components, Component, Qualified, RefString};
+

+
use crate::{tag, Branch, Namespace, Tag};
+

+
/// Iterator over [`Tag`]s.
+
#[derive(Default)]
+
pub struct Tags<'a> {
+
    references: Vec<git2::References<'a>>,
+
    current: usize,
+
}
+

+
/// Iterator over the [`Qualified`] names of [`Tag`]s.
+
pub struct TagNames<'a> {
+
    inner: Tags<'a>,
+
}
+

+
impl<'a> Tags<'a> {
+
    pub(super) fn push(&mut self, references: git2::References<'a>) {
+
        self.references.push(references)
+
    }
+

+
    pub fn names(self) -> TagNames<'a> {
+
        TagNames { inner: self }
+
    }
+
}
+

+
impl Iterator for Tags<'_> {
+
    type Item = Result<Tag, error::Tag>;
+

+
    fn next(&mut self) -> Option<Self::Item> {
+
        while self.current < self.references.len() {
+
            match self.references.get_mut(self.current) {
+
                Some(refs) => match refs.next() {
+
                    Some(res) => {
+
                        return Some(
+
                            res.map_err(error::Tag::from)
+
                                .and_then(|r| Tag::try_from(&r).map_err(error::Tag::from)),
+
                        );
+
                    }
+
                    None => self.current += 1,
+
                },
+
                None => break,
+
            }
+
        }
+
        None
+
    }
+
}
+

+
impl Iterator for TagNames<'_> {
+
    type Item = Result<Qualified<'static>, error::Tag>;
+

+
    fn next(&mut self) -> Option<Self::Item> {
+
        while self.inner.current < self.inner.references.len() {
+
            match self.inner.references.get_mut(self.inner.current) {
+
                Some(refs) => match refs.next() {
+
                    Some(res) => {
+
                        return Some(res.map_err(error::Tag::from).and_then(|r| {
+
                            tag::reference_name(&r)
+
                                .map(|name| lit::refs_tags(name).into())
+
                                .map_err(error::Tag::from)
+
                        }))
+
                    }
+
                    None => self.inner.current += 1,
+
                },
+
                None => break,
+
            }
+
        }
+
        None
+
    }
+
}
+

+
/// Iterator over [`Branch`]es.
+
#[derive(Default)]
+
pub struct Branches<'a> {
+
    references: Vec<git2::References<'a>>,
+
    current: usize,
+
}
+

+
/// Iterator over the [`Qualified`] names of [`Branch`]es.
+
pub struct BranchNames<'a> {
+
    inner: Branches<'a>,
+
}
+

+
impl<'a> Branches<'a> {
+
    pub(super) fn push(&mut self, references: git2::References<'a>) {
+
        self.references.push(references)
+
    }
+

+
    pub fn names(self) -> BranchNames<'a> {
+
        BranchNames { inner: self }
+
    }
+
}
+

+
impl Iterator for Branches<'_> {
+
    type Item = Result<Branch, error::Branch>;
+

+
    fn next(&mut self) -> Option<Self::Item> {
+
        while self.current < self.references.len() {
+
            match self.references.get_mut(self.current) {
+
                Some(refs) => match refs.next() {
+
                    Some(res) => {
+
                        return Some(
+
                            res.map_err(error::Branch::from)
+
                                .and_then(|r| Branch::try_from(&r).map_err(error::Branch::from)),
+
                        )
+
                    }
+
                    None => self.current += 1,
+
                },
+
                None => break,
+
            }
+
        }
+
        None
+
    }
+
}
+

+
impl Iterator for BranchNames<'_> {
+
    type Item = Result<Qualified<'static>, error::Branch>;
+

+
    fn next(&mut self) -> Option<Self::Item> {
+
        while self.inner.current < self.inner.references.len() {
+
            match self.inner.references.get_mut(self.inner.current) {
+
                Some(refs) => match refs.next() {
+
                    Some(res) => {
+
                        return Some(res.map_err(error::Branch::from).and_then(|r| {
+
                            Branch::try_from(&r)
+
                                .map(|branch| branch.refname().into_owned())
+
                                .map_err(error::Branch::from)
+
                        }))
+
                    }
+
                    None => self.inner.current += 1,
+
                },
+
                None => break,
+
            }
+
        }
+
        None
+
    }
+
}
+

+
// TODO: not sure this buys us much
+
/// An iterator for namespaces.
+
pub struct Namespaces {
+
    namespaces: btree_set::IntoIter<Namespace>,
+
}
+

+
impl Namespaces {
+
    pub(super) fn new(namespaces: BTreeSet<Namespace>) -> Self {
+
        Self {
+
            namespaces: namespaces.into_iter(),
+
        }
+
    }
+
}
+

+
impl Iterator for Namespaces {
+
    type Item = Namespace;
+
    fn next(&mut self) -> Option<Self::Item> {
+
        self.namespaces.next()
+
    }
+
}
+

+
#[derive(Default)]
+
pub struct Categories<'a> {
+
    references: Vec<git2::References<'a>>,
+
    current: usize,
+
}
+

+
impl<'a> Categories<'a> {
+
    pub(super) fn push(&mut self, references: git2::References<'a>) {
+
        self.references.push(references)
+
    }
+
}
+

+
impl Iterator for Categories<'_> {
+
    type Item = Result<(RefString, RefString), error::Category>;
+

+
    fn next(&mut self) -> Option<Self::Item> {
+
        while self.current < self.references.len() {
+
            match self.references.get_mut(self.current) {
+
                Some(refs) => match refs.next() {
+
                    Some(res) => {
+
                        return Some(res.map_err(error::Category::from).and_then(|r| {
+
                            let name = std::str::from_utf8(r.name_bytes())?;
+
                            let name = radicle_git_ref_format::RefStr::try_from_str(name)?;
+
                            let name = name.qualified().ok_or_else(|| {
+
                                error::Category::NotQualified(name.to_ref_string())
+
                            })?;
+
                            let (_refs, category, c, cs) = name.non_empty_components();
+
                            Ok((category.to_ref_string(), refstr_join(c, cs)))
+
                        }));
+
                    }
+
                    None => self.current += 1,
+
                },
+
                None => break,
+
            }
+
        }
+
        None
+
    }
+
}
+

+
pub mod error {
+
    use std::str;
+

+
    use radicle_git_ref_format::{self, RefString};
+
    use thiserror::Error;
+

+
    use crate::{branch, tag};
+

+
    #[derive(Debug, Error)]
+
    pub enum Branch {
+
        #[error(transparent)]
+
        Git(#[from] git2::Error),
+
        #[error(transparent)]
+
        Branch(#[from] branch::error::Branch),
+
    }
+

+
    #[derive(Debug, Error)]
+
    pub enum Category {
+
        #[error(transparent)]
+
        Git(#[from] git2::Error),
+
        #[error("the reference '{0}' was expected to be qualified, i.e. 'refs/<category>/<path>'")]
+
        NotQualified(RefString),
+
        #[error(transparent)]
+
        RefFormat(#[from] radicle_git_ref_format::Error),
+
        #[error(transparent)]
+
        Utf8(#[from] str::Utf8Error),
+
    }
+

+
    #[derive(Debug, Error)]
+
    pub enum Tag {
+
        #[error(transparent)]
+
        Git(#[from] git2::Error),
+
        #[error(transparent)]
+
        Tag(#[from] tag::error::FromReference),
+
    }
+
}
+

+
pub(crate) fn refstr_join<'a>(c: Component<'a>, cs: Components<'a>) -> RefString {
+
    std::iter::once(c).chain(cs).collect::<RefString>()
+
}
added crates/radicle-surf/src/repo.rs
@@ -0,0 +1,562 @@
+
use std::{
+
    collections::BTreeSet,
+
    convert::TryFrom,
+
    path::{Path, PathBuf},
+
    str,
+
};
+

+
use radicle_git_ref_format::{refspec::QualifiedPattern, Qualified, RefStr, RefString};
+
use radicle_oid::Oid;
+

+
use crate::{
+
    blob::{Blob, BlobRef},
+
    diff::{Diff, FileDiff},
+
    fs::{Directory, File, FileContent},
+
    refs::{BranchNames, Branches, Categories, Namespaces, TagNames, Tags},
+
    tree::{Entry, Tree},
+
    Branch, Commit, Error, Glob, History, Namespace, Revision, Signature, Stats, Tag, ToCommit,
+
};
+

+
/// Enumeration of errors that can occur in repo operations.
+
pub mod error {
+
    use std::path::PathBuf;
+
    use thiserror::Error;
+

+
    #[derive(Debug, Error)]
+
    #[non_exhaustive]
+
    pub enum Repo {
+
        #[error("path not found for: {0}")]
+
        PathNotFound(PathBuf),
+
    }
+
}
+

+
/// Represents the state associated with a Git repository.
+
///
+
/// Many other types in this crate are derived from methods in this struct.
+
pub struct Repository {
+
    /// Wrapper around the `git2`'s `git2::Repository` type.
+
    /// This is to to limit the functionality that we can do
+
    /// on the underlying object.
+
    inner: git2::Repository,
+
}
+

+
////////////////////////////////////////////
+
// Public API, ONLY add `pub fn` in here. //
+
////////////////////////////////////////////
+
impl Repository {
+
    /// Open a git repository given its exact URI.
+
    ///
+
    /// # Errors
+
    ///
+
    /// * [`Error::Git`]
+
    pub fn open(repo_uri: impl AsRef<std::path::Path>) -> Result<Self, Error> {
+
        let repo = git2::Repository::open(repo_uri)?;
+
        Ok(Self { inner: repo })
+
    }
+

+
    /// Attempt to open a git repository at or above `repo_uri` in the file
+
    /// system.
+
    pub fn discover(repo_uri: impl AsRef<std::path::Path>) -> Result<Self, Error> {
+
        let repo = git2::Repository::discover(repo_uri)?;
+
        Ok(Self { inner: repo })
+
    }
+

+
    /// What is the current namespace we're browsing in.
+
    pub fn which_namespace(&self) -> Result<Option<Namespace>, Error> {
+
        self.inner
+
            .namespace_bytes()
+
            .map(|ns| Namespace::try_from(ns).map_err(Error::from))
+
            .transpose()
+
    }
+

+
    /// Switch to a `namespace`
+
    pub fn switch_namespace(&self, namespace: &RefString) -> Result<(), Error> {
+
        Ok(self.inner.set_namespace(namespace.as_str())?)
+
    }
+

+
    pub fn with_namespace<T, F>(&self, namespace: &RefString, f: F) -> Result<T, Error>
+
    where
+
        F: FnOnce() -> Result<T, Error>,
+
    {
+
        self.switch_namespace(namespace)?;
+
        let res = f();
+
        self.inner.remove_namespace()?;
+
        res
+
    }
+

+
    /// Returns an iterator of branches that match `pattern`.
+
    pub fn branches<'a, G>(&'a self, pattern: G) -> Result<Branches<'a>, Error>
+
    where
+
        G: Into<Glob<Branch>>,
+
    {
+
        let pattern = pattern.into();
+
        let mut branches = Branches::default();
+
        for glob in pattern.globs() {
+
            let namespaced = self.namespaced_pattern(glob)?;
+
            let references = self.inner.references_glob(&namespaced)?;
+
            branches.push(references);
+
        }
+
        Ok(branches)
+
    }
+

+
    /// Lists branch names with `filter`.
+
    pub fn branch_names<'a, G>(&'a self, filter: G) -> Result<BranchNames<'a>, Error>
+
    where
+
        G: Into<Glob<Branch>>,
+
    {
+
        Ok(self.branches(filter)?.names())
+
    }
+

+
    /// Returns an iterator of tags that match `pattern`.
+
    pub fn tags<'a>(&'a self, pattern: &Glob<Tag>) -> Result<Tags<'a>, Error> {
+
        let mut tags = Tags::default();
+
        for glob in pattern.globs() {
+
            let namespaced = self.namespaced_pattern(glob)?;
+
            let references = self.inner.references_glob(&namespaced)?;
+
            tags.push(references);
+
        }
+
        Ok(tags)
+
    }
+

+
    /// Lists tag names in the local RefScope.
+
    pub fn tag_names<'a>(&'a self, filter: &Glob<Tag>) -> Result<TagNames<'a>, Error> {
+
        Ok(self.tags(filter)?.names())
+
    }
+

+
    pub fn categories<'a>(
+
        &'a self,
+
        pattern: &Glob<Qualified<'_>>,
+
    ) -> Result<Categories<'a>, Error> {
+
        let mut cats = Categories::default();
+
        for glob in pattern.globs() {
+
            let namespaced = self.namespaced_pattern(glob)?;
+
            let references = self.inner.references_glob(&namespaced)?;
+
            cats.push(references);
+
        }
+
        Ok(cats)
+
    }
+

+
    /// Returns an iterator of namespaces that match `pattern`.
+
    pub fn namespaces(&self, pattern: &Glob<Namespace>) -> Result<Namespaces, Error> {
+
        let mut set = BTreeSet::new();
+
        for glob in pattern.globs() {
+
            let new_set = self
+
                .inner
+
                .references_glob(glob)?
+
                .map(|reference| {
+
                    reference
+
                        .map_err(Error::Git)
+
                        .and_then(|r| Namespace::try_from(&r).map_err(Error::from))
+
                })
+
                .collect::<Result<BTreeSet<Namespace>, Error>>()?;
+
            set.extend(new_set);
+
        }
+
        Ok(Namespaces::new(set))
+
    }
+

+
    /// Get the [`Diff`] between two commits.
+
    pub fn diff(&self, from: impl Revision, to: impl Revision) -> Result<Diff, Error> {
+
        let from_commit = self.find_commit(self.object_id(&from)?)?;
+
        let to_commit = self.find_commit(self.object_id(&to)?)?;
+
        self.diff_commits(None, Some(&from_commit), &to_commit)
+
            .and_then(|diff| Diff::try_from(diff).map_err(Error::from))
+
    }
+

+
    /// Get the [`Diff`] of a `commit`.
+
    ///
+
    /// If the `commit` has a parent, then it the diff will be a
+
    /// comparison between itself and that parent. Otherwise, the left
+
    /// hand side of the diff will pass nothing.
+
    pub fn diff_commit(&self, commit: impl ToCommit) -> Result<Diff, Error> {
+
        let commit = commit
+
            .to_commit(self)
+
            .map_err(|err| Error::ToCommit(err.into()))?;
+
        match commit.parents.first() {
+
            Some(parent) => self.diff(*parent, commit.id),
+
            None => self.initial_diff(commit.id),
+
        }
+
    }
+

+
    /// Get the [`FileDiff`] between two revisions for a file at `path`.
+
    ///
+
    /// If `path` is only a directory name, not a file, returns
+
    /// a [`FileDiff`] for any file under `path`.
+
    pub fn diff_file<P: AsRef<Path>, R: Revision>(
+
        &self,
+
        path: &P,
+
        from: R,
+
        to: R,
+
    ) -> Result<FileDiff, Error> {
+
        let from_commit = self.find_commit(self.object_id(&from)?)?;
+
        let to_commit = self.find_commit(self.object_id(&to)?)?;
+
        let diff = self
+
            .diff_commits(Some(path.as_ref()), Some(&from_commit), &to_commit)
+
            .and_then(|diff| Diff::try_from(diff).map_err(Error::from))?;
+
        let file_diff = diff
+
            .into_files()
+
            .pop()
+
            .ok_or(error::Repo::PathNotFound(path.as_ref().to_path_buf()))?;
+
        Ok(file_diff)
+
    }
+

+
    /// Parse an [`Oid`] from the given string.
+
    pub fn oid(&self, oid: &str) -> Result<Oid, Error> {
+
        Ok(self.inner.revparse_single(oid)?.id().into())
+
    }
+

+
    /// Returns a top level `Directory` without nested sub-directories.
+
    ///
+
    /// To visit inside any nested sub-directories, call `directory.get(&repo)`
+
    /// on the sub-directory.
+
    pub fn root_dir<C: ToCommit>(&self, commit: C) -> Result<Directory, Error> {
+
        let commit = commit
+
            .to_commit(self)
+
            .map_err(|err| Error::ToCommit(err.into()))?;
+
        let git2_commit = self.inner.find_commit((commit.id).into())?;
+
        let tree = git2_commit.as_object().peel_to_tree()?;
+
        Ok(Directory::root(tree.id().into()))
+
    }
+

+
    /// Returns a [`Directory`] for `path` in `commit`.
+
    pub fn directory<C: ToCommit, P: AsRef<Path>>(
+
        &self,
+
        commit: C,
+
        path: &P,
+
    ) -> Result<Directory, Error> {
+
        let root = self.root_dir(commit)?;
+
        Ok(root.find_directory(path, self)?)
+
    }
+

+
    /// Returns a [`File`] for `path` in `commit`.
+
    pub fn file<C: ToCommit, P: AsRef<Path>>(&self, commit: C, path: &P) -> Result<File, Error> {
+
        let root = self.root_dir(commit)?;
+
        Ok(root.find_file(path, self)?)
+
    }
+

+
    /// Returns a [`Tree`] for `path` in `commit`.
+
    pub fn tree<C: ToCommit, P: AsRef<Path>>(&self, commit: C, path: &P) -> Result<Tree, Error> {
+
        let commit = commit
+
            .to_commit(self)
+
            .map_err(|e| Error::ToCommit(e.into()))?;
+
        let dir = self.directory(commit.id, path)?;
+
        let mut entries = dir
+
            .entries(self)?
+
            .map(|en| {
+
                let name = en.name().to_string();
+
                let path = en.path();
+
                Ok(Entry::new(name, path, en.into(), commit.clone()))
+
            })
+
            .collect::<Result<Vec<Entry>, Error>>()?;
+
        entries.sort();
+

+
        Ok(Tree::new(
+
            dir.id(),
+
            entries,
+
            commit,
+
            path.as_ref().to_path_buf(),
+
        ))
+
    }
+

+
    /// Returns a [`Blob`] for `path` in `commit`.
+
    pub fn blob<'a, C: ToCommit, P: AsRef<Path>>(
+
        &'a self,
+
        commit: C,
+
        path: &P,
+
    ) -> Result<Blob<BlobRef<'a>>, Error> {
+
        let commit = commit
+
            .to_commit(self)
+
            .map_err(|e| Error::ToCommit(e.into()))?;
+
        let file = self.file(commit.id, path)?;
+
        let last_commit = self
+
            .last_commit(path, commit)?
+
            .ok_or_else(|| error::Repo::PathNotFound(path.as_ref().to_path_buf()))?;
+
        let git2_blob = self.find_blob(file.id())?;
+
        Ok(Blob::<BlobRef<'a>>::new(file.id(), git2_blob, last_commit))
+
    }
+

+
    pub fn blob_ref(&self, oid: Oid) -> Result<BlobRef<'_>, Error> {
+
        Ok(BlobRef {
+
            inner: self.find_blob(oid)?,
+
        })
+
    }
+

+
    /// Returns the last commit, if exists, for a `path` in the history of
+
    /// `rev`.
+
    pub fn last_commit<P, C>(&self, path: &P, rev: C) -> Result<Option<Commit>, Error>
+
    where
+
        P: AsRef<Path>,
+
        C: ToCommit,
+
    {
+
        let history = self.history(rev)?;
+
        history.by_path(path).next().transpose()
+
    }
+

+
    /// Returns a commit for `rev`, if it exists.
+
    pub fn commit<R: Revision>(&self, rev: R) -> Result<Commit, Error> {
+
        rev.to_commit(self)
+
    }
+

+
    /// Gets the [`Stats`] of this repository starting from the
+
    /// `HEAD` (see [`Repository::head`]) of the repository.
+
    pub fn stats(&self) -> Result<Stats, Error> {
+
        self.stats_from(&self.head()?)
+
    }
+

+
    /// Gets the [`Stats`] of this repository starting from the given
+
    /// `rev`.
+
    pub fn stats_from<R>(&self, rev: &R) -> Result<Stats, Error>
+
    where
+
        R: Revision,
+
    {
+
        let branches = self.branches(Glob::all_heads())?.count();
+
        let mut history = self.history(rev)?;
+
        let (commits, contributors) = history.try_fold(
+
            (0, BTreeSet::new()),
+
            |(commits, mut contributors), commit| {
+
                let commit = commit?;
+
                contributors.insert((commit.author.name, commit.author.email));
+
                Ok::<_, Error>((commits + 1, contributors))
+
            },
+
        )?;
+
        Ok(Stats {
+
            branches,
+
            commits,
+
            contributors: contributors.len(),
+
        })
+
    }
+

+
    // TODO(finto): I think this can be removed in favour of using
+
    // `source::Blob::new`
+
    /// Retrieves the file with `path` in this commit.
+
    pub fn get_commit_file<'a, P, R>(&'a self, rev: &R, path: &P) -> Result<FileContent<'a>, Error>
+
    where
+
        P: AsRef<Path>,
+
        R: Revision,
+
    {
+
        let path = path.as_ref();
+
        let id = self.object_id(rev)?;
+
        let commit = self.find_commit(id)?;
+
        let tree = commit.tree()?;
+
        let entry = tree.get_path(path)?;
+
        let object = entry.to_object(&self.inner)?;
+
        let blob = object
+
            .into_blob()
+
            .map_err(|_| error::Repo::PathNotFound(path.to_path_buf()))?;
+
        Ok(FileContent::new(blob))
+
    }
+

+
    /// Returns the [`Oid`] of the current `HEAD`.
+
    pub fn head(&self) -> Result<Oid, Error> {
+
        let head = self.inner.head()?;
+
        let head_commit = head.peel_to_commit()?;
+
        Ok(head_commit.id().into())
+
    }
+

+
    /// Extract the signature from a commit
+
    ///
+
    /// # Arguments
+
    ///
+
    /// `field` - the name of the header field containing the signature block;
+
    ///           pass `None` to extract the default 'gpgsig'
+
    pub fn extract_signature(
+
        &self,
+
        commit: impl ToCommit,
+
        field: Option<&str>,
+
    ) -> Result<Option<Signature>, Error> {
+
        // Match is necessary here because according to the documentation for
+
        // git_commit_extract_signature at
+
        // https://libgit2.org/libgit2/#HEAD/group/commit/git_commit_extract_signature
+
        // the return value for a commit without a signature will be GIT_ENOTFOUND
+
        let commit = commit
+
            .to_commit(self)
+
            .map_err(|e| Error::ToCommit(e.into()))?;
+

+
        match self.inner.extract_signature(&commit.id.into(), field) {
+
            Err(error) => {
+
                if error.code() == git2::ErrorCode::NotFound {
+
                    Ok(None)
+
                } else {
+
                    Err(error.into())
+
                }
+
            }
+
            Ok(sig) => Ok(Some(Signature::from(sig.0))),
+
        }
+
    }
+

+
    /// Returns the history with the `head` commit.
+
    pub fn history<'a, C: ToCommit>(&'a self, head: C) -> Result<History<'a>, Error> {
+
        History::new(self, head)
+
    }
+

+
    /// Lists branches that are reachable from `rev`.
+
    pub fn revision_branches(
+
        &self,
+
        rev: impl Revision,
+
        glob: Glob<Branch>,
+
    ) -> Result<Vec<Branch>, Error> {
+
        let oid = self.object_id(&rev)?;
+
        let mut contained_branches = vec![];
+
        for branch in self.branches(glob)? {
+
            let branch = branch?;
+
            let namespaced = self.namespaced_refname(&branch.refname())?;
+
            let reference = self.inner.find_reference(namespaced.as_str())?;
+
            if self.reachable_from(&reference, &oid)? {
+
                contained_branches.push(branch);
+
            }
+
        }
+

+
        Ok(contained_branches)
+
    }
+
}
+

+
////////////////////////////////////////////////////////////
+
// 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<'a>(
+
        &'a self,
+
        name: &str,
+
    ) -> Result<git2::Submodule<'a>, 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())
+
    }
+

+
    pub(crate) fn find_commit(&self, oid: Oid) -> Result<git2::Commit<'_>, git2::Error> {
+
        self.inner.find_commit(oid.into())
+
    }
+

+
    pub(crate) fn find_tree(&self, oid: Oid) -> Result<git2::Tree<'_>, git2::Error> {
+
        self.inner.find_tree(oid.into())
+
    }
+

+
    pub(crate) fn refname_to_id<R>(&self, name: &R) -> Result<Oid, git2::Error>
+
    where
+
        R: AsRef<RefStr>,
+
    {
+
        self.inner
+
            .refname_to_id(name.as_ref().as_str())
+
            .map(Oid::from)
+
    }
+

+
    pub(crate) fn revwalk(&self) -> Result<git2::Revwalk<'_>, git2::Error> {
+
        self.inner.revwalk()
+
    }
+

+
    pub(super) fn object_id<R: Revision>(&self, r: &R) -> Result<Oid, Error> {
+
        r.object_id(self).map_err(|err| Error::Revision(err.into()))
+
    }
+

+
    /// Get the [`Diff`] of a commit with no parents.
+
    fn initial_diff<R: Revision>(&self, rev: R) -> Result<Diff, Error> {
+
        let commit = self.find_commit(self.object_id(&rev)?)?;
+
        self.diff_commits(None, None, &commit)
+
            .and_then(|diff| Diff::try_from(diff).map_err(Error::from))
+
    }
+

+
    fn reachable_from(&self, reference: &git2::Reference, oid: &Oid) -> Result<bool, Error> {
+
        let git2_oid = (*oid).into();
+
        let other = reference.peel_to_commit()?.id();
+
        let is_descendant = self.inner.graph_descendant_of(other, git2_oid)?;
+

+
        Ok(other == git2_oid || is_descendant)
+
    }
+

+
    pub(crate) fn diff_commit_and_parents<P>(
+
        &self,
+
        path: &P,
+
        commit: &git2::Commit,
+
    ) -> Result<Option<PathBuf>, Error>
+
    where
+
        P: AsRef<Path>,
+
    {
+
        let mut parents = commit.parents();
+

+
        let diff = self.diff_commits(Some(path.as_ref()), parents.next().as_ref(), commit)?;
+
        if let Some(_delta) = diff.deltas().next() {
+
            Ok(Some(path.as_ref().to_path_buf()))
+
        } else {
+
            Ok(None)
+
        }
+
    }
+

+
    /// Create a diff with the difference between two tree objects.
+
    ///
+
    /// Defines some options and flags that are passed to git2.
+
    ///
+
    /// Note:
+
    /// libgit2 optimizes around not loading the content when there's no content
+
    /// callbacks configured. Be aware that binaries aren't detected as
+
    /// expected.
+
    ///
+
    /// Reference: <https://github.com/libgit2/libgit2/issues/6637>
+
    fn diff_commits<'a>(
+
        &'a self,
+
        path: Option<&Path>,
+
        from: Option<&git2::Commit>,
+
        to: &git2::Commit,
+
    ) -> Result<git2::Diff<'a>, Error> {
+
        let new_tree = to.tree()?;
+
        let old_tree = from.map_or(Ok(None), |c| c.tree().map(Some))?;
+

+
        let mut opts = git2::DiffOptions::new();
+
        if let Some(path) = path {
+
            opts.pathspec(path.to_string_lossy().to_string());
+
            opts.skip_binary_check(false);
+
        }
+

+
        let mut diff =
+
            self.inner
+
                .diff_tree_to_tree(old_tree.as_ref(), Some(&new_tree), Some(&mut opts))?;
+

+
        // Detect renames by default.
+
        let mut find_opts = git2::DiffFindOptions::new();
+
        find_opts.renames(true);
+
        find_opts.copies(true);
+
        diff.find_similar(Some(&mut find_opts))?;
+

+
        Ok(diff)
+
    }
+

+
    /// Returns a full reference name with namespace(s) included.
+
    pub(crate) fn namespaced_refname<'a>(
+
        &'a self,
+
        refname: &Qualified<'a>,
+
    ) -> Result<Qualified<'a>, Error> {
+
        let fullname = match self.which_namespace()? {
+
            Some(namespace) => namespace.to_namespaced(refname).into_qualified(),
+
            None => refname.clone(),
+
        };
+
        Ok(fullname)
+
    }
+

+
    /// Returns a full reference name with namespace(s) included.
+
    fn namespaced_pattern<'a>(
+
        &'a self,
+
        refname: &QualifiedPattern<'a>,
+
    ) -> Result<QualifiedPattern<'a>, Error> {
+
        let fullname = match self.which_namespace()? {
+
            Some(namespace) => namespace.to_namespaced_pattern(refname).into_qualified(),
+
            None => refname.clone(),
+
        };
+
        Ok(fullname)
+
    }
+
}
+

+
impl From<git2::Repository> for Repository {
+
    fn from(repo: git2::Repository) -> Self {
+
        Repository { inner: repo }
+
    }
+
}
+

+
impl std::fmt::Debug for Repository {
+
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+
        write!(f, ".git")
+
    }
+
}
added crates/radicle-surf/src/revision.rs
@@ -0,0 +1,123 @@
+
use std::{convert::Infallible, str::FromStr};
+

+
use radicle_git_ref_format::{Qualified, RefString};
+
use radicle_oid::Oid;
+

+
use crate::{Branch, Commit, Error, Repository, Tag};
+

+
/// The signature of a commit
+
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
+
pub struct Signature(Vec<u8>);
+

+
impl From<git2::Buf> for Signature {
+
    fn from(other: git2::Buf) -> Self {
+
        Signature((*other).into())
+
    }
+
}
+

+
/// Supports various ways to specify a revision used in Git.
+
pub trait Revision {
+
    type Error: std::error::Error + Send + Sync + 'static;
+

+
    /// Returns the object id of this revision in `repo`.
+
    fn object_id(&self, repo: &Repository) -> Result<Oid, Self::Error>;
+
}
+

+
impl Revision for RefString {
+
    type Error = git2::Error;
+

+
    fn object_id(&self, repo: &Repository) -> Result<Oid, Self::Error> {
+
        repo.refname_to_id(self)
+
    }
+
}
+

+
impl Revision for Qualified<'_> {
+
    type Error = git2::Error;
+

+
    fn object_id(&self, repo: &Repository) -> Result<Oid, Self::Error> {
+
        repo.refname_to_id(self)
+
    }
+
}
+

+
impl Revision for Oid {
+
    type Error = Infallible;
+

+
    fn object_id(&self, _repo: &Repository) -> Result<Oid, Self::Error> {
+
        Ok(*self)
+
    }
+
}
+

+
impl Revision for &str {
+
    type Error = radicle_oid::str::error::ParseOidError;
+

+
    fn object_id(&self, _repo: &Repository) -> Result<Oid, Self::Error> {
+
        Oid::from_str(self)
+
    }
+
}
+

+
impl Revision for Branch {
+
    type Error = Error;
+

+
    fn object_id(&self, repo: &Repository) -> Result<Oid, Self::Error> {
+
        let refname = repo.namespaced_refname(&self.refname())?;
+
        Ok(repo.refname_to_id(&refname)?)
+
    }
+
}
+

+
impl Revision for Tag {
+
    type Error = Infallible;
+

+
    fn object_id(&self, _repo: &Repository) -> Result<Oid, Self::Error> {
+
        Ok(self.id())
+
    }
+
}
+

+
impl Revision for String {
+
    type Error = radicle_oid::str::error::ParseOidError;
+

+
    fn object_id(&self, _repo: &Repository) -> Result<Oid, Self::Error> {
+
        Oid::from_str(self)
+
    }
+
}
+

+
impl<R: Revision> Revision for &R {
+
    type Error = R::Error;
+

+
    fn object_id(&self, repo: &Repository) -> Result<Oid, Self::Error> {
+
        (*self).object_id(repo)
+
    }
+
}
+

+
impl<R: Revision> Revision for Box<R> {
+
    type Error = R::Error;
+

+
    fn object_id(&self, repo: &Repository) -> Result<Oid, Self::Error> {
+
        self.as_ref().object_id(repo)
+
    }
+
}
+

+
/// A common trait for anything that can convert to a `Commit`.
+
pub trait ToCommit {
+
    type Error: std::error::Error + Send + Sync + 'static;
+

+
    /// Converts to a commit in `repo`.
+
    fn to_commit(self, repo: &Repository) -> Result<Commit, Self::Error>;
+
}
+

+
impl ToCommit for Commit {
+
    type Error = Infallible;
+

+
    fn to_commit(self, _repo: &Repository) -> Result<Commit, Self::Error> {
+
        Ok(self)
+
    }
+
}
+

+
impl<R: Revision> ToCommit for R {
+
    type Error = Error;
+

+
    fn to_commit(self, repo: &Repository) -> Result<Commit, Self::Error> {
+
        let oid = repo.object_id(&self)?;
+
        let commit = repo.find_commit(oid)?;
+
        Ok(Commit::try_from(commit)?)
+
    }
+
}
added crates/radicle-surf/src/stats.rs
@@ -0,0 +1,14 @@
+
#[cfg(feature = "serde")]
+
use serde::Serialize;
+

+
/// Stats for a repository
+
#[cfg_attr(feature = "serde", derive(Serialize), serde(rename_all = "camelCase"))]
+
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+
pub struct Stats {
+
    /// Number of commits
+
    pub commits: usize,
+
    /// Number of local branches
+
    pub branches: usize,
+
    /// Number of contributors
+
    pub contributors: usize,
+
}
added crates/radicle-surf/src/tag.rs
@@ -0,0 +1,158 @@
+
use std::{convert::TryFrom, str};
+

+
use radicle_git_ref_format::{lit, name::component, Qualified, RefStr, RefString};
+
use radicle_oid::Oid;
+

+
use crate::{refs::refstr_join, Author};
+

+
/// The metadata of a [`Git tag`][git-tag].
+
///
+
/// [git-tag]: https://git-scm.com/book/en/v2/Git-Basics-Tagging
+
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
+
pub enum Tag {
+
    /// A light-weight git tag.
+
    Light {
+
        /// The Object ID for the `Tag`, i.e the SHA1 digest.
+
        id: Oid,
+
        /// The reference name for this `Tag`.
+
        name: RefString,
+
    },
+
    /// An annotated git tag.
+
    Annotated {
+
        /// The Object ID for the `Tag`, i.e the SHA1 digest.
+
        id: Oid,
+
        /// The Object ID for the object that is tagged.
+
        target: Oid,
+
        /// The reference name for this `Tag`.
+
        name: RefString,
+
        /// The named author of this `Tag`, if the `Tag` was annotated.
+
        tagger: Option<Author>,
+
        /// The message with this `Tag`, if the `Tag` was annotated.
+
        message: Option<String>,
+
    },
+
}
+

+
impl Tag {
+
    /// Get the `Oid` of the tag, regardless of its type.
+
    pub fn id(&self) -> Oid {
+
        match self {
+
            Self::Light { id, .. } => *id,
+
            Self::Annotated { id, .. } => *id,
+
        }
+
    }
+

+
    /// Return the short `Tag` refname,
+
    /// e.g. `release/v1`.
+
    pub fn short_name(&self) -> &RefString {
+
        match &self {
+
            Tag::Light { name, .. } => name,
+
            Tag::Annotated { name, .. } => name,
+
        }
+
    }
+

+
    /// Return the fully qualified `Tag` refname,
+
    /// e.g. `refs/tags/release/v1`.
+
    pub fn refname<'a>(&'a self) -> Qualified<'a> {
+
        lit::refs_tags(self.short_name()).into()
+
    }
+
}
+

+
pub mod error {
+
    use std::str;
+

+
    use radicle_git_ref_format::{self, RefString};
+
    use thiserror::Error;
+

+
    #[derive(Debug, Error)]
+
    pub enum FromTag {
+
        #[error(transparent)]
+
        RefFormat(#[from] radicle_git_ref_format::Error),
+
        #[error(transparent)]
+
        Utf8(#[from] str::Utf8Error),
+
    }
+

+
    #[derive(Debug, Error)]
+
    pub enum FromReference {
+
        #[error(transparent)]
+
        FromTag(#[from] FromTag),
+
        #[error(transparent)]
+
        Git(#[from] git2::Error),
+
        #[error("the refname '{0}' did not begin with 'refs/tags'")]
+
        NotQualified(String),
+
        #[error("the refname '{0}' did not begin with 'refs/tags'")]
+
        NotTag(RefString),
+
        #[error(transparent)]
+
        RefFormat(#[from] radicle_git_ref_format::Error),
+
        #[error(transparent)]
+
        Utf8(#[from] str::Utf8Error),
+
    }
+
}
+

+
impl TryFrom<&git2::Tag<'_>> for Tag {
+
    type Error = error::FromTag;
+

+
    fn try_from(tag: &git2::Tag) -> Result<Self, Self::Error> {
+
        let id = tag.id().into();
+
        let target = tag.target_id().into();
+
        let name = {
+
            let name = str::from_utf8(tag.name_bytes())?;
+
            RefStr::try_from_str(name)?.to_ref_string()
+
        };
+
        let tagger = tag.tagger().map(Author::try_from).transpose()?;
+
        let message = tag
+
            .message_bytes()
+
            .map(str::from_utf8)
+
            .transpose()?
+
            .map(|message| message.into());
+

+
        Ok(Tag::Annotated {
+
            id,
+
            target,
+
            name,
+
            tagger,
+
            message,
+
        })
+
    }
+
}
+

+
impl TryFrom<&git2::Reference<'_>> for Tag {
+
    type Error = error::FromReference;
+

+
    fn try_from(reference: &git2::Reference) -> Result<Self, Self::Error> {
+
        let name = reference_name(reference)?;
+
        match reference.peel_to_tag() {
+
            Ok(tag) => Tag::try_from(&tag).map_err(error::FromReference::from),
+
            // If we get an error peeling to a tag _BUT_ we also have confirmed the
+
            // reference is a tag, that means we have a lightweight tag,
+
            // i.e. a commit SHA and name.
+
            Err(err)
+
                if err.class() == git2::ErrorClass::Object
+
                    && err.code() == git2::ErrorCode::InvalidSpec =>
+
            {
+
                let commit = reference.peel_to_commit()?;
+
                Ok(Tag::Light {
+
                    id: commit.id().into(),
+
                    name,
+
                })
+
            }
+
            Err(err) => Err(err.into()),
+
        }
+
    }
+
}
+

+
pub(crate) fn reference_name(
+
    reference: &git2::Reference,
+
) -> Result<RefString, error::FromReference> {
+
    let name = str::from_utf8(reference.name_bytes())?;
+
    let name = RefStr::try_from_str(name)?
+
        .qualified()
+
        .ok_or_else(|| error::FromReference::NotQualified(name.to_string()))?;
+

+
    let (_refs, tags, c, cs) = name.non_empty_components();
+

+
    if tags == component::TAGS {
+
        Ok(refstr_join(c, cs))
+
    } else {
+
        Err(error::FromReference::NotTag(name.into()))
+
    }
+
}
added crates/radicle-surf/src/test/branch.rs
@@ -0,0 +1,25 @@
+
use super::gen;
+
use proptest::prelude::*;
+
// use radicle_git_ext_test::git_ref_format::gen;
+
use super::roundtrip;
+
use crate::Branch;
+
use radicle_git_ref_format::{RefStr, RefString};
+

+
proptest! {
+
    #[test]
+
    fn prop_test_branch(branch in gen_branch()) {
+
        super::roundtrip::json(branch)
+
    }
+
}
+

+
fn gen_branch() -> impl Strategy<Value = Branch> {
+
    prop_oneof![
+
        gen::valid().prop_map(|name| Branch::local(RefString::try_from(name).unwrap())),
+
        (gen::valid(), gen::valid()).prop_map(|(remote, name): (String, String)| {
+
            let remote =
+
                RefStr::try_from_str(&remote).expect("BUG: reference strings should be valid");
+
            let name = RefStr::try_from_str(&name).expect("BUG: reference strings should be valid");
+
            Branch::remote(remote.head(), name)
+
        })
+
    ]
+
}
added crates/radicle-surf/src/test/code_browsing.rs
@@ -0,0 +1,100 @@
+
use std::path::Path;
+

+
use crate::{
+
    fs::{self, Directory},
+
    Branch, Repository,
+
};
+
use radicle_git_ref_format::refname;
+

+
use super::GIT_PLATINUM;
+

+
#[test]
+
fn iterate_root_dir_recursive() {
+
    let repo = Repository::open(GIT_PLATINUM).unwrap();
+

+
    let root_dir = repo.root_dir(Branch::local(refname!("master"))).unwrap();
+
    let count = println_dir(&root_dir, &repo);
+

+
    assert_eq!(count, 36); // Check total file count.
+

+
    /// Prints items in `dir` with `indent_level`.
+
    /// For sub-directories, will do Depth-First-Search and print
+
    /// recursively.
+
    /// Returns the number of items visited (i.e. printed)
+
    fn println_dir(dir: &Directory, repo: &Repository) -> i32 {
+
        dir.traverse::<fs::error::Directory, _, _>(
+
            repo,
+
            (0, 0),
+
            &mut |(count, indent_level), entry| {
+
                println!("> {}{}", " ".repeat(indent_level * 4), entry.name());
+
                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)),
+
                }
+
            },
+
        )
+
        .unwrap()
+
        .0
+
    }
+
}
+

+
#[test]
+
fn browse_repo_lazily() {
+
    let repo = Repository::open(GIT_PLATINUM).unwrap();
+

+
    let root_dir = repo.root_dir(Branch::local(refname!("master"))).unwrap();
+
    let count = root_dir.entries(&repo).unwrap().entries().count();
+
    assert_eq!(count, 8);
+
    let count = traverse(&root_dir, &repo);
+
    assert_eq!(count, 36);
+

+
    fn traverse(dir: &Directory, repo: &Repository) -> i32 {
+
        dir.traverse::<fs::error::Directory, _, _>(repo, 0, &mut |count, _| Ok(count + 1))
+
            .unwrap()
+
    }
+
}
+

+
#[test]
+
fn test_file_history() {
+
    let repo = Repository::open(GIT_PLATINUM).unwrap();
+
    let history = repo.history(Branch::local(refname!("dev"))).unwrap();
+
    let path = Path::new("README.md");
+
    let mut file_history = history.by_path(&path);
+
    let commit = file_history.next().unwrap().unwrap();
+
    let file = repo.get_commit_file(&commit.id, &path).unwrap();
+
    assert_eq!(file.size(), 67);
+
}
+

+
#[test]
+
fn test_commit_history() {
+
    let repo = Repository::open(GIT_PLATINUM).unwrap();
+
    let head = "a0dd9122d33dff2a35f564d564db127152c88e02";
+

+
    // verify `&str` works.
+
    let h1 = repo.history(head).unwrap();
+

+
    // verify `&String` works.
+
    let head_string = head.to_string();
+
    let h2 = repo.history(&head_string).unwrap();
+

+
    assert_eq!(h1.head().id, h2.head().id);
+
}
+

+
#[test]
+
fn test_commit_signature() {
+
    let repo = Repository::open(GIT_PLATINUM).unwrap();
+
    let commit_with_signature = "e24124b7538658220b5aaf3b6ef53758f0a106dc";
+
    let signature = repo.extract_signature(commit_with_signature, None).unwrap();
+
    assert!(signature.is_some());
+

+
    let commit_without_signature = "80bacafba303bf0cdf6142921f430ff265f25095";
+
    let signature = repo
+
        .extract_signature(commit_without_signature, None)
+
        .unwrap();
+
    assert!(signature.is_none());
+

+
    let commit_nonexist = "8080808080";
+
    let signature = repo.extract_signature(commit_nonexist, None);
+
    assert!(signature.is_err());
+
}
added crates/radicle-surf/src/test/commit.rs
@@ -0,0 +1,32 @@
+
use std::str::FromStr;
+

+
use crate::{Author, Commit, Time};
+
use proptest::prelude::*;
+
use radicle_oid::Oid;
+

+
#[cfg(feature = "serde")]
+
proptest! {
+
    #[test]
+
    fn prop_test_commits(commit in commits_strategy()) {
+
        super::roundtrip::json(commit)
+
    }
+
}
+

+
fn commits_strategy() -> impl Strategy<Value = Commit> {
+
    ("[a-fA-F0-9]{40}", any::<String>(), any::<i64>()).prop_map(|(id, text, time)| Commit {
+
        id: Oid::from_str(&id).unwrap(),
+
        author: Author {
+
            name: text.clone(),
+
            email: text.clone(),
+
            time: Time::new(time, 0),
+
        },
+
        committer: Author {
+
            name: text.clone(),
+
            email: text.clone(),
+
            time: Time::new(time, 0),
+
        },
+
        message: text.clone(),
+
        summary: text,
+
        parents: vec![Oid::from_str(&id).unwrap(), Oid::from_str(&id).unwrap()],
+
    })
+
}
added crates/radicle-surf/src/test/diff.rs
@@ -0,0 +1,674 @@
+
use crate::{
+
    diff::{
+
        Added, Diff, DiffContent, DiffFile, EofNewLine, FileDiff, FileMode, FileStats, Hunk, Line,
+
        Modification, Modified, Stats,
+
    },
+
    Branch, Error, Repository,
+
};
+
use pretty_assertions::assert_eq;
+
use radicle_git_ref_format::refname;
+
use radicle_oid::Oid;
+
use std::{path::Path, str::FromStr};
+

+
use super::GIT_PLATINUM;
+

+
#[test]
+
fn test_initial_diff() -> Result<(), Error> {
+
    let repo = Repository::open(GIT_PLATINUM)?;
+
    let oid = Oid::from_str("d3464e33d75c75c99bfb90fa2e9d16efc0b7d0e3")?;
+
    let commit = repo.commit(oid).unwrap();
+
    assert!(commit.parents.is_empty());
+

+
    let diff = repo.diff_commit(oid)?;
+
    let diff_stats = *diff.stats();
+
    let diff_files = diff.into_files();
+

+
    let expected_files = vec![FileDiff::Added(Added {
+
        path: Path::new("README.md").to_path_buf(),
+
        diff: DiffContent::Plain {
+
            hunks: vec![Hunk {
+
                header: Line::from(b"@@ -0,0 +1 @@\n".to_vec()),
+
                lines: vec![Modification::addition(
+
                    b"This repository is a data source for the Upstream front-end tests.\n"
+
                        .to_vec(),
+
                    1,
+
                )],
+
                old: 0..0,
+
                new: 1..2,
+
            }]
+
            .into(),
+
            stats: FileStats {
+
                additions: 1,
+
                deletions: 0,
+
            },
+
            eof: EofNewLine::default(),
+
        },
+
        new: DiffFile {
+
            oid: Oid::from_str("7f48df0118b1674f4ab0ed1717c1368091a5dddc").unwrap(),
+
            mode: FileMode::Blob,
+
        },
+
    })];
+

+
    let expected_stats = Stats {
+
        files_changed: 1,
+
        insertions: 1,
+
        deletions: 0,
+
    };
+

+
    assert_eq!(expected_files, diff_files);
+
    assert_eq!(expected_stats, diff_stats);
+

+
    Ok(())
+
}
+

+
#[test]
+
fn test_diff_of_rev() -> Result<(), Error> {
+
    let repo = Repository::open(GIT_PLATINUM)?;
+
    let diff = repo.diff_commit("80bacafba303bf0cdf6142921f430ff265f25095")?;
+
    assert_eq!(diff.files().count(), 1);
+
    Ok(())
+
}
+

+
#[test]
+
fn test_diff_file() -> Result<(), Error> {
+
    let repo = Repository::open(GIT_PLATINUM)?;
+
    let path_buf = Path::new("README.md").to_path_buf();
+
    let diff = repo.diff_file(
+
        &path_buf,
+
        "d6880352fc7fda8f521ae9b7357668b17bb5bad5",
+
        "223aaf87d6ea62eef0014857640fd7c8dd0f80b5",
+
    )?;
+
    let expected_diff = FileDiff::Modified(Modified {
+
        path: path_buf,
+
        diff: DiffContent::Plain {
+
            hunks: vec![Hunk {
+
                header: Line::from(b"@@ -1 +1,2 @@\n".to_vec()),
+
                lines: vec![
+
                    Modification::deletion(b"This repository is a data source for the Upstream front-end tests.\n".to_vec(), 1),
+
                    Modification::addition(b"This repository is a data source for the Upstream front-end tests and the\n".to_vec(), 1),
+
                    Modification::addition(b"[`radicle-surf`](https://github.com/radicle-dev/git-platinum) unit tests.\n".to_vec(), 2),
+
                ],
+
                old: 1..2,
+
                new: 1..3,
+
            }]
+
            .into(),
+
            stats: FileStats {
+
                additions: 2,
+
                deletions: 1
+
            },
+
            eof: EofNewLine::default(),
+
        },
+
        old: DiffFile {
+
            oid: Oid::from_str("7f48df0118b1674f4ab0ed1717c1368091a5dddc").unwrap(),
+
            mode: FileMode::Blob,
+
        },
+
        new: DiffFile {
+
            oid: Oid::from_str("5e07534cd74a6a9b2ccd2729b181c4ef26173a5e").unwrap(),
+
            mode: FileMode::Blob,
+
        },
+
    });
+
    assert_eq!(expected_diff, diff);
+

+
    Ok(())
+
}
+

+
#[test]
+
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.first().unwrap();
+
    let diff = repo.diff(*parent_oid, oid)?;
+

+
    let expected_files = vec![FileDiff::Modified(Modified {
+
        path: Path::new("README.md").to_path_buf(),
+
        diff: DiffContent::Plain {
+
            hunks: vec![Hunk {
+
                header: Line::from(b"@@ -1 +1,2 @@\n".to_vec()),
+
                lines: vec![
+
                    Modification::deletion(b"This repository is a data source for the Upstream front-end tests.\n".to_vec(), 1),
+
                    Modification::addition(b"This repository is a data source for the Upstream front-end tests and the\n".to_vec(), 1),
+
                    Modification::addition(b"[`radicle-surf`](https://github.com/radicle-dev/git-platinum) unit tests.\n".to_vec(), 2),
+
                ],
+
                old: 1..2,
+
                new: 1..3,
+
            }]
+
            .into(),
+
            stats: FileStats {
+
                additions: 2,
+
                deletions: 1
+
            },
+
            eof: EofNewLine::default(),
+
        },
+
        old: DiffFile {
+
            oid: Oid::from_str("7f48df0118b1674f4ab0ed1717c1368091a5dddc").unwrap(),
+
            mode: FileMode::Blob,
+
        },
+
        new: DiffFile {
+
            oid: Oid::from_str("5e07534cd74a6a9b2ccd2729b181c4ef26173a5e").unwrap(),
+
            mode: FileMode::Blob,
+
        },
+
    })];
+
    let expected_stats = Stats {
+
        files_changed: 1,
+
        insertions: 2,
+
        deletions: 1,
+
    };
+
    let diff_stats = *diff.stats();
+
    let diff_files = diff.into_files();
+
    assert_eq!(expected_files, diff_files);
+
    assert_eq!(expected_stats, diff_stats);
+

+
    Ok(())
+
}
+

+
#[test]
+
fn test_branch_diff() -> Result<(), Error> {
+
    let repo = Repository::open(GIT_PLATINUM)?;
+
    let rev_from = Branch::local(refname!("master"));
+
    let rev_to = Branch::local(refname!("dev"));
+
    let diff = repo.diff(&rev_from, &rev_to)?;
+

+
    println!("Diff two branches: master -> dev");
+
    println!(
+
        "result: added {} deleted {} moved {} modified {}",
+
        diff.added().count(),
+
        diff.deleted().count(),
+
        diff.moved().count(),
+
        diff.modified().count()
+
    );
+
    assert_eq!(diff.added().count(), 1);
+
    assert_eq!(diff.deleted().count(), 11);
+
    assert_eq!(diff.moved().count(), 1);
+
    assert_eq!(diff.modified().count(), 2);
+
    for c in diff.added() {
+
        println!("added: {:?}", &c.path);
+
    }
+
    for d in diff.deleted() {
+
        println!("deleted: {:?}", &d.path);
+
    }
+
    for m in diff.moved() {
+
        println!("moved: {:?} -> {:?}", &m.old_path, &m.new_path);
+
    }
+
    for m in diff.modified() {
+
        println!("modified: {:?}", &m.path);
+
    }
+

+
    // Verify moved.
+
    let diff_moved = diff.moved().next().unwrap();
+

+
    // We can find a `FileDiff` for the old_path in a move.
+
    let file_diff = repo.diff_file(&diff_moved.old_path, &rev_from, &rev_to)?;
+
    println!("old path file diff: {:?}", &file_diff);
+

+
    // We can find a `FileDiff` for the new_path in a move.
+
    let file_diff = repo.diff_file(&diff_moved.new_path, &rev_from, &rev_to)?;
+
    println!("new path file diff: {:?}", &file_diff);
+

+
    // We can find a `FileDiff` if given a directory name.
+
    let dir_diff = repo.diff_file(&"special/", &rev_from, &rev_to)?;
+
    println!("dir diff: {dir_diff:?}");
+

+
    Ok(())
+
}
+

+
#[cfg(feature = "serde")]
+
#[test]
+
fn test_diff_serde() -> Result<(), Error> {
+
    let repo = Repository::open(GIT_PLATINUM)?;
+
    let rev_from = Branch::local(refname!("master"));
+
    let rev_to = Branch::local(refname!("diff-test"));
+
    let diff = repo.diff(rev_from, rev_to)?;
+

+
    let json = serde_json::json!({
+
        "files": [{
+
            "path": "LICENSE",
+
            "diff": {
+
                "type": "plain",
+
                "hunks": [{
+
                    "header": "@@ -0,0 +1,2 @@\n",
+
                    "lines": [{
+
                        "line": "This is a license file.\n",
+
                        "lineNo": 1,
+
                        "type": "addition",
+
                    },
+
                    {
+
                        "line": "\n",
+
                        "lineNo": 2,
+
                        "type": "addition",
+
                    }],
+
                    "old": { "start": 0, "end": 0 },
+
                    "new": { "start": 1, "end": 3 },
+
                }],
+
                "stats": {
+
                    "additions": 2,
+
                    "deletions": 0,
+
                },
+
                "eof": "noneMissing",
+
            },
+
            "new": {
+
                "mode": "blob",
+
                "oid": "02f70f56ec62396ceaf38804c37e169e875ab291",
+
            },
+
            "status": "added"
+
        },
+
        {
+
            "path": "README.md",
+
            "diff": {
+
                "type": "plain",
+
                "hunks": [{
+
                    "header": "@@ -1,2 +1,2 @@\n",
+
                    "lines": [
+
                        { "lineNo": 1,
+
                          "line": "This repository is a data source for the Upstream front-end tests and the\n",
+
                          "type": "deletion"
+
                        },
+
                        { "lineNo": 2,
+
                          "line": "[`radicle-surf`](https://github.com/radicle-dev/git-platinum) unit tests.\n",
+
                          "type": "deletion"
+
                        },
+
                        { "lineNo": 1,
+
                          "line": "This repository is a data source for the upstream front-end tests and the\n",
+
                          "type": "addition"
+
                        },
+
                        { "lineNo": 2,
+
                          "line": "[`radicle-surf`](https://github.com/radicle-dev/radicle-surf) unit tests.\n",
+
                          "type": "addition"
+
                        },
+
                    ],
+
                    "old": { "start": 1, "end": 3 },
+
                    "new": { "start": 1, "end": 3 },
+
                }],
+
                "stats": {
+
                    "additions": 2,
+
                    "deletions": 2
+
                },
+
                "eof": "noneMissing",
+
            },
+
            "new": {
+
                "mode": "blob",
+
                "oid": "b033ecf407a44922b28c942c696922a7d7daf06e",
+
            },
+
            "old": {
+
                "mode": "blob",
+
                "oid": "5e07534cd74a6a9b2ccd2729b181c4ef26173a5e",
+
            },
+
            "status": "modified",
+
        },
+
        {
+
            "current": {
+
                "mode": "blob",
+
                "oid": "1570277532948712fea9029d100a4208f9e34241",
+
            },
+
            "oldPath": "text/emoji.txt",
+
            "newPath": "emoji.txt",
+
            "status": "moved"
+
        },
+
        {
+
            "current": {
+
                "mode": "blob",
+
                "oid": "5e07534cd74a6a9b2ccd2729b181c4ef26173a5e",
+
            },
+
            "newPath": "file_operations/copied.md",
+
            "oldPath": "README.md",
+
            "status": "copied"
+
        },
+
        {
+
            "path": "text/arrows.txt",
+
            "status": "deleted",
+
            "old": {
+
                "mode": "blob",
+
                "oid": "95418c04010a3cc758fb3a37f9918465f147566f",
+
             },
+
            "diff": {
+
                "type": "plain",
+
                "hunks": [{
+
                    "header": "@@ -1,7 +0,0 @@\n",
+
                    "lines": [
+
                        {
+
                            "line": "  ;;;;;        ;;;;;        ;;;;;\n",
+
                            "lineNo": 1,
+
                            "type": "deletion",
+
                        },
+
                        {
+
                            "line": "  ;;;;;        ;;;;;        ;;;;;\n",
+
                            "lineNo": 2,
+
                            "type": "deletion",
+
                        },
+
                        {
+
                            "line": "  ;;;;;        ;;;;;        ;;;;;\n",
+
                            "lineNo": 3,
+
                            "type": "deletion",
+
                        },
+
                        {
+
                            "line": "  ;;;;;        ;;;;;        ;;;;;\n",
+
                            "lineNo": 4,
+
                            "type": "deletion",
+
                        },
+
                        {
+
                            "line": "..;;;;;..    ..;;;;;..    ..;;;;;..\n",
+
                            "lineNo": 5,
+
                            "type": "deletion",
+
                        },
+
                        {
+
                            "line": " ':::::'      ':::::'      ':::::'\n",
+
                            "lineNo": 6,
+
                            "type": "deletion",
+
                        },
+
                        {
+
                            "line": "   ':`          ':`          ':`\n",
+
                            "lineNo": 7,
+
                            "type": "deletion",
+
                        },
+
                    ],
+
                    "old": { "start": 1, "end": 8 },
+
                    "new": { "start": 0, "end": 0 },
+
                }],
+
                "stats": {
+
                    "additions": 0,
+
                    "deletions": 7,
+
                },
+
                "eof": "noneMissing",
+
            },
+
        }],
+
        "stats": {
+
            "deletions": 9,
+
            "filesChanged": 5,
+
            "insertions": 4,
+
        }
+
    });
+
    assert_eq!(serde_json::to_value(diff).unwrap(), json);
+

+
    Ok(())
+
}
+

+
#[cfg(feature = "serde")]
+
#[test]
+
fn test_rename_with_changes() {
+
    let buf = r"
+
diff --git a/radicle/src/node/tracking/config.rs b/radicle-node/src/service/tracking.rs
+
similarity index 96%
+
rename from radicle/src/node/tracking/config.rs
+
rename to radicle-node/src/service/tracking.rs
+
index 3f69208f3..cbc843c82 100644
+
--- a/radicle/src/node/tracking/config.rs
+
+++ b/radicle-node/src/service/tracking.rs
+
@@ -5,15 +5,17 @@ use std::ops;
+
 use log::error;
+
 use thiserror::Error;
+

+
-use crate::crypto::PublicKey;
+
-use crate::identity::IdentityError;
+
+use radicle::crypto::PublicKey;
+
+use radicle::identity::IdentityError;
+
+use radicle::storage::{Namespaces, ReadRepository as _, ReadStorage};
+
+
+
+use crate::prelude::Id;
+
+use crate::service::NodeId;
+
+
+
 pub use crate::node::tracking::store;
+
 pub use crate::node::tracking::store::Config as Store;
+
 pub use crate::node::tracking::store::Error;
+
 pub use crate::node::tracking::{Alias, Node, Policy, Repo, Scope};
+
-use crate::node::NodeId;
+
-use crate::prelude::Id;
+
-use crate::storage::{Namespaces, ReadRepository as _, ReadStorage};
+

+
 #[derive(Debug, Error)]
+
 pub enum NamespacesError {
+
";
+
    let diff = git2::Diff::from_buffer(buf.as_bytes()).unwrap();
+
    let diff = Diff::try_from(diff).unwrap();
+
    let json = serde_json::json!(
+
    {
+
        "files": [
+
            {
+
                "diff": {
+
                    "eof": "noneMissing",
+
                    "hunks": [
+
                        {
+
                            "header": "@@ -5,15 +5,17 @@ use std::ops;\n",
+
                            "lines": [
+
                                {
+
                                    "line": "use log::error;\n",
+
                                    "lineNoNew": 5,
+
                                    "lineNoOld": 5,
+
                                    "type": "context",
+
                                },
+
                                {
+
                                    "line": "use thiserror::Error;\n",
+
                                    "lineNoNew": 6,
+
                                    "lineNoOld": 6,
+
                                    "type": "context",
+
                                },
+
                                {
+
                                    "line": "\n",
+
                                    "lineNoNew": 7,
+
                                    "lineNoOld": 7,
+
                                    "type": "context",
+
                                },
+
                                {
+
                                    "line": "use crate::crypto::PublicKey;\n",
+
                                    "lineNo": 8,
+
                                    "type": "deletion",
+
                                },
+
                                {
+
                                    "line": "use crate::identity::IdentityError;\n",
+
                                    "lineNo": 9,
+
                                    "type": "deletion",
+
                                },
+
                                {
+
                                    "line": "use radicle::crypto::PublicKey;\n",
+
                                    "lineNo": 8,
+
                                    "type": "addition",
+
                                },
+
                                {
+
                                    "line": "use radicle::identity::IdentityError;\n",
+
                                    "lineNo": 9,
+
                                    "type": "addition",
+
                                },
+
                                {
+
                                    "line": "use radicle::storage::{Namespaces, ReadRepository as _, ReadStorage};\n",
+
                                    "lineNo": 10,
+
                                    "type": "addition",
+
                                },
+
                                {
+
                                    "line": "\n",
+
                                    "lineNo": 11,
+
                                    "type": "addition",
+
                                },
+
                                {
+
                                    "line": "use crate::prelude::Id;\n",
+
                                    "lineNo": 12,
+
                                    "type": "addition",
+
                                },
+
                                {
+
                                    "line": "use crate::service::NodeId;\n",
+
                                    "lineNo": 13,
+
                                    "type": "addition",
+
                                },
+
                                {
+
                                    "line": "\n",
+
                                    "lineNo": 14,
+
                                    "type": "addition",
+
                                },
+
                                {
+
                                    "line": "pub use crate::node::tracking::store;\n",
+
                                    "lineNoNew": 15,
+
                                    "lineNoOld": 10,
+
                                    "type": "context",
+
                                },
+
                                {
+
                                    "line": "pub use crate::node::tracking::store::Config as Store;\n",
+
                                    "lineNoNew": 16,
+
                                    "lineNoOld": 11,
+
                                    "type": "context",
+
                                },
+
                                {
+
                                    "line": "pub use crate::node::tracking::store::Error;\n",
+
                                    "lineNoNew": 17,
+
                                    "lineNoOld": 12,
+
                                    "type": "context",
+
                                },
+
                                {
+
                                    "line": "pub use crate::node::tracking::{Alias, Node, Policy, Repo, Scope};\n",
+
                                    "lineNoNew": 18,
+
                                    "lineNoOld": 13,
+
                                    "type": "context",
+
                                },
+
                                {
+
                                    "line": "use crate::node::NodeId;\n",
+
                                    "lineNo": 14,
+
                                    "type": "deletion",
+
                                },
+
                                {
+
                                    "line": "use crate::prelude::Id;\n",
+
                                    "lineNo": 15,
+
                                    "type": "deletion",
+
                                },
+
                                {
+
                                    "line": "use crate::storage::{Namespaces, ReadRepository as _, ReadStorage};\n",
+
                                    "lineNo": 16,
+
                                    "type": "deletion",
+
                                },
+
                                {
+
                                    "line": "\n",
+
                                    "lineNoNew": 19,
+
                                    "lineNoOld": 17,
+
                                    "type": "context",
+
                                },
+
                                {
+
                                    "line": "#[derive(Debug, Error)]\n",
+
                                    "lineNoNew": 20,
+
                                    "lineNoOld": 18,
+
                                    "type": "context",
+
                                },
+
                                {
+
                                    "line": "pub enum NamespacesError {\n",
+
                                    "lineNoNew": 21,
+
                                    "lineNoOld": 19,
+
                                    "type": "context",
+
                                },
+
                            ],
+
                            "new": {
+
                                "end": 22,
+
                                "start": 5,
+
                            },
+
                            "old": {
+
                                "end": 20,
+
                                "start": 5,
+
                            },
+
                        },
+
                    ],
+
                    "stats": {
+
                        "additions": 7,
+
                        "deletions": 5
+
                    },
+
                    "type": "plain",
+
                },
+
                "new": {
+
                    "mode": "blob",
+
                    "oid": "cbc843c820000000000000000000000000000000",
+
                },
+
                "newPath": "radicle-node/src/service/tracking.rs",
+
                "old": {
+
                    "mode": "blob",
+
                    "oid": "3f69208f30000000000000000000000000000000",
+
                },
+
                "oldPath": "radicle/src/node/tracking/config.rs",
+
                "status": "moved"
+
            },
+
        ],
+
        "stats": {
+
            "deletions": 0,
+
            "filesChanged": 1,
+
            "insertions": 0,
+
        },
+
    });
+

+
    assert_eq!(serde_json::to_value(diff).unwrap(), json);
+
}
+

+
// A possible false positive is being hit here for this clippy
+
// warning. Tracking issue:
+
// https://github.com/rust-lang/rust-clippy/issues/11402
+
#[allow(clippy::needless_raw_string_hashes)]
+
#[test]
+
fn test_both_missing_eof_newline() {
+
    let buf = r#"
+
diff --git a/.env b/.env
+
index f89e4c0..7c56eb7 100644
+
--- a/.env
+
+++ b/.env
+
@@ -1 +1 @@
+
-hello=123
+
\ No newline at end of file
+
+hello=1234
+
\ No newline at end of file
+
"#;
+
    let diff = git2::Diff::from_buffer(buf.as_bytes()).unwrap();
+
    let diff = Diff::try_from(diff).unwrap();
+
    assert_eq!(
+
        diff.modified().next().unwrap().diff.eof(),
+
        Some(EofNewLine::BothMissing)
+
    );
+
}
+

+
#[test]
+
fn test_none_missing_eof_newline() {
+
    let buf = r#"
+
diff --git a/.env b/.env
+
index f89e4c0..7c56eb7 100644
+
--- a/.env
+
+++ b/.env
+
@@ -1 +1 @@
+
-hello=123
+
+hello=1234
+
"#;
+
    let diff = git2::Diff::from_buffer(buf.as_bytes()).unwrap();
+
    let diff = Diff::try_from(diff).unwrap();
+
    assert_eq!(
+
        diff.modified().next().unwrap().diff.eof(),
+
        Some(EofNewLine::NoneMissing)
+
    );
+
}
+

+
#[test]
+
fn test_old_missing_eof_newline() {
+
    let buf = r#"
+
diff --git a/.env b/.env
+
index f89e4c0..7c56eb7 100644
+
--- a/.env
+
+++ b/.env
+
@@ -1 +1 @@
+
-hello=123
+
\ No newline at end of file
+
+hello=1234
+
"#;
+
    let diff = git2::Diff::from_buffer(buf.as_bytes()).unwrap();
+
    let diff = Diff::try_from(diff).unwrap();
+
    assert_eq!(
+
        diff.modified().next().unwrap().diff.eof(),
+
        Some(EofNewLine::OldMissing)
+
    );
+
}
+

+
#[test]
+
fn test_new_missing_eof_newline() {
+
    let buf = r#"
+
diff --git a/.env b/.env
+
index f89e4c0..7c56eb7 100644
+
--- a/.env
+
+++ b/.env
+
@@ -1 +1 @@
+
-hello=123
+
+hello=1234
+
\ No newline at end of file
+
"#;
+
    let diff = git2::Diff::from_buffer(buf.as_bytes()).unwrap();
+
    let diff = Diff::try_from(diff).unwrap();
+
    assert_eq!(
+
        diff.modified().next().unwrap().diff.eof(),
+
        Some(EofNewLine::NewMissing)
+
    );
+
}
added crates/radicle-surf/src/test/file_system.rs
@@ -0,0 +1,140 @@
+
//! Unit tests for crate::file_system
+

+
pub mod directory {
+
    use crate::{
+
        fs::{self, Entry},
+
        Branch, Repository,
+
    };
+
    use radicle_git_ref_format::refname;
+
    use std::path::Path;
+

+
    const GIT_PLATINUM: &str = "../data/git-platinum";
+

+
    #[test]
+
    fn directory_find_entry() {
+
        let repo = Repository::open(GIT_PLATINUM).unwrap();
+
        let root = repo.root_dir(Branch::local(refname!("master"))).unwrap();
+

+
        // find_entry for a file.
+
        let path = Path::new("src/memory.rs");
+
        let entry = root.find_entry(&path, &repo).unwrap();
+
        assert!(matches!(entry, fs::Entry::File(_)));
+

+
        // find_entry for a directory.
+
        let path = Path::new("this/is/a/really/deeply/nested/directory/tree");
+
        let entry = root.find_entry(&path, &repo).unwrap();
+
        assert!(matches!(entry, fs::Entry::Directory(_)));
+

+
        // find_entry for a non-leaf directory and its relative path.
+
        let path = Path::new("text");
+
        let entry = root.find_entry(&path, &repo).unwrap();
+
        assert!(matches!(entry, fs::Entry::Directory(_)));
+
        if let fs::Entry::Directory(sub_dir) = entry {
+
            let inner_path = Path::new("garden.txt");
+
            let inner_entry = sub_dir.find_entry(&inner_path, &repo).unwrap();
+
            assert!(matches!(inner_entry, fs::Entry::File(_)));
+
        }
+

+
        // find_entry for non-existing file
+
        let path = Path::new("this/is/a/really/missing_file");
+
        let result = root.find_entry(&path, &repo);
+
        assert!(matches!(result, Err(fs::error::Directory::PathNotFound(_))));
+

+
        // find_entry for absolute path: fail.
+
        let path = Path::new("/src/memory.rs");
+
        let result = root.find_entry(&path, &repo);
+
        assert!(result.is_err());
+

+
        // find entry for an empty path
+
        let path = Path::new("");
+
        let result = root.find_entry(&path, &repo);
+
        assert!(result.is_err());
+
    }
+

+
    #[test]
+
    fn directory_find_file_and_directory() {
+
        let repo = Repository::open(GIT_PLATINUM).unwrap();
+
        // Get the snapshot of the directory for a given commit.
+
        let root = repo
+
            .root_dir("80ded66281a4de2889cc07293a8f10947c6d57fe")
+
            .unwrap();
+

+
        // Assert that we can find the memory.rs file!
+
        assert!(root.find_file(&Path::new("src/memory.rs"), &repo).is_ok());
+

+
        let root_contents: Vec<Entry> = root.entries(&repo).unwrap().collect();
+
        assert_eq!(root_contents.len(), 7);
+
        assert!(root_contents[0].is_file());
+
        assert!(root_contents[1].is_file());
+
        assert!(root_contents[2].is_file());
+
        assert_eq!(root_contents[0].name(), ".i-am-well-hidden");
+
        assert_eq!(root_contents[1].name(), ".i-too-am-hidden");
+
        assert_eq!(root_contents[2].name(), "README.md");
+

+
        assert!(root_contents[3].is_directory());
+
        assert!(root_contents[4].is_directory());
+
        assert!(root_contents[5].is_directory());
+
        assert!(root_contents[6].is_directory());
+
        assert_eq!(root_contents[3].name(), "bin");
+
        assert_eq!(root_contents[4].name(), "src");
+
        assert_eq!(root_contents[5].name(), "text");
+
        assert_eq!(root_contents[6].name(), "this");
+

+
        let src = root.find_directory(&Path::new("src"), &repo).unwrap();
+
        assert_eq!(src.path(), Path::new("src").to_path_buf());
+
        let src_contents: Vec<Entry> = src.entries(&repo).unwrap().collect();
+
        assert_eq!(src_contents.len(), 3);
+
        assert_eq!(src_contents[0].name(), "Eval.hs");
+
        assert_eq!(src_contents[1].name(), "Folder.svelte");
+
        assert_eq!(src_contents[2].name(), "memory.rs");
+
    }
+

+
    #[test]
+
    fn directory_size() {
+
        let repo = Repository::open(GIT_PLATINUM).unwrap();
+
        let root = repo.root_dir(Branch::local(refname!("master"))).unwrap();
+

+
        /*
+
        git-platinum (master) $ ls -l src
+
        -rw-r--r-- 1 pi pi 10044 Oct 31 11:32 Eval.hs
+
        -rw-r--r-- 1 pi pi  6253 Oct 31 11:27 memory.rs
+

+
        10044 + 6253 = 16297
+
         */
+

+
        let path = Path::new("src");
+
        let entry = root.find_entry(&path, &repo).unwrap();
+
        assert!(matches!(entry, fs::Entry::Directory(_)));
+
        if let fs::Entry::Directory(d) = entry {
+
            assert_eq!(16297, d.size(&repo).unwrap());
+
        }
+
    }
+

+
    #[test]
+
    fn directory_last_commit() {
+
        let repo = Repository::open(GIT_PLATINUM).unwrap();
+
        let branch = Branch::local(refname!("dev"));
+
        let root = repo.root_dir(&branch).unwrap();
+
        let dir = root.find_directory(&"this/is", &repo).unwrap();
+
        let last_commit = repo.last_commit(&dir.path(), &branch).unwrap().unwrap();
+
        assert_eq!(
+
            last_commit.id.to_string(),
+
            "2429f097664f9af0c5b7b389ab998b2199ffa977"
+
        );
+
    }
+

+
    #[test]
+
    fn file_last_commit() {
+
        let repo = Repository::open(GIT_PLATINUM).unwrap();
+
        let branch = Branch::local(refname!("master"));
+
        let root = repo.root_dir(&branch).unwrap();
+

+
        // Find a file with "\" in its name.
+
        let f = root.find_file(&"special/faux\\path", &repo).unwrap();
+
        let last_commit = repo.last_commit(&f.path(), &branch).unwrap().unwrap();
+
        assert_eq!(
+
            last_commit.id.to_string(),
+
            "a0dd9122d33dff2a35f564d564db127152c88e02"
+
        );
+
    }
+
}
added crates/radicle-surf/src/test/gen.rs
@@ -0,0 +1,108 @@
+
pub(crate) mod commit;
+

+
use proptest::prelude::*;
+

+
/// Any unicode "word" is trivially a valid refname.
+
pub fn trivial() -> impl Strategy<Value = String> {
+
    "\\w+"
+
}
+

+
pub fn valid() -> impl Strategy<Value = String> {
+
    prop::collection::vec(trivial(), 1..20).prop_map(|xs| xs.join("/"))
+
}
+

+
pub fn invalid_char() -> impl Strategy<Value = char> {
+
    prop_oneof![
+
        Just('\0'),
+
        Just('\\'),
+
        Just('~'),
+
        Just('^'),
+
        Just(':'),
+
        Just('?'),
+
        Just('[')
+
    ]
+
}
+

+
pub fn with_invalid_char() -> impl Strategy<Value = String> {
+
    ("\\w*", invalid_char(), "\\w*").prop_map(|(mut pre, invalid, suf)| {
+
        pre.push(invalid);
+
        pre.push_str(&suf);
+
        pre
+
    })
+
}
+

+
pub fn ends_with_dot_lock() -> impl Strategy<Value = String> {
+
    "\\w*\\.lock"
+
}
+

+
pub fn with_double_dot() -> impl Strategy<Value = String> {
+
    "\\w*\\.\\.\\w*"
+
}
+

+
pub fn starts_with_dot() -> impl Strategy<Value = String> {
+
    "\\.\\w*"
+
}
+

+
pub fn ends_with_dot() -> impl Strategy<Value = String> {
+
    "\\w+\\."
+
}
+

+
pub fn with_control_char() -> impl Strategy<Value = String> {
+
    "\\w*[\x01-\x1F\x7F]+\\w*"
+
}
+

+
pub fn with_space() -> impl Strategy<Value = String> {
+
    "\\w* +\\w*"
+
}
+

+
pub fn with_consecutive_slashes() -> impl Strategy<Value = String> {
+
    "\\w*//\\w*"
+
}
+

+
pub fn with_glob() -> impl Strategy<Value = String> {
+
    "\\w*\\*\\w*"
+
}
+

+
pub fn multi_glob() -> impl Strategy<Value = String> {
+
    (
+
        prop::collection::vec(with_glob(), 2..5),
+
        prop::collection::vec(trivial(), 0..5),
+
    )
+
        .prop_map(|(mut globs, mut valids)| {
+
            globs.append(&mut valids);
+
            globs
+
        })
+
        .prop_shuffle()
+
        .prop_map(|xs| xs.join("/"))
+
}
+

+
pub fn invalid() -> impl Strategy<Value = String> {
+
    fn path(s: impl Strategy<Value = String>) -> impl Strategy<Value = String> {
+
        prop::collection::vec(s, 1..20).prop_map(|xs| xs.join("/"))
+
    }
+

+
    prop_oneof![
+
        Just(String::from("")),
+
        Just(String::from("@")),
+
        path(with_invalid_char()),
+
        path(ends_with_dot_lock()),
+
        path(with_double_dot()),
+
        path(starts_with_dot()),
+
        path(ends_with_dot()),
+
        path(with_control_char()),
+
        path(with_space()),
+
        path(with_consecutive_slashes()),
+
        path(trivial()).prop_map(|mut p| {
+
            p.push('/');
+
            p
+
        }),
+
    ]
+
}
+

+
pub fn alphanumeric() -> impl Strategy<Value = String> {
+
    "[a-zA-Z0-9_]+"
+
}
+

+
pub fn alpha() -> impl Strategy<Value = String> {
+
    "[a-zA-Z]+"
+
}
added crates/radicle-surf/src/test/gen/commit.rs
@@ -0,0 +1,102 @@
+
use std::convert::Infallible;
+

+
use proptest::strategy::Strategy;
+
use radicle_git_metadata::commit::CommitData;
+

+
mod author;
+
mod headers;
+
mod trailers;
+

+
pub use author::author;
+
pub use headers::headers;
+
pub use trailers::trailers;
+

+
use super::alphanumeric;
+

+
pub fn commit() -> impl Strategy<Value = CommitData<TreeData, Infallible>> {
+
    (
+
        TreeData::gen(),
+
        author(),
+
        author(),
+
        headers(),
+
        alphanumeric(),
+
        trailers(3),
+
    )
+
        .prop_map(|(tree, author, committer, headers, message, trailers)| {
+
            CommitData::new(tree, vec![], author, committer, headers, message, trailers)
+
        })
+
}
+

+
pub fn write_commits(
+
    repo: &git2::Repository,
+
    linear: Vec<CommitData<TreeData, Infallible>>,
+
) -> Result<Vec<git2::Oid>, git2::Error> {
+
    let mut parent = None;
+
    let mut commits = Vec::new();
+
    for commit in linear {
+
        let commit = commit.map_tree(|tree| tree.write(repo))?;
+
        let commit = match parent {
+
            Some(parent) => commit
+
                .map_parents::<git2::Oid, Infallible, _>(|_| Ok(parent))
+
                .unwrap(),
+
            None => commit
+
                .map_parents::<git2::Oid, Infallible, _>(|_| unreachable!("no parents"))
+
                .unwrap(),
+
        };
+
        let tree = repo.find_tree(*commit.tree())?;
+
        let oid = repo.commit(
+
            None,
+
            &git2::Signature::try_from(commit.author()).unwrap(),
+
            &git2::Signature::try_from(commit.committer()).unwrap(),
+
            commit.message(),
+
            &tree,
+
            &[],
+
        )?;
+
        commits.push(oid);
+
        parent = Some(oid);
+
    }
+
    Ok(commits)
+
}
+

+
#[derive(Clone, Debug)]
+
pub enum TreeData {
+
    Blob { name: String, data: String },
+
    Tree { name: String, inner: Vec<TreeData> },
+
}
+

+
impl TreeData {
+
    fn gen() -> impl Strategy<Value = Self> {
+
        let leaf =
+
            (alphanumeric(), alphanumeric()).prop_map(|(name, data)| Self::Blob { name, data });
+
        leaf.prop_recursive(8, 16, 5, |inner| {
+
            (proptest::collection::vec(inner, 1..5), alphanumeric())
+
                .prop_map(|(inner, name)| Self::Tree { name, inner })
+
        })
+
    }
+

+
    fn write(&self, repo: &git2::Repository) -> Result<git2::Oid, git2::Error> {
+
        let mut builder = repo.treebuilder(None)?;
+
        self.write_(repo, &mut builder)?;
+
        builder.write()
+
    }
+

+
    fn write_(
+
        &self,
+
        repo: &git2::Repository,
+
        builder: &mut git2::TreeBuilder,
+
    ) -> Result<git2::Oid, git2::Error> {
+
        match self {
+
            Self::Blob { name, data } => {
+
                let oid = repo.blob(data.as_bytes())?;
+
                builder.insert(name, oid, git2::FileMode::Blob.into())?;
+
            }
+
            Self::Tree { name, inner } => {
+
                for data in inner {
+
                    let oid = data.write_(repo, builder)?;
+
                    builder.insert(name, oid, git2::FileMode::Tree.into())?;
+
                }
+
            }
+
        }
+
        builder.write()
+
    }
+
}
added crates/radicle-surf/src/test/gen/commit/author.rs
@@ -0,0 +1,19 @@
+
use proptest::strategy::{Just, Strategy};
+
use radicle_git_metadata::author::{Author, Time};
+

+
use crate::test::gen;
+

+
pub fn author() -> impl Strategy<Value = Author> {
+
    gen::alphanumeric().prop_flat_map(move |name| {
+
        (Just(name), gen::alphanumeric()).prop_flat_map(|(name, domain)| {
+
            (Just(name), Just(domain), (0..1000i64)).prop_map(move |(name, domain, time)| {
+
                let email = format!("{name}@{domain}");
+
                Author {
+
                    name,
+
                    email,
+
                    time: Time::new(time, 0),
+
                }
+
            })
+
        })
+
    })
+
}
added crates/radicle-surf/src/test/gen/commit/headers.rs
@@ -0,0 +1,30 @@
+
use proptest::{collection, prop_oneof, strategy::Strategy};
+
use radicle_git_metadata::commit::headers::Headers;
+

+
use crate::test::gen;
+

+
pub fn headers() -> impl Strategy<Value = Headers> {
+
    collection::vec(prop_oneof![header(), signature()], 0..5).prop_map(|hs| {
+
        let mut headers = Headers::new();
+
        for (k, v) in hs {
+
            headers.push(&k, &v);
+
        }
+
        headers
+
    })
+
}
+

+
fn header() -> impl Strategy<Value = (String, String)> {
+
    (prop_oneof!["test", "foo", "foobar"], gen::alphanumeric())
+
}
+

+
pub fn signature() -> impl Strategy<Value = (String, String)> {
+
    ("gpgsig", prop_oneof![pgp(), ssh()])
+
}
+

+
pub fn pgp() -> impl Strategy<Value = String> {
+
    "-----BEGIN PGP SIGNATURE-----\r?\n([A-Za-z0-9+/=\r\n]+)\r?\n-----END PGP SIGNATURE-----"
+
}
+

+
pub fn ssh() -> impl Strategy<Value = String> {
+
    "-----BEGIN SSH SIGNATURE-----\r?\n([A-Za-z0-9+/=\r\n]+)\r?\n-----END SSH SIGNATURE-----"
+
}
added crates/radicle-surf/src/test/gen/commit/trailers.rs
@@ -0,0 +1,18 @@
+
use proptest::{collection, strategy::Strategy};
+
use radicle_git_metadata::commit::trailers::{OwnedTrailer, Token, Trailer};
+

+
use crate::test::gen;
+

+
pub fn trailers(n: usize) -> impl Strategy<Value = Vec<OwnedTrailer>> {
+
    collection::vec(trailer(), 0..n)
+
}
+

+
pub fn trailer() -> impl Strategy<Value = OwnedTrailer> {
+
    (gen::alpha(), gen::alphanumeric()).prop_map(|(token, value)| {
+
        Trailer {
+
            token: Token::try_from(format!("X-{}", token).as_str()).unwrap(),
+
            value: value.into(),
+
        }
+
        .to_owned()
+
    })
+
}
added crates/radicle-surf/src/test/last_commit.rs
@@ -0,0 +1,120 @@
+
use std::{path::PathBuf, str::FromStr};
+

+
use crate::{Branch, Repository};
+
use radicle_git_ref_format::refname;
+
use radicle_oid::Oid;
+

+
use super::GIT_PLATINUM;
+

+
#[test]
+
fn readme_missing_and_memory() {
+
    let repo = Repository::open(GIT_PLATINUM)
+
        .expect("Could not retrieve ./data/git-platinum as git repository");
+
    let oid =
+
        Oid::from_str("d3464e33d75c75c99bfb90fa2e9d16efc0b7d0e3").expect("Failed to parse SHA");
+

+
    // memory.rs is committed later so it should not exist here.
+
    let memory_last_commit_oid = repo
+
        .last_commit(&"src/memory.rs", oid)
+
        .expect("Failed to get last commit")
+
        .map(|commit| commit.id);
+

+
    assert_eq!(memory_last_commit_oid, None);
+

+
    // README.md exists in this commit.
+
    let readme_last_commit = repo
+
        .last_commit(&"README.md", oid)
+
        .expect("Failed to get last commit")
+
        .map(|commit| commit.id);
+

+
    assert_eq!(readme_last_commit, Some(oid));
+
}
+

+
#[test]
+
fn folder_svelte() {
+
    let repo = Repository::open(GIT_PLATINUM)
+
        .expect("Could not retrieve ./data/git-platinum as git repository");
+
    // Check that last commit is the actual last commit even if head commit differs.
+
    let oid =
+
        Oid::from_str("19bec071db6474af89c866a1bd0e4b1ff76e2b97").expect("Could not parse SHA");
+

+
    let expected_commit_id = Oid::from_str("f3a089488f4cfd1a240a9c01b3fcc4c34a4e97b2").unwrap();
+

+
    let folder_svelte = repo
+
        .last_commit(&"examples/Folder.svelte", oid)
+
        .expect("Failed to get last commit")
+
        .map(|commit| commit.id);
+

+
    assert_eq!(folder_svelte, Some(expected_commit_id));
+
}
+

+
#[test]
+
fn nest_directory() {
+
    let repo = Repository::open(GIT_PLATINUM)
+
        .expect("Could not retrieve ./data/git-platinum as git repository");
+
    // Check that last commit is the actual last commit even if head commit differs.
+
    let oid =
+
        Oid::from_str("19bec071db6474af89c866a1bd0e4b1ff76e2b97").expect("Failed to parse SHA");
+

+
    let expected_commit_id = Oid::from_str("2429f097664f9af0c5b7b389ab998b2199ffa977").unwrap();
+

+
    let nested_directory_tree_commit_id = repo
+
        .last_commit(&"this/is/a/really/deeply/nested/directory/tree", oid)
+
        .expect("Failed to get last commit")
+
        .map(|commit| commit.id);
+

+
    assert_eq!(nested_directory_tree_commit_id, Some(expected_commit_id));
+
}
+

+
#[test]
+
#[cfg(not(windows))]
+
fn can_get_last_commit_for_special_filenames() {
+
    let repo = Repository::open(GIT_PLATINUM)
+
        .expect("Could not retrieve ./data/git-platinum as git repository");
+

+
    // Check that last commit is the actual last commit even if head commit differs.
+
    let oid =
+
        Oid::from_str("a0dd9122d33dff2a35f564d564db127152c88e02").expect("Failed to parse SHA");
+

+
    let expected_commit_id = Oid::from_str("a0dd9122d33dff2a35f564d564db127152c88e02").unwrap();
+

+
    let backslash_commit_id = repo
+
        .last_commit(&r"special/faux\\path", oid)
+
        .expect("Failed to get last commit")
+
        .map(|commit| commit.id);
+
    assert_eq!(backslash_commit_id, Some(expected_commit_id));
+

+
    let ogre_commit_id = repo
+
        .last_commit(&"special/👹👹👹", oid)
+
        .expect("Failed to get last commit")
+
        .map(|commit| commit.id);
+
    assert_eq!(ogre_commit_id, Some(expected_commit_id));
+
}
+

+
#[test]
+
fn root() {
+
    let repo = Repository::open(GIT_PLATINUM)
+
        .expect("Could not retrieve ./data/git-platinum as git repository");
+
    let rev = Branch::local(refname!("master"));
+
    let root_last_commit_id = repo
+
        .last_commit(&PathBuf::new(), rev)
+
        .expect("Failed to get last commit")
+
        .map(|commit| commit.id);
+

+
    let expected_oid = repo
+
        .history(Branch::local(refname!("master")))
+
        .unwrap()
+
        .head()
+
        .id;
+
    assert_eq!(root_last_commit_id, Some(expected_oid));
+
}
+

+
#[test]
+
fn binary_file() {
+
    let repo = Repository::open(GIT_PLATINUM)
+
        .expect("Could not retrieve ./data/git-platinum as git repository");
+
    let history = repo.history(Branch::local(refname!("dev"))).unwrap();
+
    let file_commit = history.by_path(&"bin/cat").next();
+
    assert!(file_commit.is_some());
+
    println!("file commit: {:?}", &file_commit);
+
}
added crates/radicle-surf/src/test/mod.rs
@@ -0,0 +1,44 @@
+
#[cfg(test)]
+
const GIT_PLATINUM: &str = "../../data/git-platinum";
+

+
#[cfg(test)]
+
mod file_system;
+

+
#[cfg(all(test, feature = "serde"))]
+
mod source;
+

+
#[cfg(all(test, feature = "serde"))]
+
mod branch;
+

+
#[cfg(test)]
+
mod code_browsing;
+

+
#[cfg(test)]
+
mod commit;
+

+
#[cfg(test)]
+
mod diff;
+

+
#[cfg(test)]
+
mod last_commit;
+

+
#[cfg(test)]
+
mod namespace;
+

+
#[cfg(test)]
+
mod reference;
+

+
#[cfg(test)]
+
mod rev;
+

+
#[cfg(test)]
+
mod submodule;
+

+
#[cfg(test)]
+
mod threading;
+

+
mod roundtrip;
+

+
mod gen;
+

+
mod repository;
added crates/radicle-surf/src/test/namespace.rs
@@ -0,0 +1,121 @@
+
use crate::{Branch, Error, Glob, Repository};
+
use pretty_assertions::{assert_eq, assert_ne};
+
use radicle_git_ref_format::{component, pattern, refname};
+

+
use super::GIT_PLATINUM;
+

+
#[test]
+
fn switch_to_banana() -> Result<(), Error> {
+
    let repo = Repository::open(GIT_PLATINUM)?;
+
    let history_master = repo.history(Branch::local(refname!("master")))?;
+
    repo.switch_namespace(&refname!("golden"))?;
+
    let history_banana = repo.history(Branch::local(refname!("banana")))?;
+

+
    assert_ne!(history_master.head(), history_banana.head());
+

+
    Ok(())
+
}
+

+
#[test]
+
fn me_namespace() -> Result<(), Error> {
+
    let repo = Repository::open(GIT_PLATINUM)?;
+
    let history = repo.history(Branch::local(refname!("master")))?;
+

+
    assert_eq!(repo.which_namespace().unwrap(), None);
+

+
    repo.switch_namespace(&refname!("me"))?;
+
    assert_eq!(repo.which_namespace().unwrap(), Some("me".parse()?));
+

+
    let history_feature = repo.history(Branch::local(refname!("feature/#1194")))?;
+
    assert_eq!(history.head(), history_feature.head());
+

+
    let expected_branches: Vec<Branch> = vec![Branch::local(refname!("feature/#1194"))];
+
    let mut branches = repo
+
        .branches(Glob::all_heads())?
+
        .collect::<Result<Vec<_>, _>>()?;
+
    branches.sort();
+

+
    assert_eq!(expected_branches, branches);
+

+
    let expected_branches: Vec<Branch> = vec![Branch::remote(
+
        component!("fein"),
+
        refname!("heads/feature/#1194"),
+
    )];
+
    let mut branches = repo
+
        .branches(Glob::remotes(pattern!("fein/*")))?
+
        .collect::<Result<Vec<_>, _>>()?;
+
    branches.sort();
+

+
    assert_eq!(expected_branches, branches);
+

+
    Ok(())
+
}
+

+
#[test]
+
fn golden_namespace() -> Result<(), Error> {
+
    let repo = Repository::open(GIT_PLATINUM)?;
+
    let history = repo.history(Branch::local(refname!("master")))?;
+

+
    assert_eq!(repo.which_namespace().unwrap(), None);
+

+
    repo.switch_namespace(&refname!("golden"))?;
+

+
    assert_eq!(repo.which_namespace().unwrap(), Some("golden".parse()?));
+

+
    let golden_history = repo.history(Branch::local(refname!("master")))?;
+
    assert_eq!(history.head(), golden_history.head());
+

+
    let expected_branches: Vec<Branch> = vec![
+
        Branch::local(refname!("banana")),
+
        Branch::local(refname!("master")),
+
    ];
+
    let mut branches = repo
+
        .branches(Glob::all_heads())?
+
        .collect::<Result<Vec<_>, _>>()?;
+
    branches.sort();
+

+
    assert_eq!(expected_branches, branches);
+

+
    // NOTE: these tests used to remove the categories, i.e. heads & tags, but that
+
    // was specialised logic based on the radicle-link storage layout.
+
    let remote = component!("kickflip");
+
    let expected_branches: Vec<Branch> = vec![
+
        Branch::remote(remote.clone(), refname!("heads/fakie/bigspin")),
+
        Branch::remote(remote.clone(), refname!("heads/heelflip")),
+
        Branch::remote(remote, refname!("tags/v0.1.0")),
+
    ];
+
    let mut branches = repo
+
        .branches(Glob::remotes(pattern!("kickflip/*")))?
+
        .collect::<Result<Vec<_>, _>>()?;
+
    branches.sort();
+

+
    assert_eq!(expected_branches, branches);
+

+
    Ok(())
+
}
+

+
#[test]
+
fn silver_namespace() -> Result<(), Error> {
+
    let repo = Repository::open(GIT_PLATINUM)?;
+
    let history = repo.history(Branch::local(refname!("master")))?;
+

+
    assert_eq!(repo.which_namespace().unwrap(), None);
+

+
    repo.switch_namespace(&refname!("golden/silver"))?;
+
    assert_eq!(
+
        repo.which_namespace().unwrap(),
+
        Some("golden/silver".parse()?)
+
    );
+
    let silver_history = repo.history(Branch::local(refname!("master")))?;
+
    assert_ne!(history.head(), silver_history.head());
+

+
    let expected_branches: Vec<Branch> = vec![Branch::local(refname!("master"))];
+
    let mut branches = repo
+
        .branches(Glob::all_heads().branches().and(Glob::all_remotes()))?
+
        .collect::<Result<Vec<_>, _>>()?;
+
    branches.sort();
+

+
    assert_eq!(expected_branches, branches);
+

+
    Ok(())
+
}
added crates/radicle-surf/src/test/reference.rs
@@ -0,0 +1,49 @@
+
use crate::{Glob, Repository};
+
use radicle_git_ref_format::pattern;
+

+
use super::GIT_PLATINUM;
+

+
#[test]
+
fn test_branches() {
+
    let repo = Repository::open(GIT_PLATINUM).unwrap();
+
    let heads = Glob::all_heads();
+
    let branches = repo.branches(heads.clone()).unwrap();
+
    for b in branches {
+
        println!("{}", b.unwrap().refname());
+
    }
+
    let branches = repo
+
        .branches(heads.branches().and(Glob::remotes(pattern!("banana/*"))))
+
        .unwrap();
+
    for b in branches {
+
        println!("{}", b.unwrap().refname());
+
    }
+
}
+

+
#[test]
+
fn test_tag_snapshot() {
+
    let repo = Repository::open(GIT_PLATINUM).unwrap();
+
    let tags = repo
+
        .tags(&Glob::all_tags())
+
        .unwrap()
+
        .collect::<Result<Vec<_>, _>>()
+
        .unwrap();
+
    assert_eq!(tags.len(), 6);
+
    let root_dir = repo.root_dir(&tags[0]).unwrap();
+
    assert_eq!(root_dir.entries(&repo).unwrap().entries().count(), 1);
+
}
+

+
#[test]
+
fn test_namespaces() {
+
    let repo = Repository::open(GIT_PLATINUM).unwrap();
+

+
    let namespaces = repo.namespaces(&Glob::all_namespaces()).unwrap();
+
    assert_eq!(namespaces.count(), 3);
+
    let namespaces = repo
+
        .namespaces(&Glob::namespaces(pattern!("golden/*")))
+
        .unwrap();
+
    assert_eq!(namespaces.count(), 2);
+
    let namespaces = repo
+
        .namespaces(&Glob::namespaces(pattern!("golden/*")).insert(pattern!("me/*")))
+
        .unwrap();
+
    assert_eq!(namespaces.count(), 3);
+
}
added crates/radicle-surf/src/test/repository.rs
@@ -0,0 +1,93 @@
+
use std::{convert::Infallible, io, path::Path};
+

+
use git2::Oid;
+
use radicle_git_metadata::commit::CommitData;
+
use radicle_git_ref_format::RefString;
+

+
use crate::test::gen::commit::{self, TreeData};
+

+
pub struct Fixture {
+
    pub dir: tempfile::TempDir,
+
    pub repo: git2::Repository,
+
    pub head: Option<git2::Oid>,
+
}
+

+
/// Initialise a [`git2::Repository`] in a temporary directory.
+
///
+
/// The provided `commits` will be added to the repository, and the
+
/// head commit will be returned.
+
pub fn fixture(
+
    refname: &RefString,
+
    commits: Vec<CommitData<TreeData, Infallible>>,
+
) -> io::Result<Fixture> {
+
    let dir = tempfile::tempdir().unwrap();
+
    let repo = git2::Repository::init(dir.path()).map_err(io_other)?;
+
    let commits = commit::write_commits(&repo, commits).map_err(io_other)?;
+
    let head = commits.last().copied();
+

+
    if let Some(head) = head {
+
        repo.reference(refname.as_str(), head, false, "Initialise repository")
+
            .map_err(io_other)?;
+
    }
+

+
    Ok(Fixture { dir, repo, head })
+
}
+

+
pub fn bare_fixture(
+
    refname: &RefString,
+
    commits: Vec<CommitData<TreeData, Infallible>>,
+
) -> io::Result<Fixture> {
+
    let dir = tempfile::tempdir().unwrap();
+
    let repo = git2::Repository::init_bare(dir.path()).map_err(io_other)?;
+
    let commits = commit::write_commits(&repo, commits).map_err(io_other)?;
+
    let head = commits.last().copied();
+

+
    if let Some(head) = head {
+
        repo.reference(refname.as_str(), head, false, "Initialise repository")
+
            .map_err(io_other)?;
+
    }
+

+
    Ok(Fixture { dir, repo, head })
+
}
+

+
pub fn submodule<'a>(
+
    parent: &'a git2::Repository,
+
    child: &'a git2::Repository,
+
    refname: &RefString,
+
    head: Oid,
+
    author: &git2::Signature,
+
) -> io::Result<git2::Submodule<'a>> {
+
    let url = format!("file://{}", child.path().canonicalize()?.display());
+
    let mut sub = parent
+
        .submodule(url.as_str(), Path::new("submodule"), true)
+
        .map_err(io_other)?;
+
    sub.open().map_err(io_other)?;
+
    sub.clone(Some(&mut git2::SubmoduleUpdateOptions::default()))
+
        .map_err(io_other)?;
+
    sub.add_to_index(true).map_err(io_other)?;
+
    sub.add_finalize().map_err(io_other)?;
+
    {
+
        let mut ix = parent.index().map_err(io_other)?;
+
        let tree = ix.write_tree_to(parent).map_err(io_other)?;
+
        let tree = parent.find_tree(tree).map_err(io_other)?;
+
        let head = parent.find_commit(head).map_err(io_other)?;
+
        parent
+
            .commit(
+
                Some(refname.as_str()),
+
                author,
+
                author,
+
                "Commit submodule",
+
                &tree,
+
                &[&head],
+
            )
+
            .map_err(io_other)?;
+
    }
+
    Ok(sub)
+
}
+

+
fn io_other<E>(e: E) -> io::Error
+
where
+
    E: std::error::Error + Send + Sync + 'static,
+
{
+
    io::Error::other(e)
+
}
added crates/radicle-surf/src/test/rev.rs
@@ -0,0 +1,92 @@
+
use std::str::FromStr;
+

+
use crate::{Branch, Error, Oid, Repository};
+
use radicle_git_ref_format::{component, refname};
+

+
use super::GIT_PLATINUM;
+

+
// **FIXME**: This seems to break occasionally on
+
// buildkite. For some reason the commit
+
// 3873745c8f6ffb45c990eb23b491d4b4b6182f95, which is on master
+
// (currently HEAD), is not found. It seems to load the history
+
// with d6880352fc7fda8f521ae9b7357668b17bb5bad5 as the HEAD.
+
//
+
// To temporarily fix this, we need to select "New Build" from the build kite
+
// build page that's failing.
+
// * Under "Message" put whatever you want.
+
// * Under "Branch" put in the branch you're working on.
+
// * Expand "Options" and select "clean checkout".
+
#[test]
+
fn _master() -> Result<(), Error> {
+
    let repo = Repository::open(GIT_PLATINUM)?;
+
    let mut history = repo.history(Branch::remote(component!("origin"), refname!("master")))?;
+

+
    let commit1 = Oid::from_str("3873745c8f6ffb45c990eb23b491d4b4b6182f95")?;
+
    assert!(
+
        history.any(|commit| commit.unwrap().id == commit1),
+
        "commit_id={}, history =\n{:#?}",
+
        commit1,
+
        &history
+
    );
+

+
    let commit2 = Oid::from_str("d6880352fc7fda8f521ae9b7357668b17bb5bad5")?;
+
    assert!(
+
        history.any(|commit| commit.unwrap().id == commit2),
+
        "commit_id={}, history =\n{:#?}",
+
        commit2,
+
        &history
+
    );
+

+
    Ok(())
+
}
+

+
#[test]
+
fn commit() -> Result<(), Error> {
+
    let repo = Repository::open(GIT_PLATINUM)?;
+
    let rev = Oid::from_str("3873745c8f6ffb45c990eb23b491d4b4b6182f95")?;
+
    let mut history = repo.history(rev)?;
+

+
    let commit1 = Oid::from_str("3873745c8f6ffb45c990eb23b491d4b4b6182f95")?;
+
    assert!(history.any(|commit| commit.unwrap().id == commit1));
+

+
    Ok(())
+
}
+

+
#[test]
+
fn commit_parents() -> Result<(), Error> {
+
    let repo = Repository::open(GIT_PLATINUM)?;
+
    let rev = Oid::from_str("3873745c8f6ffb45c990eb23b491d4b4b6182f95")?;
+
    let history = repo.history(rev)?;
+
    let commit = history.head();
+

+
    assert_eq!(
+
        commit.parents,
+
        vec![Oid::from_str("d6880352fc7fda8f521ae9b7357668b17bb5bad5")?]
+
    );
+

+
    Ok(())
+
}
+

+
#[test]
+
fn commit_short() -> Result<(), Error> {
+
    let repo = Repository::open(GIT_PLATINUM)?;
+
    let rev = repo.oid("3873745c8")?;
+
    let mut history = repo.history(rev)?;
+

+
    let commit1 = Oid::from_str("3873745c8f6ffb45c990eb23b491d4b4b6182f95")?;
+
    assert!(history.any(|commit| commit.unwrap().id == commit1));
+

+
    Ok(())
+
}
+

+
#[test]
+
fn tag() -> Result<(), Error> {
+
    let repo = Repository::open(GIT_PLATINUM)?;
+
    let rev = refname!("refs/tags/v0.2.0");
+
    let history = repo.history(&rev)?;
+

+
    let commit1 = Oid::from_str("2429f097664f9af0c5b7b389ab998b2199ffa977")?;
+
    assert_eq!(history.head().id, commit1);
+

+
    Ok(())
+
}
added crates/radicle-surf/src/test/roundtrip.rs
@@ -0,0 +1,44 @@
+
use std::{
+
    fmt::{Debug, Display},
+
    str::FromStr,
+
};
+

+
use pretty_assertions::assert_eq;
+

+
#[cfg(feature = "serde")]
+
pub fn json<A>(a: A)
+
where
+
    for<'de> A: Debug + PartialEq + serde::Serialize + serde::Deserialize<'de>,
+
{
+
    assert_eq!(
+
        a,
+
        serde_json::from_str(&serde_json::to_string(&a).unwrap()).unwrap()
+
    )
+
}
+

+
#[cfg(feature = "serde")]
+
pub fn json_value<A>(a: A)
+
where
+
    for<'de> A: Clone + Debug + PartialEq + serde::Serialize + serde::Deserialize<'de>,
+
{
+
    assert_eq!(
+
        a.clone(),
+
        serde_json::from_value(serde_json::to_value(a).unwrap()).unwrap()
+
    )
+
}
+

+
#[cfg(feature = "minicbor")]
+
pub fn cbor<A>(a: A)
+
where
+
    for<'de> A: Debug + PartialEq + minicbor::Encode + minicbor::Decode<'de>,
+
{
+
    assert_eq!(a, minicbor::decode(&minicbor::to_vec(&a).unwrap()).unwrap())
+
}
+

+
pub fn str<A>(a: A)
+
where
+
    A: Debug + PartialEq + Display + FromStr,
+
    <A as FromStr>::Err: Debug,
+
{
+
    assert_eq!(a, a.to_string().parse().unwrap())
+
}
added crates/radicle-surf/src/test/source.rs
@@ -0,0 +1,222 @@
+
use std::path::PathBuf;
+

+
use crate::{Branch, Glob, Repository};
+
use radicle_git_ref_format::refname;
+
use serde_json::json;
+

+
const GIT_PLATINUM: &str = "../data/git-platinum";
+

+
#[test]
+
fn tree_serialization() {
+
    let repo = Repository::open(GIT_PLATINUM).unwrap();
+
    let tree = repo.tree(refname!("refs/heads/master"), &"src").unwrap();
+

+
    let expected = json!({
+
      "oid": "ed52e9f8dfe1d8b374b2a118c25235349a743dd2",
+
      "entries": [
+
        {
+
          "name": "Eval.hs",
+
          "kind": "blob",
+
          "oid": "7d6240123a8d8ea8a8376610168a0a4bcb96afd0",
+
          "commit": "src/Eval.hs"
+
        },
+
        {
+
          "name": "memory.rs",
+
          "kind": "blob",
+
          "oid": "b84992d24be67536837f5ab45a943f1b3f501878",
+
          "commit": "src/memory.rs"
+
        }
+
      ],
+
      "commit": {
+
        "id": "a0dd9122d33dff2a35f564d564db127152c88e02",
+
        "author": {
+
          "name": "Rūdolfs Ošiņš",
+
          "email": "rudolfs@osins.org",
+
          "time": 1602778504
+
        },
+
        "committer": {
+
          "name": "GitHub",
+
          "email": "noreply@github.com",
+
          "time": 1602778504
+
        },
+
        "summary": "Add files with special characters in their filenames (#5)",
+
        "message": "Add files with special characters in their filenames (#5)\n\n",
+
        "description": "",
+
        "parents": [
+
          "223aaf87d6ea62eef0014857640fd7c8dd0f80b5"
+
        ]
+
      },
+
      "root": "src"
+
    });
+

+
    assert_eq!(
+
        serde_json::to_value(&tree).unwrap(),
+
        expected,
+
        "Got:\n{}",
+
        serde_json::to_string_pretty(&tree).unwrap()
+
    )
+
}
+

+
#[test]
+
fn test_tree_last_commit() {
+
    let repo = Repository::open(GIT_PLATINUM).unwrap();
+
    let tree = repo.tree(refname!("refs/heads/master"), &"src").unwrap();
+
    let last_commit = tree.last_commit(&repo).unwrap();
+
    assert_ne!(*tree.commit(), last_commit);
+
    assert_eq!(
+
        last_commit.id.to_string(),
+
        "a57846bbc8ced6587bf8329fc4bce970eb7b757e"
+
    )
+
}
+

+
#[test]
+
fn repo_tree_empty_branch() {
+
    let repo = Repository::open(GIT_PLATINUM).unwrap();
+
    let rev = Branch::local(refname!("empty-branch"));
+
    let tree = repo.tree(rev, &"").unwrap();
+
    assert_eq!(tree.entries().len(), 0);
+

+
    // Verify the last commit is the empty commit.
+
    assert_eq!(
+
        tree.commit().id.to_string(),
+
        "e972683fe8136bf8a5cb2378cf50303554008049"
+
    );
+
}
+

+
#[test]
+
fn repo_tree() {
+
    let repo = Repository::open(GIT_PLATINUM).unwrap();
+
    let tree = repo
+
        .tree("27acd68c7504755aa11023300890bb85bbd69d45", &"src")
+
        .unwrap();
+
    assert_eq!(tree.entries().len(), 3);
+

+
    let commit_header = tree.commit();
+
    assert_eq!(
+
        commit_header.id.to_string(),
+
        "27acd68c7504755aa11023300890bb85bbd69d45"
+
    );
+

+
    let tree_oid = tree.object_id();
+
    assert_eq!(
+
        tree_oid.to_string(),
+
        "dbd5d80c64a00969f521b96401a315e9481e9561"
+
    );
+

+
    let entries = tree.entries();
+
    assert_eq!(entries.len(), 3);
+
    let entry = &entries[0];
+
    assert!(!entry.is_tree());
+
    assert_eq!(entry.name(), "Eval.hs");
+
    assert_eq!(
+
        entry.object_id().to_string(),
+
        "8c7447d13b907aa994ac3a38317c1e9633bf0732"
+
    );
+
    let commit = entry.commit();
+
    assert_eq!(
+
        commit.id.to_string(),
+
        "27acd68c7504755aa11023300890bb85bbd69d45"
+
    );
+
    let last_commit = entry.last_commit(&repo).unwrap();
+
    assert_eq!(
+
        last_commit.id.to_string(),
+
        "e24124b7538658220b5aaf3b6ef53758f0a106dc"
+
    );
+

+
    // Verify that an empty path works for getting the root tree.
+
    let root_tree = repo
+
        .tree("27acd68c7504755aa11023300890bb85bbd69d45", &"")
+
        .unwrap();
+
    assert_eq!(root_tree.entries().len(), 8);
+
}
+

+
#[test]
+
fn repo_blob() {
+
    let repo = Repository::open(GIT_PLATINUM).unwrap();
+
    let blob = repo
+
        .blob("27acd68c7504755aa11023300890bb85bbd69d45", &"src/memory.rs")
+
        .unwrap();
+

+
    let blob_oid = blob.object_id();
+
    assert_eq!(
+
        blob_oid.to_string(),
+
        "b84992d24be67536837f5ab45a943f1b3f501878"
+
    );
+

+
    let commit_header = blob.commit();
+
    assert_eq!(
+
        commit_header.id.to_string(),
+
        "e24124b7538658220b5aaf3b6ef53758f0a106dc"
+
    );
+

+
    assert!(!blob.is_binary());
+

+
    // Verify the blob content size matches with the file size of "memory.rs"
+
    let content = blob.content();
+
    assert_eq!(blob.size(), 6253);
+

+
    // Verify to_owned().
+
    let blob_owned = blob.to_owned();
+
    assert_eq!(blob_owned.size(), 6253);
+
    assert_eq!(blob.content(), blob_owned.content());
+

+
    // Verify JSON output is the same.
+
    let json_ref = json!({ "content": content }).to_string();
+
    let json_owned = json!( {
+
      "content": blob_owned.content()
+
    })
+
    .to_string();
+
    assert_eq!(json_ref, json_owned);
+
}
+

+
#[test]
+
fn tree_ordering() {
+
    let repo = Repository::open(GIT_PLATINUM).unwrap();
+
    let tree = repo
+
        .tree(refname!("refs/heads/master"), &PathBuf::new())
+
        .unwrap();
+
    assert_eq!(
+
        tree.entries()
+
            .iter()
+
            .map(|entry| entry.name().to_string())
+
            .collect::<Vec<_>>(),
+
        vec![
+
            "bin".to_string(),
+
            "special".to_string(),
+
            "src".to_string(),
+
            "text".to_string(),
+
            "this".to_string(),
+
            ".i-am-well-hidden".to_string(),
+
            ".i-too-am-hidden".to_string(),
+
            "README.md".to_string(),
+
        ]
+
    );
+
}
+

+
#[test]
+
fn commit_branches() {
+
    let repo = Repository::open(GIT_PLATINUM).unwrap();
+
    let init_commit = "d3464e33d75c75c99bfb90fa2e9d16efc0b7d0e3";
+
    let glob = Glob::all_heads().branches().and(Glob::all_remotes());
+
    let branches = repo.revision_branches(init_commit, glob).unwrap();
+

+
    assert_eq!(branches.len(), 11);
+

+
    let refnames: Vec<_> = branches.iter().map(|b| b.refname().to_string()).collect();
+
    assert_eq!(
+
        refnames,
+
        vec![
+
            "refs/heads/dev",
+
            "refs/heads/diff-test",
+
            "refs/heads/empty-branch",
+
            "refs/heads/master",
+
            "refs/remotes/banana/orange/pineapple",
+
            "refs/remotes/banana/pineapple",
+
            "refs/remotes/origin/HEAD",
+
            "refs/remotes/origin/dev",
+
            "refs/remotes/origin/diff-test",
+
            "refs/remotes/origin/empty-branch",
+
            "refs/remotes/origin/master"
+
        ]
+
    );
+
}
added crates/radicle-surf/src/test/submodule.rs
@@ -0,0 +1,87 @@
+
use std::{convert::Infallible, path::Path};
+

+
use super::gen;
+
use crate::tree::EntryKind;
+
use crate::{fs, Branch, Repository};
+
use proptest::{collection, proptest};
+
use radicle_git_metadata::commit::CommitData;
+
use radicle_git_ref_format::refname;
+

+
proptest! {
+
    #[test]
+
    fn test_submodule(
+
        initial in gen::commit::commit(),
+
        commits in collection::vec(gen::commit::commit(), 1..5)
+
    ) {
+
        prop::test_submodule(initial, commits)
+
    }
+

+
    #[ignore = "segfault"]
+
    #[test]
+
    fn test_submodule_bare(
+
        initial in gen::commit::commit(),
+
        commits in collection::vec(gen::commit::commit(), 1..5)
+
    ) {
+
        prop::test_submodule_bare(initial, commits)
+
    }
+

+
}
+

+
mod prop {
+
    use crate::test::gen::commit;
+
    use crate::test::repository;
+

+
    use super::*;
+

+
    pub fn test_submodule(
+
        initial: CommitData<commit::TreeData, Infallible>,
+
        commits: Vec<CommitData<commit::TreeData, Infallible>>,
+
    ) {
+
        let refname = refname!("refs/heads/master");
+
        let author = git2::Signature::try_from(initial.author()).unwrap();
+

+
        let submodule = repository::fixture(&refname, commits).unwrap();
+
        let parent = repository::fixture(&refname, vec![initial]).unwrap();
+

+
        let head = parent.head.expect("missing initial commit");
+
        let sub =
+
            repository::submodule(&parent.repo, &submodule.repo, &refname, head, &author).unwrap();
+

+
        let repo = Repository::open(parent.repo.path()).unwrap();
+
        let branch = Branch::local(refname);
+
        let dir = repo.root_dir(&branch).unwrap();
+

+
        let platinum = dir.find_entry(&sub.path(), &repo).unwrap();
+
        assert!(matches!(&platinum, fs::Entry::Submodule(module) if module.url().is_some()));
+

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

+
    pub fn test_submodule_bare(
+
        initial: CommitData<commit::TreeData, Infallible>,
+
        commits: Vec<CommitData<commit::TreeData, Infallible>>,
+
    ) {
+
        let refname = refname!("refs/heads/master");
+
        let author = git2::Signature::try_from(initial.author()).unwrap();
+

+
        let submodule = repository::fixture(&refname, commits).unwrap();
+
        let parent = repository::bare_fixture(&refname, vec![initial]).unwrap();
+

+
        let head = parent.head.expect("missing initial commit");
+
        let sub =
+
            repository::submodule(&parent.repo, &submodule.repo, &refname, head, &author).unwrap();
+

+
        let repo = Repository::open(parent.repo.path()).unwrap();
+
        let branch = Branch::local(refname);
+
        let dir = repo.root_dir(&branch).unwrap();
+

+
        let platinum = dir.find_entry(&sub.path(), &repo).unwrap();
+
        assert!(matches!(&platinum, fs::Entry::Submodule(module) if module.url().is_some()));
+

+
        let root = repo.tree(&branch, &Path::new("")).unwrap();
+
        let kind = EntryKind::from(platinum);
+
        assert!(root.entries().iter().any(|e| e.entry() == &kind));
+
    }
+
}
added crates/radicle-surf/src/test/threading.rs
@@ -0,0 +1,37 @@
+
use std::sync::{Mutex, MutexGuard};
+

+
use crate::{Branch, Error, Glob, Repository};
+
use radicle_git_ref_format::{component, refname};
+

+
use super::GIT_PLATINUM;
+

+
#[test]
+
fn basic_test() -> Result<(), Error> {
+
    let shared_repo = Mutex::new(Repository::open(GIT_PLATINUM)?);
+
    let locked_repo: MutexGuard<Repository> = shared_repo.lock().unwrap();
+
    let mut branches = locked_repo
+
        .branches(Glob::all_heads().branches().and(Glob::all_remotes()))?
+
        .collect::<Result<Vec<_>, _>>()?;
+
    branches.sort();
+

+
    let origin = component!("origin");
+
    let banana = component!("banana");
+
    assert_eq!(
+
        branches,
+
        vec![
+
            Branch::local(refname!("dev")),
+
            Branch::local(refname!("diff-test")),
+
            Branch::local(refname!("empty-branch")),
+
            Branch::local(refname!("master")),
+
            Branch::remote(banana.clone(), refname!("orange/pineapple")),
+
            Branch::remote(banana, refname!("pineapple")),
+
            Branch::remote(origin.clone(), refname!("HEAD")),
+
            Branch::remote(origin.clone(), refname!("dev")),
+
            Branch::remote(origin.clone(), refname!("diff-test")),
+
            Branch::remote(origin.clone(), refname!("empty-branch")),
+
            Branch::remote(origin, refname!("master")),
+
        ]
+
    );
+

+
    Ok(())
+
}
added crates/radicle-surf/src/tree.rs
@@ -0,0 +1,290 @@
+
//! Represents git object type 'tree', i.e. like directory entries in Unix.
+
//! See git [doc](https://git-scm.com/book/en/v2/Git-Internals-Git-Objects) for more details.
+

+
use std::cmp::Ordering;
+
use std::path::PathBuf;
+

+
use radicle_oid::Oid;
+
#[cfg(feature = "serde")]
+
use serde::{
+
    ser::{SerializeStruct as _, Serializer},
+
    Serialize,
+
};
+
use url::Url;
+

+
use crate::{fs, Commit, Error, Repository};
+

+
/// Represents a tree object as in git. It is essentially the content of
+
/// one directory. Note that multiple directories can have the same content,
+
/// i.e. have the same tree object. Hence this struct does not embed its path.
+
#[derive(Clone, Debug)]
+
pub struct Tree {
+
    /// The object id of this tree.
+
    id: Oid,
+
    /// The first descendant entries for this tree.
+
    entries: Vec<Entry>,
+
    /// The commit object that created this tree object.
+
    commit: Commit,
+
    /// The root path this tree was constructed from.
+
    root: PathBuf,
+
}
+

+
#[derive(Debug, thiserror::Error)]
+
pub enum LastCommitError {
+
    #[error(transparent)]
+
    Repo(#[from] Error),
+
    #[error("could not get the last commit for this entry")]
+
    Missing,
+
}
+

+
impl Tree {
+
    /// Creates a new tree, ensuring the `entries` are sorted.
+
    pub(crate) fn new(id: Oid, mut entries: Vec<Entry>, commit: Commit, root: PathBuf) -> Self {
+
        entries.sort();
+
        Self {
+
            id,
+
            entries,
+
            commit,
+
            root,
+
        }
+
    }
+

+
    pub fn object_id(&self) -> Oid {
+
        self.id
+
    }
+

+
    /// Returns the commit for which this [`Tree`] was constructed from.
+
    pub fn commit(&self) -> &Commit {
+
        &self.commit
+
    }
+

+
    /// Returns the commit that last touched this [`Tree`].
+
    pub fn last_commit(&self, repo: &Repository) -> Result<Commit, LastCommitError> {
+
        repo.last_commit(&self.root, self.commit().clone())?
+
            .ok_or(LastCommitError::Missing)
+
    }
+

+
    /// Returns the entries of the tree.
+
    pub fn entries(&self) -> &Vec<Entry> {
+
        &self.entries
+
    }
+
}
+

+
#[cfg(feature = "serde")]
+
impl Serialize for Tree {
+
    /// Sample output:
+
    /// (for `<entry_1>` and `<entry_2>` sample output, see [`Entry`])
+
    /// ```
+
    /// {
+
    ///   "entries": [
+
    ///     { <entry_1> },
+
    ///     { <entry_2> },
+
    ///   ],
+
    ///   "root": "src/foo",
+
    ///   "commit": {
+
    ///     "author": {
+
    ///       "email": "foobar@gmail.com",
+
    ///       "name": "Foo Bar"
+
    ///     },
+
    ///     "committer": {
+
    ///       "email": "noreply@github.com",
+
    ///       "name": "GitHub"
+
    ///     },
+
    ///     "committerTime": 1582198877,
+
    ///     "description": "A sample commit.",
+
    ///     "sha1": "b57846bbc8ced6587bf8329fc4bce970eb7b757e",
+
    ///     "summary": "Add a new sample"
+
    ///   },
+
    ///   "oid": "dd52e9f8dfe1d8b374b2a118c25235349a743dd2"
+
    /// }
+
    /// ```
+
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+
    where
+
        S: Serializer,
+
    {
+
        const FIELDS: usize = 4;
+
        let mut state = serializer.serialize_struct("Tree", FIELDS)?;
+
        state.serialize_field("oid", &self.id)?;
+
        state.serialize_field("entries", &self.entries)?;
+
        state.serialize_field("commit", &self.commit)?;
+
        state.serialize_field("root", &self.root)?;
+
        state.end()
+
    }
+
}
+

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

+
impl PartialOrd for EntryKind {
+
    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
+
        Some(self.cmp(other))
+
    }
+
}
+

+
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,
+
        }
+
    }
+
}
+

+
/// An entry that can be found in a tree.
+
///
+
/// # Ordering
+
///
+
/// The ordering of a [`Entry`] is first by its `entry` where
+
/// [`EntryKind::Tree`]s come before [`EntryKind::Blob`]. If both kinds
+
/// are equal then they are next compared by the lexicographical ordering
+
/// of their `name`s.
+
#[derive(Clone, Debug)]
+
pub struct Entry {
+
    name: String,
+
    entry: EntryKind,
+
    path: PathBuf,
+
    /// The commit from which this entry was constructed from.
+
    commit: Commit,
+
}
+

+
impl Entry {
+
    pub(crate) fn new(name: String, path: PathBuf, entry: EntryKind, commit: Commit) -> Self {
+
        Self {
+
            name,
+
            entry,
+
            path,
+
            commit,
+
        }
+
    }
+

+
    pub fn name(&self) -> &str {
+
        &self.name
+
    }
+

+
    /// The full path to this entry from the root of the Git repository
+
    pub fn path(&self) -> &PathBuf {
+
        &self.path
+
    }
+

+
    pub fn entry(&self) -> &EntryKind {
+
        &self.entry
+
    }
+

+
    pub fn is_tree(&self) -> bool {
+
        matches!(self.entry, EntryKind::Tree(_))
+
    }
+

+
    pub fn commit(&self) -> &Commit {
+
        &self.commit
+
    }
+

+
    pub fn object_id(&self) -> Oid {
+
        match self.entry {
+
            EntryKind::Blob(id) => id,
+
            EntryKind::Tree(id) => id,
+
            EntryKind::Submodule { id, .. } => id,
+
        }
+
    }
+

+
    /// Returns the commit that last touched this [`Entry`].
+
    pub fn last_commit(&self, repo: &Repository) -> Result<Commit, LastCommitError> {
+
        repo.last_commit(&self.path, self.commit.clone())?
+
            .ok_or(LastCommitError::Missing)
+
    }
+
}
+

+
// To support `sort`.
+
impl Ord for Entry {
+
    fn cmp(&self, other: &Self) -> Ordering {
+
        self.entry
+
            .cmp(&other.entry)
+
            .then(self.name.cmp(&other.name))
+
    }
+
}
+

+
impl PartialOrd for Entry {
+
    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
+
        Some(self.cmp(other))
+
    }
+
}
+

+
impl PartialEq for Entry {
+
    fn eq(&self, other: &Self) -> bool {
+
        self.entry == other.entry && self.name == other.name
+
    }
+
}
+

+
impl Eq for Entry {}
+

+
impl From<fs::Entry> for EntryKind {
+
    fn from(entry: fs::Entry) -> Self {
+
        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(),
+
            },
+
        }
+
    }
+
}
+

+
#[cfg(feature = "serde")]
+
impl Serialize for Entry {
+
    /// Sample output:
+
    /// ```json
+
    ///  {
+
    ///     "kind": "blob",
+
    ///     "commit": {
+
    ///       "author": {
+
    ///         "email": "foobar@gmail.com",
+
    ///         "name": "Foo Bar"
+
    ///       },
+
    ///       "committer": {
+
    ///         "email": "noreply@github.com",
+
    ///         "name": "GitHub"
+
    ///       },
+
    ///       "committerTime": 1578309972,
+
    ///       "description": "This is a sample file",
+
    ///       "sha1": "2873745c8f6ffb45c990eb23b491d4b4b6182f95",
+
    ///       "summary": "Add a new sample"
+
    ///     },
+
    ///     "path": "src/foo/Sample.rs",
+
    ///     "name": "Sample.rs",
+
    ///     "oid": "6d6240123a8d8ea8a8376610168a0a4bcb96afd0"
+
    ///   },
+
    /// ```
+
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+
    where
+
        S: Serializer,
+
    {
+
        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("commit", &self.path)?;
+
        state.end()
+
    }
+
}
added crates/radicle-surf/t/Cargo.toml
@@ -0,0 +1,23 @@
+
[package]
+
name = "radicle-surf-test"
+
version = "0.1.0"
+
edition.workspace = true
+
license.workspace = true
+

+
publish = false
+

+
[lib]
+
test = true
+

+
[features]
+
test = []
+

+
[dev-dependencies]
+
nonempty = "0.5"
+
pretty_assertions = "1.3.0"
+
proptest = "1"
+
serde_json = "1"
+
url = "2.5"
+

+
[dev-dependencies.test-helpers]
+
path = "../../test/test-helpers"