Radish alpha
r
rad:z6cFWeWpnZNHh9rUW8phgA3b5yGt
Git libraries for Radicle
Radicle
Git
copy radicle-surf and radicle-source into this repo.
Han Xu committed 3 years ago
commit 58d69ebf7f441cfde043593fc7efbc8912033759
parent ce7dc38
49 files changed +9783 -1
modified .github/workflows/ci.yaml
@@ -82,6 +82,13 @@ jobs:
    continue-on-error: ${{ matrix.toolchain == 'nightly' }}
    steps:
      - uses: actions/checkout@master
+
        with:
+
          fetch-depth: 0
+
      # The 'main' branch is needed for radicle-surf vcs::git::tests::test_submodule_failure,
+
      # however actions/checkout uses the default branch 'master' at this moment.
+
      # We will improve the test soon to remove this need.
+
      - run: git branch main
+
      - run: git branch -u origin/main main
      - uses: actions-rs/toolchain@v1
        with:
          profile: minimal
@@ -111,6 +118,10 @@ jobs:
    needs: build-macos
    steps:
      - uses: actions/checkout@master
+
        with:
+
          fetch-depth: 0
+
      - run: git branch main
+
      - run: git branch -u origin/main main
      - uses: actions-rs/toolchain@v1
        with:
          profile: minimal
modified .gitignore
@@ -4,3 +4,7 @@ tags
Cargo.lock
# These are backup files generated by rustfmt
**/*.rs.bk
+

+
# This is an inner git repo unpacked (if necessary) from the archive.
+
# Git doesn't like nested repos, and we don't really want to use submodules.
+
radicle-surf/data/git-platinum
modified Cargo.toml
@@ -6,7 +6,9 @@ members = [
  "radicle-git-ext",
  "radicle-git-types",
  "radicle-macros",
+
  "radicle-source",
  "radicle-std-ext",
+
  "radicle-surf",
  # TODO: port gitd-lib over
  # "cli/gitd-lib",
  "test",
modified deny.toml
@@ -47,12 +47,63 @@ allow = [
    "CC0-1.0",
    "GPL-3.0",
    "MIT",
+
    "Unicode-DFS-2016",
    "Unlicense",
]
# List of explictly disallowed licenses
# See https://spdx.org/licenses/ for list of possible licenses
# [possible values: any SPDX 3.7 short identifier (+ optional exception)].
-
deny = []
+
deny = [
+
    # As per https://www.gnu.org/licenses/license-list.html#GPLIncompatibleLicenses
+
    "AGPL-1.0",
+
# fails to parse:
+
#   "AFL-1.0",
+
#   "AFL-1.2",
+
#   "AFL-2.0",
+
#   "AFL-2.1",
+
#   "AFL-3.0",
+
    "Apache-1.0",
+
    "Apache-1.1",
+
    "APSL-2.0",
+
    "BitTorrent-1.0",
+
    "BitTorrent-1.1",
+
    "BSD-4-Clause",
+
    "CECILL-B",
+
    "CECILL-C",
+
    "CDDL-1.0",
+
    "CDDL-1.1",
+
    "CNRI-Python",
+
    "CPAL-1.0",
+
    "CPL-1.0",
+
    "Condor-1.1",
+
    "EPL-1.0",
+
    "EPL-2.0",
+
    "EUPL-1.1",
+
    "EUPL-1.2",
+
    "gnuplot",
+
    "IPL-1.0",
+
    "LPPL-1.3a",
+
    "LPPL-1.2",
+
    "LPL-1.02",
+
    "MS-PL",
+
    "MS-RL",
+
    "MPL-1.1",
+
    "NOSL",
+
    "NPL-1.0",
+
    "NPL-1.1",
+
    "Nokia",
+
    "OpenSSL",
+
    "PHP-3.01",
+
    "QPL-1.0",
+
    "RPSL-1.0",
+
    "SISSL",
+
    "SPL-1.0",
+
    "xinetd",
+
    "YPL-1.1",
+
    "Zend-2.0",
+
    "Zimbra-1.3",
+
    "ZPL-1.1"
+
]
# Lint level for licenses considered copyleft
copyleft = "allow"
# Blanket approval or denial for OSI-approved or FSF Free/Libre licenses
added radicle-source/Cargo.toml
@@ -0,0 +1,31 @@
+
[package]
+
name = "radicle-source"
+
description = "A high level API for browsing source files"
+
version = "0.4.0"
+
authors = ["The Radicle Team <dev@radicle.xyz>"]
+
edition = "2018"
+
homepage = "https://github.com/radicle-dev/radicle-surf"
+
repository = "https://github.com/radicle-dev/radicle-surf"
+
license = "GPL-3.0-or-later"
+

+
[features]
+
syntax = ["syntect"]
+

+
[dependencies]
+
base64 = "0.13"
+
log = "0.4"
+
lazy_static = "1.4"
+
nonempty = "0.6"
+
serde = { version = "1.0", features = [ "derive" ] }
+
syntect = { version = "4.2", optional = true }
+
thiserror = "1.0"
+

+
[dependencies.git2]
+
version = ">= 0.12"
+
default-features = false
+
features = []
+

+
[dependencies.radicle-surf]
+
version = "^0.8.0"
+
features = ["serialize"]
+
path = "../radicle-surf"
added radicle-source/src/branch.rs
@@ -0,0 +1,108 @@
+
// This file is part of radicle-surf
+
// <https://github.com/radicle-dev/radicle-surf>
+
//
+
// Copyright (C) 2019-2020 The Radicle Team <dev@radicle.xyz>
+
//
+
// This program is free software: you can redistribute it and/or modify
+
// it under the terms of the GNU General Public License version 3 or
+
// later as published by the Free Software Foundation.
+
//
+
// This program is distributed in the hope that it will be useful,
+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+
// GNU General Public License for more details.
+
//
+
// You should have received a copy of the GNU General Public License
+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
+

+
use std::fmt;
+

+
use serde::{Deserialize, Serialize};
+

+
use radicle_surf::vcs::git::{self, Browser, RefScope};
+

+
use crate::error::Error;
+

+
/// Branch name representation.
+
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Deserialize, Serialize)]
+
pub struct Branch(pub(crate) String);
+

+
impl From<String> for Branch {
+
    fn from(name: String) -> Self {
+
        Self(name)
+
    }
+
}
+

+
impl From<git::Branch> for Branch {
+
    fn from(branch: git::Branch) -> Self {
+
        Self(branch.name.to_string())
+
    }
+
}
+

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

+
/// Given a project id to a repo returns the list of branches.
+
///
+
/// # Errors
+
///
+
/// Will return [`Error`] if the project doesn't exist or the surf interaction
+
/// fails.
+
pub fn branches(browser: &Browser<'_>, filter: RefScope) -> Result<Vec<Branch>, Error> {
+
    let mut branches = browser
+
        .list_branches(filter)?
+
        .into_iter()
+
        .map(|b| Branch(b.name.name().to_string()))
+
        .collect::<Vec<Branch>>();
+

+
    branches.sort();
+

+
    Ok(branches)
+
}
+

+
/// Information about a locally checked out repository.
+
#[derive(Deserialize, Serialize)]
+
pub struct LocalState {
+
    /// List of branches.
+
    branches: Vec<Branch>,
+
}
+

+
/// Given a path to a repo returns the list of branches and if it is managed by
+
/// coco.
+
///
+
/// # Errors
+
///
+
/// Will return [`Error`] if the repository doesn't exist.
+
pub fn local_state(repo_path: &str, default_branch: &str) -> Result<LocalState, Error> {
+
    let repo = git2::Repository::open(repo_path).map_err(git::error::Error::from)?;
+
    let first_branch = repo
+
        .branches(Some(git2::BranchType::Local))
+
        .map_err(git::error::Error::from)?
+
        .filter_map(|branch_result| {
+
            let (branch, _) = branch_result.ok()?;
+
            let name = branch.name().ok()?;
+
            name.map(String::from)
+
        })
+
        .min()
+
        .ok_or(Error::NoBranches)?;
+

+
    let repo = git::Repository::new(repo_path)?;
+

+
    let browser = match Browser::new(&repo, git::Branch::local(default_branch)) {
+
        Ok(browser) => browser,
+
        Err(_) => Browser::new(&repo, git::Branch::local(&first_branch))?,
+
    };
+

+
    let mut branches = browser
+
        .list_branches(RefScope::Local)?
+
        .into_iter()
+
        .map(|b| Branch(b.name.name().to_string()))
+
        .collect::<Vec<Branch>>();
+

+
    branches.sort();
+

+
    Ok(LocalState { branches })
+
}
added radicle-source/src/commit.rs
@@ -0,0 +1,243 @@
+
// This file is part of radicle-surf
+
// <https://github.com/radicle-dev/radicle-surf>
+
//
+
// Copyright (C) 2019-2020 The Radicle Team <dev@radicle.xyz>
+
//
+
// This program is free software: you can redistribute it and/or modify
+
// it under the terms of the GNU General Public License version 3 or
+
// later as published by the Free Software Foundation.
+
//
+
// This program is distributed in the hope that it will be useful,
+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+
// GNU General Public License for more details.
+
//
+
// You should have received a copy of the GNU General Public License
+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
+

+
use std::convert::TryFrom as _;
+

+
use serde::{
+
    ser::{SerializeStruct as _, Serializer},
+
    Serialize,
+
};
+

+
use radicle_surf::{
+
    diff,
+
    vcs::git::{self, Browser, Rev},
+
};
+

+
use crate::{branch::Branch, error::Error, person::Person, revision::Revision};
+

+
/// Commit statistics.
+
#[derive(Clone, Serialize)]
+
pub struct Stats {
+
    /// Additions.
+
    pub additions: u64,
+
    /// Deletions.
+
    pub deletions: u64,
+
}
+

+
/// Representation of a changeset between two revs.
+
#[derive(Clone, Serialize)]
+
pub struct Commit {
+
    /// The commit header.
+
    pub header: Header,
+
    /// The change statistics for this commit.
+
    pub stats: Stats,
+
    /// The changeset introduced by this commit.
+
    pub diff: diff::Diff,
+
    /// The list of branches this commit belongs to.
+
    pub branches: Vec<Branch>,
+
}
+

+
/// Representation of a code commit.
+
#[derive(Clone)]
+
pub struct Header {
+
    /// Identifier of the commit in the form of a sha1 hash. Often referred to
+
    /// as oid or object id.
+
    pub sha1: git2::Oid,
+
    /// The author of the commit.
+
    pub author: Person,
+
    /// The summary of the commit message body.
+
    pub summary: String,
+
    /// The entire commit message body.
+
    pub message: String,
+
    /// The committer of the commit.
+
    pub committer: Person,
+
    /// The recorded time of the committer signature. This is a convenience
+
    /// alias until we expose the actual author and commiter signatures.
+
    pub committer_time: git2::Time,
+
}
+

+
impl Header {
+
    /// 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()
+
    }
+
}
+

+
impl From<&git::Commit> for Header {
+
    fn from(commit: &git::Commit) -> Self {
+
        Self {
+
            sha1: commit.id,
+
            author: Person {
+
                name: commit.author.name.clone(),
+
                email: commit.author.email.clone(),
+
            },
+
            summary: commit.summary.clone(),
+
            message: commit.message.clone(),
+
            committer: Person {
+
                name: commit.committer.name.clone(),
+
                email: commit.committer.email.clone(),
+
            },
+
            committer_time: commit.committer.time,
+
        }
+
    }
+
}
+

+
impl Serialize for Header {
+
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+
    where
+
        S: Serializer,
+
    {
+
        let mut state = serializer.serialize_struct("Header", 6)?;
+
        state.serialize_field("sha1", &self.sha1.to_string())?;
+
        state.serialize_field("author", &self.author)?;
+
        state.serialize_field("summary", &self.summary)?;
+
        state.serialize_field("description", &self.description())?;
+
        state.serialize_field("committer", &self.committer)?;
+
        state.serialize_field("committerTime", &self.committer_time.seconds())?;
+
        state.end()
+
    }
+
}
+

+
/// A selection of commit headers and their statistics.
+
#[derive(Serialize)]
+
pub struct Commits {
+
    /// The commit headers
+
    pub headers: Vec<Header>,
+
    /// The statistics for the commit headers
+
    pub stats: radicle_surf::vcs::git::Stats,
+
}
+

+
/// Retrieves a [`Commit`].
+
///
+
/// # Errors
+
///
+
/// Will return [`Error`] if the project doesn't exist or the surf interaction
+
/// fails.
+
pub fn commit(browser: &mut Browser<'_>, sha1: git2::Oid) -> Result<Commit, Error> {
+
    browser.commit(sha1)?;
+

+
    let history = browser.get();
+
    let commit = history.first();
+

+
    let diff = if let Some(parent) = commit.parents.first() {
+
        browser.diff(*parent, sha1)?
+
    } else {
+
        browser.initial_diff(sha1)?
+
    };
+

+
    let mut deletions = 0;
+
    let mut additions = 0;
+

+
    for file in &diff.modified {
+
        if let diff::FileDiff::Plain { ref hunks } = file.diff {
+
            for hunk in hunks.iter() {
+
                for line in &hunk.lines {
+
                    match line {
+
                        diff::LineDiff::Addition { .. } => additions += 1,
+
                        diff::LineDiff::Deletion { .. } => deletions += 1,
+
                        _ => {},
+
                    }
+
                }
+
            }
+
        }
+
    }
+

+
    for file in &diff.created {
+
        if let diff::FileDiff::Plain { ref hunks } = file.diff {
+
            for hunk in hunks.iter() {
+
                for line in &hunk.lines {
+
                    if let diff::LineDiff::Addition { .. } = line {
+
                        additions += 1
+
                    }
+
                }
+
            }
+
        }
+
    }
+

+
    for file in &diff.deleted {
+
        if let diff::FileDiff::Plain { ref hunks } = file.diff {
+
            for hunk in hunks.iter() {
+
                for line in &hunk.lines {
+
                    if let diff::LineDiff::Deletion { .. } = line {
+
                        deletions += 1
+
                    }
+
                }
+
            }
+
        }
+
    }
+

+
    let branches = browser
+
        .revision_branches(sha1)?
+
        .into_iter()
+
        .map(Branch::from)
+
        .collect();
+

+
    Ok(Commit {
+
        header: Header::from(commit),
+
        stats: Stats {
+
            additions,
+
            deletions,
+
        },
+
        diff,
+
        branches,
+
    })
+
}
+

+
/// Retrieves the [`Header`] for the given `sha1`.
+
///
+
/// # Errors
+
///
+
/// Will return [`Error`] if the project doesn't exist or the surf interaction
+
/// fails.
+
pub fn header(browser: &mut Browser<'_>, sha1: git2::Oid) -> Result<Header, Error> {
+
    browser.commit(sha1)?;
+

+
    let history = browser.get();
+
    let commit = history.first();
+

+
    Ok(Header::from(commit))
+
}
+

+
/// Retrieves the [`Commit`] history for the given `revision`.
+
///
+
/// # Errors
+
///
+
/// Will return [`Error`] if the project doesn't exist or the surf interaction
+
/// fails.
+
pub fn commits<P>(
+
    browser: &mut Browser<'_>,
+
    maybe_revision: Option<Revision<P>>,
+
) -> Result<Commits, Error>
+
where
+
    P: ToString,
+
{
+
    let maybe_revision = maybe_revision.map(Rev::try_from).transpose()?;
+

+
    if let Some(revision) = maybe_revision {
+
        browser.rev(revision)?;
+
    }
+

+
    let headers = browser.get().iter().map(Header::from).collect();
+
    let stats = browser.get_stats()?;
+

+
    Ok(Commits { headers, stats })
+
}
added radicle-source/src/error.rs
@@ -0,0 +1,46 @@
+
// This file is part of radicle-surf
+
// <https://github.com/radicle-dev/radicle-surf>
+
//
+
// Copyright (C) 2019-2020 The Radicle Team <dev@radicle.xyz>
+
//
+
// This program is free software: you can redistribute it and/or modify
+
// it under the terms of the GNU General Public License version 3 or
+
// later as published by the Free Software Foundation.
+
//
+
// This program is distributed in the hope that it will be useful,
+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+
// GNU General Public License for more details.
+
//
+
// You should have received a copy of the GNU General Public License
+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
+

+
use radicle_surf::{file_system, git};
+

+
/// An error occurred when interacting with [`radicle_surf`] for browsing source
+
/// code.
+
#[derive(Debug, thiserror::Error)]
+
pub enum Error {
+
    /// We expect at least one [`crate::revision::Revisions`] when looking at a
+
    /// project, however the computation found none.
+
    #[error(
+
        "while trying to get user revisions we could not find any, there should be at least one"
+
    )]
+
    EmptyRevisions,
+

+
    /// An error occurred during a [`radicle_surf::file_system`] operation.
+
    #[error(transparent)]
+
    FileSystem(#[from] file_system::Error),
+

+
    /// An error occurred during a [`radicle_surf::git`] operation.
+
    #[error(transparent)]
+
    Git(#[from] git::error::Error),
+

+
    /// When trying to query a repositories branches, but there are none.
+
    #[error("the repository has no branches")]
+
    NoBranches,
+

+
    /// Trying to find a file path which could not be found.
+
    #[error("the path '{0}' was not found")]
+
    PathNotFound(file_system::Path),
+
}
added radicle-source/src/lib.rs
@@ -0,0 +1,51 @@
+
// This file is part of radicle-surf
+
// <https://github.com/radicle-dev/radicle-surf>
+
//
+
// Copyright (C) 2019-2020 The Radicle Team <dev@radicle.xyz>
+
//
+
// This program is free software: you can redistribute it and/or modify
+
// it under the terms of the GNU General Public License version 3 or
+
// later as published by the Free Software Foundation.
+
//
+
// This program is distributed in the hope that it will be useful,
+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+
// GNU General Public License for more details.
+
//
+
// You should have received a copy of the GNU General Public License
+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
+

+
//! Source code related functionality.
+

+
/// To avoid incompatible versions of `radicle-surf`, `radicle-source`
+
/// re-exports the package under the `surf` alias.
+
pub use radicle_surf as surf;
+

+
pub mod branch;
+
pub use branch::{branches, local_state, Branch, LocalState};
+

+
pub mod commit;
+
pub use commit::{commit, commits, Commit};
+

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

+
pub mod object;
+
pub use object::{blob, tree, Blob, BlobContent, Info, ObjectType, Tree};
+

+
pub mod oid;
+
pub use oid::Oid;
+

+
pub mod person;
+
pub use person::Person;
+

+
pub mod revision;
+
pub use revision::Revision;
+

+
#[cfg(feature = "syntax")]
+
pub mod syntax;
+
#[cfg(feature = "syntax")]
+
pub use syntax::SYNTAX_SET;
+

+
pub mod tag;
+
pub use tag::{tags, Tag};
added radicle-source/src/object.rs
@@ -0,0 +1,76 @@
+
// This file is part of radicle-surf
+
// <https://github.com/radicle-dev/radicle-surf>
+
//
+
// Copyright (C) 2019-2020 The Radicle Team <dev@radicle.xyz>
+
//
+
// This program is free software: you can redistribute it and/or modify
+
// it under the terms of the GNU General Public License version 3 or
+
// later as published by the Free Software Foundation.
+
//
+
// This program is distributed in the hope that it will be useful,
+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+
// GNU General Public License for more details.
+
//
+
// You should have received a copy of the GNU General Public License
+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
+

+
use serde::{
+
    ser::{SerializeStruct as _, Serializer},
+
    Serialize,
+
};
+

+
pub mod blob;
+
pub use blob::{blob, Blob, BlobContent};
+

+
pub mod tree;
+
pub use tree::{tree, Tree, TreeEntry};
+

+
use crate::commit;
+

+
/// Git object types.
+
///
+
/// `shafiul.github.io/gitbook/1_the_git_object_model.html`
+
#[derive(Debug, Eq, Ord, PartialOrd, PartialEq)]
+
pub enum ObjectType {
+
    /// References a list of other trees and blobs.
+
    Tree,
+
    /// Used to store file data.
+
    Blob,
+
}
+

+
impl Serialize for ObjectType {
+
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+
    where
+
        S: Serializer,
+
    {
+
        match self {
+
            Self::Blob => serializer.serialize_unit_variant("ObjectType", 0, "BLOB"),
+
            Self::Tree => serializer.serialize_unit_variant("ObjectType", 1, "TREE"),
+
        }
+
    }
+
}
+

+
/// Set of extra information we carry for blob and tree objects returned from
+
/// the API.
+
pub struct Info {
+
    /// Name part of an object.
+
    pub name: String,
+
    /// The type of the object.
+
    pub object_type: ObjectType,
+
    /// The last commmit that touched this object.
+
    pub last_commit: Option<commit::Header>,
+
}
+

+
impl Serialize for Info {
+
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+
    where
+
        S: Serializer,
+
    {
+
        let mut state = serializer.serialize_struct("Info", 3)?;
+
        state.serialize_field("name", &self.name)?;
+
        state.serialize_field("objectType", &self.object_type)?;
+
        state.serialize_field("lastCommit", &self.last_commit)?;
+
        state.end()
+
    }
+
}
added radicle-source/src/object/blob.rs
@@ -0,0 +1,218 @@
+
// This file is part of radicle-surf
+
// <https://github.com/radicle-dev/radicle-surf>
+
//
+
// Copyright (C) 2019-2020 The Radicle Team <dev@radicle.xyz>
+
//
+
// This program is free software: you can redistribute it and/or modify
+
// it under the terms of the GNU General Public License version 3 or
+
// later as published by the Free Software Foundation.
+
//
+
// This program is distributed in the hope that it will be useful,
+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+
// GNU General Public License for more details.
+
//
+
// You should have received a copy of the GNU General Public License
+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
+

+
use std::{
+
    convert::TryFrom as _,
+
    str::{self, FromStr as _},
+
};
+

+
use serde::{
+
    ser::{SerializeStruct as _, Serializer},
+
    Serialize,
+
};
+

+
use radicle_surf::{
+
    file_system,
+
    vcs::git::{Browser, Rev},
+
};
+

+
use crate::{
+
    commit,
+
    error::Error,
+
    object::{Info, ObjectType},
+
    revision::Revision,
+
};
+

+
#[cfg(feature = "syntax")]
+
use crate::syntax;
+

+
/// File data abstraction.
+
pub struct Blob {
+
    /// Actual content of the file, if the content is ASCII.
+
    pub content: BlobContent,
+
    /// Extra info for the file.
+
    pub info: Info,
+
    /// Absolute path to the object from the root of the repo.
+
    pub path: String,
+
}
+

+
impl Blob {
+
    /// Indicates if the content of the [`Blob`] is binary.
+
    #[must_use]
+
    pub fn is_binary(&self) -> bool {
+
        matches!(self.content, BlobContent::Binary(_))
+
    }
+

+
    /// Indicates if the content of the [`Blob`] is HTML.
+
    #[must_use]
+
    pub const fn is_html(&self) -> bool {
+
        matches!(self.content, BlobContent::Html(_))
+
    }
+
}
+

+
impl Serialize for Blob {
+
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+
    where
+
        S: Serializer,
+
    {
+
        let mut state = serializer.serialize_struct("Blob", 5)?;
+
        state.serialize_field("binary", &self.is_binary())?;
+
        state.serialize_field("html", &self.is_html())?;
+
        state.serialize_field("content", &self.content)?;
+
        state.serialize_field("info", &self.info)?;
+
        state.serialize_field("path", &self.path)?;
+
        state.end()
+
    }
+
}
+

+
/// Variants of blob content.
+
#[derive(PartialEq, Eq)]
+
pub enum BlobContent {
+
    /// Content is plain text and can be passed as a string.
+
    Plain(String),
+
    /// Content is syntax-highlighted HTML.
+
    ///
+
    /// Note that is necessary to enable the `syntax` feature flag for this
+
    /// variant to be constructed. Use `highlighting::blob`, instead of
+
    /// [`blob`] to get highlighted content.
+
    Html(String),
+
    /// Content is binary and needs special treatment.
+
    Binary(Vec<u8>),
+
}
+

+
impl Serialize for BlobContent {
+
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+
    where
+
        S: Serializer,
+
    {
+
        match self {
+
            Self::Plain(content) | Self::Html(content) => serializer.serialize_str(content),
+
            Self::Binary(bytes) => {
+
                let encoded = base64::encode(bytes);
+
                serializer.serialize_str(&encoded)
+
            },
+
        }
+
    }
+
}
+

+
/// Returns the [`Blob`] for a file at `revision` under `path`.
+
///
+
/// # Errors
+
///
+
/// Will return [`Error`] if the project doesn't exist or a surf interaction
+
/// fails.
+
pub fn blob<P>(
+
    browser: &mut Browser,
+
    maybe_revision: Option<Revision<P>>,
+
    path: &str,
+
) -> Result<Blob, Error>
+
where
+
    P: ToString,
+
{
+
    make_blob(browser, maybe_revision, path, content)
+
}
+

+
fn make_blob<P, C>(
+
    browser: &mut Browser,
+
    maybe_revision: Option<Revision<P>>,
+
    path: &str,
+
    content: C,
+
) -> Result<Blob, Error>
+
where
+
    P: ToString,
+
    C: FnOnce(&[u8]) -> BlobContent,
+
{
+
    let maybe_revision = maybe_revision.map(Rev::try_from).transpose()?;
+
    if let Some(revision) = maybe_revision {
+
        browser.rev(revision)?;
+
    }
+

+
    let root = browser.get_directory()?;
+
    let p = file_system::Path::from_str(path)?;
+

+
    let file = root
+
        .find_file(p.clone())
+
        .ok_or_else(|| Error::PathNotFound(p.clone()))?;
+

+
    let mut commit_path = file_system::Path::root();
+
    commit_path.append(p.clone());
+

+
    let last_commit = browser
+
        .last_commit(commit_path)?
+
        .map(|c| commit::Header::from(&c));
+
    let (_rest, last) = p.split_last();
+

+
    let content = content(&file.contents);
+

+
    Ok(Blob {
+
        content,
+
        info: Info {
+
            name: last.to_string(),
+
            object_type: ObjectType::Blob,
+
            last_commit,
+
        },
+
        path: path.to_string(),
+
    })
+
}
+

+
/// Return a [`BlobContent`] given a byte slice.
+
fn content(content: &[u8]) -> BlobContent {
+
    match str::from_utf8(content) {
+
        Ok(utf8) => BlobContent::Plain(utf8.to_owned()),
+
        Err(_) => BlobContent::Binary(content.to_owned()),
+
    }
+
}
+

+
#[cfg(feature = "syntax")]
+
pub mod highlighting {
+
    use super::*;
+

+
    /// Returns the [`Blob`] for a file at `revision` under `path`.
+
    ///
+
    /// # Errors
+
    ///
+
    /// Will return [`Error`] if the project doesn't exist or a surf interaction
+
    /// fails.
+
    pub fn blob<P>(
+
        browser: &mut Browser,
+
        maybe_revision: Option<Revision<P>>,
+
        path: &str,
+
        theme: Option<&str>,
+
    ) -> Result<Blob, Error>
+
    where
+
        P: ToString,
+
    {
+
        make_blob(browser, maybe_revision, path, |contents| {
+
            content(path, contents, theme)
+
        })
+
    }
+

+
    /// Return a [`BlobContent`] given a file path, content and theme. Attempts
+
    /// to perform syntax highlighting when the theme is `Some`.
+
    fn content(path: &str, content: &[u8], theme_name: Option<&str>) -> BlobContent {
+
        let content = match str::from_utf8(content) {
+
            Ok(content) => content,
+
            Err(_) => return BlobContent::Binary(content.to_owned()),
+
        };
+

+
        match theme_name {
+
            None => BlobContent::Plain(content.to_owned()),
+
            Some(theme) => syntax::highlight(path, content, theme)
+
                .map_or_else(|| BlobContent::Plain(content.to_owned()), BlobContent::Html),
+
        }
+
    }
+
}
added radicle-source/src/object/tree.rs
@@ -0,0 +1,177 @@
+
// This file is part of radicle-surf
+
// <https://github.com/radicle-dev/radicle-surf>
+
//
+
// Copyright (C) 2019-2020 The Radicle Team <dev@radicle.xyz>
+
//
+
// This program is free software: you can redistribute it and/or modify
+
// it under the terms of the GNU General Public License version 3 or
+
// later as published by the Free Software Foundation.
+
//
+
// This program is distributed in the hope that it will be useful,
+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+
// GNU General Public License for more details.
+
//
+
// You should have received a copy of the GNU General Public License
+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
+

+
use std::{convert::TryFrom as _, str::FromStr as _};
+

+
use serde::{
+
    ser::{SerializeStruct as _, Serializer},
+
    Serialize,
+
};
+

+
use radicle_surf::{
+
    file_system,
+
    vcs::git::{Browser, Rev},
+
};
+

+
use crate::{
+
    commit,
+
    error::Error,
+
    object::{Info, ObjectType},
+
    revision::Revision,
+
};
+

+
/// Result of a directory listing, carries other trees and blobs.
+
pub struct Tree {
+
    /// Absolute path to the tree object from the repo root.
+
    pub path: String,
+
    /// Entries listed in that tree result.
+
    pub entries: Vec<TreeEntry>,
+
    /// Extra info for the tree object.
+
    pub info: Info,
+
}
+

+
impl Serialize for Tree {
+
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+
    where
+
        S: Serializer,
+
    {
+
        let mut state = serializer.serialize_struct("Tree", 3)?;
+
        state.serialize_field("path", &self.path)?;
+
        state.serialize_field("entries", &self.entries)?;
+
        state.serialize_field("info", &self.info)?;
+
        state.end()
+
    }
+
}
+

+
// TODO(xla): Ensure correct by construction.
+
/// Entry in a Tree result.
+
pub struct TreeEntry {
+
    /// Extra info for the entry.
+
    pub info: Info,
+
    /// Absolute path to the object from the root of the repo.
+
    pub path: String,
+
}
+

+
impl Serialize for TreeEntry {
+
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+
    where
+
        S: Serializer,
+
    {
+
        let mut state = serializer.serialize_struct("Tree", 2)?;
+
        state.serialize_field("path", &self.path)?;
+
        state.serialize_field("info", &self.info)?;
+
        state.end()
+
    }
+
}
+

+
/// Retrieve the [`Tree`] for the given `revision` and directory `prefix`.
+
///
+
/// # Errors
+
///
+
/// Will return [`Error`] if any of the surf interactions fail.
+
pub fn tree<P>(
+
    browser: &mut Browser<'_>,
+
    maybe_revision: Option<Revision<P>>,
+
    maybe_prefix: Option<String>,
+
) -> Result<Tree, Error>
+
where
+
    P: ToString,
+
{
+
    let maybe_revision = maybe_revision.map(Rev::try_from).transpose()?;
+
    let prefix = maybe_prefix.unwrap_or_default();
+

+
    if let Some(revision) = maybe_revision {
+
        browser.rev(revision)?;
+
    }
+

+
    let path = if prefix == "/" || prefix.is_empty() {
+
        file_system::Path::root()
+
    } else {
+
        file_system::Path::from_str(&prefix)?
+
    };
+

+
    let root_dir = browser.get_directory()?;
+
    let prefix_dir = if path.is_root() {
+
        root_dir
+
    } else {
+
        root_dir
+
            .find_directory(path.clone())
+
            .ok_or_else(|| Error::PathNotFound(path.clone()))?
+
    };
+
    let mut prefix_contents = prefix_dir.list_directory();
+
    prefix_contents.sort();
+

+
    let entries_results: Result<Vec<TreeEntry>, Error> = prefix_contents
+
        .iter()
+
        .map(|(label, system_type)| {
+
            let entry_path = if path.is_root() {
+
                file_system::Path::new(label.clone())
+
            } else {
+
                let mut p = path.clone();
+
                p.push(label.clone());
+
                p
+
            };
+
            let mut commit_path = file_system::Path::root();
+
            commit_path.append(entry_path.clone());
+

+
            let info = Info {
+
                name: label.to_string(),
+
                object_type: match system_type {
+
                    file_system::SystemType::Directory => ObjectType::Tree,
+
                    file_system::SystemType::File => ObjectType::Blob,
+
                },
+
                last_commit: None,
+
            };
+

+
            Ok(TreeEntry {
+
                info,
+
                path: entry_path.to_string(),
+
            })
+
        })
+
        .collect();
+

+
    let mut entries = entries_results?;
+

+
    // We want to ensure that in the response Tree entries come first. `Ord` being
+
    // derived on the enum ensures Variant declaration order.
+
    //
+
    // https://doc.rust-lang.org/std/cmp/trait.Ord.html#derivable
+
    entries.sort_by(|a, b| a.info.object_type.cmp(&b.info.object_type));
+

+
    let last_commit = if path.is_root() {
+
        Some(commit::Header::from(browser.get().first()))
+
    } else {
+
        None
+
    };
+
    let name = if path.is_root() {
+
        "".into()
+
    } else {
+
        let (_first, last) = path.split_last();
+
        last.to_string()
+
    };
+
    let info = Info {
+
        name,
+
        object_type: ObjectType::Tree,
+
        last_commit,
+
    };
+

+
    Ok(Tree {
+
        path: prefix,
+
        entries,
+
        info,
+
    })
+
}
added radicle-source/src/oid.rs
@@ -0,0 +1,44 @@
+
// This file is part of radicle-surf
+
// <https://github.com/radicle-dev/radicle-surf>
+
//
+
// Copyright (C) 2019-2020 The Radicle Team <dev@radicle.xyz>
+
//
+
// This program is free software: you can redistribute it and/or modify
+
// it under the terms of the GNU General Public License version 3 or
+
// later as published by the Free Software Foundation.
+
//
+
// This program is distributed in the hope that it will be useful,
+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+
// GNU General Public License for more details.
+
//
+
// You should have received a copy of the GNU General Public License
+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
+

+
use std::convert::TryFrom;
+

+
use serde::{Deserialize, Serialize};
+

+
#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
+
#[serde(try_from = "&str", into = "String")]
+
pub struct Oid(pub git2::Oid);
+

+
impl TryFrom<&str> for Oid {
+
    type Error = git2::Error;
+

+
    fn try_from(value: &str) -> Result<Self, Self::Error> {
+
        value.parse().map(Oid)
+
    }
+
}
+

+
impl From<Oid> for String {
+
    fn from(oid: Oid) -> Self {
+
        oid.0.to_string()
+
    }
+
}
+

+
impl From<Oid> for git2::Oid {
+
    fn from(oid: Oid) -> Self {
+
        oid.0
+
    }
+
}
added radicle-source/src/person.rs
@@ -0,0 +1,28 @@
+
// This file is part of radicle-surf
+
// <https://github.com/radicle-dev/radicle-surf>
+
//
+
// Copyright (C) 2019-2020 The Radicle Team <dev@radicle.xyz>
+
//
+
// This program is free software: you can redistribute it and/or modify
+
// it under the terms of the GNU General Public License version 3 or
+
// later as published by the Free Software Foundation.
+
//
+
// This program is distributed in the hope that it will be useful,
+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+
// GNU General Public License for more details.
+
//
+
// You should have received a copy of the GNU General Public License
+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
+

+
use serde::Serialize;
+

+
/// Representation of a person (e.g. committer, author, signer) from a
+
/// repository. Usually extracted from a signature.
+
#[derive(Clone, Debug, Serialize)]
+
pub struct Person {
+
    /// Name part of the commit signature.
+
    pub name: String,
+
    /// Email part of the commit signature.
+
    pub email: String,
+
}
added radicle-source/src/revision.rs
@@ -0,0 +1,175 @@
+
// This file is part of radicle-surf
+
// <https://github.com/radicle-dev/radicle-surf>
+
//
+
// Copyright (C) 2019-2020 The Radicle Team <dev@radicle.xyz>
+
//
+
// This program is free software: you can redistribute it and/or modify
+
// it under the terms of the GNU General Public License version 3 or
+
// later as published by the Free Software Foundation.
+
//
+
// This program is distributed in the hope that it will be useful,
+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+
// GNU General Public License for more details.
+
//
+
// You should have received a copy of the GNU General Public License
+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
+

+
use std::convert::TryFrom;
+

+
use nonempty::NonEmpty;
+
use serde::{Deserialize, Serialize};
+

+
use radicle_surf::vcs::git::{self, Browser, RefScope, Rev};
+

+
use crate::{
+
    branch::{branches, Branch},
+
    error::Error,
+
    oid::Oid,
+
    tag::{tags, Tag},
+
};
+

+
pub enum Category<P, U> {
+
    Local { peer_id: P, user: U },
+
    Remote { peer_id: P, user: U },
+
}
+

+
/// A revision selector for a `Browser`.
+
#[derive(Debug, Clone, Serialize, Deserialize)]
+
#[serde(rename_all = "camelCase", tag = "type")]
+
pub enum Revision<P> {
+
    /// Select a tag under the name provided.
+
    #[serde(rename_all = "camelCase")]
+
    Tag {
+
        /// Name of the tag.
+
        name: String,
+
    },
+
    /// Select a branch under the name provided.
+
    #[serde(rename_all = "camelCase")]
+
    Branch {
+
        /// Name of the branch.
+
        name: String,
+
        /// The remote peer, if specified.
+
        peer_id: Option<P>,
+
    },
+
    /// Select a SHA1 under the name provided.
+
    #[serde(rename_all = "camelCase")]
+
    Sha {
+
        /// The SHA1 value.
+
        sha: Oid,
+
    },
+
}
+

+
impl<P> TryFrom<Revision<P>> for Rev
+
where
+
    P: ToString,
+
{
+
    type Error = Error;
+

+
    fn try_from(other: Revision<P>) -> Result<Self, Self::Error> {
+
        match other {
+
            Revision::Tag { name } => Ok(git::TagName::new(&name).into()),
+
            Revision::Branch { name, peer_id } => Ok(match peer_id {
+
                Some(peer) => {
+
                    git::Branch::remote(&format!("heads/{}", name), &peer.to_string()).into()
+
                },
+
                None => git::Branch::local(&name).into(),
+
            }),
+
            Revision::Sha { sha } => {
+
                let oid: git2::Oid = sha.into();
+
                Ok(oid.into())
+
            },
+
        }
+
    }
+
}
+

+
/// Bundled response to retrieve both [`Branch`]es and [`Tag`]s for a user's
+
/// repo.
+
#[derive(Clone, Debug, PartialEq, Eq)]
+
pub struct Revisions<P, U> {
+
    /// The peer peer_id for the user.
+
    pub peer_id: P,
+
    /// The user who owns these revisions.
+
    pub user: U,
+
    /// List of [`git::Branch`].
+
    pub branches: NonEmpty<Branch>,
+
    /// List of [`git::Tag`].
+
    pub tags: Vec<Tag>,
+
}
+

+
/// Provide the [`Revisions`] for the given `peer_id`, looking for the
+
/// branches as [`RefScope::Remote`].
+
///
+
/// If there are no branches then this returns `None`.
+
///
+
/// # Errors
+
///
+
///   * If we cannot get the branches from the `Browser`
+
pub fn remote<P, U>(
+
    browser: &Browser,
+
    peer_id: P,
+
    user: U,
+
) -> Result<Option<Revisions<P, U>>, Error>
+
where
+
    P: Clone + ToString,
+
{
+
    let remote_branches = branches(browser, Some(peer_id.clone()).into())?;
+
    Ok(
+
        NonEmpty::from_vec(remote_branches).map(|branches| Revisions {
+
            peer_id,
+
            user,
+
            branches,
+
            // TODO(rudolfs): implement remote peer tags once we decide how
+
            // https://radicle.community/t/git-tags/214
+
            tags: vec![],
+
        }),
+
    )
+
}
+

+
/// Provide the [`Revisions`] for the given `peer_id`, looking for the
+
/// branches as [`RefScope::Local`].
+
///
+
/// If there are no branches then this returns `None`.
+
///
+
/// # Errors
+
///
+
///   * If we cannot get the branches from the `Browser`
+
pub fn local<P, U>(browser: &Browser, peer_id: P, user: U) -> Result<Option<Revisions<P, U>>, Error>
+
where
+
    P: Clone + ToString,
+
{
+
    let local_branches = branches(browser, RefScope::Local)?;
+
    let tags = tags(browser)?;
+
    Ok(
+
        NonEmpty::from_vec(local_branches).map(|branches| Revisions {
+
            peer_id,
+
            user,
+
            branches,
+
            tags,
+
        }),
+
    )
+
}
+

+
/// Provide the [`Revisions`] of a peer.
+
///
+
/// If the peer is [`Category::Local`], meaning that is the current person doing
+
/// the browsing and no remote is set for the reference.
+
///
+
/// Othewise, the peer is [`Category::Remote`], meaning that we are looking into
+
/// a remote part of a reference.
+
///
+
/// # Errors
+
///
+
///   * If we cannot get the branches from the `Browser`
+
pub fn revisions<P, U>(
+
    browser: &Browser,
+
    peer: Category<P, U>,
+
) -> Result<Option<Revisions<P, U>>, Error>
+
where
+
    P: Clone + ToString,
+
{
+
    match peer {
+
        Category::Local { peer_id, user } => local(browser, peer_id, user),
+
        Category::Remote { peer_id, user } => remote(browser, peer_id, user),
+
    }
+
}
added radicle-source/src/syntax.rs
@@ -0,0 +1,87 @@
+
// This file is part of radicle-surf
+
// <https://github.com/radicle-dev/radicle-surf>
+
//
+
// Copyright (C) 2019-2020 The Radicle Team <dev@radicle.xyz>
+
//
+
// This program is free software: you can redistribute it and/or modify
+
// it under the terms of the GNU General Public License version 3 or
+
// later as published by the Free Software Foundation.
+
//
+
// This program is distributed in the hope that it will be useful,
+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+
// GNU General Public License for more details.
+
//
+
// You should have received a copy of the GNU General Public License
+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
+

+
use std::path;
+

+
use syntect::{
+
    easy::HighlightLines,
+
    highlighting::ThemeSet,
+
    parsing::SyntaxSet,
+
    util::LinesWithEndings,
+
};
+

+
lazy_static::lazy_static! {
+
    // The syntax set is slow to load (~30ms), so we make sure to only load it once.
+
    // It _will_ affect the latency of the first request that uses syntax highlighting,
+
    // but this is acceptable for now.
+
    pub static ref SYNTAX_SET: SyntaxSet = {
+
        let default_set = SyntaxSet::load_defaults_newlines();
+
        let mut builder = default_set.into_builder();
+

+
        if cfg!(debug_assertions) {
+
            // In development assets are relative to the proxy source.
+
            // Don't crash if we aren't able to load additional syntaxes for some reason.
+
            builder.add_from_folder("./assets", true).ok();
+
        } else {
+
            // In production assets are relative to the proxy executable.
+
            let exe_path = std::env::current_exe().expect("Can't get current exe path");
+
            let root_path = exe_path
+
                .parent()
+
                .expect("Could not get parent path of current executable");
+
            let mut tmp = root_path.to_path_buf();
+
            tmp.push("assets");
+
            let asset_path = tmp.to_str().expect("Couldn't convert pathbuf to str");
+

+
            // Don't crash if we aren't able to load additional syntaxes for some reason.
+
            match builder.add_from_folder(asset_path, true) {
+
                Ok(_) => (),
+
                Err(err) => log::warn!("Syntax builder error : {}", err),
+
            };
+
        }
+
        builder.build()
+
    };
+
}
+

+
/// Return a [`BlobContent`] given a file path, content and theme. Attempts to
+
/// perform syntax highlighting when the theme is `Some`.
+
pub fn highlight(path: &str, content: &str, theme_name: &str) -> Option<String> {
+
    let syntax = path::Path::new(path)
+
        .extension()
+
        .and_then(std::ffi::OsStr::to_str)
+
        .and_then(|ext| SYNTAX_SET.find_syntax_by_extension(ext));
+

+
    let ts = ThemeSet::load_defaults();
+
    let theme = ts.themes.get(theme_name);
+

+
    match (syntax, theme) {
+
        (Some(syntax), Some(theme)) => {
+
            let mut highlighter = HighlightLines::new(syntax, theme);
+
            let mut html = String::with_capacity(content.len());
+

+
            for line in LinesWithEndings::from(content) {
+
                let regions = highlighter.highlight(line, &SYNTAX_SET);
+
                syntect::html::append_highlighted_html_for_styled_line(
+
                    &regions[..],
+
                    syntect::html::IncludeBackground::No,
+
                    &mut html,
+
                );
+
            }
+
            Some(html)
+
        },
+
        _ => None,
+
    }
+
}
added radicle-source/src/tag.rs
@@ -0,0 +1,60 @@
+
// This file is part of radicle-surf
+
// <https://github.com/radicle-dev/radicle-surf>
+
//
+
// Copyright (C) 2019-2020 The Radicle Team <dev@radicle.xyz>
+
//
+
// This program is free software: you can redistribute it and/or modify
+
// it under the terms of the GNU General Public License version 3 or
+
// later as published by the Free Software Foundation.
+
//
+
// This program is distributed in the hope that it will be useful,
+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+
// GNU General Public License for more details.
+
//
+
// You should have received a copy of the GNU General Public License
+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
+

+
use std::fmt;
+

+
use serde::Serialize;
+

+
use radicle_surf::{git::RefScope, vcs::git::Browser};
+

+
use crate::error::Error;
+

+
/// Tag name representation.
+
///
+
/// We still need full tag support.
+
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)]
+
pub struct Tag(pub(crate) String);
+

+
impl From<String> for Tag {
+
    fn from(name: String) -> Self {
+
        Self(name)
+
    }
+
}
+

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

+
/// Retrieves the list of [`Tag`] for the given project `id`.
+
///
+
/// # Errors
+
///
+
/// Will return [`Error`] if the project doesn't exist or the surf interaction
+
/// fails.
+
pub fn tags(browser: &Browser<'_>) -> Result<Vec<Tag>, Error> {
+
    let tag_names = browser.list_tags(RefScope::Local)?;
+
    let mut tags: Vec<Tag> = tag_names
+
        .into_iter()
+
        .map(|tag_name| Tag(tag_name.name().to_string()))
+
        .collect();
+

+
    tags.sort();
+

+
    Ok(tags)
+
}
added radicle-surf/Cargo.toml
@@ -0,0 +1,52 @@
+
[package]
+
name = "radicle-surf"
+
description = "A code surfing library for VCS file systems"
+
readme = "README.md"
+
version = "0.8.0"
+
authors = ["The Radicle Team <dev@radicle.xyz>"]
+
edition = "2018"
+
homepage = "https://github.com/radicle-dev/radicle-surf"
+
repository = "https://github.com/radicle-dev/radicle-surf"
+
license = "GPL-3.0-or-later"
+

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

+
[features]
+
serialize = ["serde"]
+
# 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 = []
+

+
[dependencies]
+
either = "1.5"
+
nom = "6"
+
nonempty = "0.5"
+
regex = ">= 1.5.5"
+
serde = { features = ["serde_derive"], optional = true, version = "1" }
+
thiserror = "1.0"
+

+
[dependencies.git2]
+
version = ">= 0.12"
+
default-features = false
+
features = []
+

+
[dev-dependencies]
+
pretty_assertions = "1.3.0"
+
proptest = "0.9"
+
criterion = "0.3"
+
serde_json = "1"
+

+
[build-dependencies]
+
anyhow = "1.0"
+
flate2 = "1"
+
tar = "0.4"
+

+
[[bench]]
+
name = "last_commit"
+
harness = false
added radicle-surf/DEVELOPMENT.md
@@ -0,0 +1,83 @@
+

+
# Radicle Surfing 🏄
+

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

+
# Licensing
+

+
We are [GPL-3.0-or-later](./LICENSE) licensed project. To keep in compliance with this we must
+
add a [license header](./.license-header) to any new files added. This is checked on each run of CI.
+

+
## Building & Testing 🏗️
+

+
We try to make development as seemless 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 esnure 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 radicle-surf/README.md
@@ -0,0 +1,74 @@
+
# radicle-surf
+

+
A code surfing library for VCS file systems 🏄‍♀️🏄‍♂️
+

+
Welcome to `radicle-surf`!
+

+
`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.
+

+
## 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 disccussions at [radicle.community](https://radicle.community)!
+

+
# Example
+

+
To a taste for the capabilities of `radicle-surf` we provide an example below, but we also
+
keep our documentation and doc-tests up to date.
+

+
```rust
+
use radicle_surf::vcs::git;
+
use radicle_surf::file_system::{Label, Path, SystemType};
+
use radicle_surf::file_system::unsound;
+
use pretty_assertions::assert_eq;
+
use std::str::FromStr;
+

+
// We're going to point to this repo.
+
let repo = git::Repository::new("./data/git-platinum")?;
+

+
// Here we initialise a new Broswer for a the git repo.
+
let mut browser = git::Browser::new(&repo, "master")?;
+

+
// Set the history to a particular commit
+
let commit = git::Oid::from_str("80ded66281a4de2889cc07293a8f10947c6d57fe")?;
+
browser.commit(commit)?;
+

+
// Get the snapshot of the directory for our current HEAD of history.
+
let directory = browser.get_directory()?;
+

+
// Let's get a Path to the memory.rs file
+
let memory = unsound::path::new("src/memory.rs");
+

+
// And assert that we can find it!
+
assert!(directory.find_file(memory).is_some());
+

+
let root_contents = directory.list_directory();
+

+
assert_eq!(root_contents, vec![
+
    SystemType::file(unsound::label::new(".i-am-well-hidden")),
+
    SystemType::file(unsound::label::new(".i-too-am-hidden")),
+
    SystemType::file(unsound::label::new("README.md")),
+
    SystemType::directory(unsound::label::new("bin")),
+
    SystemType::directory(unsound::label::new("src")),
+
    SystemType::directory(unsound::label::new("text")),
+
    SystemType::directory(unsound::label::new("this")),
+
]);
+

+
let src = directory
+
    .find_directory(Path::new(unsound::label::new("src")))
+
    .expect("failed to find src");
+
let src_contents = src.list_directory();
+

+
assert_eq!(src_contents, vec![
+
    SystemType::file(unsound::label::new("Eval.hs")),
+
    SystemType::file(unsound::label::new("Folder.svelte")),
+
    SystemType::file(unsound::label::new("memory.rs")),
+
]);
+
```
added radicle-surf/benches/last_commit.rs
@@ -0,0 +1,45 @@
+
// This file is part of radicle-surf
+
// <https://github.com/radicle-dev/radicle-surf>
+
//
+
// Copyright (C) 2019-2020 The Radicle Team <dev@radicle.xyz>
+
//
+
// This program is free software: you can redistribute it and/or modify
+
// it under the terms of the GNU General Public License version 3 or
+
// later as published by the Free Software Foundation.
+
//
+
// This program is distributed in the hope that it will be useful,
+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+
// GNU General Public License for more details.
+
//
+
// You should have received a copy of the GNU General Public License
+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
+

+
use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion};
+
use radicle_surf::{
+
    file_system::{unsound, Path},
+
    vcs::git::{Branch, Browser, Repository},
+
};
+

+
fn last_commit_comparison(c: &mut Criterion) {
+
    let repo = Repository::new("./data/git-platinum")
+
        .expect("Could not retrieve ./data/git-platinum as git repository");
+
    let browser =
+
        Browser::new(&repo, Branch::local("master")).expect("Could not initialise Browser");
+

+
    let mut group = c.benchmark_group("Last Commit");
+
    for path in [
+
        Path::root(),
+
        unsound::path::new("~/src/memory.rs"),
+
        unsound::path::new("~/this/is/a/really/deeply/nested/directory/tree"),
+
    ]
+
    .iter()
+
    {
+
        group.bench_with_input(BenchmarkId::new("", path), path, |b, path| {
+
            b.iter(|| browser.last_commit(path.clone()))
+
        });
+
    }
+
}
+

+
criterion_group!(benches, last_commit_comparison);
+
criterion_main!(benches);
added radicle-surf/build.rs
@@ -0,0 +1,81 @@
+
// This file is part of radicle-surf
+
// <https://github.com/radicle-dev/radicle-surf>
+
//
+
// Copyright (C) 2019-2020 The Radicle Team <dev@radicle.xyz>
+
//
+
// This program is free software: you can redistribute it and/or modify
+
// it under the terms of the GNU General Public License version 3 or
+
// later as published by the Free Software Foundation.
+
//
+
// This program is distributed in the hope that it will be useful,
+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+
// GNU General Public License for more details.
+
//
+
// You should have received a copy of the GNU General Public License
+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
+

+
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(|err| io::Error::new(io::ErrorKind::Other, err))?,
+
            ))
+
        })
+
    }
+

+
    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);
+
}
+

+
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 radicle-surf/data/README.md
@@ -0,0 +1,21 @@
+
# Updating [git-platinum][]
+

+
1. Push your changes to [`radicle-dev/git-platinum`][git-platinum] and/or update
+
   `surf/data/mock-branches.txt`.
+
2. Run `scripts/update-git-platinum.sh` from the repo root. This updates
+
   `surf/data/git-platinum.tgz`.
+
3. Run `cargo build` to unpack the updated repo.
+
4. Run the tests
+
5. Commit your changes. We provide a template below so that we can easily
+
   identify changes to `git-platinum`. Please fill in the details that follow a
+
   comment (`#`):
+
   ```
+
   data/git-platinum: # short reason for updating
+

+
   # provide a longer reason for making changes to git-platinum
+
   # as well as what has changed.
+
   ```
+

+

+

+
[git-platinum]: https://github.com/radicle-dev/git-platinum
added radicle-surf/data/git-platinum.tgz
added radicle-surf/data/mock-branches.txt
@@ -0,0 +1,12 @@
+
refs/namespaces/golden/refs/heads/master,refs/heads/master
+
refs/namespaces/golden/refs/heads/banana,refs/heads/dev
+
refs/namespaces/golden/refs/tags/v0.1.0,refs/tags/v0.1.0
+
refs/namespaces/golden/refs/tags/v0.2.0,refs/tags/v0.2.0
+
refs/namespaces/golden/refs/remotes/kickflip/heads/heelflip,refs/heads/dev
+
refs/namespaces/golden/refs/remotes/kickflip/heads/fakie/bigspin,refs/heads/dev
+
refs/namespaces/golden/refs/remotes/kickflip/tags/v0.1.0,refs/tags/v0.1.0
+
refs/namespaces/golden/refs/namespaces/silver/refs/heads/master,refs/heads/dev
+
refs/remotes/banana/pineapple,refs/remotes/origin/master
+
refs/remotes/banana/orange/pineapple,refs/remotes/origin/master
+
refs/namespaces/me/refs/heads/feature/#1194,refs/heads/master
+
refs/namespaces/me/refs/remotes/fein/heads/feature/#1194,refs/heads/dev
added 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 occassionally 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 browseable, 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 Chnage 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 radicle-surf/examples/diff.rs
@@ -0,0 +1,176 @@
+
// This file is part of radicle-surf
+
// <https://github.com/radicle-dev/radicle-surf>
+
//
+
// Copyright (C) 2019-2020 The Radicle Team <dev@radicle.xyz>
+
//
+
// This program is free software: you can redistribute it and/or modify
+
// it under the terms of the GNU General Public License version 3 or
+
// later as published by the Free Software Foundation.
+
//
+
// This program is distributed in the hope that it will be useful,
+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+
// GNU General Public License for more details.
+
//
+
// You should have received a copy of the GNU General Public License
+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
+

+
extern crate radicle_surf;
+

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

+
use git2::Oid;
+
use nonempty::NonEmpty;
+

+
use radicle_surf::{
+
    diff::Diff,
+
    file_system::Directory,
+
    vcs::{git, History},
+
};
+

+
fn main() {
+
    let options = get_options_or_exit();
+
    let repo = init_repository_or_exit(&options.path_to_repo);
+
    let mut browser =
+
        git::Browser::new(&repo, git::Branch::local("master")).expect("failed to create browser:");
+

+
    match options.head_revision {
+
        HeadRevision::Head => {
+
            reset_browser_to_head_or_exit(&mut browser);
+
        },
+
        HeadRevision::Commit(id) => {
+
            set_browser_history_or_exit(&mut browser, &id);
+
        },
+
    }
+
    let head_directory = get_directory_or_exit(&browser);
+

+
    set_browser_history_or_exit(&mut browser, &options.base_revision);
+
    let base_directory = get_directory_or_exit(&browser);
+

+
    let now = Instant::now();
+
    let elapsed_nanos = now.elapsed().as_nanos();
+
    let diff = Diff::diff(base_directory, head_directory);
+
    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) -> git::Repository {
+
    match git::Repository::new(path_to_repo) {
+
        Ok(repo) => repo,
+
        Err(e) => {
+
            println!("Failed to create repository: {:?}", e);
+
            std::process::exit(1);
+
        },
+
    }
+
}
+

+
fn reset_browser_to_head_or_exit(browser: &mut git::Browser) {
+
    if let Err(e) = browser.head() {
+
        println!("Failed to set browser to HEAD: {:?}", e);
+
        std::process::exit(1);
+
    }
+
}
+

+
fn set_browser_history_or_exit(browser: &mut git::Browser, commit_id: &str) {
+
    // TODO: Might consider to not require resetting to HEAD when history is not at
+
    // HEAD
+
    reset_browser_to_head_or_exit(browser);
+
    if let Err(e) = set_browser_history(browser, commit_id) {
+
        println!("Failed to set browser history: {:?}", e);
+
        std::process::exit(1);
+
    }
+
}
+

+
fn set_browser_history(browser: &mut git::Browser, commit_id: &str) -> Result<(), String> {
+
    let oid = match Oid::from_str(commit_id) {
+
        Ok(oid) => oid,
+
        Err(e) => return Err(format!("{}", e)),
+
    };
+
    let commit = match browser.get().find_in_history(&oid, |artifact| artifact.id) {
+
        Some(commit) => commit,
+
        None => return Err(format!("Git commit not found: {}", commit_id)),
+
    };
+
    browser.set(History(NonEmpty::new(commit)));
+
    Ok(())
+
}
+

+
fn get_directory_or_exit(browser: &git::Browser) -> Directory {
+
    match browser.get_directory() {
+
        Ok(dir) => dir,
+
        Err(e) => {
+
            println!("Failed to get directory: {:?}", e);
+
            std::process::exit(1)
+
        },
+
    }
+
}
+

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

+
    println!(
+
        "created {} / deleted {} / modified {} / total {}",
+
        diff.created.len(),
+
        diff.deleted.len(),
+
        diff.modified.len(),
+
        diff.created.len() + diff.deleted.len() + diff.modified.len()
+
    );
+
    println!("diff took {} micros ", elapsed_nanos / 1000);
+
}
+

+
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 radicle-surf/src/diff.rs
@@ -0,0 +1,767 @@
+
// This file is part of radicle-surf
+
// <https://github.com/radicle-dev/radicle-surf>
+
//
+
// Copyright (C) 2019-2020 The Radicle Team <dev@radicle.xyz>
+
//
+
// This program is free software: you can redistribute it and/or modify
+
// it under the terms of the GNU General Public License version 3 or
+
// later as published by the Free Software Foundation.
+
//
+
// This program is distributed in the hope that it will be useful,
+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+
// GNU General Public License for more details.
+
//
+
// You should have received a copy of the GNU General Public License
+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
+

+
#![allow(dead_code, unused_variables, missing_docs)]
+

+
use std::{cell::RefCell, cmp::Ordering, convert::TryFrom, ops::Deref, rc::Rc, slice};
+

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

+
use crate::file_system::{Directory, DirectoryContents, Path};
+

+
pub mod git;
+

+
#[cfg_attr(
+
    feature = "serialize",
+
    derive(Serialize),
+
    serde(rename_all = "camelCase")
+
)]
+
#[derive(Clone, Debug, PartialEq, Eq)]
+
pub struct Diff {
+
    pub created: Vec<CreateFile>,
+
    pub deleted: Vec<DeleteFile>,
+
    pub moved: Vec<MoveFile>,
+
    pub copied: Vec<CopyFile>,
+
    pub modified: Vec<ModifiedFile>,
+
}
+

+
impl Default for Diff {
+
    fn default() -> Self {
+
        Self::new()
+
    }
+
}
+

+
#[cfg_attr(feature = "serialize", derive(Serialize))]
+
#[derive(Clone, Debug, PartialEq, Eq)]
+
pub struct CreateFile {
+
    pub path: Path,
+
    pub diff: FileDiff,
+
}
+

+
#[cfg_attr(feature = "serialize", derive(Serialize))]
+
#[derive(Clone, Debug, PartialEq, Eq)]
+
pub struct DeleteFile {
+
    pub path: Path,
+
    pub diff: FileDiff,
+
}
+

+
#[cfg_attr(
+
    feature = "serialize",
+
    derive(Serialize),
+
    serde(rename_all = "camelCase")
+
)]
+
#[derive(Clone, Debug, PartialEq, Eq)]
+
pub struct MoveFile {
+
    pub old_path: Path,
+
    pub new_path: Path,
+
}
+

+
#[cfg_attr(
+
    feature = "serialize",
+
    derive(Serialize),
+
    serde(rename_all = "camelCase")
+
)]
+
#[derive(Clone, Debug, PartialEq, Eq)]
+
pub struct CopyFile {
+
    pub old_path: Path,
+
    pub new_path: Path,
+
}
+

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

+
#[cfg_attr(
+
    feature = "serialize",
+
    derive(Serialize),
+
    serde(rename_all = "camelCase")
+
)]
+
#[derive(Clone, Debug, PartialEq, Eq)]
+
pub struct ModifiedFile {
+
    pub path: Path,
+
    pub diff: FileDiff,
+
    pub eof: Option<EofNewLine>,
+
}
+

+
/// A set of changes belonging to one file.
+
#[cfg_attr(
+
    feature = "serialize",
+
    derive(Serialize),
+
    serde(tag = "type", rename_all = "camelCase")
+
)]
+
#[derive(Clone, Debug, PartialEq, Eq)]
+
pub enum FileDiff {
+
    Binary,
+
    #[cfg_attr(feature = "serialize", serde(rename_all = "camelCase"))]
+
    Plain {
+
        hunks: Hunks,
+
    },
+
}
+

+
/// A set of line changes.
+
#[cfg_attr(
+
    feature = "serialize",
+
    derive(Serialize),
+
    serde(rename_all = "camelCase")
+
)]
+
#[derive(Clone, Debug, PartialEq, Eq)]
+
pub struct Hunk {
+
    pub header: Line,
+
    pub lines: Vec<LineDiff>,
+
}
+

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

+
pub struct IterHunks<'a> {
+
    inner: slice::Iter<'a, Hunk>,
+
}
+

+
impl Hunks {
+
    pub fn iter(&self) -> IterHunks<'_> {
+
        IterHunks {
+
            inner: self.0.iter(),
+
        }
+
    }
+
}
+

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

+
impl<'a> Iterator for IterHunks<'a> {
+
    type Item = &'a Hunk;
+

+
    fn next(&mut self) -> Option<Self::Item> {
+
        self.inner.next()
+
    }
+
}
+

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

+
    fn try_from(patch: git2::Patch) -> Result<Self, Self::Error> {
+
        let mut hunks = Vec::new();
+
        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<LineDiff> = Vec::new();
+

+
            for l in 0..hunk_lines {
+
                let line = patch.line_in_hunk(h, l)?;
+
                let line = LineDiff::try_from(line)?;
+
                lines.push(line);
+
            }
+
            hunks.push(Hunk { header, lines });
+
        }
+
        Ok(Hunks(hunks))
+
    }
+
}
+

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

+
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 = "serialize")]
+
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)
+
    }
+
}
+

+
/// Single line delta. Two of these are need to represented a modified line: one
+
/// addition and one deletion. Context is also represented with this type.
+
#[cfg_attr(
+
    feature = "serialize",
+
    derive(Serialize),
+
    serde(tag = "type", rename_all = "camelCase")
+
)]
+
#[derive(Clone, Debug, PartialEq, Eq)]
+
pub enum LineDiff {
+
    /// Line added.
+
    #[cfg_attr(feature = "serialize", serde(rename_all = "camelCase"))]
+
    Addition { line: Line, line_num: u32 },
+

+
    /// Line deleted.
+
    #[cfg_attr(feature = "serialize", serde(rename_all = "camelCase"))]
+
    Deletion { line: Line, line_num: u32 },
+

+
    /// Line context.
+
    #[cfg_attr(feature = "serialize", serde(rename_all = "camelCase"))]
+
    Context {
+
        line: Line,
+
        line_num_old: u32,
+
        line_num_new: u32,
+
    },
+
}
+

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

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

+
    pub fn context(line: impl Into<Line>, line_num_old: u32, line_num_new: u32) -> Self {
+
        Self::Context {
+
            line: line.into(),
+
            line_num_old,
+
            line_num_new,
+
        }
+
    }
+
}
+

+
impl Diff {
+
    pub fn new() -> Self {
+
        Diff {
+
            created: Vec::new(),
+
            deleted: Vec::new(),
+
            moved: Vec::new(),
+
            copied: Vec::new(),
+
            modified: Vec::new(),
+
        }
+
    }
+

+
    // TODO: Direction of comparison is not obvious with this signature.
+
    // For now using conventional approach with the right being "newer".
+
    #[allow(clippy::self_named_constructors)]
+
    pub fn diff(left: Directory, right: Directory) -> Self {
+
        let mut diff = Diff::new();
+
        let path = Rc::new(RefCell::new(Path::from_labels(right.current(), &[])));
+
        Diff::collect_diff(&left, &right, &path, &mut diff);
+

+
        // TODO: Some of the deleted files may actually be moved (renamed) to one of the
+
        // created files. Finding out which of the deleted files were deleted
+
        // and which were moved will probably require performing some variant of
+
        // the longest common substring algorithm for each pair in D x C. Final
+
        // decision can be based on heuristics, e.g. the file can be considered
+
        // moved, if len(LCS) > 0,25 * min(size(d), size(c)), and
+
        // deleted otherwise.
+

+
        diff
+
    }
+

+
    fn collect_diff(
+
        old: &Directory,
+
        new: &Directory,
+
        parent_path: &Rc<RefCell<Path>>,
+
        diff: &mut Diff,
+
    ) {
+
        let mut old_iter = old.iter();
+
        let mut new_iter = new.iter();
+
        let mut old_entry_opt = old_iter.next();
+
        let mut new_entry_opt = new_iter.next();
+

+
        while old_entry_opt.is_some() || new_entry_opt.is_some() {
+
            match (&old_entry_opt, &new_entry_opt) {
+
                (Some(ref old_entry), Some(ref new_entry)) => {
+
                    match new_entry.label().cmp(&old_entry.label()) {
+
                        Ordering::Greater => {
+
                            diff.add_deleted_files(old_entry, parent_path);
+
                            old_entry_opt = old_iter.next();
+
                        },
+
                        Ordering::Less => {
+
                            diff.add_created_files(new_entry, parent_path);
+
                            new_entry_opt = new_iter.next();
+
                        },
+
                        Ordering::Equal => match (new_entry, old_entry) {
+
                            (
+
                                DirectoryContents::File {
+
                                    name: new_file_name,
+
                                    file: new_file,
+
                                },
+
                                DirectoryContents::File {
+
                                    name: old_file_name,
+
                                    file: old_file,
+
                                },
+
                            ) => {
+
                                if old_file.size != new_file.size
+
                                    || old_file.checksum() != new_file.checksum()
+
                                {
+
                                    let mut path = parent_path.borrow().clone();
+
                                    path.push(new_file_name.clone());
+

+
                                    diff.add_modified_file(path, vec![], None);
+
                                }
+
                                old_entry_opt = old_iter.next();
+
                                new_entry_opt = new_iter.next();
+
                            },
+
                            (
+
                                DirectoryContents::File {
+
                                    name: new_file_name,
+
                                    file: new_file,
+
                                },
+
                                DirectoryContents::Directory(old_dir),
+
                            ) => {
+
                                let mut path = parent_path.borrow().clone();
+
                                path.push(new_file_name.clone());
+

+
                                diff.add_created_file(
+
                                    path,
+
                                    FileDiff::Plain {
+
                                        hunks: Hunks::default(),
+
                                    },
+
                                );
+
                                diff.add_deleted_files(old_entry, parent_path);
+

+
                                old_entry_opt = old_iter.next();
+
                                new_entry_opt = new_iter.next();
+
                            },
+
                            (
+
                                DirectoryContents::Directory(new_dir),
+
                                DirectoryContents::File {
+
                                    name: old_file_name,
+
                                    file: old_file,
+
                                },
+
                            ) => {
+
                                let mut path = parent_path.borrow().clone();
+
                                path.push(old_file_name.clone());
+

+
                                diff.add_created_files(new_entry, parent_path);
+
                                diff.add_deleted_file(
+
                                    path,
+
                                    FileDiff::Plain {
+
                                        hunks: Hunks::default(),
+
                                    },
+
                                );
+

+
                                old_entry_opt = old_iter.next();
+
                                new_entry_opt = new_iter.next();
+
                            },
+
                            (
+
                                DirectoryContents::Directory(new_dir),
+
                                DirectoryContents::Directory(old_dir),
+
                            ) => {
+
                                parent_path.borrow_mut().push(new_dir.current().clone());
+
                                Diff::collect_diff(
+
                                    old_dir.deref(),
+
                                    new_dir.deref(),
+
                                    parent_path,
+
                                    diff,
+
                                );
+
                                parent_path.borrow_mut().pop();
+
                                old_entry_opt = old_iter.next();
+
                                new_entry_opt = new_iter.next();
+
                            },
+
                        },
+
                    }
+
                },
+
                (Some(ref old_entry), None) => {
+
                    diff.add_deleted_files(old_entry, parent_path);
+
                    old_entry_opt = old_iter.next();
+
                },
+
                (None, Some(ref new_entry)) => {
+
                    diff.add_created_files(new_entry, parent_path);
+
                    new_entry_opt = new_iter.next();
+
                },
+
                (None, None) => break,
+
            }
+
        }
+
    }
+

+
    // if entry is a file, then return this file,
+
    // or a list of files in the directory tree otherwise
+
    fn collect_files_from_entry<F, T>(
+
        entry: &DirectoryContents,
+
        parent_path: &Rc<RefCell<Path>>,
+
        mapper: F,
+
    ) -> Vec<T>
+
    where
+
        F: Fn(Path) -> T + Copy,
+
    {
+
        match entry {
+
            DirectoryContents::Directory(dir) => Diff::collect_files(dir, parent_path, mapper),
+
            DirectoryContents::File { name, .. } => {
+
                let mut path = parent_path.borrow().clone();
+
                path.push(name.clone());
+

+
                vec![mapper(path)]
+
            },
+
        }
+
    }
+

+
    fn collect_files<F, T>(dir: &Directory, parent_path: &Rc<RefCell<Path>>, mapper: F) -> Vec<T>
+
    where
+
        F: Fn(Path) -> T + Copy,
+
    {
+
        let mut files: Vec<T> = Vec::new();
+
        Diff::collect_files_inner(dir, parent_path, mapper, &mut files);
+
        files
+
    }
+

+
    fn collect_files_inner<'a, F, T>(
+
        dir: &'a Directory,
+
        parent_path: &Rc<RefCell<Path>>,
+
        mapper: F,
+
        files: &mut Vec<T>,
+
    ) where
+
        F: Fn(Path) -> T + Copy,
+
    {
+
        parent_path.borrow_mut().push(dir.current());
+
        for entry in dir.iter() {
+
            match entry {
+
                DirectoryContents::Directory(subdir) => {
+
                    Diff::collect_files_inner(&subdir, parent_path, mapper, files);
+
                },
+
                DirectoryContents::File { name, .. } => {
+
                    let mut path = parent_path.borrow().clone();
+
                    path.push(name);
+
                    files.push(mapper(path));
+
                },
+
            }
+
        }
+
        parent_path.borrow_mut().pop();
+
    }
+

+
    pub(crate) fn add_modified_file(
+
        &mut self,
+
        path: Path,
+
        hunks: impl Into<Hunks>,
+
        eof: Option<EofNewLine>,
+
    ) {
+
        // TODO: file diff can be calculated at this point
+
        // Use pijul's transaction diff as an inspiration?
+
        // https://nest.pijul.com/pijul_org/pijul:master/1468b7281a6f3785e9#anesp4Qdq3V
+
        self.modified.push(ModifiedFile {
+
            path,
+
            diff: FileDiff::Plain {
+
                hunks: hunks.into(),
+
            },
+
            eof,
+
        });
+
    }
+

+
    pub(crate) fn add_moved_file(&mut self, old_path: Path, new_path: Path) {
+
        self.moved.push(MoveFile { old_path, new_path });
+
    }
+

+
    pub(crate) fn add_copied_file(&mut self, old_path: Path, new_path: Path) {
+
        self.copied.push(CopyFile { old_path, new_path });
+
    }
+

+
    pub(crate) fn add_modified_binary_file(&mut self, path: Path) {
+
        self.modified.push(ModifiedFile {
+
            path,
+
            diff: FileDiff::Binary,
+
            eof: None,
+
        });
+
    }
+

+
    pub(crate) fn add_created_file(&mut self, path: Path, diff: FileDiff) {
+
        self.created.push(CreateFile { path, diff });
+
    }
+

+
    fn add_created_files(&mut self, dc: &DirectoryContents, parent_path: &Rc<RefCell<Path>>) {
+
        let mut new_files: Vec<CreateFile> =
+
            Diff::collect_files_from_entry(dc, parent_path, |path| CreateFile {
+
                path,
+
                diff: FileDiff::Plain {
+
                    hunks: Hunks::default(),
+
                },
+
            });
+
        self.created.append(&mut new_files);
+
    }
+

+
    pub(crate) fn add_deleted_file(&mut self, path: Path, diff: FileDiff) {
+
        self.deleted.push(DeleteFile { path, diff });
+
    }
+

+
    fn add_deleted_files(&mut self, dc: &DirectoryContents, parent_path: &Rc<RefCell<Path>>) {
+
        let mut new_files: Vec<DeleteFile> =
+
            Diff::collect_files_from_entry(dc, parent_path, |path| DeleteFile {
+
                path,
+
                diff: FileDiff::Plain {
+
                    hunks: Hunks::default(),
+
                },
+
            });
+
        self.deleted.append(&mut new_files);
+
    }
+
}
+

+
#[cfg(test)]
+
mod tests {
+
    use crate::{
+
        diff::*,
+
        file_system::{unsound, *},
+
    };
+
    use pretty_assertions::assert_eq;
+

+
    #[test]
+
    fn test_create_file() {
+
        let directory = Directory::root();
+

+
        let mut new_directory = Directory::root();
+
        new_directory.insert_file(unsound::path::new("banana.rs"), File::new(b"use banana"));
+

+
        let diff = Diff::diff(directory, new_directory);
+

+
        let expected_diff = Diff {
+
            created: vec![CreateFile {
+
                path: Path::with_root(&[unsound::label::new("banana.rs")]),
+
                diff: FileDiff::Plain {
+
                    hunks: Hunks::default(),
+
                },
+
            }],
+
            deleted: vec![],
+
            copied: vec![],
+
            moved: vec![],
+
            modified: vec![],
+
        };
+

+
        assert_eq!(diff, expected_diff)
+
    }
+

+
    #[test]
+
    fn test_delete_file() {
+
        let mut directory = Directory::root();
+
        directory.insert_file(unsound::path::new("banana.rs"), File::new(b"use banana"));
+

+
        let new_directory = Directory::root();
+

+
        let diff = Diff::diff(directory, new_directory);
+

+
        let expected_diff = Diff {
+
            created: vec![],
+
            deleted: vec![DeleteFile {
+
                path: Path::with_root(&[unsound::label::new("banana.rs")]),
+
                diff: FileDiff::Plain {
+
                    hunks: Hunks::default(),
+
                },
+
            }],
+
            moved: vec![],
+
            copied: vec![],
+
            modified: vec![],
+
        };
+

+
        assert_eq!(diff, expected_diff)
+
    }
+

+
    /* TODO(fintan): Move is not detected yet
+
    #[test]
+
    fn test_moved_file() {
+
        let mut directory = Directory::root();
+
        directory.insert_file(&unsound::path::new("mod.rs"), File::new(b"use banana"));
+

+
        let mut new_directory = Directory::root();
+
        new_directory.insert_file(&unsound::path::new("banana.rs"), File::new(b"use banana"));
+

+
        let diff = Diff::diff(directory, new_directory).expect("diff failed");
+

+
        assert_eq!(diff, Diff::new())
+
    }
+
    */
+

+
    #[test]
+
    fn test_modify_file() {
+
        let mut directory = Directory::root();
+
        directory.insert_file(unsound::path::new("banana.rs"), File::new(b"use banana"));
+

+
        let mut new_directory = Directory::root();
+
        new_directory.insert_file(unsound::path::new("banana.rs"), File::new(b"use banana;"));
+

+
        let diff = Diff::diff(directory, new_directory);
+

+
        let expected_diff = Diff {
+
            created: vec![],
+
            deleted: vec![],
+
            moved: vec![],
+
            copied: vec![],
+
            modified: vec![ModifiedFile {
+
                path: Path::with_root(&[unsound::label::new("banana.rs")]),
+
                diff: FileDiff::Plain {
+
                    hunks: Hunks::default(),
+
                },
+
                eof: None,
+
            }],
+
        };
+

+
        assert_eq!(diff, expected_diff)
+
    }
+

+
    #[test]
+
    fn test_create_directory() {
+
        let directory = Directory::root();
+

+
        let mut new_directory = Directory::root();
+
        new_directory.insert_file(
+
            unsound::path::new("src/banana.rs"),
+
            File::new(b"use banana"),
+
        );
+

+
        let diff = Diff::diff(directory, new_directory);
+

+
        let expected_diff = Diff {
+
            created: vec![CreateFile {
+
                path: Path::with_root(&[
+
                    unsound::label::new("src"),
+
                    unsound::label::new("banana.rs"),
+
                ]),
+
                diff: FileDiff::Plain {
+
                    hunks: Hunks::default(),
+
                },
+
            }],
+
            deleted: vec![],
+
            moved: vec![],
+
            copied: vec![],
+
            modified: vec![],
+
        };
+

+
        assert_eq!(diff, expected_diff)
+
    }
+

+
    #[test]
+
    fn test_delete_directory() {
+
        let mut directory = Directory::root();
+
        directory.insert_file(
+
            unsound::path::new("src/banana.rs"),
+
            File::new(b"use banana"),
+
        );
+

+
        let new_directory = Directory::root();
+

+
        let diff = Diff::diff(directory, new_directory);
+

+
        let expected_diff = Diff {
+
            created: vec![],
+
            deleted: vec![DeleteFile {
+
                path: Path::with_root(&[
+
                    unsound::label::new("src"),
+
                    unsound::label::new("banana.rs"),
+
                ]),
+
                diff: FileDiff::Plain {
+
                    hunks: Hunks::default(),
+
                },
+
            }],
+
            moved: vec![],
+
            copied: vec![],
+
            modified: vec![],
+
        };
+

+
        assert_eq!(diff, expected_diff)
+
    }
+

+
    #[test]
+
    fn test_modify_file_directory() {
+
        let mut directory = Directory::root();
+
        directory.insert_file(
+
            unsound::path::new("src/banana.rs"),
+
            File::new(b"use banana"),
+
        );
+

+
        let mut new_directory = Directory::root();
+
        new_directory.insert_file(
+
            unsound::path::new("src/banana.rs"),
+
            File::new(b"use banana;"),
+
        );
+

+
        let diff = Diff::diff(directory, new_directory);
+

+
        let expected_diff = Diff {
+
            created: vec![],
+
            deleted: vec![],
+
            moved: vec![],
+
            copied: vec![],
+
            modified: vec![ModifiedFile {
+
                path: Path::with_root(&[
+
                    unsound::label::new("src"),
+
                    unsound::label::new("banana.rs"),
+
                ]),
+
                diff: FileDiff::Plain {
+
                    hunks: Hunks::default(),
+
                },
+
                eof: None,
+
            }],
+
        };
+

+
        assert_eq!(diff, expected_diff)
+
    }
+

+
    /* TODO(fintan): Tricky stuff
+
    #[test]
+
    fn test_disjoint_directories() {
+
        let mut directory = Directory::root();
+
        directory.insert_file(
+
            &unsound::path::new("foo/src/banana.rs"),
+
            File::new(b"use banana"),
+
        );
+

+
        let mut other_directory = Directory::root();
+
        other_directory.insert_file(
+
            &unsound::path::new("bar/src/pineapple.rs"),
+
            File::new(b"use pineapple"),
+
        );
+

+
        let diff = Diff::diff(directory, other_directory).expect("diff failed");
+

+
        let expected_diff = Diff {
+
            created: vec![CreateFile(Path::from_labels(
+
                unsound::label::new("bar"),
+
                &[
+
                    unsound::label::new("src"),
+
                    unsound::label::new("pineapple.rs"),
+
                ],
+
            ))],
+
            deleted: vec![DeleteFile(Path::from_labels(
+
                unsound::label::new("foo"),
+
                &[unsound::label::new("src"), unsound::label::new("banana.rs")],
+
            ))],
+
            moved: vec![],
+
            modified: vec![],
+
        };
+

+
        assert_eq!(diff, expected_diff)
+
    }
+
    */
+
}
added radicle-surf/src/diff/git.rs
@@ -0,0 +1,306 @@
+
// This file is part of radicle-surf
+
// <https://github.com/radicle-dev/radicle-surf>
+
//
+
// Copyright (C) 2019-2020 The Radicle Team <dev@radicle.xyz>
+
//
+
// This program is free software: you can redistribute it and/or modify
+
// it under the terms of the GNU General Public License version 3 or
+
// later as published by the Free Software Foundation.
+
//
+
// This program is distributed in the hope that it will be useful,
+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+
// GNU General Public License for more details.
+
//
+
// You should have received a copy of the GNU General Public License
+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
+

+
use std::convert::TryFrom;
+

+
use crate::{
+
    diff::{self, Diff, EofNewLine, Hunk, Hunks, Line, LineDiff},
+
    file_system::Path,
+
};
+

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

+
    use crate::file_system::{self, Path};
+

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

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

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

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

+
    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::LineDiff::Invalid),
+
        }
+
    }
+
}
+

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

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

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

+
        for (idx, delta) in git_diff.deltas().enumerate() {
+
            match delta.status() {
+
                Delta::Added => {
+
                    let diff_file = delta.new_file();
+
                    let path = diff_file.path().ok_or(error::Diff::PathUnavailable)?;
+
                    let path = Path::try_from(path.to_path_buf())?;
+

+
                    let patch = Patch::from_diff(&git_diff, idx)?;
+
                    if let Some(patch) = patch {
+
                        diff.add_created_file(
+
                            path,
+
                            diff::FileDiff::Plain {
+
                                hunks: Hunks::try_from(patch)?,
+
                            },
+
                        );
+
                    } else {
+
                        diff.add_created_file(
+
                            path,
+
                            diff::FileDiff::Plain {
+
                                hunks: Hunks::default(),
+
                            },
+
                        );
+
                    }
+
                },
+
                Delta::Deleted => {
+
                    let diff_file = delta.old_file();
+
                    let path = diff_file.path().ok_or(error::Diff::PathUnavailable)?;
+
                    let path = Path::try_from(path.to_path_buf())?;
+

+
                    let patch = Patch::from_diff(&git_diff, idx)?;
+
                    if let Some(patch) = patch {
+
                        diff.add_deleted_file(
+
                            path,
+
                            diff::FileDiff::Plain {
+
                                hunks: Hunks::try_from(patch)?,
+
                            },
+
                        );
+
                    } else {
+
                        diff.add_deleted_file(
+
                            path,
+
                            diff::FileDiff::Plain {
+
                                hunks: Hunks::default(),
+
                            },
+
                        );
+
                    }
+
                },
+
                Delta::Modified => {
+
                    let diff_file = delta.new_file();
+
                    let path = diff_file.path().ok_or(error::Diff::PathUnavailable)?;
+
                    let path = Path::try_from(path.to_path_buf())?;
+

+
                    let patch = Patch::from_diff(&git_diff, idx)?;
+

+
                    if let Some(patch) = patch {
+
                        let mut hunks: Vec<Hunk> = Vec::new();
+
                        let mut old_missing_eof = false;
+
                        let mut new_missing_eof = false;
+

+
                        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<LineDiff> = 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::AddEOFNL => {
+
                                        old_missing_eof = true;
+
                                        continue;
+
                                    },
+
                                    git2::DiffLineType::DeleteEOFNL => {
+
                                        new_missing_eof = true;
+
                                        continue;
+
                                    },
+
                                    _ => {},
+
                                }
+
                                let line = LineDiff::try_from(line)?;
+
                                lines.push(line);
+
                            }
+
                            hunks.push(Hunk { header, lines });
+
                        }
+
                        let eof = match (old_missing_eof, new_missing_eof) {
+
                            (true, true) => Some(EofNewLine::BothMissing),
+
                            (true, false) => Some(EofNewLine::OldMissing),
+
                            (false, true) => Some(EofNewLine::NewMissing),
+
                            (false, false) => None,
+
                        };
+
                        diff.add_modified_file(path, hunks, eof);
+
                    } else if diff_file.is_binary() {
+
                        diff.add_modified_binary_file(path);
+
                    } else {
+
                        return Err(error::Diff::PatchUnavailable(path));
+
                    }
+
                },
+
                Delta::Renamed => {
+
                    let old = delta
+
                        .old_file()
+
                        .path()
+
                        .ok_or(error::Diff::PathUnavailable)?;
+
                    let new = delta
+
                        .new_file()
+
                        .path()
+
                        .ok_or(error::Diff::PathUnavailable)?;
+

+
                    let old_path = Path::try_from(old.to_path_buf())?;
+
                    let new_path = Path::try_from(new.to_path_buf())?;
+

+
                    diff.add_moved_file(old_path, new_path);
+
                },
+
                Delta::Copied => {
+
                    let old = delta
+
                        .old_file()
+
                        .path()
+
                        .ok_or(error::Diff::PathUnavailable)?;
+
                    let new = delta
+
                        .new_file()
+
                        .path()
+
                        .ok_or(error::Diff::PathUnavailable)?;
+

+
                    let old_path = Path::try_from(old.to_path_buf())?;
+
                    let new_path = Path::try_from(new.to_path_buf())?;
+

+
                    diff.add_copied_file(old_path, new_path);
+
                },
+
                status => {
+
                    return Err(error::Diff::DeltaUnhandled(status));
+
                },
+
            }
+
        }
+

+
        Ok(diff)
+
    }
+
}
+

+
#[cfg(test)]
+
mod tests {
+
    use super::*;
+

+
    #[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[0].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[0].eof, None);
+
    }
+

+
    // TODO(xphoniex): uncomment once libgit2 has fixed the bug
+
    //#[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[0].eof, Some(EofNewLine::OldMissing));
+
    }
+

+
    // TODO(xphoniex): uncomment once libgit2 has fixed the bug
+
    //#[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[0].eof, Some(EofNewLine::NewMissing));
+
    }
+
}
added radicle-surf/src/file_system.rs
@@ -0,0 +1,118 @@
+
// This file is part of radicle-surf
+
// <https://github.com/radicle-dev/radicle-surf>
+
//
+
// Copyright (C) 2019-2020 The Radicle Team <dev@radicle.xyz>
+
//
+
// This program is free software: you can redistribute it and/or modify
+
// it under the terms of the GNU General Public License version 3 or
+
// later as published by the Free Software Foundation.
+
//
+
// This program is distributed in the hope that it will be useful,
+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+
// GNU General Public License for more details.
+
//
+
// You should have received a copy of the GNU General Public License
+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
+

+
//! A model of a non-empty directory data structure that can be searched,
+
//! queried, and rendered. The concept is to represent VCS directory, but is not
+
//! necessarily tied to one.
+
//!
+
//! # Examples
+
//!
+
//! ```
+
//! use nonempty::NonEmpty;
+
//! use radicle_surf::file_system as fs;
+
//!
+
//! // This used for unsafe set up of the directory, but should not be used in production code.
+
//! use radicle_surf::file_system::unsound;
+
//!
+
//! let mut directory = fs::Directory::root();
+
//!
+
//! // Set up root files
+
//! let readme = fs::File::new(b"Radicle Surfing");
+
//! let cargo = fs::File::new(b"[package]\nname = \"radicle-surf\"");
+
//! let root_files = NonEmpty::from((
+
//!     (unsound::label::new("README.md"), readme),
+
//!     vec![(unsound::label::new("Cargo.toml"), cargo)],
+
//! ));
+
//!
+
//! // Set up src files
+
//! let lib = fs::File::new(b"pub mod diff;\npub mod file_system;\n pub mod vcs;");
+
//! let file_system_mod = fs::File::new(b"pub mod directory;\npub mod error;\nmod path;");
+
//!
+
//! directory.insert_files(&[], root_files);
+
//! directory.insert_file(unsound::path::new("src/lib.rs"), lib.clone());
+
//! directory.insert_file(unsound::path::new("src/file_system/mod.rs"), file_system_mod);
+
//!
+
//! // With a directory in place we can begin to operate on it
+
//! // The first we will do is list what contents are at the root.
+
//! let root_contents = directory.list_directory();
+
//!
+
//! // Checking that we have the correct contents
+
//! assert_eq!(
+
//!     root_contents,
+
//!     vec![
+
//!         fs::SystemType::file(unsound::label::new("Cargo.toml")),
+
//!         fs::SystemType::file(unsound::label::new("README.md")),
+
//!         fs::SystemType::directory(unsound::label::new("src")),
+
//!     ]
+
//! );
+
//!
+
//! // We can then go down one level to explore sub-directories
+
//! // Note here that we can use `Path::new`, since there's guranteed to be a `Label`,
+
//! // although we cheated and created the label unsafely.
+
//! let src = directory.find_directory(fs::Path::new(unsound::label::new("src")));
+
//!
+
//! // Ensure that we found the src directory
+
//! assert!(src.is_some());
+
//! let src = src.unwrap();
+
//!
+
//! let src_contents = src.list_directory();
+
//!
+
//! // Checking we have the correct contents of 'src'
+
//! assert_eq!(
+
//!     src_contents,
+
//!     vec![
+
//!         fs::SystemType::directory(unsound::label::new("file_system")),
+
//!         fs::SystemType::file(unsound::label::new("lib.rs")),
+
//!     ]
+
//! );
+
//!
+
//! // We can dive down to 'file_system' either from the root or src, they should be the same.
+
//! assert_eq!(
+
//!     src.find_directory(unsound::path::new("file_system")),
+
//!     directory.find_directory(unsound::path::new("src/file_system")),
+
//! );
+
//!
+
//! // We can also find files
+
//! assert_eq!(
+
//!     src.find_file(unsound::path::new("lib.rs")),
+
//!     Some(lib)
+
//! );
+
//!
+
//! // From anywhere
+
//! assert_eq!(
+
//!     directory.find_file(unsound::path::new("src/file_system/mod.rs")),
+
//!     src.find_file(unsound::path::new("file_system/mod.rs")),
+
//! );
+
//!
+
//! // And we can also check the size of directories and files
+
//! assert_eq!(
+
//!     directory.find_file(unsound::path::new("src/file_system/mod.rs")).map(|f| f.size()),
+
//!     Some(43),
+
//! );
+
//!
+
//! assert_eq!(
+
//!     directory.size(),
+
//!     137,
+
//! );
+
//! ```
+

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

+
pub use self::{directory::*, path::*};
added radicle-surf/src/file_system/directory.rs
@@ -0,0 +1,722 @@
+
// This file is part of radicle-surf
+
// <https://github.com/radicle-dev/radicle-surf>
+
//
+
// Copyright (C) 2019-2020 The Radicle Team <dev@radicle.xyz>
+
//
+
// This program is free software: you can redistribute it and/or modify
+
// it under the terms of the GNU General Public License version 3 or
+
// later as published by the Free Software Foundation.
+
//
+
// This program is distributed in the hope that it will be useful,
+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+
// GNU General Public License for more details.
+
//
+
// You should have received a copy of the GNU General Public License
+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
+

+
//! 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.
+
//!
+
//! As well as this, this module contains [`DirectoryContents`] which is the
+
//! output of iterating over a [`Directory`], and also [`SystemType`] which is
+
//! an identifier of what type of [`DirectoryContents`] one is viewing when
+
//! [listing](#method.list_directory) a directory.
+

+
use crate::{file_system::path::*, tree::*};
+
use nonempty::NonEmpty;
+
use std::{
+
    collections::{hash_map::DefaultHasher, HashMap},
+
    hash::{Hash, Hasher},
+
};
+

+
/// `SystemType` is an enumeration over what can be found in a [`Directory`] so
+
/// we can report back to the caller a [`Label`] and its type.
+
///
+
/// See [`SystemType::file`](#method.file) and
+
/// [`SystemType::directory`](#method.directory).
+
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
+
pub enum SystemType {
+
    /// The `File` type in a directory system.
+
    File,
+
    /// The `Directory` type in a directory system.
+
    Directory,
+
}
+

+
impl SystemType {
+
    /// A file name and [`SystemType::File`].
+
    pub fn file(label: Label) -> (Label, Self) {
+
        (label, SystemType::File)
+
    }
+

+
    /// A directory name and [`SystemType::Directory`].
+
    pub fn directory(label: Label) -> (Label, Self) {
+
        (label, SystemType::Directory)
+
    }
+
}
+

+
/// A `File` consists of its file contents (a [`Vec`] of bytes).
+
///
+
/// The `Debug` instance of `File` will show the first few bytes of the file and
+
/// its [`size`](#method.size).
+
#[derive(Clone, PartialEq, Eq)]
+
pub struct File {
+
    /// The contents of a `File` as a vector of bytes.
+
    pub contents: Vec<u8>,
+
    pub(crate) size: usize,
+
}
+

+
impl std::fmt::Debug for File {
+
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+
        let mut contents = self.contents.clone();
+
        contents.truncate(10);
+
        write!(
+
            f,
+
            "File {{ contents: {:?}, size: {} }}",
+
            contents, self.size
+
        )
+
    }
+
}
+

+
impl File {
+
    /// Create a new `File` with the contents provided.
+
    pub fn new(contents: &[u8]) -> Self {
+
        let size = contents.len();
+
        File {
+
            contents: contents.to_vec(),
+
            size,
+
        }
+
    }
+

+
    /// Get the size of the `File` corresponding to the number of bytes in the
+
    /// file contents.
+
    ///
+
    /// # Examples
+
    ///
+
    /// ```
+
    /// use radicle_surf::file_system::File;
+
    ///
+
    /// let file = File::new(b"pub mod diff;\npub mod file_system;\npub mod vcs;\npub use crate::vcs::git;\n");
+
    ///
+
    /// assert_eq!(file.size(), 73);
+
    /// ```
+
    pub fn size(&self) -> usize {
+
        self.size
+
    }
+

+
    /// Get the hash of the `File` corresponding to the contents of the file.
+
    ///
+
    /// # Examples
+
    ///
+
    /// ```
+
    /// use radicle_surf::file_system::File;
+
    ///
+
    /// let file = File::new(
+
    ///     b"pub mod diff;\npub mod file_system;\npub mod vcs;\npub use crate::vcs::git;\n",
+
    /// );
+
    ///
+
    /// assert_eq!(file.checksum(), 8457766712413557403);
+
    /// ```
+
    pub fn checksum(&self) -> u64 {
+
        let mut hasher = DefaultHasher::new();
+
        self.contents.hash(&mut hasher);
+
        hasher.finish()
+
    }
+
}
+

+
#[derive(Debug, Clone, PartialEq, Eq)]
+
enum Location {
+
    Root,
+
    SubDirectory(Label),
+
}
+

+
/// A `Directory` can be thought of as a non-empty set of entries of
+
/// sub-directories and files. The reason for the non-empty property is that a
+
/// VCS directory would have at least one artifact as a sub-directory which
+
/// tracks the VCS work, e.g. git using the `.git` folder.
+
///
+
/// On top of that, some VCSes, such as git, will not track an empty directory,
+
/// and so when creating a new directory to track it will have to contain at
+
/// least one file.
+
#[derive(Debug, Clone, PartialEq, Eq)]
+
pub struct Directory {
+
    current: Location,
+
    sub_directories: Forest<Label, File>,
+
}
+

+
/// `DirectoryContents` is an enumeration of what a [`Directory`] can contain
+
/// and is used for when we are [`iter`](struct.Directory.html#method.iter)ating
+
/// through a [`Directory`].
+
#[derive(Debug, Clone, PartialEq, Eq)]
+
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),
+
}
+

+
impl DirectoryContents {
+
    /// Get a label for the `DirectoryContents`, either the name of the [`File`]
+
    /// or the name of the [`Directory`].
+
    pub fn label(&self) -> Label {
+
        match self {
+
            DirectoryContents::File { name, .. } => name.clone(),
+
            DirectoryContents::Directory(directory) => directory.current(),
+
        }
+
    }
+
}
+

+
impl From<SubTree<Label, File>> for DirectoryContents {
+
    fn from(sub_tree: SubTree<Label, File>) -> Self {
+
        match sub_tree {
+
            SubTree::Node { key, value } => DirectoryContents::File {
+
                name: key,
+
                file: value,
+
            },
+
            SubTree::Branch { key, forest } => DirectoryContents::Directory(Directory {
+
                current: Location::SubDirectory(key),
+
                sub_directories: (*forest).into(),
+
            }),
+
        }
+
    }
+
}
+

+
impl Directory {
+
    /// Create a root directory.
+
    ///
+
    /// This function is usually used for testing and demonstation purposes.
+
    pub fn root() -> Self {
+
        Directory {
+
            current: Location::Root,
+
            sub_directories: Forest::root(),
+
        }
+
    }
+

+
    /// Create a directory, similar to `root`, except with a given name.
+
    ///
+
    /// This function is usually used for testing and demonstation purposes.
+
    pub fn new(label: Label) -> Self {
+
        Directory {
+
            current: Location::SubDirectory(label),
+
            sub_directories: Forest::root(),
+
        }
+
    }
+

+
    /// List the current `Directory`'s files and sub-directories.
+
    ///
+
    /// The listings are a pair of [`Label`] and [`SystemType`], where the
+
    /// [`Label`] represents the name of the file or directory.
+
    ///
+
    /// ```
+
    /// use nonempty::NonEmpty;
+
    /// use radicle_surf::file_system::{Directory, File, SystemType};
+
    /// use radicle_surf::file_system::unsound;
+
    ///
+
    /// let mut directory = Directory::root();
+
    ///
+
    /// // Root files set up
+
    /// let root_files = NonEmpty::from((
+
    ///     (unsound::label::new("foo.rs"), File::new(b"use crate::bar")),
+
    ///     vec![(
+
    ///         unsound::label::new("bar.rs"),
+
    ///         File::new(b"fn hello_world()"),
+
    ///     )],
+
    /// ));
+
    /// directory.insert_files(&[], root_files);
+
    ///
+
    /// // Haskell files set up
+
    /// let haskell_files = NonEmpty::from((
+
    ///     (
+
    ///         unsound::label::new("foo.hs"),
+
    ///         File::new(b"module Foo where"),
+
    ///     ),
+
    ///     vec![(
+
    ///         unsound::label::new("bar.hs"),
+
    ///         File::new(b"module Bar where"),
+
    ///     )],
+
    /// ));
+
    ///
+
    /// directory.insert_files(&[unsound::label::new("haskell")], haskell_files);
+
    ///
+
    /// let mut directory_contents = directory.list_directory();
+
    /// directory_contents.sort();
+
    ///
+
    /// assert_eq!(
+
    ///     directory_contents,
+
    ///     vec![
+
    ///         SystemType::file(unsound::label::new("bar.rs")),
+
    ///         SystemType::file(unsound::label::new("foo.rs")),
+
    ///         SystemType::directory(unsound::label::new("haskell")),
+
    ///     ]
+
    /// );
+
    /// ```
+
    pub fn list_directory(&self) -> Vec<(Label, SystemType)> {
+
        let forest = &self.sub_directories;
+
        match &forest.0 {
+
            None => vec![],
+
            Some(trees) => trees
+
                .0
+
                .iter()
+
                .map(|tree| match tree {
+
                    SubTree::Node { key: name, .. } => SystemType::file(name.clone()),
+
                    SubTree::Branch { key: name, .. } => SystemType::directory(name.clone()),
+
                })
+
                .collect(),
+
        }
+
    }
+

+
    /// Get the [`Label`] of the current directory.
+
    ///
+
    /// # Examples
+
    ///
+
    /// ```
+
    /// use radicle_surf::file_system::{Directory, DirectoryContents, File, Label};
+
    /// use radicle_surf::file_system::unsound;
+
    ///
+
    /// let mut root = Directory::root();
+
    ///
+
    /// let main = File::new(b"println!(\"Hello, world!\")");
+
    /// root.insert_file(unsound::path::new("main.rs"), main.clone());
+
    ///
+
    /// let lib = File::new(b"struct Hello(String)");
+
    /// root.insert_file(unsound::path::new("lib.rs"), lib.clone());
+
    ///
+
    /// let test_mod = File::new(b"assert_eq!(1 + 1, 2);");
+
    /// root.insert_file(unsound::path::new("test/mod.rs"), test_mod.clone());
+
    ///
+
    /// let mut root_iter = root.iter();
+
    ///
+
    /// assert_eq!(root_iter.next(), Some(DirectoryContents::File {
+
    ///     name: unsound::label::new("lib.rs"),
+
    ///     file: lib
+
    /// }));
+
    ///
+
    /// assert_eq!(root_iter.next(), Some(DirectoryContents::File {
+
    ///     name: unsound::label::new("main.rs"),
+
    ///     file: main
+
    /// }));
+
    ///
+
    /// let mut test_dir = Directory::new(unsound::label::new("test"));
+
    /// test_dir.insert_file(unsound::path::new("mod.rs"), test_mod);
+
    ///
+
    /// assert_eq!(root_iter.next(), Some(DirectoryContents::Directory(test_dir)));
+
    /// ```
+
    pub fn iter(&self) -> impl Iterator<Item = DirectoryContents> + '_ {
+
        let mut empty_iter = None;
+
        let mut trees_iter = None;
+
        match &self.sub_directories.0 {
+
            None => empty_iter = Some(std::iter::empty()),
+
            Some(trees) => {
+
                trees_iter = Some(
+
                    trees
+
                        .iter_subtrees()
+
                        .cloned()
+
                        .map(|sub_tree| sub_tree.into()),
+
                )
+
            },
+
        }
+

+
        empty_iter
+
            .into_iter()
+
            .flatten()
+
            .chain(trees_iter.into_iter().flatten())
+
    }
+

+
    /// Find a [`File`] in the directory given the [`Path`] to the [`File`].
+
    ///
+
    /// # Failures
+
    ///
+
    /// This operation fails if the path does not lead to a [`File`].
+
    /// If the search is for a `Directory` then use `find_directory`.
+
    ///
+
    /// # Examples
+
    ///
+
    /// Search for a file in the path:
+
    ///     * `foo/bar/baz.hs`
+
    ///     * `foo`
+
    ///     * `foo/bar/qux.rs`
+
    ///
+
    /// ```
+
    /// use radicle_surf::file_system::{Directory, File};
+
    /// use radicle_surf::file_system::unsound;
+
    ///
+
    /// let file = File::new(b"module Banana ...");
+
    ///
+
    /// let mut directory = Directory::root();
+
    /// directory.insert_file(unsound::path::new("foo/bar/baz.rs"), file.clone());
+
    ///
+
    /// // The file is succesfully found
+
    /// assert_eq!(directory.find_file(unsound::path::new("foo/bar/baz.rs")), Some(file));
+
    ///
+
    /// // We shouldn't be able to find a directory
+
    /// assert_eq!(directory.find_file(unsound::path::new("foo")), None);
+
    ///
+
    /// // We shouldn't be able to find a file that doesn't exist
+
    /// assert_eq!(directory.find_file(unsound::path::new("foo/bar/qux.rs")), None);
+
    /// ```
+
    pub fn find_file(&self, path: Path) -> Option<File> {
+
        self.sub_directories.find_node(path.0).cloned()
+
    }
+

+
    /// Find a `Directory` in the directory given the [`Path`] to the
+
    /// `Directory`.
+
    ///
+
    /// # Failures
+
    ///
+
    /// This operation fails if the path does not lead to the `Directory`.
+
    ///
+
    /// # Examples
+
    ///
+
    /// Search for directories in the path:
+
    ///     * `foo`
+
    ///     * `foo/bar`
+
    ///     * `foo/baz`
+
    ///
+
    /// ```
+
    /// use radicle_surf::file_system::{Directory, File};
+
    /// use radicle_surf::file_system::unsound;
+
    ///
+
    /// let file = File::new(b"module Banana ...");
+
    ///
+
    /// let mut directory = Directory::root();
+
    /// directory.insert_file(unsound::path::new("foo/bar/baz.rs"), file.clone());
+
    ///
+
    /// // Can find the first level
+
    /// assert!(directory.find_directory(unsound::path::new("foo")).is_some());
+
    ///
+
    /// // Can find the second level
+
    /// assert!(directory.find_directory(unsound::path::new("foo/bar")).is_some());
+
    ///
+
    /// // Cannot find 'baz' since it does not exist
+
    /// assert!(directory.find_directory(unsound::path::new("foo/baz")).is_none());
+
    ///
+
    /// // 'baz.rs' is a file and not a directory
+
    /// assert!(directory.find_directory(unsound::path::new("foo/bar/baz.rs")).is_none());
+
    /// ```
+
    pub fn find_directory(&self, path: Path) -> Option<Self> {
+
        self.sub_directories
+
            .find_branch(path.0.clone())
+
            .cloned()
+
            .map(|tree| {
+
                let (_, current) = path.split_last();
+
                Directory {
+
                    current: Location::SubDirectory(current),
+
                    sub_directories: tree.into(),
+
                }
+
            })
+
    }
+

+
    /// Get the [`Label`] of the current directory.
+
    ///
+
    /// # Examples
+
    ///
+
    /// ```
+
    /// use radicle_surf::file_system::{Directory, File, Label};
+
    /// use radicle_surf::file_system::unsound;
+
    ///
+
    /// let mut root = Directory::root();
+
    /// root.insert_file(unsound::path::new("main.rs"), File::new(b"println!(\"Hello, world!\")"));
+
    /// root.insert_file(unsound::path::new("lib.rs"), File::new(b"struct Hello(String)"));
+
    /// root.insert_file(unsound::path::new("test/mod.rs"), File::new(b"assert_eq!(1 + 1, 2);"));
+
    ///
+
    /// assert_eq!(root.current(), Label::root());
+
    ///
+
    /// let test = root.find_directory(
+
    ///     unsound::path::new("test")
+
    /// ).expect("Missing test directory");
+
    /// assert_eq!(test.current(), unsound::label::new("test"));
+
    /// ```
+
    pub fn current(&self) -> Label {
+
        match &self.current {
+
            Location::Root => Label::root(),
+
            Location::SubDirectory(label) => label.clone(),
+
        }
+
    }
+

+
    // 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: Label) -> 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`.
+
    ///
+
    /// # Examples
+
    ///
+
    /// ```
+
    /// use radicle_surf::file_system::{Directory, File};
+
    /// use radicle_surf::file_system::unsound;
+
    ///
+
    /// let mut root = Directory::root();
+
    /// root.insert_file(unsound::path::new("main.rs"), File::new(b"println!(\"Hello, world!\")"));
+
    /// root.insert_file(unsound::path::new("lib.rs"), File::new(b"struct Hello(String)"));
+
    /// root.insert_file(unsound::path::new("test/mod.rs"), File::new(b"assert_eq!(1 + 1, 2);"));
+
    ///
+
    /// assert_eq!(root.size(), 66);
+
    /// ```
+
    pub fn size(&self) -> usize {
+
        self.sub_directories
+
            .iter()
+
            .fold(0, |size, file| size + file.size())
+
    }
+

+
    /// Insert a file into a directory, given the full path to file (file name
+
    /// inclusive) and the `File` itself.
+
    ///
+
    /// This function is usually used for testing and demonstation purposes.
+
    pub fn insert_file(&mut self, path: Path, file: File) {
+
        self.sub_directories.insert(path.0, file)
+
    }
+

+
    /// Insert files into a shared directory path.
+
    ///
+
    /// `directory_path` is used as the prefix to where the files should go. If
+
    /// empty the files will be placed in the current `Directory`.
+
    ///
+
    /// `files` are pairs of file name and the [`File`] itself.
+
    ///
+
    /// This function is usually used for testing and demonstation purposes.
+
    pub fn insert_files(&mut self, directory_path: &[Label], files: NonEmpty<(Label, File)>) {
+
        match NonEmpty::from_slice(directory_path) {
+
            None => {
+
                for (file_name, file) in files.into_iter() {
+
                    self.insert_file(Path::new(file_name), file)
+
                }
+
            },
+
            Some(path) => {
+
                for (file_name, file) in files.into_iter() {
+
                    // The clone is necessary here because we use it as a prefix.
+
                    let mut file_path = Path(path.clone());
+
                    file_path.push(file_name);
+

+
                    self.insert_file(file_path, file)
+
                }
+
            },
+
        }
+
    }
+

+
    pub(crate) fn from_hash_map(files: HashMap<Path, NonEmpty<(Label, File)>>) -> Self {
+
        let mut directory: Self = Directory::root();
+

+
        for (path, files) in files.into_iter() {
+
            for (file_name, file) in files.into_iter() {
+
                let file_path = if path.is_root() {
+
                    Path::new(file_name)
+
                } else {
+
                    let mut new_path = path.clone();
+
                    new_path.push(file_name);
+
                    new_path
+
                };
+

+
                directory.insert_file(file_path, file)
+
            }
+
        }
+

+
        directory
+
    }
+
}
+

+
#[cfg(test)]
+
pub mod tests {
+
    #[cfg(test)]
+
    mod list_directory {
+
        use crate::file_system::{unsound, Directory, File, SystemType};
+

+
        #[test]
+
        fn root_files() {
+
            let mut directory = Directory::root();
+
            directory.insert_file(
+
                unsound::path::new("foo.hs"),
+
                File::new(b"module BananaFoo ..."),
+
            );
+
            directory.insert_file(
+
                unsound::path::new("bar.hs"),
+
                File::new(b"module BananaBar ..."),
+
            );
+
            directory.insert_file(
+
                unsound::path::new("baz.hs"),
+
                File::new(b"module BananaBaz ..."),
+
            );
+

+
            assert_eq!(
+
                directory.list_directory(),
+
                vec![
+
                    SystemType::file(unsound::label::new("bar.hs")),
+
                    SystemType::file(unsound::label::new("baz.hs")),
+
                    SystemType::file(unsound::label::new("foo.hs")),
+
                ]
+
            );
+
        }
+
    }
+

+
    #[cfg(test)]
+
    mod find_file {
+
        use crate::file_system::{unsound, *};
+

+
        #[test]
+
        fn in_root() {
+
            let file = File::new(b"module Banana ...");
+
            let mut directory = Directory::root();
+
            directory.insert_file(unsound::path::new("foo.hs"), file.clone());
+

+
            assert_eq!(
+
                directory.find_file(unsound::path::new("foo.hs")),
+
                Some(file)
+
            );
+
        }
+

+
        #[test]
+
        fn file_does_not_exist() {
+
            let file_path = unsound::path::new("bar.hs");
+

+
            let file = File::new(b"module Banana ...");
+

+
            let mut directory = Directory::root();
+
            directory.insert_file(unsound::path::new("foo.hs"), file);
+

+
            assert_eq!(directory.find_file(file_path), None)
+
        }
+
    }
+

+
    #[cfg(test)]
+
    mod directory_size {
+
        use crate::file_system::{unsound, Directory, File};
+
        use nonempty::NonEmpty;
+

+
        #[test]
+
        fn root_directory_files() {
+
            let mut root = Directory::root();
+
            root.insert_files(
+
                &[],
+
                NonEmpty::from((
+
                    (
+
                        unsound::label::new("main.rs"),
+
                        File::new(b"println!(\"Hello, world!\")"),
+
                    ),
+
                    vec![(
+
                        unsound::label::new("lib.rs"),
+
                        File::new(b"struct Hello(String)"),
+
                    )],
+
                )),
+
            );
+

+
            assert_eq!(root.size(), 45);
+
        }
+
    }
+

+
    #[cfg(test)]
+
    mod properties {
+
        use crate::file_system::{unsound, *};
+
        use nonempty::NonEmpty;
+
        use proptest::{collection, prelude::*};
+
        use std::collections::HashMap;
+

+
        #[test]
+
        fn test_all_directories_and_files() {
+
            let mut directory_map = HashMap::new();
+

+
            let path1 = unsound::path::new("foo/bar/baz");
+
            let file1 = (unsound::label::new("monadic.rs"), File::new(&[]));
+
            let file2 = (unsound::label::new("oscoin.rs"), File::new(&[]));
+
            directory_map.insert(path1, NonEmpty::from((file1, vec![file2])));
+

+
            let path2 = unsound::path::new("foor/bar/quuz");
+
            let file3 = (unsound::label::new("radicle.rs"), File::new(&[]));
+

+
            directory_map.insert(path2, NonEmpty::new(file3));
+

+
            assert!(prop_all_directories_and_files(directory_map))
+
        }
+

+
        fn label_strategy() -> impl Strategy<Value = Label> {
+
            // ASCII regex, excluding '/' because of posix file paths
+
            "[ -.|0-~]+".prop_map(|label| unsound::label::new(&label))
+
        }
+

+
        fn path_strategy(max_size: usize) -> impl Strategy<Value = Path> {
+
            (
+
                label_strategy(),
+
                collection::vec(label_strategy(), 0..max_size),
+
            )
+
                .prop_map(|(label, labels)| Path((label, labels).into()))
+
        }
+

+
        fn file_strategy() -> impl Strategy<Value = (Label, File)> {
+
            // ASCII regex, see: https://catonmat.net/my-favorite-regex
+
            (label_strategy(), "[ -~]*")
+
                .prop_map(|(name, contents)| (name, File::new(contents.as_bytes())))
+
        }
+

+
        fn directory_map_strategy(
+
            path_size: usize,
+
            n_files: usize,
+
            map_size: usize,
+
        ) -> impl Strategy<Value = HashMap<Path, NonEmpty<(Label, File)>>> {
+
            collection::hash_map(
+
                path_strategy(path_size),
+
                collection::vec(file_strategy(), 1..n_files).prop_map(|files| {
+
                    NonEmpty::from_slice(&files).expect("Strategy generated files of length 0")
+
                }),
+
                0..map_size,
+
            )
+
        }
+

+
        // TODO(fintan): This is a bit slow. Could be time to benchmark some functions.
+
        proptest! {
+
            #[test]
+
            fn prop_test_all_directories_and_files(directory_map in directory_map_strategy(10, 10, 10)) {
+
                prop_all_directories_and_files(directory_map);
+
            }
+
        }
+

+
        fn prop_all_directories_and_files(
+
            directory_map: HashMap<Path, NonEmpty<(Label, File)>>,
+
        ) -> bool {
+
            let mut new_directory_map = HashMap::new();
+
            for (path, files) in directory_map {
+
                new_directory_map.insert(path.clone(), files);
+
            }
+

+
            let directory = Directory::from_hash_map(new_directory_map.clone());
+

+
            for (directory_path, files) in new_directory_map {
+
                for (file_name, _) in files.iter() {
+
                    let mut path = directory_path.clone();
+
                    if directory.find_directory(path.clone()).is_none() {
+
                        eprintln!("Search Directory: {:#?}", directory);
+
                        eprintln!("Path to find: {:#?}", path);
+
                        return false;
+
                    }
+

+
                    path.push(file_name.clone());
+
                    if directory.find_file(path.clone()).is_none() {
+
                        eprintln!("Search Directory: {:#?}", directory);
+
                        eprintln!("Path to find: {:#?}", path);
+
                        return false;
+
                    }
+
                }
+
            }
+
            true
+
        }
+

+
        #[test]
+
        fn test_file_name_is_same_as_root() {
+
            // This test ensures that if the name is the same the root of the
+
            // directory, that search_path.split_last() doesn't toss away the prefix.
+
            let path = unsound::path::new("foo/bar/~");
+
            let mut directory_map = HashMap::new();
+
            directory_map.insert(path, NonEmpty::new((Label::root(), File::new(b"root"))));
+

+
            assert!(prop_all_directories_and_files(directory_map));
+
        }
+
    }
+
}
added radicle-surf/src/file_system/error.rs
@@ -0,0 +1,79 @@
+
// This file is part of radicle-surf
+
// <https://github.com/radicle-dev/radicle-surf>
+
//
+
// Copyright (C) 2019-2020 The Radicle Team <dev@radicle.xyz>
+
//
+
// This program is free software: you can redistribute it and/or modify
+
// it under the terms of the GNU General Public License version 3 or
+
// later as published by the Free Software Foundation.
+
//
+
// This program is distributed in the hope that it will be useful,
+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+
// GNU General Public License for more details.
+
//
+
// You should have received a copy of the GNU General Public License
+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
+

+
//! Errors that can occur within the file system logic.
+
//!
+
//! These errors occur due to [`Label`](super::path::Label) and
+
//! [`Path`](super::path::Path) parsing when using their respective `TryFrom`
+
//! instances.
+

+
use std::ffi::OsStr;
+
use thiserror::Error;
+

+
pub(crate) const EMPTY_PATH: Error = Error::Path(PathError::Empty);
+
pub(crate) const EMPTY_LABEL: Error = Error::Label(LabelError::Empty);
+

+
/// Build an [`Error::Label(LabelError::InvalidUTF8)`] from an
+
/// [`OsStr`](std::ffi::OsStr)
+
pub(crate) fn label_invalid_utf8(item: &OsStr) -> Error {
+
    Error::Label(LabelError::InvalidUTF8 {
+
        label: item.to_string_lossy().into(),
+
    })
+
}
+

+
/// Build an [`Error::Label(LabelError::ContainsSlash)`] from a [`str`]
+
pub(crate) fn label_has_slash(item: &str) -> Error {
+
    Error::Label(LabelError::ContainsSlash { label: item.into() })
+
}
+

+
/// Error type for all file system errors that can occur.
+
#[derive(Debug, Clone, PartialEq, Eq, Error)]
+
#[non_exhaustive]
+
pub enum Error {
+
    /// A `LabelError` specific error for parsing a
+
    /// [`Path`](super::path::Label).
+
    #[error(transparent)]
+
    Label(#[from] LabelError),
+
    /// A `PathError` specific error for parsing a [`Path`](super::path::Path).
+
    #[error(transparent)]
+
    Path(#[from] PathError),
+
}
+

+
/// Parse errors for when parsing a string to a [`Path`](super::path::Path).
+
#[derive(Debug, Clone, PartialEq, Eq, Error)]
+
#[non_exhaustive]
+
pub enum PathError {
+
    /// An error signifying that a [`Path`](super::path::Path) is empty.
+
    #[error("path is empty")]
+
    Empty,
+
}
+

+
/// Parse errors for when parsing a string to a [`Label`](super::path::Label).
+
#[derive(Debug, Clone, PartialEq, Eq, Error)]
+
#[non_exhaustive]
+
pub enum LabelError {
+
    /// An error signifying that a [`Label`](super::path::Label) is contains
+
    /// invalid UTF-8.
+
    #[error("label '{label}' contains invalid UTF-8")]
+
    InvalidUTF8 { label: String },
+
    /// An error signifying that a [`Label`](super::path::Label) contains a `/`.
+
    #[error("label '{label}' contains a slash")]
+
    ContainsSlash { label: String },
+
    /// An error signifying that a [`Label`](super::path::Label) is empty.
+
    #[error("label is empty")]
+
    Empty,
+
}
added radicle-surf/src/file_system/path.rs
@@ -0,0 +1,470 @@
+
// This file is part of radicle-surf
+
// <https://github.com/radicle-dev/radicle-surf>
+
//
+
// Copyright (C) 2019-2020 The Radicle Team <dev@radicle.xyz>
+
//
+
// This program is free software: you can redistribute it and/or modify
+
// it under the terms of the GNU General Public License version 3 or
+
// later as published by the Free Software Foundation.
+
//
+
// This program is distributed in the hope that it will be useful,
+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+
// GNU General Public License for more details.
+
//
+
// You should have received a copy of the GNU General Public License
+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
+

+
use std::fmt::Write as _;
+

+
use crate::{file_system::error, nonempty::split_last};
+
use nonempty::NonEmpty;
+
use std::{convert::TryFrom, ffi::CString, fmt, ops::Deref, path, str::FromStr};
+

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

+
pub mod unsound;
+

+
/// `Label` is a special case of a `String` identifier for
+
/// [`Directory`](`crate::file_system::directory::Directory`) and
+
/// [`File`](`crate::file_system::directory::File`) names, and is used in
+
/// [`Path`] as the component parts of a path.
+
///
+
/// A `Label` should not be empty or contain `/`s. It is encouraged to use the
+
/// `TryFrom` instance to create a `Label`.
+
#[cfg_attr(feature = "serialize", derive(Serialize))]
+
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
+
pub struct Label {
+
    pub(crate) label: String,
+
    pub(crate) hidden: bool,
+
}
+

+
impl Deref for Label {
+
    type Target = String;
+

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

+
impl Label {
+
    /// The root label for the root directory, i.e. `"~"`.
+
    ///
+
    /// Prefer creating a root [`Path`], by using
+
    /// [`Path::root`](struct.Path.html#method.root).
+
    ///
+
    /// # Examples
+
    ///
+
    /// ```
+
    /// use radicle_surf::file_system::{Label, Path};
+
    ///
+
    /// let root = Path::root();
+
    /// assert_eq!(*root.split_first().0, Label::root());
+
    /// ```
+
    pub fn root() -> Self {
+
        Label {
+
            label: "~".into(),
+
            hidden: false,
+
        }
+
    }
+

+
    /// Check that the label is equivalent to [`Label::root`].
+
    ///
+
    /// # Examples
+
    ///
+
    /// ```
+
    /// use radicle_surf::file_system::Label;
+
    /// use radicle_surf::file_system::unsound;
+
    ///
+
    /// let root = unsound::label::new("~");
+
    /// assert!(root.is_root());
+
    /// ```
+
    pub fn is_root(&self) -> bool {
+
        *self == Self::root()
+
    }
+
}
+

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

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

+
    fn try_from(item: &str) -> Result<Self, Self::Error> {
+
        if item.is_empty() {
+
            Err(error::EMPTY_LABEL)
+
        } else if item.contains('/') {
+
            Err(error::label_has_slash(item))
+
        } else {
+
            Ok(Label {
+
                label: item.into(),
+
                hidden: false,
+
            })
+
        }
+
    }
+
}
+

+
impl FromStr for Label {
+
    type Err = error::Error;
+

+
    fn from_str(item: &str) -> Result<Self, Self::Err> {
+
        Label::try_from(item)
+
    }
+
}
+

+
/// A non-empty set of [`Label`]s to define a path to a directory or file.
+
///
+
/// `Path` tends to be used for insertion or find operations.
+
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+
pub struct Path(pub NonEmpty<Label>);
+

+
impl fmt::Display for Path {
+
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+
        let (prefix, suffix) = self.clone().split_last();
+
        for p in prefix {
+
            if p.is_root() {
+
                continue;
+
            }
+
            write!(f, "{}/", p)?;
+
        }
+
        write!(f, "{}", suffix)
+
    }
+
}
+

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

+
    fn try_from(item: &str) -> Result<Self, Self::Error> {
+
        let mut path = Vec::new();
+

+
        for label in item.trim_end_matches('/').split('/') {
+
            let l = Label::try_from(label)?;
+
            path.push(l);
+
        }
+

+
        NonEmpty::from_slice(&path)
+
            .ok_or(error::EMPTY_PATH)
+
            .map(Path)
+
    }
+
}
+

+
impl FromStr for Path {
+
    type Err = error::Error;
+

+
    fn from_str(item: &str) -> Result<Self, Self::Err> {
+
        Path::try_from(item)
+
    }
+
}
+

+
impl From<Path> for Vec<Label> {
+
    fn from(path: Path) -> Self {
+
        path.0.into()
+
    }
+
}
+

+
impl git2::IntoCString for Path {
+
    fn into_c_string(self) -> Result<CString, git2::Error> {
+
        if self.is_root() {
+
            // the root pathsec is empty
+
            "".into_c_string()
+
        } else {
+
            // build the file path pathsec
+
            let path = self.0.tail;
+
            let mut pathspec = "".to_string();
+
            for p in path.iter() {
+
                // If we have a label such as 'faux\path' we need to double escape it for
+
                // `git2::DiffOptions::pathspec` to work properly. As far as we're aware this is
+
                // the only use of IntoCString for Path.
+
                let label = p.label.replace('\\', "\\\\");
+
                let _ = write!(pathspec, "{label}/");
+
            }
+
            let pathspec = pathspec.trim_end_matches('/');
+
            pathspec.into_c_string()
+
        }
+
    }
+
}
+

+
#[cfg(feature = "serialize")]
+
impl Serialize for Path {
+
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+
    where
+
        S: Serializer,
+
    {
+
        serializer.serialize_str(self.to_string().as_str())
+
    }
+
}
+

+
impl Path {
+
    /// Create a new `Path` with a single [`Label`].
+
    pub fn new(label: Label) -> Path {
+
        Path(NonEmpty::new(label))
+
    }
+

+
    /// The root path is a `Path` made up of the single root label (see:
+
    /// [`Label::root`](#method.root).
+
    ///
+
    /// # Examples
+
    ///
+
    /// ```
+
    /// use radicle_surf::file_system::{Label, Path};
+
    ///
+
    /// let root = Path::root();
+
    /// assert_eq!(*root.split_first().0, Label::root());
+
    /// ```
+
    pub fn root() -> Self {
+
        Path(NonEmpty::new(Label::root()))
+
    }
+

+
    /// Check that this is the root path.
+
    ///
+
    /// # Examples
+
    ///
+
    /// ```
+
    /// use radicle_surf::file_system::Path;
+
    /// use radicle_surf::file_system::unsound;
+
    /// use std::convert::TryFrom;
+
    ///
+
    /// let root = Path::root();
+
    /// let not_root = unsound::path::new("src/lib.rs");
+
    ///
+
    /// assert!(root.is_root());
+
    /// assert!(!not_root.is_root());
+
    /// ```
+
    pub fn is_root(&self) -> bool {
+
        *self == Self::root()
+
    }
+

+
    /// Append two `Path`s together.
+
    ///
+
    /// # Examples
+
    ///
+
    /// ```
+
    /// use radicle_surf::file_system::Path;
+
    /// use radicle_surf::file_system::unsound;
+
    /// use std::convert::TryFrom;
+
    ///
+
    /// let mut path1 = unsound::path::new("foo/bar");
+
    /// let path2 = unsound::path::new("baz/quux");
+
    /// path1.append(path2);
+
    /// let expected = unsound::path::new("foo/bar/baz/quux");
+
    /// assert_eq!(path1, expected);
+
    /// ```
+
    pub fn append(&mut self, path: Self) {
+
        let mut other = path.0.into();
+
        self.0.append(&mut other)
+
    }
+

+
    /// Push a new [`Label`] onto the `Path`.
+
    ///
+
    /// # Examples
+
    ///
+
    /// ```
+
    /// use radicle_surf::file_system::{Label, Path};
+
    /// use radicle_surf::file_system::unsound;
+
    ///
+
    /// let mut root = Path::root();
+
    /// root.push(unsound::label::new("src"));
+
    /// root.push(unsound::label::new("lib.rs"));
+
    ///
+
    /// assert_eq!(root, unsound::path::new("~/src/lib.rs"));
+
    /// ```
+
    pub fn push(&mut self, label: Label) {
+
        self.0.push(label)
+
    }
+

+
    /// Pop the [`Label`] from the end of the tail.
+
    ///
+
    /// # Examples
+
    ///
+
    /// ```
+
    /// use radicle_surf::file_system::{Label, Path};
+
    /// use radicle_surf::file_system::unsound;
+
    ///
+
    /// let mut root = Path::root();
+
    /// root.push(unsound::label::new("src"));
+
    /// root.push(unsound::label::new("lib.rs"));
+
    ///
+
    /// assert_eq!(root.pop(), Some(unsound::label::new("lib.rs")));
+
    /// ```
+
    pub fn pop(&mut self) -> Option<Label> {
+
        self.0.pop()
+
    }
+

+
    /// Iterator over the [`Label`]s in the `Path`.
+
    ///
+
    /// # Examples
+
    ///
+
    /// ```
+
    /// use radicle_surf::file_system::{Label, Path};
+
    /// use radicle_surf::file_system::unsound;
+
    ///
+
    /// let path = unsound::path::new("~/src/lib.rs");
+
    /// let mut path_iter = path.iter();
+
    ///
+
    /// assert_eq!(path_iter.next(), Some(&Label::root()));
+
    /// assert_eq!(path_iter.next(), Some(&unsound::label::new("src")));
+
    /// assert_eq!(path_iter.next(), Some(&unsound::label::new("lib.rs")));
+
    /// ```
+
    pub fn iter(&self) -> impl Iterator<Item = &Label> {
+
        self.0.iter()
+
    }
+

+
    /// Get the first [`Label`] in the `Path` and the rest of the [`Label`]s
+
    /// after it.
+
    ///
+
    /// # Examples
+
    ///
+
    /// ```
+
    /// use radicle_surf::file_system::{Label, Path};
+
    /// use radicle_surf::file_system::unsound;
+
    ///
+
    /// let path = unsound::path::new("~/src/lib.rs");
+
    ///
+
    /// assert_eq!(
+
    ///     path.split_first(),
+
    ///     (&Label::root(), &[unsound::label::new("src"), unsound::label::new("lib.rs")][..])
+
    /// );
+
    /// ```
+
    pub fn split_first(&self) -> (&Label, &[Label]) {
+
        self.0.split_first()
+
    }
+

+
    /// Get the prefix of the [`Label`]s and the last [`Label`].
+
    ///
+
    /// This is useful when the prefix is a directory path and the last label is
+
    /// a file name.
+
    ///
+
    /// # Examples
+
    ///
+
    /// ```
+
    /// use radicle_surf::file_system::{Label, Path};
+
    /// use radicle_surf::file_system::unsound;
+
    ///
+
    /// let path = unsound::path::new("~/src/lib.rs");
+
    /// assert_eq!(path.split_last(), (vec![Label::root(), unsound::label::new("src")], unsound::label::new("lib.rs")));
+
    /// ```
+
    ///
+
    /// ```
+
    /// use radicle_surf::file_system::{Label, Path};
+
    /// use radicle_surf::file_system::unsound;
+
    ///
+
    /// let path = unsound::path::new("foo/bar/baz");
+
    /// assert_eq!(
+
    ///     path.split_last(),
+
    ///     (vec![unsound::label::new("foo"), unsound::label::new("bar")], unsound::label::new("baz"))
+
    /// );
+
    /// ```
+
    pub fn split_last(self) -> (Vec<Label>, Label) {
+
        split_last(self.0)
+
    }
+

+
    /// Construct a `Path` given at least one [`Label`] followed by 0 or more
+
    /// [`Label`]s.
+
    ///
+
    /// # Examples
+
    ///
+
    /// ```
+
    /// use nonempty::NonEmpty;
+
    /// use radicle_surf::file_system::{Path, Label};
+
    /// use radicle_surf::file_system::unsound;
+
    ///
+
    /// let path = Path::from_labels(
+
    ///     Label::root(),
+
    ///     &[unsound::label::new("foo"), unsound::label::new("bar"), unsound::label::new("baz.rs")]
+
    /// );
+
    ///
+
    /// let mut expected = Path::root();
+
    /// expected.push(unsound::label::new("foo"));
+
    /// expected.push(unsound::label::new("bar"));
+
    /// expected.push(unsound::label::new("baz.rs"));
+
    ///
+
    /// assert_eq!(path, expected);
+
    /// let path_vec: Vec<Label> = path.0.into();
+
    /// assert_eq!(
+
    ///     path_vec,
+
    ///     vec![Label::root(), unsound::label::new("foo"), unsound::label::new("bar"),
+
    ///     unsound::label::new("baz.rs")]
+
    /// );
+
    /// ```
+
    pub fn from_labels(root: Label, labels: &[Label]) -> Path {
+
        Path((root, labels.to_vec()).into())
+
    }
+

+
    /// Construct a `Path` using [`Label::root`](#method.root) as the head of
+
    /// the `Path.
+
    ///
+
    /// # Examples
+
    ///
+
    /// ```
+
    /// use nonempty::NonEmpty;
+
    /// use radicle_surf::file_system::{Label, Path};
+
    /// use radicle_surf::file_system::unsound;
+
    ///
+
    /// let path = Path::with_root(
+
    ///     &[unsound::label::new("foo"), unsound::label::new("bar"), unsound::label::new("baz.rs")]
+
    /// );
+
    ///
+
    /// let mut expected = Path::root();
+
    /// expected.push(unsound::label::new("foo"));
+
    /// expected.push(unsound::label::new("bar"));
+
    /// expected.push(unsound::label::new("baz.rs"));
+
    ///
+
    /// assert_eq!(path, expected);
+
    /// let path_vec: Vec<Label> = path.0.into();
+
    /// assert_eq!(
+
    ///     path_vec,
+
    ///     vec![Label::root(), unsound::label::new("foo"), unsound::label::new("bar"),
+
    ///     unsound::label::new("baz.rs")]
+
    /// );
+
    /// ```
+
    pub fn with_root(labels: &[Label]) -> Path {
+
        Path::from_labels(Label::root(), labels)
+
    }
+
}
+

+
impl TryFrom<path::PathBuf> for Path {
+
    type Error = error::Error;
+

+
    fn try_from(path_buf: path::PathBuf) -> Result<Self, Self::Error> {
+
        let mut path = Path::root();
+
        for p in path_buf.iter() {
+
            let p = p.to_str().ok_or_else(|| error::label_invalid_utf8(p))?;
+
            let l = Label::try_from(p)?;
+
            path.push(l);
+
        }
+

+
        Ok(path)
+
    }
+
}
+

+
#[cfg(test)]
+
mod tests {
+
    #[cfg(test)]
+
    mod path {
+
        use crate::file_system::unsound;
+

+
        #[test]
+
        fn split_last_root_and_foo() {
+
            let path = unsound::path::new("foo");
+
            assert_eq!(path.split_last(), (vec![], unsound::label::new("foo")));
+
        }
+

+
        #[test]
+
        fn split_last_same_labels() {
+
            // An interesting case for when first == last, but doesn't imply a singleton
+
            // Path.
+
            let path = unsound::path::new("foo/bar/foo");
+
            assert_eq!(
+
                path.split_last(),
+
                (
+
                    vec![unsound::label::new("foo"), unsound::label::new("bar")],
+
                    unsound::label::new("foo")
+
                )
+
            );
+
        }
+
    }
+
}
added radicle-surf/src/file_system/path/unsound.rs
@@ -0,0 +1,62 @@
+
// This file is part of radicle-surf
+
// <https://github.com/radicle-dev/radicle-surf>
+
//
+
// Copyright (C) 2019-2020 The Radicle Team <dev@radicle.xyz>
+
//
+
// This program is free software: you can redistribute it and/or modify
+
// it under the terms of the GNU General Public License version 3 or
+
// later as published by the Free Software Foundation.
+
//
+
// This program is distributed in the hope that it will be useful,
+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+
// GNU General Public License for more details.
+
//
+
// You should have received a copy of the GNU General Public License
+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
+

+
//! In Irish slang there exists the term "sound". One is a "sound" person if
+
//! they are nice and you can rely on them. This module is the anithesis of
+
//! being "sound", you might say it is "unsound".
+
//!
+
//! The aim of this module is to make testing easier. During test time, _we
+
//! know_ that a string is going to be non-empty because we are using the
+
//! literal `"sound_label"`. The same for knowing that the form
+
//! `"what/a/sound/bunch"` is a valid path.
+
//!
+
//! On the other hand, if we do not control the data coming in we should use the
+
//! more "sound" method of the [`std::convert::TryFrom`] instance for
+
//! [`crate::file_system::Label`] and [`crate::file_system::Path`]
+
//! to ensure we have valid data to use for further operations.
+

+
pub mod path {
+
    //! Unsound creation of [`Path`]s.
+

+
    use crate::file_system::path::Path;
+
    use std::convert::TryFrom;
+

+
    /// **NB**: Use with caution!
+
    ///
+
    /// Calls `try_from` on the input and expects it to not fail.
+
    ///
+
    /// Used for testing and playground purposes.
+
    pub fn new(path: &str) -> Path {
+
        Path::try_from(path).expect("unsafe_path: Failed to parse path")
+
    }
+
}
+

+
pub mod label {
+
    //! Unsound creation of [`Label`]s.
+

+
    use crate::file_system::path::Label;
+
    use std::convert::TryFrom;
+

+
    /// **NB**: Use with caution!
+
    ///
+
    /// Calls `try_from` on the input and expects it to not fail.
+
    ///
+
    /// Used for testing and playground purposes.
+
    pub fn new(path: &str) -> Label {
+
        Label::try_from(path).expect("unsafe_path: Failed to parse label")
+
    }
+
}
added radicle-surf/src/lib.rs
@@ -0,0 +1,93 @@
+
// This file is part of radicle-surf
+
// <https://github.com/radicle-dev/radicle-surf>
+
//
+
// Copyright (C) 2019-2020 The Radicle Team <dev@radicle.xyz>
+
//
+
// This program is free software: you can redistribute it and/or modify
+
// it under the terms of the GNU General Public License version 3 or
+
// later as published by the Free Software Foundation.
+
//
+
// This program is distributed in the hope that it will be useful,
+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+
// GNU General Public License for more details.
+
//
+
// You should have received a copy of the GNU General Public License
+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
+

+
#![deny(missing_docs, unused_import_braces, unused_qualifications, warnings)]
+

+
//! Welcome to `radicle-surf`!
+
//!
+
//! `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.
+
//!
+
//! Let's start surfing (and apologies for the `expect`s):
+
//!
+
//! ```
+
//! use radicle_surf::vcs::git;
+
//! use radicle_surf::file_system::{Label, Path, SystemType};
+
//! use radicle_surf::file_system::unsound;
+
//! use pretty_assertions::assert_eq;
+
//! use std::str::FromStr;
+
//! # use std::error::Error;
+
//!
+
//! # fn main() -> Result<(), Box<dyn Error>> {
+
//! // We're going to point to this repo.
+
//! let repo = git::Repository::new("./data/git-platinum")?;
+
//!
+
//! // Here we initialise a new Broswer for a the git repo.
+
//! let mut browser = git::Browser::new(&repo, git::Branch::local("master"))?;
+
//!
+
//! // Set the history to a particular commit
+
//! let commit = git::Oid::from_str("80ded66281a4de2889cc07293a8f10947c6d57fe")?;
+
//! browser.commit(commit)?;
+
//!
+
//! // Get the snapshot of the directory for our current HEAD of history.
+
//! let directory = browser.get_directory()?;
+
//!
+
//! // Let's get a Path to the memory.rs file
+
//! let memory = unsound::path::new("src/memory.rs");
+
//!
+
//! // And assert that we can find it!
+
//! assert!(directory.find_file(memory).is_some());
+
//!
+
//! let root_contents = directory.list_directory();
+
//!
+
//! assert_eq!(root_contents, vec![
+
//!     SystemType::file(unsound::label::new(".i-am-well-hidden")),
+
//!     SystemType::file(unsound::label::new(".i-too-am-hidden")),
+
//!     SystemType::file(unsound::label::new("README.md")),
+
//!     SystemType::directory(unsound::label::new("bin")),
+
//!     SystemType::directory(unsound::label::new("src")),
+
//!     SystemType::directory(unsound::label::new("text")),
+
//!     SystemType::directory(unsound::label::new("this")),
+
//! ]);
+
//!
+
//! let src = directory
+
//!     .find_directory(Path::new(unsound::label::new("src")))
+
//!     .expect("failed to find src");
+
//! let src_contents = src.list_directory();
+
//!
+
//! assert_eq!(src_contents, vec![
+
//!     SystemType::file(unsound::label::new("Eval.hs")),
+
//!     SystemType::file(unsound::label::new("Folder.svelte")),
+
//!     SystemType::file(unsound::label::new("memory.rs")),
+
//! ]);
+
//! #
+
//! # Ok(())
+
//! # }
+
//! ```
+
pub mod diff;
+
pub mod file_system;
+
pub mod vcs;
+

+
// Private modules
+
mod nonempty;
+
mod tree;
+

+
pub use crate::vcs::git;
added radicle-surf/src/nonempty.rs
@@ -0,0 +1,33 @@
+
// This file is part of radicle-surf
+
// <https://github.com/radicle-dev/radicle-surf>
+
//
+
// Copyright (C) 2019-2020 The Radicle Team <dev@radicle.xyz>
+
//
+
// This program is free software: you can redistribute it and/or modify
+
// it under the terms of the GNU General Public License version 3 or
+
// later as published by the Free Software Foundation.
+
//
+
// This program is distributed in the hope that it will be useful,
+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+
// GNU General Public License for more details.
+
//
+
// You should have received a copy of the GNU General Public License
+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
+

+
use nonempty::NonEmpty;
+

+
pub fn split_last<T>(non_empty: NonEmpty<T>) -> (Vec<T>, T)
+
where
+
    T: Eq,
+
{
+
    let (head, mut tail) = non_empty.into();
+
    let last = tail.pop();
+
    match last {
+
        None => (vec![], head),
+
        Some(last) => {
+
            tail.insert(0, head);
+
            (tail, last)
+
        },
+
    }
+
}
added radicle-surf/src/tree.rs
@@ -0,0 +1,1191 @@
+
// This file is part of radicle-surf
+
// <https://github.com/radicle-dev/radicle-surf>
+
//
+
// Copyright (C) 2019-2020 The Radicle Team <dev@radicle.xyz>
+
//
+
// This program is free software: you can redistribute it and/or modify
+
// it under the terms of the GNU General Public License version 3 or
+
// later as published by the Free Software Foundation.
+
//
+
// This program is distributed in the hope that it will be useful,
+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+
// GNU General Public License for more details.
+
//
+
// You should have received a copy of the GNU General Public License
+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
+

+
use crate::nonempty::split_last;
+
use nonempty::NonEmpty;
+
use std::cmp::Ordering;
+

+
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
+
pub enum SubTree<K, A> {
+
    Node { key: K, value: A },
+
    Branch { key: K, forest: Box<Tree<K, A>> },
+
}
+

+
impl<K, A> SubTree<K, A> {
+
    /// Create a new `Branch` from a key and sub-tree.
+
    ///
+
    /// This function is a convenience for now having to
+
    /// remember to use `Box::new`.
+
    fn branch(key: K, tree: Tree<K, A>) -> Self {
+
        SubTree::Branch {
+
            key,
+
            forest: Box::new(tree),
+
        }
+
    }
+

+
    fn key(&self) -> &K {
+
        match self {
+
            SubTree::Node { key, .. } => key,
+
            SubTree::Branch { key, .. } => key,
+
        }
+
    }
+

+
    pub fn find(&self, keys: NonEmpty<K>) -> Option<&Self>
+
    where
+
        K: Ord,
+
    {
+
        let (head, tail) = keys.into();
+
        let tail = NonEmpty::from_vec(tail);
+
        match self {
+
            SubTree::Node { key, .. } => match tail {
+
                None if *key == head => Some(self),
+
                _ => None,
+
            },
+
            SubTree::Branch { key, ref forest } => match tail {
+
                None if *key == head => Some(self),
+
                None => None,
+
                Some(keys) => forest.find(keys),
+
            },
+
        }
+
    }
+

+
    pub fn to_nonempty(&self) -> NonEmpty<A>
+
    where
+
        A: Clone,
+
        K: Clone,
+
    {
+
        match self {
+
            Self::Node { value, .. } => NonEmpty::new(value.clone()),
+
            Self::Branch { forest, .. } => forest.to_nonempty(),
+
        }
+
    }
+

+
    pub(crate) fn iter<'a>(&'a self) -> Box<dyn Iterator<Item = &A> + 'a> {
+
        match self {
+
            SubTree::Node { value, .. } => Box::new(std::iter::once(value)),
+
            SubTree::Branch { ref forest, .. } => Box::new(forest.iter()),
+
        }
+
    }
+

+
    fn iter_keys<'a>(&'a self) -> Box<dyn Iterator<Item = &K> + 'a> {
+
        match self {
+
            SubTree::Node { key, .. } => Box::new(std::iter::once(key)),
+
            SubTree::Branch {
+
                ref key,
+
                ref forest,
+
            } => Box::new(std::iter::once(key).chain(forest.iter_keys())),
+
        }
+
    }
+

+
    fn compare_by<F>(&self, other: &Self, f: &F) -> Ordering
+
    where
+
        F: Fn(&A, &A) -> Ordering,
+
    {
+
        match (self, other) {
+
            (
+
                SubTree::Node { value, .. },
+
                SubTree::Node {
+
                    value: other_value, ..
+
                },
+
            ) => f(value, other_value),
+
            (SubTree::Branch { forest, .. }, SubTree::Node { value, .. }) => {
+
                let max_forest = forest.maximum_by(f);
+
                f(max_forest, value)
+
            },
+
            (SubTree::Node { value, .. }, SubTree::Branch { forest, .. }) => {
+
                let max_forest = &forest.maximum_by(f);
+
                f(value, max_forest)
+
            },
+
            (
+
                SubTree::Branch { forest, .. },
+
                SubTree::Branch {
+
                    forest: other_forest,
+
                    ..
+
                },
+
            ) => {
+
                let max_forest = forest.maximum_by(f);
+
                let max_other_forest = other_forest.maximum_by(f);
+
                f(max_forest, max_other_forest)
+
            },
+
        }
+
    }
+

+
    pub fn maximum_by<F>(&self, f: &F) -> &A
+
    where
+
        F: Fn(&A, &A) -> Ordering,
+
    {
+
        match self {
+
            SubTree::Node { value, .. } => value,
+
            SubTree::Branch { forest, .. } => forest.maximum_by(f),
+
        }
+
    }
+

+
    pub fn map<F, B>(self, f: &mut F) -> SubTree<K, B>
+
    where
+
        F: FnMut(A) -> B,
+
    {
+
        match self {
+
            SubTree::Node { key, value } => SubTree::Node {
+
                key,
+
                value: f(value),
+
            },
+
            SubTree::Branch { key, forest } => SubTree::Branch {
+
                key,
+
                forest: Box::new(forest.map(f)),
+
            },
+
        }
+
    }
+
}
+

+
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
+
pub struct Tree<K, A>(pub(crate) NonEmpty<SubTree<K, A>>);
+

+
impl<K, A> From<Tree<K, A>> for Forest<K, A> {
+
    fn from(tree: Tree<K, A>) -> Self {
+
        Forest(Some(tree))
+
    }
+
}
+

+
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
+
pub struct Forest<K, A>(pub(crate) Option<Tree<K, A>>);
+

+
impl<K, A> Tree<K, A> {
+
    /// Create a new `Tree` containing a single `Branch` given
+
    /// the key and sub-tree.
+
    fn branch(key: K, forest: Self) -> Self {
+
        Tree(NonEmpty::new(SubTree::branch(key, forest)))
+
    }
+

+
    /// Create a new `Tree` containing a single `Node`.
+
    fn node(key: K, value: A) -> Self {
+
        Tree(NonEmpty::new(SubTree::Node { key, value }))
+
    }
+

+
    /// Create a new `Tree` that creates a series of
+
    /// `Branch`es built using the `keys`. The final `Branch`
+
    /// will contain the `node`.
+
    fn new(keys: NonEmpty<K>, node: A) -> Self
+
    where
+
        K: Ord,
+
    {
+
        let (start, mut middle) = keys.into();
+
        let last = middle.pop();
+

+
        match last {
+
            None => Tree::node(start, node),
+
            Some(last) => {
+
                let mut branch = Tree::node(last, node);
+

+
                for key in middle.into_iter().rev() {
+
                    branch = Tree(NonEmpty::new(SubTree::branch(key, branch)))
+
                }
+

+
                Tree::branch(start, branch)
+
            },
+
        }
+
    }
+

+
    /// Perform a binary search in the sub-trees, based on comparing
+
    /// each of the sub-trees' key to the provided `key`.
+
    fn search(&self, key: &K) -> Result<usize, usize>
+
    where
+
        K: Ord,
+
    {
+
        self.0.binary_search_by(|tree| tree.key().cmp(key))
+
    }
+

+
    pub fn map<F, B>(self, mut f: F) -> Tree<K, B>
+
    where
+
        F: FnMut(A) -> B,
+
    {
+
        Tree(self.0.map(|tree| tree.map(&mut f)))
+
    }
+

+
    /// Insert a `node` into the list of sub-trees.
+
    ///
+
    /// The node's position will be based on the `Ord` instance
+
    /// of `K`.
+
    fn insert_node_with<F>(&mut self, key: K, value: A, f: F)
+
    where
+
        F: FnOnce(&mut A),
+
        K: Ord,
+
    {
+
        let result = self.search(&key);
+

+
        match result {
+
            Ok(index) => {
+
                let old_node = self.0.get_mut(index).unwrap();
+
                match old_node {
+
                    SubTree::Node { value: old, .. } => f(old),
+
                    SubTree::Branch { .. } => *old_node = SubTree::Node { key, value },
+
                }
+
            },
+
            Err(index) => self.0.insert(index, SubTree::Node { key, value }),
+
        }
+
    }
+

+
    /// Insert the `node` in the position given by `keys`.
+
    ///
+
    /// If the same path to a node is provided the `node` will replace the old
+
    /// one, i.e. if `a/b/c` exists in the tree and `a/b/c` is the full path
+
    /// to the node, then `c` will be replaced.
+
    ///
+
    /// If the path points to a branch, then the `node` will be inserted in this
+
    /// branch.
+
    ///
+
    /// If a portion of the path points to a node then a branch will be created
+
    /// in its place, i.e. if `a/b/c` exists in the tree and the provided
+
    /// path is `a/b/c/d`, then the node `c` will be replaced by a branch
+
    /// `c/d`.
+
    ///
+
    /// If the path does not exist it will be inserted into the set of
+
    /// sub-trees.
+
    fn insert_with<F>(&mut self, keys: NonEmpty<K>, value: A, f: F)
+
    where
+
        F: FnOnce(&mut A),
+
        K: Ord,
+
    {
+
        let (head, tail) = keys.into();
+
        let maybe_keys = NonEmpty::from_vec(tail);
+
        match self.search(&head) {
+
            // Found the label in our set of sub-trees
+
            Ok(index) => match maybe_keys {
+
                // The keys have been exhausted and so its time to insert the node
+
                None => {
+
                    let sub_tree = self.0.get_mut(index).unwrap();
+
                    match sub_tree {
+
                        // Our sub-tree was a node.
+
                        SubTree::Node { key, value } => {
+
                            let _ = std::mem::replace(key, head);
+
                            f(value);
+
                        },
+
                        SubTree::Branch { .. } => *sub_tree = SubTree::Node { key: head, value },
+
                    }
+
                },
+
                Some(keys) => {
+
                    let sub_tree = self.0.get_mut(index).unwrap();
+
                    match sub_tree {
+
                        // We have reached a node, but still have keys left to get through.
+
                        SubTree::Node { .. } => {
+
                            let new_tree = SubTree::branch(head, Tree::new(keys, value));
+
                            *sub_tree = new_tree
+
                        },
+
                        // We keep moving down the set of keys to find where to insert this node.
+
                        SubTree::Branch { forest, .. } => forest.insert_with(keys, value, f),
+
                    }
+
                },
+
            },
+
            // The label was not found and we have an index for insertion.
+
            Err(index) => match maybe_keys {
+
                // We create the branch with the head label and node, since there are
+
                // no more labels left.
+
                None => self.0.insert(index, SubTree::Node { key: head, value }),
+
                // We insert an entirely new branch with the full list of keys.
+
                Some(tail) => self
+
                    .0
+
                    .insert(index, SubTree::branch(head, Tree::new(tail, value))),
+
            },
+
        }
+
    }
+

+
    pub fn insert(&mut self, keys: NonEmpty<K>, value: A)
+
    where
+
        A: Clone,
+
        K: Ord,
+
    {
+
        self.insert_with(keys, value.clone(), |old| *old = value)
+
    }
+

+
    pub fn to_nonempty(&self) -> NonEmpty<A>
+
    where
+
        A: Clone,
+
        K: Clone,
+
    {
+
        self.0.clone().flat_map(|sub_tree| sub_tree.to_nonempty())
+
    }
+

+
    pub fn iter<'a>(&'a self) -> impl Iterator<Item = &A> + 'a {
+
        self.0.iter().flat_map(|tree| tree.iter())
+
    }
+

+
    pub fn iter_keys<'a>(&'a self) -> impl Iterator<Item = &K> + 'a {
+
        self.0.iter().flat_map(|tree| tree.iter_keys())
+
    }
+

+
    pub fn iter_subtrees<'a>(&'a self) -> impl Iterator<Item = &SubTree<K, A>> + 'a {
+
        self.0.iter()
+
    }
+

+
    pub fn find_node(&self, keys: NonEmpty<K>) -> Option<&A>
+
    where
+
        K: Ord,
+
    {
+
        self.find(keys).and_then(|tree| match tree {
+
            SubTree::Node { value, .. } => Some(value),
+
            SubTree::Branch { .. } => None,
+
        })
+
    }
+

+
    pub fn find_branch(&self, keys: NonEmpty<K>) -> Option<&Self>
+
    where
+
        K: Ord,
+
    {
+
        self.find(keys).and_then(|tree| match tree {
+
            SubTree::Node { .. } => None,
+
            SubTree::Branch { ref forest, .. } => Some(&**forest),
+
        })
+
    }
+

+
    /// Find a `SubTree` given a search path. If the path does not match
+
    /// it will return `None`.
+
    pub fn find(&self, keys: NonEmpty<K>) -> Option<&SubTree<K, A>>
+
    where
+
        K: Ord,
+
    {
+
        let (head, tail) = keys.into();
+
        let tail = NonEmpty::from_vec(tail);
+
        match self.search(&head) {
+
            Err(_) => None,
+
            Ok(index) => {
+
                let sub_tree = self.0.get(index).unwrap();
+
                match tail {
+
                    None => match sub_tree {
+
                        SubTree::Node { .. } => Some(sub_tree),
+
                        SubTree::Branch { .. } => Some(sub_tree),
+
                    },
+
                    Some(mut tail) => {
+
                        tail.insert(0, head);
+
                        sub_tree.find(tail)
+
                    },
+
                }
+
            },
+
        }
+
    }
+

+
    pub fn maximum_by<F>(&self, f: &F) -> &A
+
    where
+
        F: Fn(&A, &A) -> Ordering,
+
    {
+
        self.0.maximum_by(|s, t| s.compare_by(t, f)).maximum_by(f)
+
    }
+

+
    #[allow(dead_code)]
+
    pub fn maximum(&self) -> &A
+
    where
+
        A: Ord,
+
    {
+
        self.maximum_by(&|a, b| a.cmp(b))
+
    }
+
}
+

+
impl<K, A> Forest<K, A> {
+
    pub fn root() -> Self {
+
        Forest(None)
+
    }
+

+
    #[allow(dead_code)]
+
    pub fn is_empty(&self) -> bool {
+
        self.0.is_none()
+
    }
+

+
    fn insert_forest(&mut self, forest: Tree<K, A>) {
+
        self.0 = Some(forest)
+
    }
+

+
    /// Insert the `node` in the position given by `keys`.
+
    ///
+
    /// If the same path to a node is provided the `node` will replace the old
+
    /// one, i.e. if `a/b/c` exists in the tree and `a/b/c` is the full path
+
    /// to the node, then `c` will be replaced.
+
    ///
+
    /// If the path points to a branch, then the `node` will be inserted in this
+
    /// branch.
+
    ///
+
    /// If a portion of the path points to a node then a branch will be created
+
    /// in its place, i.e. if `a/b/c` exists in the tree and the provided
+
    /// path is `a/b/c/d`, then the node `c` will be replaced by a branch
+
    /// `c/d`.
+
    ///
+
    /// If the path does not exist it will be inserted into the set of
+
    /// sub-trees.
+
    #[allow(dead_code)]
+
    pub fn insert(&mut self, keys: NonEmpty<K>, node: A)
+
    where
+
        A: Clone,
+
        K: Ord,
+
    {
+
        self.insert_with(keys, node.clone(), |old| *old = node)
+
    }
+

+
    pub fn insert_with<F>(&mut self, keys: NonEmpty<K>, node: A, f: F)
+
    where
+
        F: FnOnce(&mut A),
+
        K: Ord,
+
    {
+
        let (prefix, node_key) = split_last(keys);
+
        match self.0.as_mut() {
+
            Some(forest) => match NonEmpty::from_vec(prefix) {
+
                None => {
+
                    // Insert the node at the root
+
                    forest.insert_node_with(node_key, node, f)
+
                },
+
                Some(mut keys) => {
+
                    keys.push(node_key);
+
                    forest.insert_with(keys, node, f)
+
                },
+
            },
+
            None => match NonEmpty::from_vec(prefix) {
+
                None => self.insert_forest(Tree::node(node_key, node)),
+
                Some(mut keys) => {
+
                    keys.push(node_key);
+
                    self.insert_forest(Tree::new(keys, node))
+
                },
+
            },
+
        }
+
    }
+

+
    pub fn find_node(&self, keys: NonEmpty<K>) -> Option<&A>
+
    where
+
        K: Ord,
+
    {
+
        self.0.as_ref().and_then(|trees| trees.find_node(keys))
+
    }
+

+
    pub fn find_branch(&self, keys: NonEmpty<K>) -> Option<&Tree<K, A>>
+
    where
+
        K: Ord,
+
    {
+
        self.0.as_ref().and_then(|trees| trees.find_branch(keys))
+
    }
+

+
    #[allow(dead_code)]
+
    /// Find a `SubTree` given a search path. If the path does not match
+
    /// it will return `None`.
+
    pub fn find(&self, keys: NonEmpty<K>) -> Option<&SubTree<K, A>>
+
    where
+
        K: Ord,
+
    {
+
        self.0.as_ref().and_then(|trees| trees.find(keys))
+
    }
+

+
    #[allow(dead_code)]
+
    pub fn maximum_by<F>(&self, f: F) -> Option<&A>
+
    where
+
        F: Fn(&A, &A) -> Ordering,
+
    {
+
        self.0.as_ref().map(|trees| trees.maximum_by(&f))
+
    }
+

+
    pub fn iter<'a>(&'a self) -> impl Iterator<Item = &A> + 'a {
+
        self.0.iter().flat_map(|trees| trees.iter())
+
    }
+

+
    #[allow(dead_code)]
+
    pub fn iter_keys<'a>(&'a self) -> impl Iterator<Item = &K> + 'a {
+
        self.0.iter().flat_map(|trees| trees.iter_keys())
+
    }
+
}
+

+
#[cfg(test)]
+
mod tests {
+
    use super::*;
+
    use pretty_assertions::assert_eq;
+

+
    #[derive(Debug, Clone, PartialEq, Eq)]
+
    struct TestNode {
+
        id: u32,
+
    }
+

+
    #[test]
+
    fn test_is_empty() {
+
        let mut tree = Forest::root();
+
        assert!(tree.is_empty());
+

+
        let a_node = TestNode { id: 1 };
+

+
        tree.insert(NonEmpty::new(String::from("a")), a_node);
+
        assert!(!tree.is_empty());
+
    }
+

+
    #[test]
+
    fn test_insert_root_node() {
+
        let a_label = String::from("a");
+

+
        let mut tree = Forest::root();
+

+
        let a_node = TestNode { id: 1 };
+

+
        tree.insert(NonEmpty::new(a_label), a_node.clone());
+

+
        assert_eq!(tree, Forest(Some(Tree::node(String::from("a"), a_node))));
+
    }
+

+
    #[test]
+
    fn test_insert_with_prepending_root_nodes() {
+
        let a_label = String::from("a");
+

+
        let mut tree = Forest::root();
+

+
        let a_node = TestNode { id: 1 };
+
        let b_node = TestNode { id: 2 };
+

+
        tree.insert_with(
+
            NonEmpty::new(a_label.clone()),
+
            NonEmpty::new(a_node.clone()),
+
            |nodes| nodes.insert(0, a_node.clone()),
+
        );
+
        tree.insert_with(
+
            NonEmpty::new(a_label),
+
            NonEmpty::new(b_node.clone()),
+
            |nodes| nodes.insert(0, b_node.clone()),
+
        );
+

+
        assert_eq!(
+
            tree,
+
            Forest(Some(Tree::node(
+
                String::from("a"),
+
                NonEmpty::from((b_node, vec![a_node]))
+
            )))
+
        );
+
    }
+

+
    #[test]
+
    fn test_insert_with_prepending_branch_nodes() {
+
        let a_label = String::from("a");
+
        let b_label = String::from("b");
+
        let path = NonEmpty::from((a_label, vec![b_label]));
+

+
        let mut tree = Forest::root();
+

+
        let a_node = TestNode { id: 1 };
+
        let b_node = TestNode { id: 2 };
+

+
        tree.insert_with(path.clone(), NonEmpty::new(a_node.clone()), |nodes| {
+
            nodes.insert(0, a_node.clone())
+
        });
+
        tree.insert_with(path, NonEmpty::new(b_node.clone()), |nodes| {
+
            nodes.insert(0, b_node.clone())
+
        });
+

+
        assert_eq!(
+
            tree,
+
            Forest(Some(Tree::branch(
+
                String::from("a"),
+
                Tree::node(String::from("b"), NonEmpty::from((b_node, vec![a_node])))
+
            )))
+
        );
+
    }
+

+
    #[test]
+
    fn test_insert_single_node() {
+
        let a_label = String::from("a");
+
        let b_label = String::from("b");
+
        let c_label = String::from("c");
+
        let path = NonEmpty::from((a_label, vec![b_label, c_label]));
+

+
        let mut tree = Forest::root();
+

+
        let c_node = TestNode { id: 1 };
+

+
        tree.insert(path, c_node.clone());
+

+
        assert_eq!(
+
            tree,
+
            Forest(Some(Tree::branch(
+
                String::from("a"),
+
                Tree::branch(String::from("b"), Tree::node(String::from("c"), c_node))
+
            )))
+
        );
+
    }
+

+
    #[test]
+
    fn test_insert_two_nodes() {
+
        let a_label = String::from("a");
+
        let b_label = String::from("b");
+
        let c_label = String::from("c");
+
        let d_label = String::from("d");
+
        let c_path = NonEmpty::from((a_label.clone(), vec![b_label.clone(), c_label]));
+
        let d_path = NonEmpty::from((a_label, vec![b_label, d_label]));
+

+
        let mut tree = Forest::root();
+

+
        let c_node = TestNode { id: 1 };
+

+
        tree.insert(c_path, c_node.clone());
+

+
        let d_node = TestNode { id: 3 };
+

+
        tree.insert(d_path, d_node.clone());
+

+
        assert_eq!(
+
            tree,
+
            Forest(Some(Tree::branch(
+
                String::from("a"),
+
                Tree::branch(
+
                    String::from("b"),
+
                    Tree(NonEmpty::from((
+
                        SubTree::Node {
+
                            key: String::from("c"),
+
                            value: c_node
+
                        },
+
                        vec![SubTree::Node {
+
                            key: String::from("d"),
+
                            value: d_node
+
                        }]
+
                    )))
+
                )
+
            )))
+
        );
+
    }
+

+
    #[test]
+
    fn test_insert_replaces_node() {
+
        let a_label = String::from("a");
+
        let b_label = String::from("b");
+
        let c_label = String::from("c");
+
        let c_path = NonEmpty::from((a_label, vec![b_label, c_label]));
+

+
        let mut tree = Forest::root();
+

+
        let c_node = TestNode { id: 1 };
+

+
        tree.insert(c_path.clone(), c_node);
+

+
        let new_c_node = TestNode { id: 3 };
+

+
        tree.insert(c_path, new_c_node.clone());
+

+
        assert_eq!(
+
            tree,
+
            Forest(Some(Tree::branch(
+
                String::from("a"),
+
                Tree::branch(
+
                    String::from("b"),
+
                    Tree(NonEmpty::new(SubTree::Node {
+
                        key: String::from("c"),
+
                        value: new_c_node
+
                    },))
+
                )
+
            )))
+
        );
+
    }
+

+
    #[test]
+
    fn test_insert_replaces_root_node() {
+
        let c_label = String::from("c");
+

+
        let mut tree = Forest::root();
+

+
        let c_node = TestNode { id: 1 };
+

+
        tree.insert(NonEmpty::new(c_label.clone()), c_node);
+

+
        let new_c_node = TestNode { id: 3 };
+

+
        tree.insert(NonEmpty::new(c_label), new_c_node.clone());
+

+
        assert_eq!(
+
            tree,
+
            Forest(Some(Tree::node(String::from("c"), new_c_node)))
+
        );
+
    }
+

+
    #[test]
+
    fn test_insert_replaces_branch_node() {
+
        let a_label = String::from("a");
+
        let c_label = String::from("c");
+
        let c_path = NonEmpty::from((a_label, vec![c_label]));
+

+
        let mut tree = Forest::root();
+

+
        let c_node = TestNode { id: 1 };
+

+
        tree.insert(c_path.clone(), c_node);
+

+
        let new_c_node = TestNode { id: 3 };
+

+
        tree.insert(c_path, new_c_node.clone());
+

+
        assert_eq!(
+
            tree,
+
            Forest(Some(Tree::branch(
+
                String::from("a"),
+
                Tree::node(String::from("c"), new_c_node),
+
            )))
+
        );
+
    }
+

+
    #[test]
+
    fn test_insert_replaces_branch_with_node() {
+
        let a_label = String::from("a");
+
        let b_label = String::from("b");
+
        let c_label = String::from("c");
+
        let c_path = NonEmpty::from((a_label.clone(), vec![b_label.clone(), c_label]));
+

+
        let mut tree = Forest::root();
+

+
        let c_node = TestNode { id: 1 };
+

+
        tree.insert(c_path, c_node);
+

+
        let new_c_node = TestNode { id: 3 };
+

+
        tree.insert(NonEmpty::from((a_label, vec![b_label])), new_c_node.clone());
+

+
        assert_eq!(
+
            tree,
+
            Forest(Some(Tree::branch(
+
                String::from("a"),
+
                Tree::node(String::from("b"), new_c_node),
+
            )))
+
        );
+
    }
+

+
    #[test]
+
    fn test_insert_replaces_node_with_branch() {
+
        let a_label = String::from("a");
+
        let b_label = String::from("b");
+
        let c_label = String::from("c");
+
        let b_path = NonEmpty::from((a_label.clone(), vec![b_label.clone()]));
+
        let c_path = NonEmpty::from((a_label, vec![b_label, c_label]));
+

+
        let mut tree = Forest::root();
+

+
        let b_node = TestNode { id: 1 };
+

+
        tree.insert(b_path, b_node);
+

+
        let new_c_node = TestNode { id: 3 };
+

+
        tree.insert(c_path, new_c_node.clone());
+

+
        assert_eq!(
+
            tree,
+
            Forest(Some(Tree::branch(
+
                String::from("a"),
+
                Tree::branch(
+
                    String::from("b"),
+
                    Tree(NonEmpty::new(SubTree::Node {
+
                        key: String::from("c"),
+
                        value: new_c_node
+
                    },))
+
                )
+
            )))
+
        );
+
    }
+

+
    #[test]
+
    fn test_insert_replaces_node_with_branch_foo() {
+
        let a_label = String::from("a");
+
        let b_label = String::from("b");
+
        let c_label = String::from("c");
+
        let d_label = String::from("d");
+
        let b_path = NonEmpty::from((a_label.clone(), vec![b_label.clone()]));
+
        let d_path = NonEmpty::from((a_label, vec![b_label, c_label, d_label]));
+

+
        let mut tree = Forest::root();
+

+
        let b_node = TestNode { id: 1 };
+

+
        tree.insert(b_path, b_node);
+

+
        let d_node = TestNode { id: 3 };
+

+
        tree.insert(d_path, d_node.clone());
+

+
        assert_eq!(
+
            tree,
+
            Forest(Some(Tree::branch(
+
                String::from("a"),
+
                Tree::branch(
+
                    String::from("b"),
+
                    Tree::branch(String::from("c"), Tree::node(String::from("d"), d_node))
+
                )
+
            )))
+
        );
+
    }
+

+
    #[test]
+
    fn test_insert_two_nodes_out_of_order() {
+
        let a_label = String::from("a");
+
        let b_label = String::from("b");
+
        let c_label = String::from("c");
+
        let d_label = String::from("d");
+
        let c_path = NonEmpty::from((a_label.clone(), vec![b_label.clone(), c_label]));
+
        let d_path = NonEmpty::from((a_label, vec![b_label, d_label]));
+

+
        let mut tree = Forest::root();
+

+
        let d_node = TestNode { id: 3 };
+

+
        tree.insert(d_path, d_node.clone());
+

+
        let c_node = TestNode { id: 1 };
+

+
        tree.insert(c_path, c_node.clone());
+

+
        assert_eq!(
+
            tree,
+
            Forest(Some(Tree::branch(
+
                String::from("a"),
+
                Tree::branch(
+
                    String::from("b"),
+
                    Tree(NonEmpty::from((
+
                        SubTree::Node {
+
                            key: String::from("c"),
+
                            value: c_node
+
                        },
+
                        vec![SubTree::Node {
+
                            key: String::from("d"),
+
                            value: d_node
+
                        }]
+
                    )))
+
                )
+
            )))
+
        );
+
    }
+

+
    #[test]
+
    fn test_insert_branch() {
+
        let a_label = String::from("a");
+
        let b_label = String::from("b");
+
        let c_label = String::from("c");
+
        let d_label = String::from("d");
+
        let e_label = String::from("e");
+
        let f_label = String::from("f");
+

+
        let c_path = NonEmpty::from((a_label.clone(), vec![b_label.clone(), c_label]));
+
        let d_path = NonEmpty::from((a_label.clone(), vec![b_label, d_label]));
+
        let f_path = NonEmpty::from((a_label, vec![e_label, f_label]));
+

+
        let mut tree = Forest::root();
+

+
        let c_node = TestNode { id: 1 };
+

+
        let d_node = TestNode { id: 3 };
+

+
        let f_node = TestNode { id: 2 };
+

+
        tree.insert(d_path, d_node.clone());
+
        tree.insert(c_path, c_node.clone());
+
        tree.insert(f_path, f_node.clone());
+

+
        assert_eq!(
+
            tree,
+
            Forest(Some(Tree::branch(
+
                String::from("a"),
+
                Tree(NonEmpty::from((
+
                    SubTree::Branch {
+
                        key: String::from("b"),
+
                        forest: Box::new(Tree(NonEmpty::from((
+
                            SubTree::Node {
+
                                key: String::from("c"),
+
                                value: c_node
+
                            },
+
                            vec![SubTree::Node {
+
                                key: String::from("d"),
+
                                value: d_node
+
                            }]
+
                        ))))
+
                    },
+
                    vec![SubTree::Branch {
+
                        key: String::from("e"),
+
                        forest: Box::new(Tree::node(String::from("f"), f_node))
+
                    },]
+
                )))
+
            )))
+
        );
+
    }
+

+
    #[test]
+
    fn test_insert_two_branches() {
+
        let a_label = String::from("a");
+
        let b_label = String::from("b");
+
        let c_label = String::from("c");
+
        let d_label = String::from("d");
+
        let e_label = String::from("e");
+
        let f_label = String::from("f");
+

+
        let c_path = NonEmpty::from((a_label, vec![b_label, c_label]));
+
        let f_path = NonEmpty::from((d_label, vec![e_label, f_label]));
+

+
        let mut tree = Forest::root();
+

+
        let c_node = TestNode { id: 1 };
+

+
        let f_node = TestNode { id: 2 };
+

+
        tree.insert(c_path, c_node.clone());
+
        tree.insert(f_path, f_node.clone());
+

+
        assert_eq!(
+
            tree,
+
            Forest(Some(Tree(NonEmpty::from((
+
                SubTree::Branch {
+
                    key: String::from("a"),
+
                    forest: Box::new(Tree::branch(
+
                        String::from("b"),
+
                        Tree::node(String::from("c"), c_node)
+
                    )),
+
                },
+
                vec![SubTree::Branch {
+
                    key: String::from("d"),
+
                    forest: Box::new(Tree::branch(
+
                        String::from("e"),
+
                        Tree::node(String::from("f"), f_node)
+
                    ))
+
                }]
+
            )))))
+
        );
+
    }
+

+
    #[test]
+
    fn test_insert_branches_and_node() {
+
        let a_label = String::from("a");
+
        let b_label = String::from("b");
+
        let c_label = String::from("c");
+
        let d_label = String::from("d");
+
        let e_label = String::from("e");
+
        let f_label = String::from("f");
+
        let g_label = String::from("g");
+

+
        let c_path = NonEmpty::from((a_label.clone(), vec![b_label.clone(), c_label]));
+
        let d_path = NonEmpty::from((a_label.clone(), vec![b_label, d_label]));
+
        let e_path = NonEmpty::from((a_label.clone(), vec![e_label]));
+
        let g_path = NonEmpty::from((a_label, vec![f_label, g_label]));
+

+
        let mut tree = Forest::root();
+

+
        let c_node = TestNode { id: 1 };
+

+
        let d_node = TestNode { id: 3 };
+

+
        let e_node = TestNode { id: 2 };
+

+
        let g_node = TestNode { id: 2 };
+

+
        tree.insert(d_path, d_node.clone());
+
        tree.insert(c_path, c_node.clone());
+
        tree.insert(e_path, e_node.clone());
+
        tree.insert(g_path, g_node.clone());
+

+
        assert_eq!(
+
            tree,
+
            Forest(Some(Tree::branch(
+
                String::from("a"),
+
                Tree(NonEmpty::from((
+
                    SubTree::Branch {
+
                        key: String::from("b"),
+
                        forest: Box::new(Tree(NonEmpty::from((
+
                            SubTree::Node {
+
                                key: String::from("c"),
+
                                value: c_node
+
                            },
+
                            vec![SubTree::Node {
+
                                key: String::from("d"),
+
                                value: d_node
+
                            }]
+
                        ))))
+
                    },
+
                    vec![
+
                        SubTree::Node {
+
                            key: String::from("e"),
+
                            value: e_node
+
                        },
+
                        SubTree::Branch {
+
                            key: String::from("f"),
+
                            forest: Box::new(Tree::node(String::from("g"), g_node))
+
                        },
+
                    ]
+
                )))
+
            )))
+
        );
+
    }
+

+
    #[test]
+
    fn test_find_root_node() {
+
        let a_label = String::from("a");
+

+
        let mut tree = Forest::root();
+

+
        let a_node = TestNode { id: 1 };
+

+
        tree.insert(NonEmpty::new(a_label), a_node.clone());
+

+
        assert_eq!(
+
            tree.find(NonEmpty::new(String::from("a"))),
+
            Some(&SubTree::Node {
+
                key: String::from("a"),
+
                value: a_node
+
            })
+
        );
+

+
        assert_eq!(tree.find(NonEmpty::new(String::from("b"))), None);
+
    }
+

+
    #[test]
+
    fn test_find_branch_and_node() {
+
        let a_label = String::from("a");
+
        let b_label = String::from("b");
+
        let c_label = String::from("c");
+
        let path = NonEmpty::from((a_label, vec![b_label, c_label]));
+

+
        let mut tree = Forest::root();
+

+
        let c_node = TestNode { id: 1 };
+

+
        tree.insert(path, c_node.clone());
+

+
        assert_eq!(
+
            tree.find(NonEmpty::new(String::from("a"))),
+
            Some(&SubTree::Branch {
+
                key: String::from("a"),
+
                forest: Box::new(Tree::branch(
+
                    String::from("b"),
+
                    Tree::node(String::from("c"), c_node.clone())
+
                ))
+
            })
+
        );
+

+
        assert_eq!(
+
            tree.find(NonEmpty::from((String::from("a"), vec![String::from("b")]))),
+
            Some(&SubTree::Branch {
+
                key: String::from("b"),
+
                forest: Box::new(Tree::node(String::from("c"), c_node.clone()))
+
            })
+
        );
+

+
        assert_eq!(
+
            tree.find(NonEmpty::from((
+
                String::from("a"),
+
                vec![String::from("b"), String::from("c")]
+
            ))),
+
            Some(&SubTree::Node {
+
                key: String::from("c"),
+
                value: c_node
+
            })
+
        );
+

+
        assert_eq!(tree.find(NonEmpty::new(String::from("b"))), None);
+

+
        assert_eq!(
+
            tree.find(NonEmpty::from((String::from("a"), vec![String::from("c")]))),
+
            None
+
        );
+
    }
+

+
    #[test]
+
    fn test_maximum_by_root_nodes() {
+
        let mut tree = Forest::root();
+

+
        let a_node = TestNode { id: 1 };
+

+
        let b_node = TestNode { id: 3 };
+

+
        tree.insert(NonEmpty::new(String::from("a")), a_node.clone());
+
        tree.insert(NonEmpty::new(String::from("b")), b_node.clone());
+

+
        assert_eq!(tree.maximum_by(|a, b| a.id.cmp(&b.id)), Some(&b_node));
+
        assert_eq!(
+
            tree.maximum_by(|a, b| a.id.cmp(&b.id).reverse()),
+
            Some(&a_node)
+
        );
+
    }
+

+
    #[test]
+
    fn test_maximum_by_branch_and_node() {
+
        let mut tree = Forest::root();
+

+
        let a_node = TestNode { id: 1 };
+

+
        let b_node = TestNode { id: 3 };
+

+
        tree.insert(
+
            NonEmpty::from((String::from("c"), vec![String::from("a")])),
+
            a_node.clone(),
+
        );
+
        tree.insert(NonEmpty::new(String::from("b")), b_node.clone());
+

+
        assert_eq!(tree.maximum_by(|a, b| a.id.cmp(&b.id)), Some(&b_node));
+
        assert_eq!(
+
            tree.maximum_by(|a, b| a.id.cmp(&b.id).reverse()),
+
            Some(&a_node)
+
        );
+
    }
+

+
    #[test]
+
    fn test_maximum_by_branch_and_branch() {
+
        let mut tree = Forest::root();
+

+
        let a_node = TestNode { id: 1 };
+

+
        let b_node = TestNode { id: 3 };
+

+
        tree.insert(
+
            NonEmpty::from((String::from("c"), vec![String::from("a")])),
+
            a_node.clone(),
+
        );
+
        tree.insert(
+
            NonEmpty::from((String::from("d"), vec![String::from("a")])),
+
            b_node.clone(),
+
        );
+

+
        assert_eq!(tree.maximum_by(|a, b| a.id.cmp(&b.id)), Some(&b_node));
+
        assert_eq!(
+
            tree.maximum_by(|a, b| a.id.cmp(&b.id).reverse()),
+
            Some(&a_node)
+
        );
+
    }
+

+
    #[test]
+
    fn test_maximum_by_branch_nodes() {
+
        let mut tree = Forest::root();
+

+
        let a_node = TestNode { id: 1 };
+

+
        let b_node = TestNode { id: 3 };
+

+
        tree.insert(
+
            NonEmpty::from((String::from("c"), vec![String::from("a")])),
+
            a_node.clone(),
+
        );
+
        tree.insert(
+
            NonEmpty::from((String::from("c"), vec![String::from("b")])),
+
            b_node.clone(),
+
        );
+

+
        assert_eq!(tree.maximum_by(|a, b| a.id.cmp(&b.id)), Some(&b_node));
+
        assert_eq!(
+
            tree.maximum_by(|a, b| a.id.cmp(&b.id).reverse()),
+
            Some(&a_node)
+
        );
+
    }
+

+
    #[test]
+
    fn test_fold_root_nodes() {
+
        let mut tree = Forest::root();
+

+
        let a_node = TestNode { id: 1 };
+

+
        let b_node = TestNode { id: 3 };
+

+
        tree.insert(NonEmpty::new(String::from("a")), a_node);
+
        tree.insert(NonEmpty::new(String::from("b")), b_node);
+

+
        assert_eq!(tree.iter().fold(0, |b, a| a.id + b), 4);
+
    }
+
}
added radicle-surf/src/vcs.rs
@@ -0,0 +1,238 @@
+
// This file is part of radicle-surf
+
// <https://github.com/radicle-dev/radicle-surf>
+
//
+
// Copyright (C) 2019-2020 The Radicle Team <dev@radicle.xyz>
+
//
+
// This program is free software: you can redistribute it and/or modify
+
// it under the terms of the GNU General Public License version 3 or
+
// later as published by the Free Software Foundation.
+
//
+
// This program is distributed in the hope that it will be useful,
+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+
// GNU General Public License for more details.
+
//
+
// You should have received a copy of the GNU General Public License
+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
+

+
//! A model of a general VCS. The components consist of a [`History`], a
+
//! [`Browser`], and a [`Vcs`] trait.
+

+
use crate::file_system::directory::Directory;
+
use nonempty::NonEmpty;
+

+
pub mod git;
+

+
/// A non-empty bag of artifacts which are used to
+
/// derive a [`crate::file_system::Directory`] view. Examples of artifacts
+
/// would be commits in Git or patches in Pijul.
+
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
+
pub struct History<A>(pub NonEmpty<A>);
+

+
impl<A> History<A> {
+
    /// Create a new `History` consisting of one artifact.
+
    pub fn new(a: A) -> Self {
+
        History(NonEmpty::new(a))
+
    }
+

+
    /// Push an artifact to the end of the `History`.
+
    pub fn push(&mut self, a: A) {
+
        self.0.push(a)
+
    }
+

+
    /// Iterator over the artifacts.
+
    pub fn iter<'a>(&'a self) -> impl Iterator<Item = &A> + 'a {
+
        self.0.iter()
+
    }
+

+
    /// Get the firest artifact in the `History`.
+
    pub fn first(&self) -> &A {
+
        self.0.first()
+
    }
+

+
    /// Get the length of `History` (aka the artefacts count)
+
    pub fn len(&self) -> usize {
+
        self.0.len()
+
    }
+

+
    /// Check if `History` is empty
+
    pub fn is_empty(&self) -> bool {
+
        self.0.is_empty()
+
    }
+

+
    /// Given that the `History` is topological order from most
+
    /// recent artifact to least recent, `find_suffix` gets returns
+
    /// the history up until the point of the given artifact.
+
    ///
+
    /// This operation may fail if the artifact does not exist in
+
    /// the given `History`.
+
    pub fn find_suffix(&self, artifact: &A) -> Option<Self>
+
    where
+
        A: Clone + PartialEq,
+
    {
+
        let new_history: Option<NonEmpty<A>> = NonEmpty::from_slice(
+
            &self
+
                .iter()
+
                .cloned()
+
                .skip_while(|current| *current != *artifact)
+
                .collect::<Vec<_>>(),
+
        );
+

+
        new_history.map(History)
+
    }
+

+
    /// Apply a function from `A` to `B` over the `History`
+
    pub fn map<F, B>(self, f: F) -> History<B>
+
    where
+
        F: FnMut(A) -> B,
+
    {
+
        History(self.0.map(f))
+
    }
+

+
    /// Find an artifact in the `History`.
+
    ///
+
    /// The function provided should return `Some` if the item is the desired
+
    /// output and `None` otherwise.
+
    pub fn find<F, B>(&self, f: F) -> Option<B>
+
    where
+
        F: Fn(&A) -> Option<B>,
+
    {
+
        self.iter().find_map(f)
+
    }
+

+
    /// Find an atrifact in the given `History` using the artifacts ID.
+
    ///
+
    /// This operation may fail if the artifact does not exist in the history.
+
    pub fn find_in_history<Identifier, F>(&self, identifier: &Identifier, id_of: F) -> Option<A>
+
    where
+
        A: Clone,
+
        F: Fn(&A) -> Identifier,
+
        Identifier: PartialEq,
+
    {
+
        self.iter()
+
            .find(|artifact| {
+
                let current_id = id_of(artifact);
+
                *identifier == current_id
+
            })
+
            .cloned()
+
    }
+

+
    /// Find all occurences of an artifact in a bag of `History`s.
+
    pub fn find_in_histories<Identifier, F>(
+
        histories: Vec<Self>,
+
        identifier: &Identifier,
+
        id_of: F,
+
    ) -> Vec<Self>
+
    where
+
        A: Clone,
+
        F: Fn(&A) -> Identifier + Copy,
+
        Identifier: PartialEq,
+
    {
+
        histories
+
            .into_iter()
+
            .filter(|history| history.find_in_history(identifier, id_of).is_some())
+
            .collect()
+
    }
+
}
+

+
/// A Snapshot is a function that renders a `Directory` given
+
/// the `Repo` object and a `History` of artifacts.
+
type Snapshot<A, Repo, Error> = Box<dyn Fn(&Repo, &History<A>) -> Result<Directory, Error>>;
+

+
/// A `Browser` is a way of rendering a `History` into a
+
/// `Directory` snapshot, and the current `History` it is
+
/// viewing.
+
pub struct Browser<Repo, A, Error> {
+
    snapshot: Snapshot<A, Repo, Error>,
+
    history: History<A>,
+
    repository: Repo,
+
}
+

+
impl<Repo, A, Error> Browser<Repo, A, Error> {
+
    /// Get the current `History` the `Browser` is viewing.
+
    pub fn get(&self) -> History<A>
+
    where
+
        A: Clone,
+
    {
+
        self.history.clone()
+
    }
+

+
    /// Set the `History` the `Browser` should view.
+
    pub fn set(&mut self, history: History<A>) {
+
        self.history = history;
+
    }
+

+
    /// Render the `Directory` for this `Browser`.
+
    pub fn get_directory(&self) -> Result<Directory, Error> {
+
        (self.snapshot)(&self.repository, &self.history)
+
    }
+

+
    /// Modify the `History` in this `Browser`.
+
    pub fn modify<F>(&mut self, f: F)
+
    where
+
        F: Fn(&History<A>) -> History<A>,
+
    {
+
        self.history = f(&self.history)
+
    }
+

+
    /// Change the `Browser`'s view of `History` by modifying it, or
+
    /// using the default `History` provided if the operation fails.
+
    pub fn view_at<F>(&mut self, default_history: History<A>, f: F)
+
    where
+
        A: Clone,
+
        F: Fn(&History<A>) -> Option<History<A>>,
+
    {
+
        self.modify(|history| f(history).unwrap_or_else(|| default_history.clone()))
+
    }
+
}
+

+
impl<Repo, A, Error> Vcs<A, Error> for Browser<Repo, A, Error>
+
where
+
    Repo: Vcs<A, Error>,
+
{
+
    type HistoryId = Repo::HistoryId;
+
    type ArtefactId = Repo::ArtefactId;
+

+
    fn get_history(&self, identifier: Self::HistoryId) -> Result<History<A>, Error> {
+
        self.repository.get_history(identifier)
+
    }
+

+
    fn get_histories(&self) -> Result<Vec<History<A>>, Error> {
+
        self.repository.get_histories()
+
    }
+

+
    fn get_identifier(artifact: &A) -> Self::ArtefactId {
+
        Repo::get_identifier(artifact)
+
    }
+
}
+

+
pub(crate) trait GetVcs<Error>
+
where
+
    Self: Sized,
+
{
+
    /// The way to identify a Repository.
+
    type RepoId;
+

+
    /// Find a Repository
+
    fn get_repo(identifier: Self::RepoId) -> Result<Self, Error>;
+
}
+

+
/// The `VCS` trait encapsulates the minimal amount of information for
+
/// interacting with some notion of `History` from a given
+
/// Version-Control-System.
+
pub trait Vcs<A, Error> {
+
    /// The way to identify a History.
+
    type HistoryId;
+

+
    /// The way to identify an artefact.
+
    type ArtefactId;
+

+
    /// Find a History in a Repo given a way to identify it
+
    fn get_history(&self, identifier: Self::HistoryId) -> Result<History<A>, Error>;
+

+
    /// Find all histories in a Repo
+
    fn get_histories(&self) -> Result<Vec<History<A>>, Error>;
+

+
    /// Identify artefacts of a Repository
+
    fn get_identifier(artefact: &A) -> Self::ArtefactId;
+
}
added radicle-surf/src/vcs/git.rs
@@ -0,0 +1,1725 @@
+
// This file is part of radicle-surf
+
// <https://github.com/radicle-dev/radicle-surf>
+
//
+
// Copyright (C) 2019-2020 The Radicle Team <dev@radicle.xyz>
+
//
+
// This program is free software: you can redistribute it and/or modify
+
// it under the terms of the GNU General Public License version 3 or
+
// later as published by the Free Software Foundation.
+
//
+
// This program is distributed in the hope that it will be useful,
+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+
// GNU General Public License for more details.
+
//
+
// You should have received a copy of the GNU General Public License
+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
+

+
//! ```
+
//! use nonempty::NonEmpty;
+
//! use radicle_surf::file_system::{Directory, File, Label, Path, SystemType};
+
//! use radicle_surf::file_system::unsound;
+
//! use radicle_surf::vcs::git::*;
+
//! use std::collections::HashMap;
+
//! # use std::error::Error;
+
//!
+
//! # fn main() -> Result<(), Box<dyn Error>> {
+
//! let repo = Repository::new("./data/git-platinum")?;
+
//!
+
//! // Pin the browser to a parituclar commit.
+
//! let pin_commit = Oid::from_str("3873745c8f6ffb45c990eb23b491d4b4b6182f95")?;
+
//! let mut browser = Browser::new(&repo, Branch::local("master"))?;
+
//! browser.commit(pin_commit)?;
+
//!
+
//! let directory = browser.get_directory()?;
+
//! let mut directory_contents = directory.list_directory();
+
//! directory_contents.sort();
+
//!
+
//! assert_eq!(directory_contents, vec![
+
//!     SystemType::file(unsound::label::new(".i-am-well-hidden")),
+
//!     SystemType::file(unsound::label::new(".i-too-am-hidden")),
+
//!     SystemType::file(unsound::label::new("README.md")),
+
//!     SystemType::directory(unsound::label::new("bin")),
+
//!     SystemType::directory(unsound::label::new("src")),
+
//!     SystemType::directory(unsound::label::new("text")),
+
//!     SystemType::directory(unsound::label::new("this")),
+
//! ]);
+
//!
+
//! // find src directory in the Git directory and the in-memory directory
+
//! let src_directory = directory
+
//!     .find_directory(Path::new(unsound::label::new("src")))
+
//!     .expect("failed to find src");
+
//! let mut src_directory_contents = src_directory.list_directory();
+
//! src_directory_contents.sort();
+
//!
+
//! assert_eq!(src_directory_contents, vec![
+
//!     SystemType::file(unsound::label::new("Eval.hs")),
+
//!     SystemType::file(unsound::label::new("Folder.svelte")),
+
//!     SystemType::file(unsound::label::new("memory.rs")),
+
//! ]);
+
//! #
+
//! # Ok(())
+
//! # }
+
//! ```
+

+
// Re-export git2 as sub-module
+
pub use git2::{self, Error as Git2Error, Oid, Time};
+

+
/// Provides ways of selecting a particular reference/revision.
+
mod reference;
+
pub use reference::{Ref, Rev};
+

+
mod repo;
+
pub use repo::{History, Repository, RepositoryRef};
+

+
pub mod error;
+

+
mod ext;
+

+
/// Provides the data for talking about branches.
+
pub mod branch;
+
pub use branch::{Branch, BranchName, BranchType};
+

+
/// Provides the data for talking about tags.
+
pub mod tag;
+
pub use tag::{Tag, TagName};
+

+
/// Provides the data for talking about commits.
+
pub mod commit;
+
pub use commit::{Author, Commit};
+

+
/// Provides the data for talking about namespaces.
+
pub mod namespace;
+
pub use namespace::Namespace;
+

+
/// Provides the data for talking about repository statistics.
+
pub mod stats;
+
pub use stats::Stats;
+

+
pub use crate::diff::Diff;
+

+
use crate::{
+
    file_system,
+
    file_system::directory,
+
    vcs,
+
    vcs::{git::error::*, Vcs},
+
};
+
use nonempty::NonEmpty;
+
use std::{
+
    collections::{BTreeSet, HashMap},
+
    convert::TryFrom,
+
    str,
+
};
+

+
/// 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())
+
    }
+
}
+

+
/// Determines whether to look for local or remote references or both.
+
pub enum RefScope {
+
    /// List all branches by default.
+
    All,
+
    /// List only local branches.
+
    Local,
+
    /// List only remote branches.
+
    Remote {
+
        /// Name of the remote. If `None`, then get the reference from all
+
        /// remotes.
+
        name: Option<String>,
+
    },
+
}
+

+
/// Turn an `Option<P>` into a [`RefScope`]. If the `P` is present then
+
/// this is set as the remote of the `RefScope`. Otherwise, it's local
+
/// branch.
+
impl<P> From<Option<P>> for RefScope
+
where
+
    P: ToString,
+
{
+
    fn from(peer_id: Option<P>) -> Self {
+
        peer_id.map_or(RefScope::Local, |peer_id| RefScope::Remote {
+
            // We qualify the remotes as the PeerId + heads, otherwise we would grab the tags too.
+
            name: Some(format!("{}/heads", peer_id.to_string())),
+
        })
+
    }
+
}
+

+
/// A [`crate::vcs::Browser`] that uses [`Repository`] as the underlying
+
/// repository backend, [`git2::Commit`] as the artifact, and [`Error`] for
+
/// error reporting.
+
pub type Browser<'a> = vcs::Browser<RepositoryRef<'a>, Commit, Error>;
+

+
impl<'a> Browser<'a> {
+
    /// Create a new browser to interact with.
+
    ///
+
    /// The `revspec` provided will be used to kick off the [`History`] for this
+
    /// `Browser`.
+
    ///
+
    /// # Errors
+
    ///
+
    /// * [`error::Error::Git`]
+
    ///
+
    /// # Examples
+
    ///
+
    /// ```
+
    /// use radicle_surf::vcs::git::{Browser, Branch, Repository};
+
    /// # use std::error::Error;
+
    ///
+
    /// # fn main() -> Result<(), Box<dyn Error>> {
+
    /// let repo = Repository::new("./data/git-platinum")?;
+
    /// let browser = Browser::new(&repo, Branch::local("master"))?;
+
    /// #
+
    /// # Ok(())
+
    /// # }
+
    /// ```
+
    pub fn new(
+
        repository: impl Into<RepositoryRef<'a>>,
+
        rev: impl Into<Rev>,
+
    ) -> Result<Self, Error> {
+
        let repository = repository.into();
+
        let history = repository.get_history(rev.into())?;
+
        Ok(Self::init(repository, history))
+
    }
+

+
    /// Create a new `Browser` that starts in a given `namespace`.
+
    ///
+
    /// # Errors
+
    ///
+
    /// * [`error::Error::Git`]
+
    ///
+
    /// # Examples
+
    ///
+
    /// ```
+
    /// use radicle_surf::vcs::git::{Browser, Repository, Branch, RefScope, BranchName, Namespace};
+
    /// use std::convert::TryFrom;
+
    /// # use std::error::Error;
+
    ///
+
    /// # fn main() -> Result<(), Box<dyn Error>> {
+
    /// let repo = Repository::new("./data/git-platinum")?;
+
    /// let browser = Browser::new_with_namespace(
+
    ///     &repo,
+
    ///     &Namespace::try_from("golden")?,
+
    ///     Branch::local("master")
+
    /// )?;
+
    ///
+
    /// let mut branches = browser.list_branches(RefScope::Local)?;
+
    /// branches.sort();
+
    ///
+
    /// assert_eq!(
+
    ///     branches,
+
    ///     vec![
+
    ///         Branch::local("banana"),
+
    ///         Branch::local("master"),
+
    ///     ]
+
    /// );
+
    /// #
+
    /// # Ok(())
+
    /// # }
+
    /// ```
+
    pub fn new_with_namespace(
+
        repository: impl Into<RepositoryRef<'a>>,
+
        namespace: &Namespace,
+
        rev: impl Into<Rev>,
+
    ) -> Result<Self, Error> {
+
        let repository = repository.into();
+
        // This is a bit weird, the references don't seem to all be present unless we
+
        // make a call to `references` o_O.
+
        let _ = repository.repo_ref.references()?;
+
        repository.switch_namespace(&namespace.to_string())?;
+
        let history = repository.get_history(rev.into())?;
+
        Ok(Self::init(repository, history))
+
    }
+

+
    fn init(repository: RepositoryRef<'a>, history: History) -> Self {
+
        let snapshot = Box::new(|repository: &RepositoryRef<'a>, history: &History| {
+
            let tree = Self::get_tree(repository.repo_ref, history.0.first())?;
+
            Ok(directory::Directory::from_hash_map(tree))
+
        });
+
        vcs::Browser {
+
            snapshot,
+
            history,
+
            repository,
+
        }
+
    }
+

+
    /// Switch the namespace you are browsing in. This will consume the previous
+
    /// `Browser` and give you back a new `Browser` for that particular
+
    /// namespace. The `revision` provided will kick-off the history for
+
    /// this `Browser`.
+
    pub fn switch_namespace(
+
        self,
+
        namespace: &Namespace,
+
        rev: impl Into<Ref>,
+
    ) -> Result<Self, Error> {
+
        self.repository.switch_namespace(&namespace.to_string())?;
+
        let history = self.get_history(Rev::from(rev))?;
+
        Ok(Browser {
+
            snapshot: self.snapshot,
+
            repository: self.repository,
+
            history,
+
        })
+
    }
+

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

+
    /// Set the current `Browser` history to the `HEAD` commit of the underlying
+
    /// repository.
+
    ///
+
    /// # Errors
+
    ///
+
    /// * [`error::Error::Git`]
+
    ///
+
    /// # Examples
+
    ///
+
    /// ```
+
    /// use radicle_surf::vcs::git::{Browser, Repository, Branch};
+
    /// # use std::error::Error;
+
    ///
+
    /// # fn main() -> Result<(), Box<dyn Error>> {
+
    /// let repo = Repository::new("./data/git-platinum")?;
+
    /// let mut browser = Browser::new(&repo, Branch::local("master"))?;
+
    ///
+
    /// // ensure we're at HEAD
+
    /// browser.head();
+
    ///
+
    /// let directory = browser.get_directory();
+
    ///
+
    /// // We are able to render the directory
+
    /// assert!(directory.is_ok());
+
    /// #
+
    /// # Ok(())
+
    /// # }
+
    /// ```
+
    pub fn head(&mut self) -> Result<(), Error> {
+
        let history = self.repository.head()?;
+
        self.set(history);
+
        Ok(())
+
    }
+

+
    /// Set the current `Browser`'s [`History`] to the given [`BranchName`]
+
    /// provided.
+
    ///
+
    /// # Errors
+
    ///
+
    /// * [`error::Error::Git`]
+
    /// * [`error::Error::NotBranch`]
+
    ///
+
    /// # Examples
+
    ///
+
    /// ```
+
    /// use radicle_surf::vcs::git::{Branch, Browser, Repository};
+
    /// # use std::error::Error;
+
    ///
+
    /// # fn main() -> Result<(), Box<dyn Error>> {
+
    /// let repo = Repository::new("./data/git-platinum")?;
+
    /// let mut browser = Browser::new(&repo, Branch::local("master"))?;
+
    ///
+
    /// // ensure we're on 'master'
+
    /// browser.branch(Branch::local("master"));
+
    ///
+
    /// let directory = browser.get_directory();
+
    ///
+
    /// // We are able to render the directory
+
    /// assert!(directory.is_ok());
+
    /// #
+
    /// # Ok(())
+
    /// # }
+
    /// ```
+
    ///
+
    /// ```
+
    /// use radicle_surf::vcs::git::{Branch, Browser, Repository};
+
    /// use radicle_surf::file_system::{Label, Path, SystemType};
+
    /// use radicle_surf::file_system::unsound;
+
    /// # use std::error::Error;
+
    ///
+
    /// # fn main() -> Result<(), Box<dyn Error>> {
+
    /// let repo = Repository::new("./data/git-platinum")?;
+
    /// let mut browser = Browser::new(&repo, Branch::local("master"))?;
+
    /// browser.branch(Branch::remote("dev", "origin"))?;
+
    ///
+
    /// let directory = browser.get_directory()?;
+
    /// let mut directory_contents = directory.list_directory();
+
    /// directory_contents.sort();
+
    ///
+
    /// assert!(directory_contents.contains(
+
    ///     &SystemType::file(unsound::label::new("here-we-are-on-a-dev-branch.lol"))
+
    /// ));
+
    /// #
+
    /// # Ok(())
+
    /// # }
+
    /// ```
+
    pub fn branch(&mut self, branch: Branch) -> Result<(), Error> {
+
        let name = BranchName(branch.name());
+
        self.set(self.repository.reference(branch, |reference| {
+
            let is_branch = ext::is_branch(reference) || reference.is_remote();
+
            if !is_branch {
+
                Some(Error::NotBranch(name))
+
            } else {
+
                None
+
            }
+
        })?);
+
        Ok(())
+
    }
+

+
    /// Set the current `Browser`'s [`History`] to the [`TagName`] provided.
+
    ///
+
    /// # Errors
+
    ///
+
    /// * [`error::Error::Git`]
+
    /// * [`error::Error::NotTag`]
+
    ///
+
    /// # Examples
+
    ///
+
    /// ```
+
    /// use nonempty::NonEmpty;
+
    /// use radicle_surf::vcs::History;
+
    /// use radicle_surf::vcs::git::{TagName, Branch, Browser, Oid, Repository};
+
    /// # use std::error::Error;
+
    ///
+
    /// # fn main() -> Result<(), Box<dyn Error>> {
+
    /// let repo = Repository::new("./data/git-platinum")?;
+
    /// let mut browser = Browser::new(&repo, Branch::local("master"))?;
+
    ///
+
    /// // Switch to "v0.3.0"
+
    /// browser.tag(TagName::new("v0.3.0"))?;
+
    ///
+
    /// let expected_history = History(NonEmpty::from((
+
    ///     Oid::from_str("19bec071db6474af89c866a1bd0e4b1ff76e2b97")?,
+
    ///     vec![
+
    ///         Oid::from_str("f3a089488f4cfd1a240a9c01b3fcc4c34a4e97b2")?,
+
    ///         Oid::from_str("2429f097664f9af0c5b7b389ab998b2199ffa977")?,
+
    ///         Oid::from_str("d3464e33d75c75c99bfb90fa2e9d16efc0b7d0e3")?,
+
    ///     ]
+
    /// )));
+
    ///
+
    /// let history_ids = browser.get().map(|commit| commit.id);
+
    ///
+
    /// // We are able to render the directory
+
    /// assert_eq!(history_ids, expected_history);
+
    /// #
+
    /// # Ok(())
+
    /// # }
+
    /// ```
+
    pub fn tag(&mut self, tag_name: TagName) -> Result<(), Error> {
+
        let name = tag_name.clone();
+
        self.set(self.repository.reference(tag_name, |reference| {
+
            if !ext::is_tag(reference) {
+
                Some(Error::NotTag(name))
+
            } else {
+
                None
+
            }
+
        })?);
+
        Ok(())
+
    }
+

+
    /// Set the current `Browser`'s [`History`] to the [`Oid`] (SHA digest)
+
    /// provided.
+
    ///
+
    /// # Errors
+
    ///
+
    /// * [`error::Error::Git`]
+
    ///
+
    /// # Examples
+
    ///
+
    /// ```
+
    /// use radicle_surf::file_system::{Label, SystemType};
+
    /// use radicle_surf::file_system::unsound;
+
    /// use radicle_surf::vcs::git::{Branch, Browser, Oid, Repository};
+
    /// use std::str::FromStr;
+
    /// # use std::error::Error;
+
    ///
+
    /// # fn main() -> Result<(), Box<dyn Error>> {
+
    /// let repo = Repository::new("./data/git-platinum")?;
+
    /// let mut browser = Browser::new(&repo, Branch::local("master"))?;
+
    ///
+
    /// // Set to the initial commit
+
    /// let commit = Oid::from_str("e24124b7538658220b5aaf3b6ef53758f0a106dc")?;
+
    ///
+
    /// browser.commit(commit)?;
+
    ///
+
    /// let directory = browser.get_directory()?;
+
    /// let mut directory_contents = directory.list_directory();
+
    ///
+
    /// assert_eq!(
+
    ///     directory_contents,
+
    ///     vec![
+
    ///         SystemType::file(unsound::label::new("README.md")),
+
    ///         SystemType::directory(unsound::label::new("bin")),
+
    ///         SystemType::directory(unsound::label::new("src")),
+
    ///         SystemType::directory(unsound::label::new("this")),
+
    ///     ]
+
    /// );
+
    /// #
+
    /// # Ok(())
+
    /// # }
+
    /// ```
+
    pub fn commit(&mut self, oid: Oid) -> Result<(), Error> {
+
        self.set(self.get_history(Rev::Oid(oid))?);
+
        Ok(())
+
    }
+

+
    /// Set a `Browser`'s [`History`] based on a [revspec](https://git-scm.com/docs/git-rev-parse.html#_specifying_revisions).
+
    ///
+
    /// # Errors
+
    ///
+
    /// * [`error::Error::Git`]
+
    /// * [`error::Error::RevParseFailure`]
+
    ///
+
    /// # Examples
+
    ///
+
    /// ```
+
    /// use radicle_surf::file_system::{Label, SystemType};
+
    /// use radicle_surf::file_system::unsound;
+
    /// use radicle_surf::vcs::git::{Browser, Branch, Oid, Repository};
+
    /// use std::str::FromStr;
+
    /// # use std::error::Error;
+
    ///
+
    /// # fn main() -> Result<(), Box<dyn Error>> {
+
    /// let repo = Repository::new("./data/git-platinum")?;
+
    /// let mut browser = Browser::new(&repo, Branch::local("master"))?;
+
    ///
+
    /// browser.rev(Branch::remote("dev", "origin"))?;
+
    ///
+
    /// let directory = browser.get_directory()?;
+
    /// let mut directory_contents = directory.list_directory();
+
    /// directory_contents.sort();
+
    ///
+
    /// assert!(directory_contents.contains(
+
    ///     &SystemType::file(unsound::label::new("here-we-are-on-a-dev-branch.lol"))
+
    /// ));
+
    /// #
+
    /// # Ok(())
+
    /// # }
+
    /// ```
+
    pub fn rev(&mut self, rev: impl Into<Rev>) -> Result<(), Error> {
+
        let history = self.get_history(rev.into())?;
+
        self.set(history);
+
        Ok(())
+
    }
+

+
    /// Parse an [`Oid`] from the given string. This is useful if we have a
+
    /// shorthand version of the `Oid`, as opposed to the full one.
+
    ///
+
    /// # Examples
+
    ///
+
    /// ```
+
    /// use radicle_surf::vcs::git::{Branch, Browser, Oid, Repository};
+
    /// use std::str::FromStr;
+
    /// # use std::error::Error;
+
    ///
+
    /// # fn main() -> Result<(), Box<dyn Error>> {
+
    /// let repo = Repository::new("./data/git-platinum")?;
+
    /// let mut browser = Browser::new(&repo, Branch::local("master"))?;
+
    ///
+
    /// // Set to the initial commit
+
    /// let commit = Oid::from_str("e24124b7538658220b5aaf3b6ef53758f0a106dc")?;
+
    ///
+
    /// assert_eq!(
+
    ///     commit,
+
    ///     browser.oid("e24124b")?,
+
    /// );
+
    /// #
+
    /// # Ok(())
+
    /// # }
+
    /// ```
+
    pub fn oid(&self, oid: &str) -> Result<Oid, Error> {
+
        self.repository.oid(oid)
+
    }
+

+
    /// Get the [`Diff`] between two commits.
+
    pub fn diff(&self, from: Oid, to: Oid) -> Result<Diff, Error> {
+
        self.repository.diff(from, to)
+
    }
+

+
    /// Get the [`Diff`] of a commit with no parents.
+
    pub fn initial_diff(&self, oid: Oid) -> Result<Diff, Error> {
+
        self.repository.initial_diff(oid)
+
    }
+

+
    /// List the names of the _branches_ that are contained in the underlying
+
    /// [`Repository`].
+
    ///
+
    /// # Errors
+
    ///
+
    /// * [`error::Error::Git`]
+
    ///
+
    /// # Examples
+
    ///
+
    /// ```
+
    /// use radicle_surf::vcs::git::{Branch, RefScope, BranchName, Browser, Namespace, Repository};
+
    /// use std::convert::TryFrom;
+
    /// # use std::error::Error;
+
    ///
+
    /// # fn main() -> Result<(), Box<dyn Error>> {
+
    /// let repo = Repository::new("./data/git-platinum")?;
+
    /// let mut browser = Browser::new(&repo, Branch::local("master"))?;
+
    ///
+
    /// let branches = browser.list_branches(RefScope::All)?;
+
    ///
+
    /// // 'master' exists in the list of branches
+
    /// assert!(branches.contains(&Branch::local("master")));
+
    ///
+
    /// // Filter the branches by `Remote` 'origin'.
+
    /// let mut branches = browser.list_branches(RefScope::Remote {
+
    ///     name: Some("origin".to_string())
+
    /// })?;
+
    /// branches.sort();
+
    ///
+
    /// assert_eq!(branches, vec![
+
    ///     Branch::remote("HEAD", "origin"),
+
    ///     Branch::remote("dev", "origin"),
+
    ///     Branch::remote("master", "origin"),
+
    /// ]);
+
    ///
+
    /// // Filter the branches by all `Remote`s.
+
    /// let mut branches = browser.list_branches(RefScope::Remote {
+
    ///     name: None
+
    /// })?;
+
    /// branches.sort();
+
    ///
+
    /// assert_eq!(branches, vec![
+
    ///     Branch::remote("HEAD", "origin"),
+
    ///     Branch::remote("dev", "origin"),
+
    ///     Branch::remote("master", "origin"),
+
    ///     Branch::remote("orange/pineapple", "banana"),
+
    ///     Branch::remote("pineapple", "banana"),
+
    /// ]);
+
    ///
+
    /// // We can also switch namespaces and list the branches in that namespace.
+
    /// let golden = browser.switch_namespace(&Namespace::try_from("golden")?, Branch::local("master"))?;
+
    ///
+
    /// let mut branches = golden.list_branches(RefScope::Local)?;
+
    /// branches.sort();
+
    ///
+
    /// assert_eq!(branches, vec![
+
    ///     Branch::local("banana"),
+
    ///     Branch::local("master"),
+
    /// ]);
+
    /// #
+
    /// # Ok(())
+
    /// # }
+
    /// ```
+
    pub fn list_branches(&self, filter: RefScope) -> Result<Vec<Branch>, Error> {
+
        self.repository.list_branches(filter)
+
    }
+

+
    /// List the names of the _tags_ that are contained in the underlying
+
    /// [`Repository`].
+
    ///
+
    /// # Errors
+
    ///
+
    /// * [`error::Error::Git`]
+
    ///
+
    /// # Examples
+
    ///
+
    /// ```
+
    /// use radicle_surf::vcs::git::{Branch, RefScope, Browser, Namespace, Oid, Repository, Tag, TagName, Author, Time};
+
    /// use std::convert::TryFrom;
+
    /// # use std::error::Error;
+
    ///
+
    /// # fn main() -> Result<(), Box<dyn Error>> {
+
    /// let repo = Repository::new("./data/git-platinum")?;
+
    /// let mut browser = Browser::new(&repo, Branch::local("master"))?;
+
    ///
+
    /// let tags = browser.list_tags(RefScope::Local)?;
+
    ///
+
    /// assert_eq!(
+
    ///     tags,
+
    ///     vec![
+
    ///         Tag::Light {
+
    ///             id: Oid::from_str("d3464e33d75c75c99bfb90fa2e9d16efc0b7d0e3")?,
+
    ///             name: TagName::new("v0.1.0"),
+
    ///             remote: None,
+
    ///         },
+
    ///         Tag::Light {
+
    ///             id: Oid::from_str("2429f097664f9af0c5b7b389ab998b2199ffa977")?,
+
    ///             name: TagName::new("v0.2.0"),
+
    ///             remote: None,
+
    ///         },
+
    ///         Tag::Light {
+
    ///             id: Oid::from_str("19bec071db6474af89c866a1bd0e4b1ff76e2b97")?,
+
    ///             name: TagName::new("v0.3.0"),
+
    ///             remote: None,
+
    ///         },
+
    ///         Tag::Light {
+
    ///             id: Oid::from_str("91b69e00cd8e5a07e20942e9e4457d83ce7a3ff1")?,
+
    ///             name: TagName::new("v0.4.0"),
+
    ///             remote: None,
+
    ///         },
+
    ///         Tag::Light {
+
    ///             id: Oid::from_str("80ded66281a4de2889cc07293a8f10947c6d57fe")?,
+
    ///             name: TagName::new("v0.5.0"),
+
    ///             remote: None,
+
    ///         },
+
    ///         Tag::Annotated {
+
    ///             id: Oid::from_str("4d1f4af2703074d37cb877f4fdbe36322c8e541d")?,
+
    ///             target_id: Oid::from_str("d6880352fc7fda8f521ae9b7357668b17bb5bad5")?,
+
    ///             name: TagName::new("v0.6.0"),
+
    ///             remote: None,
+
    ///             tagger: Some(Author {
+
    ///               name: "Thomas Scholtes".to_string(),
+
    ///               email: "thomas@monadic.xyz".to_string(),
+
    ///               time: Time::new(1620740737, 120),
+
    ///             }),
+
    ///             message: Some("An annotated tag message for v0.6.0\n".to_string())
+
    ///         },
+
    ///     ]
+
    /// );
+
    ///
+
    /// // We can also switch namespaces and list the branches in that namespace.
+
    /// let golden = browser.switch_namespace(&Namespace::try_from("golden")?, Branch::local("master"))?;
+
    ///
+
    /// let branches = golden.list_tags(RefScope::Local)?;
+
    /// assert_eq!(branches, vec![
+
    ///     Tag::Light {
+
    ///         id: Oid::from_str("d3464e33d75c75c99bfb90fa2e9d16efc0b7d0e3")?,
+
    ///         name: TagName::new("v0.1.0"),
+
    ///         remote: None,
+
    ///     },
+
    ///     Tag::Light {
+
    ///         id: Oid::from_str("2429f097664f9af0c5b7b389ab998b2199ffa977")?,
+
    ///         name: TagName::new("v0.2.0"),
+
    ///         remote: None,
+
    ///     },
+
    /// ]);
+
    /// let golden = golden.switch_namespace(&Namespace::try_from("golden")?, Branch::local("master"))?;
+
    ///
+
    /// let branches = golden.list_tags(RefScope::Remote { name: Some("kickflip".to_string()) })?;
+
    /// assert_eq!(branches, vec![
+
    ///     Tag::Light {
+
    ///         id: Oid::from_str("d3464e33d75c75c99bfb90fa2e9d16efc0b7d0e3")?,
+
    ///         name: TagName::new("v0.1.0"),
+
    ///         remote: Some("kickflip".to_string()),
+
    ///     },
+
    /// ]);
+
    /// #
+
    /// # Ok(())
+
    /// # }
+
    /// ```
+
    pub fn list_tags(&self, scope: RefScope) -> Result<Vec<Tag>, Error> {
+
        self.repository.list_tags(scope)
+
    }
+

+
    /// List the namespaces within a `Browser`, filtering out ones that do not
+
    /// parse correctly.
+
    ///
+
    /// # Errors
+
    ///
+
    /// * [`Error::Git`]
+
    ///
+
    /// # Examples
+
    ///
+
    /// ```
+
    /// use radicle_surf::vcs::git::{Branch, BranchType, BranchName, Browser, Namespace, Repository};
+
    /// use std::convert::TryFrom;
+
    /// # use std::error::Error;
+
    ///
+
    /// # fn main() -> Result<(), Box<dyn Error>> {
+
    /// let repo = Repository::new("./data/git-platinum")?;
+
    /// let mut browser = Browser::new(&repo, Branch::local("master"))?;
+
    ///
+
    /// let mut namespaces = browser.list_namespaces()?;
+
    /// namespaces.sort();
+
    ///
+
    /// assert_eq!(namespaces, vec![
+
    ///     Namespace::try_from("golden")?,
+
    ///     Namespace::try_from("golden/silver")?,
+
    ///     Namespace::try_from("me")?,
+
    /// ]);
+
    ///
+
    ///
+
    /// #
+
    /// # Ok(())
+
    /// # }
+
    /// ```
+
    pub fn list_namespaces(&self) -> Result<Vec<Namespace>, Error> {
+
        self.repository.list_namespaces()
+
    }
+

+
    /// Given a [`crate::file_system::Path`] to a file, return the last
+
    /// [`Commit`] that touched that file or directory.
+
    ///
+
    /// # Errors
+
    ///
+
    /// * [`error::Error::Git`]
+
    /// * [`error::Error::LastCommitException`]
+
    ///
+
    /// # Examples
+
    ///
+
    /// ```
+
    /// use radicle_surf::vcs::git::{Branch, Browser, Oid, Repository};
+
    /// use radicle_surf::file_system::{Label, Path, SystemType};
+
    /// use radicle_surf::file_system::unsound;
+
    /// use std::str::FromStr;
+
    /// # use std::error::Error;
+
    ///
+
    /// # fn main() -> Result<(), Box<dyn Error>> {
+
    /// let repo = Repository::new("./data/git-platinum")?;
+
    /// let mut browser = Browser::new(&repo, Branch::local("master"))?;
+
    ///
+
    /// // Clamp the Browser to a particular commit
+
    /// let commit = Oid::from_str("d6880352fc7fda8f521ae9b7357668b17bb5bad5")?;
+
    /// browser.commit(commit)?;
+
    ///
+
    /// let head_commit = browser.get().first().clone();
+
    /// let expected_commit = Oid::from_str("d3464e33d75c75c99bfb90fa2e9d16efc0b7d0e3")?;
+
    ///
+
    /// let readme_last_commit = browser
+
    ///     .last_commit(Path::with_root(&[unsound::label::new("README.md")]))?
+
    ///     .map(|commit| commit.id);
+
    ///
+
    /// assert_eq!(readme_last_commit, Some(expected_commit));
+
    ///
+
    /// let expected_commit = Oid::from_str("e24124b7538658220b5aaf3b6ef53758f0a106dc")?;
+
    ///
+
    /// let memory_last_commit = browser
+
    ///     .last_commit(Path::with_root(&[unsound::label::new("src"), unsound::label::new("memory.rs")]))?
+
    ///     .map(|commit| commit.id);
+
    ///
+
    /// assert_eq!(memory_last_commit, Some(expected_commit));
+
    /// #
+
    /// # Ok(())
+
    /// # }
+
    /// ```
+
    pub fn last_commit(&self, path: file_system::Path) -> Result<Option<Commit>, Error> {
+
        let file_history = self.repository.file_history(
+
            &path,
+
            repo::CommitHistory::Last,
+
            self.get().first().clone(),
+
        )?;
+
        Ok(file_history.first().cloned())
+
    }
+

+
    /// Get the commit history for a file _or_ directory.
+
    ///
+
    /// # Examples
+
    ///
+
    /// ```
+
    /// use nonempty::NonEmpty;
+
    /// use radicle_surf::vcs::git::{Branch, Browser, Oid, Repository};
+
    /// use radicle_surf::file_system::{Label, Path, SystemType};
+
    /// use radicle_surf::file_system::unsound;
+
    /// use std::str::FromStr;
+
    /// # use std::error::Error;
+
    ///
+
    /// # fn main() -> Result<(), Box<dyn Error>> {
+
    /// let repo = Repository::new("./data/git-platinum")?;
+
    /// let mut browser = Browser::new(&repo, Branch::local("master"))?;
+
    ///
+
    /// // Clamp the Browser to a particular commit
+
    /// let commit = Oid::from_str("223aaf87d6ea62eef0014857640fd7c8dd0f80b5")?;
+
    /// browser.commit(commit)?;
+
    ///
+
    /// let root_commits: Vec<Oid> = browser
+
    ///     .file_history(unsound::path::new("~"))?
+
    ///     .into_iter()
+
    ///     .map(|commit| commit.id)
+
    ///     .collect();
+
    ///
+
    /// assert_eq!(root_commits,
+
    ///     vec![
+
    ///         Oid::from_str("223aaf87d6ea62eef0014857640fd7c8dd0f80b5")?,
+
    ///         Oid::from_str("80bacafba303bf0cdf6142921f430ff265f25095")?,
+
    ///         Oid::from_str("a57846bbc8ced6587bf8329fc4bce970eb7b757e")?,
+
    ///         Oid::from_str("3873745c8f6ffb45c990eb23b491d4b4b6182f95")?,
+
    ///         Oid::from_str("80ded66281a4de2889cc07293a8f10947c6d57fe")?,
+
    ///         Oid::from_str("91b69e00cd8e5a07e20942e9e4457d83ce7a3ff1")?,
+
    ///         Oid::from_str("1820cb07c1a890016ca5578aa652fd4d4c38967e")?,
+
    ///         Oid::from_str("1e0206da8571ca71c51c91154e2fee376e09b4e7")?,
+
    ///         Oid::from_str("e24124b7538658220b5aaf3b6ef53758f0a106dc")?,
+
    ///         Oid::from_str("19bec071db6474af89c866a1bd0e4b1ff76e2b97")?,
+
    ///         Oid::from_str("f3a089488f4cfd1a240a9c01b3fcc4c34a4e97b2")?,
+
    ///         Oid::from_str("2429f097664f9af0c5b7b389ab998b2199ffa977")?,
+
    ///         Oid::from_str("d3464e33d75c75c99bfb90fa2e9d16efc0b7d0e3")?,
+
    ///     ]
+
    /// );
+
    ///
+
    /// let eval_commits: Vec<Oid> = browser
+
    ///     .file_history(unsound::path::new("~/src/Eval.hs"))?
+
    ///     .into_iter()
+
    ///     .map(|commit| commit.id)
+
    ///     .collect();
+
    ///
+
    /// assert_eq!(eval_commits,
+
    ///     vec![
+
    ///         Oid::from_str("3873745c8f6ffb45c990eb23b491d4b4b6182f95")?,
+
    ///         Oid::from_str("e24124b7538658220b5aaf3b6ef53758f0a106dc")?,
+
    ///     ]
+
    /// );
+
    /// #
+
    /// # Ok(())
+
    /// # }
+
    /// ```
+
    pub fn file_history(&self, path: file_system::Path) -> Result<Vec<Commit>, Error> {
+
        self.repository
+
            .file_history(&path, repo::CommitHistory::Full, self.get().first().clone())
+
    }
+

+
    /// Extract the signature for a commit
+
    ///
+
    /// # Arguments
+
    ///
+
    /// * `commit` - The commit to extract the signature for
+
    /// * `field` - the name of the header field containing the signature block;
+
    ///   pass `None` to extract the default 'gpgsig'
+
    ///
+
    /// # Examples
+
    ///
+
    /// ```
+
    /// use radicle_surf::vcs::git::{Branch, Browser, Repository, Oid, error};
+
    /// # use std::error::Error;
+
    ///
+
    /// # fn main() -> Result<(), Box<dyn Error>> {
+
    /// let repo = Repository::new("./data/git-platinum")?;
+
    /// let mut browser = Browser::new(&repo, Branch::local("master"))?;
+
    ///
+
    /// let commit_with_signature_oid = Oid::from_str(
+
    ///     "e24124b7538658220b5aaf3b6ef53758f0a106dc"
+
    /// )?;
+
    ///
+
    /// browser.commit(commit_with_signature_oid)?;
+
    /// let history = browser.get();
+
    /// let commit_with_signature = history.first();
+
    /// let signature = browser.extract_signature(commit_with_signature, None)?;
+
    ///
+
    /// // We have a signature
+
    /// assert!(signature.is_some());
+
    ///
+
    /// let commit_without_signature_oid = Oid::from_str(
+
    ///     "80bacafba303bf0cdf6142921f430ff265f25095"
+
    /// )?;
+
    ///
+
    /// browser.commit(commit_without_signature_oid)?;
+
    /// let history = browser.get();
+
    /// let commit_without_signature = history.first();
+
    /// let signature = browser.extract_signature(commit_without_signature, None)?;
+
    ///
+
    /// // There is no signature
+
    /// assert!(signature.is_none());
+
    /// #
+
    /// # Ok(())
+
    /// # }
+
    /// ```
+
    pub fn extract_signature(
+
        &self,
+
        commit: &Commit,
+
        field: Option<&str>,
+
    ) -> Result<Option<Signature>, Error> {
+
        self.repository.extract_signature(&commit.id, field)
+
    }
+

+
    /// List the [`Branch`]es, which contain the provided [`Commit`].
+
    ///
+
    /// # Errors
+
    ///
+
    /// * [`error::Error::Git`]
+
    ///
+
    /// # Examples
+
    ///
+
    /// ```
+
    /// use radicle_surf::vcs::git::{Browser, Repository, Branch, BranchName, Namespace, Oid};
+
    /// use std::convert::TryFrom;
+
    /// # use std::error::Error;
+
    ///
+
    /// # fn main() -> Result<(), Box<dyn Error>> {
+
    /// let repo = Repository::new("./data/git-platinum")?;
+
    /// let browser = Browser::new(&repo, Branch::local("master"))?;
+
    ///
+
    ///
+
    /// let branches = browser.revision_branches(Oid::from_str("27acd68c7504755aa11023300890bb85bbd69d45")?)?;
+
    /// assert_eq!(
+
    ///     branches,
+
    ///     vec![
+
    ///         Branch::local("dev"),
+
    ///         Branch::remote("dev", "origin"),
+
    ///     ]
+
    /// );
+
    ///
+
    /// // TODO(finto): I worry that this test will fail as other branches get added
+
    /// let mut branches = browser.revision_branches(Oid::from_str("1820cb07c1a890016ca5578aa652fd4d4c38967e")?)?;
+
    /// branches.sort();
+
    /// assert_eq!(
+
    ///     branches,
+
    ///     vec![
+
    ///         Branch::remote("HEAD", "origin"),
+
    ///         Branch::local("dev"),
+
    ///         Branch::remote("dev", "origin"),
+
    ///         Branch::local("master"),
+
    ///         Branch::remote("master", "origin"),
+
    ///         Branch::remote("orange/pineapple", "banana"),
+
    ///         Branch::remote("pineapple", "banana"),
+
    ///     ]
+
    /// );
+
    ///
+
    /// let golden_browser = browser.switch_namespace(&Namespace::try_from("golden")?,
+
    /// Branch::local("master"))?;
+
    ///
+
    /// let branches = golden_browser.revision_branches(Oid::from_str("27acd68c7504755aa11023300890bb85bbd69d45")?)?;
+
    /// assert_eq!(
+
    ///     branches,
+
    ///     vec![
+
    ///         Branch::local("banana"),
+
    ///         Branch::remote("fakie/bigspin", "kickflip"),
+
    ///         Branch::remote("heelflip", "kickflip"),
+
    ///     ]
+
    /// );
+
    /// #
+
    /// # Ok(())
+
    /// # }
+
    /// ```
+
    pub fn revision_branches(&self, rev: impl Into<Rev>) -> Result<Vec<Branch>, Error> {
+
        let commit = self.repository.rev_to_commit(&rev.into())?;
+
        self.repository.revision_branches(&commit.id())
+
    }
+

+
    /// Get the [`Stats`] of the underlying [`Repository`].
+
    ///
+
    /// # Errors
+
    ///
+
    /// * [`error::Error::Git`]
+
    ///
+
    /// # Examples
+
    ///
+
    /// ```
+
    /// use radicle_surf::vcs::git::{Branch, Browser, Repository};
+
    /// # use std::error::Error;
+
    ///
+
    /// # fn main() -> Result<(), Box<dyn Error>> {
+
    /// let repo = Repository::new("./data/git-platinum")?;
+
    /// let mut browser = Browser::new(&repo, Branch::local("master"))?;
+
    ///
+
    /// let stats = browser.get_stats()?;
+
    ///
+
    /// assert_eq!(stats.branches, 2);
+
    ///
+
    /// assert_eq!(stats.commits, 15);
+
    ///
+
    /// assert_eq!(stats.contributors, 4);
+
    ///
+
    /// # Ok(())
+
    /// # }
+
    /// ```
+
    pub fn get_stats(&self) -> Result<Stats, Error> {
+
        let branches = self.list_branches(RefScope::Local)?.len();
+
        let commits = self.history.len();
+

+
        let contributors = self
+
            .history
+
            .iter()
+
            .cloned()
+
            .map(|commit| (commit.author.name, commit.author.email))
+
            .collect::<BTreeSet<_>>();
+

+
        Ok(Stats {
+
            branches,
+
            commits,
+
            contributors: contributors.len(),
+
        })
+
    }
+

+
    /// Do a pre-order TreeWalk of the given commit. This turns a Tree
+
    /// into a HashMap of Paths and a list of Files. We can then turn that
+
    /// into a Directory.
+
    fn get_tree(
+
        repo: &git2::Repository,
+
        commit: &Commit,
+
    ) -> Result<HashMap<file_system::Path, NonEmpty<(file_system::Label, directory::File)>>, Error>
+
    {
+
        let mut file_paths_or_error: Result<
+
            HashMap<file_system::Path, NonEmpty<(file_system::Label, directory::File)>>,
+
            Error,
+
        > = Ok(HashMap::new());
+

+
        let commit = repo.find_commit(commit.id)?;
+
        let tree = commit.as_object().peel_to_tree()?;
+

+
        tree.walk(
+
            git2::TreeWalkMode::PreOrder,
+
            |s, entry| match Self::tree_entry_to_file_and_path(repo, s, entry) {
+
                Ok((path, name, file)) => {
+
                    match file_paths_or_error.as_mut() {
+
                        Ok(files) => Self::update_file_map(path, name, file, files),
+

+
                        // We don't need to update, we want to keep the error.
+
                        Err(_err) => {},
+
                    }
+
                    git2::TreeWalkResult::Ok
+
                },
+
                Err(err) => match err {
+
                    // We want to continue if the entry was not a Blob.
+
                    TreeWalkError::NotBlob => git2::TreeWalkResult::Ok,
+

+
                    // We found a ObjectType::Commit (likely a submodule) and
+
                    // so we can skip it.
+
                    TreeWalkError::Commit => git2::TreeWalkResult::Ok,
+

+
                    // But we want to keep the error and abort otherwise.
+
                    TreeWalkError::Git(err) => {
+
                        file_paths_or_error = Err(err);
+
                        git2::TreeWalkResult::Abort
+
                    },
+
                },
+
            },
+
        )?;
+

+
        file_paths_or_error
+
    }
+

+
    /// Find the best common ancestor between two commits if it exists.
+
    ///
+
    /// See [`git2::Repository::merge_base`] for details.
+
    pub fn merge_base(&self, one: Oid, two: Oid) -> Result<Option<Oid>, Error> {
+
        match self.repository.repo_ref.merge_base(one, two) {
+
            Ok(merge_base) => Ok(Some(merge_base)),
+
            Err(err) => {
+
                if err.code() == git2::ErrorCode::NotFound {
+
                    Ok(None)
+
                } else {
+
                    Err(Error::Git(err))
+
                }
+
            },
+
        }
+
    }
+

+
    fn update_file_map(
+
        path: file_system::Path,
+
        name: file_system::Label,
+
        file: directory::File,
+
        files: &mut HashMap<file_system::Path, NonEmpty<(file_system::Label, directory::File)>>,
+
    ) {
+
        files
+
            .entry(path)
+
            .and_modify(|entries| entries.push((name.clone(), file.clone())))
+
            .or_insert_with(|| NonEmpty::new((name, file)));
+
    }
+

+
    fn tree_entry_to_file_and_path(
+
        repo: &git2::Repository,
+
        tree_path: &str,
+
        entry: &git2::TreeEntry,
+
    ) -> Result<(file_system::Path, file_system::Label, directory::File), TreeWalkError> {
+
        // Account for the "root" of git being the empty string
+
        let path = if tree_path.is_empty() {
+
            Ok(file_system::Path::root())
+
        } else {
+
            file_system::Path::try_from(tree_path)
+
        }?;
+

+
        // We found a Commit object in the Tree, likely a submodule.
+
        // We will skip this entry.
+
        if let Some(git2::ObjectType::Commit) = entry.kind() {
+
            return Err(TreeWalkError::Commit);
+
        }
+

+
        let object = entry.to_object(repo)?;
+
        let blob = object.as_blob().ok_or(TreeWalkError::NotBlob)?;
+
        let name = str::from_utf8(entry.name_bytes())?;
+

+
        let name = file_system::Label::try_from(name).map_err(Error::FileSystem)?;
+

+
        Ok((
+
            path,
+
            name,
+
            directory::File {
+
                contents: blob.content().to_owned(),
+
                size: blob.size(),
+
            },
+
        ))
+
    }
+
}
+

+
#[cfg(test)]
+
mod tests {
+
    use super::*;
+

+
    #[cfg(not(feature = "gh-actions"))]
+
    #[test]
+
    // An issue with submodules, see: https://github.com/radicle-dev/radicle-surf/issues/54
+
    fn test_submodule_failure() {
+
        let repo = Repository::new("..").unwrap();
+
        let browser = Browser::new(&repo, Branch::local("main")).unwrap();
+

+
        browser.get_directory().unwrap();
+
    }
+

+
    #[cfg(test)]
+
    mod namespace {
+
        use super::*;
+
        use pretty_assertions::{assert_eq, assert_ne};
+

+
        #[test]
+
        fn switch_to_banana() -> Result<(), Error> {
+
            let repo = Repository::new("./data/git-platinum")?;
+
            let mut browser = Browser::new_with_namespace(
+
                &repo,
+
                &Namespace::try_from("golden")?,
+
                Branch::local("master"),
+
            )?;
+
            let history = browser.history.clone();
+

+
            browser.branch(Branch::local("banana"))?;
+

+
            assert_ne!(history, browser.history);
+

+
            Ok(())
+
        }
+

+
        #[test]
+
        fn me_namespace() -> Result<(), Error> {
+
            let repo = Repository::new("./data/git-platinum")?;
+
            let browser = Browser::new(&repo, Branch::local("master"))?;
+
            let history = browser.history.clone();
+

+
            assert_eq!(browser.which_namespace(), Ok(None));
+

+
            let browser = browser
+
                .switch_namespace(&Namespace::try_from("me")?, Branch::local("feature/#1194"))?;
+

+
            assert_eq!(
+
                browser.which_namespace(),
+
                Ok(Some(Namespace::try_from("me")?))
+
            );
+
            assert_eq!(history, browser.history);
+

+
            let expected_branches: Vec<Branch> = vec![Branch::local("feature/#1194")];
+
            let mut branches = browser.list_branches(RefScope::Local)?;
+
            branches.sort();
+

+
            assert_eq!(expected_branches, branches);
+

+
            let expected_branches: Vec<Branch> = vec![Branch::remote("feature/#1194", "fein")];
+
            let mut branches = browser.list_branches(RefScope::Remote {
+
                name: Some("fein".to_string()),
+
            })?;
+
            branches.sort();
+

+
            assert_eq!(expected_branches, branches);
+

+
            Ok(())
+
        }
+

+
        #[test]
+
        fn golden_namespace() -> Result<(), Error> {
+
            let repo = Repository::new("./data/git-platinum")?;
+
            let browser = Browser::new(&repo, Branch::local("master"))?;
+
            let history = browser.history.clone();
+

+
            assert_eq!(browser.which_namespace(), Ok(None));
+

+
            let golden_browser = browser
+
                .switch_namespace(&Namespace::try_from("golden")?, Branch::local("master"))?;
+

+
            assert_eq!(
+
                golden_browser.which_namespace(),
+
                Ok(Some(Namespace::try_from("golden")?))
+
            );
+
            assert_eq!(history, golden_browser.history);
+

+
            let expected_branches: Vec<Branch> =
+
                vec![Branch::local("banana"), Branch::local("master")];
+
            let mut branches = golden_browser.list_branches(RefScope::Local)?;
+
            branches.sort();
+

+
            assert_eq!(expected_branches, branches);
+

+
            let expected_branches: Vec<Branch> = vec![
+
                Branch::remote("fakie/bigspin", "kickflip"),
+
                Branch::remote("heelflip", "kickflip"),
+
                Branch::remote("v0.1.0", "kickflip"),
+
            ];
+
            let mut branches = golden_browser.list_branches(RefScope::Remote {
+
                name: Some("kickflip".to_string()),
+
            })?;
+
            branches.sort();
+

+
            assert_eq!(expected_branches, branches);
+

+
            Ok(())
+
        }
+

+
        #[test]
+
        fn silver_namespace() -> Result<(), Error> {
+
            let repo = Repository::new("./data/git-platinum")?;
+
            let browser = Browser::new(&repo, Branch::local("master"))?;
+
            let history = browser.history.clone();
+

+
            assert_eq!(browser.which_namespace(), Ok(None));
+

+
            let silver_browser = browser.switch_namespace(
+
                &Namespace::try_from("golden/silver")?,
+
                Branch::local("master"),
+
            )?;
+

+
            assert_eq!(
+
                silver_browser.which_namespace(),
+
                Ok(Some(Namespace::try_from("golden/silver")?))
+
            );
+
            assert_ne!(history, silver_browser.history);
+

+
            let expected_branches: Vec<Branch> = vec![Branch::local("master")];
+
            let mut branches = silver_browser.list_branches(RefScope::All)?;
+
            branches.sort();
+

+
            assert_eq!(expected_branches, branches);
+

+
            Ok(())
+
        }
+
    }
+

+
    #[cfg(test)]
+
    mod rev {
+
        use super::{Branch, Browser, Error, Oid, Repository, TagName};
+

+
        // **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::new("./data/git-platinum")?;
+
            let mut browser = Browser::new(&repo, Branch::local("master"))?;
+
            browser.rev(Branch::remote("master", "origin"))?;
+

+
            let commit1 = Oid::from_str("3873745c8f6ffb45c990eb23b491d4b4b6182f95")?;
+
            assert!(
+
                browser
+
                    .history
+
                    .find(|commit| if commit.id == commit1 {
+
                        Some(commit.clone())
+
                    } else {
+
                        None
+
                    })
+
                    .is_some(),
+
                "commit_id={}, history =\n{:#?}",
+
                commit1,
+
                browser.history
+
            );
+

+
            let commit2 = Oid::from_str("d6880352fc7fda8f521ae9b7357668b17bb5bad5")?;
+
            assert!(
+
                browser
+
                    .history
+
                    .find(|commit| if commit.id == commit2 {
+
                        Some(commit.clone())
+
                    } else {
+
                        None
+
                    })
+
                    .is_some(),
+
                "commit_id={}, history =\n{:#?}",
+
                commit2,
+
                browser.history
+
            );
+

+
            Ok(())
+
        }
+

+
        #[test]
+
        fn commit() -> Result<(), Error> {
+
            let repo = Repository::new("./data/git-platinum")?;
+
            let mut browser = Browser::new(&repo, Branch::local("master"))?;
+
            browser.rev(Oid::from_str("3873745c8f6ffb45c990eb23b491d4b4b6182f95")?)?;
+

+
            let commit1 = Oid::from_str("3873745c8f6ffb45c990eb23b491d4b4b6182f95")?;
+
            assert!(browser
+
                .history
+
                .find(|commit| if commit.id == commit1 {
+
                    Some(commit.clone())
+
                } else {
+
                    None
+
                })
+
                .is_some());
+

+
            Ok(())
+
        }
+

+
        #[test]
+
        fn commit_parents() -> Result<(), Error> {
+
            let repo = Repository::new("./data/git-platinum")?;
+
            let mut browser = Browser::new(&repo, Branch::local("master"))?;
+
            browser.rev(Oid::from_str("3873745c8f6ffb45c990eb23b491d4b4b6182f95")?)?;
+
            let commit = browser.history.first();
+

+
            assert_eq!(
+
                commit.parents,
+
                vec![Oid::from_str("d6880352fc7fda8f521ae9b7357668b17bb5bad5")?]
+
            );
+

+
            Ok(())
+
        }
+

+
        #[test]
+
        fn commit_short() -> Result<(), Error> {
+
            let repo = Repository::new("./data/git-platinum")?;
+
            let mut browser = Browser::new(&repo, Branch::local("master"))?;
+
            browser.rev(browser.oid("3873745c8")?)?;
+

+
            let commit1 = Oid::from_str("3873745c8f6ffb45c990eb23b491d4b4b6182f95")?;
+
            assert!(browser
+
                .history
+
                .find(|commit| if commit.id == commit1 {
+
                    Some(commit.clone())
+
                } else {
+
                    None
+
                })
+
                .is_some());
+

+
            Ok(())
+
        }
+

+
        #[test]
+
        fn tag() -> Result<(), Error> {
+
            let repo = Repository::new("./data/git-platinum")?;
+
            let mut browser = Browser::new(&repo, Branch::local("master"))?;
+
            browser.rev(TagName::new("v0.2.0"))?;
+

+
            let commit1 = Oid::from_str("2429f097664f9af0c5b7b389ab998b2199ffa977")?;
+
            assert_eq!(browser.history.first().id, commit1);
+

+
            Ok(())
+
        }
+
    }
+

+
    #[cfg(test)]
+
    mod last_commit {
+
        use crate::{
+
            file_system::{unsound, Path},
+
            vcs::git::{Branch, Browser, Oid, Repository},
+
        };
+

+
        #[test]
+
        fn readme_missing_and_memory() {
+
            let repo = Repository::new("./data/git-platinum")
+
                .expect("Could not retrieve ./data/git-platinum as git repository");
+
            let mut browser =
+
                Browser::new(&repo, Branch::local("master")).expect("Could not initialise Browser");
+

+
            // Set the browser history to the initial commit
+
            let commit = Oid::from_str("d3464e33d75c75c99bfb90fa2e9d16efc0b7d0e3")
+
                .expect("Failed to parse SHA");
+
            browser.commit(commit).unwrap();
+

+
            let head_commit = browser.get().0.first().clone();
+

+
            // memory.rs is commited later so it should not exist here.
+
            let memory_last_commit = browser
+
                .last_commit(Path::with_root(&[
+
                    unsound::label::new("src"),
+
                    unsound::label::new("memory.rs"),
+
                ]))
+
                .expect("Failed to get last commit")
+
                .map(|commit| commit.id);
+

+
            assert_eq!(memory_last_commit, None);
+

+
            // README.md exists in this commit.
+
            let readme_last_commit = browser
+
                .last_commit(Path::with_root(&[unsound::label::new("README.md")]))
+
                .expect("Failed to get last commit")
+
                .map(|commit| commit.id);
+

+
            assert_eq!(readme_last_commit, Some(head_commit.id));
+
        }
+

+
        #[test]
+
        fn folder_svelte() {
+
            let repo = Repository::new("./data/git-platinum")
+
                .expect("Could not retrieve ./data/git-platinum as git repository");
+
            let mut browser =
+
                Browser::new(&repo, Branch::local("master")).expect("Could not initialise Browser");
+

+
            // Check that last commit is the actual last commit even if head commit differs.
+
            let commit = Oid::from_str("19bec071db6474af89c866a1bd0e4b1ff76e2b97")
+
                .expect("Could not parse SHA");
+
            browser.commit(commit).unwrap();
+

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

+
            let folder_svelte = browser
+
                .last_commit(unsound::path::new("~/examples/Folder.svelte"))
+
                .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::new("./data/git-platinum")
+
                .expect("Could not retrieve ./data/git-platinum as git repository");
+
            let mut browser =
+
                Browser::new(&repo, Branch::local("master")).expect("Could not initialise Browser");
+

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

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

+
            let nested_directory_tree_commit_id = browser
+
                .last_commit(unsound::path::new(
+
                    "~/this/is/a/really/deeply/nested/directory/tree",
+
                ))
+
                .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::new("./data/git-platinum")
+
                .expect("Could not retrieve ./data/git-platinum as git repository");
+
            let mut browser =
+
                Browser::new(&repo, Branch::local("master")).expect("Could not initialise Browser");
+

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

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

+
            let backslash_commit_id = browser
+
                .last_commit(unsound::path::new("~/special/faux\\path"))
+
                .expect("Failed to get last commit")
+
                .map(|commit| commit.id);
+
            assert_eq!(backslash_commit_id, Some(expected_commit_id));
+

+
            let ogre_commit_id = browser
+
                .last_commit(unsound::path::new("~/special/👹👹👹"))
+
                .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::new("./data/git-platinum")
+
                .expect("Could not retrieve ./data/git-platinum as git repository");
+
            let browser =
+
                Browser::new(&repo, Branch::local("master")).expect("Could not initialise Browser");
+

+
            let root_last_commit_id = browser
+
                .last_commit(Path::root())
+
                .expect("Failed to get last commit")
+
                .map(|commit| commit.id);
+

+
            assert_eq!(root_last_commit_id, Some(browser.get().first().id));
+
        }
+
    }
+

+
    #[cfg(test)]
+
    mod diff {
+
        use crate::{diff::*, vcs::git::*};
+

+
        #[test]
+
        fn test_initial_diff() -> Result<(), Error> {
+
            use file_system::*;
+
            use pretty_assertions::assert_eq;
+

+
            let oid = Oid::from_str("d3464e33d75c75c99bfb90fa2e9d16efc0b7d0e3")?;
+
            let repo = Repository::new("./data/git-platinum")?;
+
            let commit = repo.0.find_commit(oid).unwrap();
+

+
            assert!(commit.parents().count() == 0);
+
            assert!(commit.parent(0).is_err());
+

+
            let bro = Browser::new(&repo, Branch::local("master"))?;
+
            let diff = bro.initial_diff(oid)?;
+

+
            let expected_diff = Diff {
+
                created: vec![CreateFile {
+
                    path: Path::with_root(&[unsound::label::new("README.md")]),
+
                    diff: FileDiff::Plain {
+
                        hunks: vec![Hunk {
+
                            header: Line(b"@@ -0,0 +1 @@\n".to_vec()),
+
                            lines: vec![
+
                                LineDiff::addition(b"This repository is a data source for the Upstream front-end tests.\n".to_vec(), 1),
+
                            ]
+
                        }].into()
+
                    },
+
                }],
+
                deleted: vec![],
+
                moved: vec![],
+
                copied: vec![],
+
                modified: vec![],
+
            };
+
            assert_eq!(expected_diff, diff);
+

+
            Ok(())
+
        }
+

+
        #[test]
+
        fn test_diff() -> Result<(), Error> {
+
            use file_system::*;
+
            use pretty_assertions::assert_eq;
+

+
            let repo = Repository::new("./data/git-platinum")?;
+
            let commit = repo
+
                .0
+
                .find_commit(Oid::from_str("80bacafba303bf0cdf6142921f430ff265f25095")?)
+
                .unwrap();
+
            let parent = commit.parent(0)?;
+

+
            let bro = Browser::new(&repo, Branch::local("master"))?;
+

+
            let diff = bro.diff(parent.id(), commit.id())?;
+

+
            let expected_diff = Diff {
+
                created: vec![],
+
                deleted: vec![],
+
                moved: vec![],
+
                copied: vec![],
+
                modified: vec![ModifiedFile {
+
                    path: Path::with_root(&[unsound::label::new("README.md")]),
+
                    diff: FileDiff::Plain {
+
                        hunks: vec![Hunk {
+
                            header: Line(b"@@ -1 +1,2 @@\n".to_vec()),
+
                            lines: vec![
+
                                LineDiff::deletion(b"This repository is a data source for the Upstream front-end tests.\n".to_vec(), 1),
+
                                LineDiff::addition(b"This repository is a data source for the Upstream front-end tests and the\n".to_vec(), 1),
+
                                LineDiff::addition(b"[`radicle-surf`](https://github.com/radicle-dev/git-platinum) unit tests.\n".to_vec(), 2),
+
                            ]
+
                        }].into()
+
                    },
+
                    eof: None,
+
                }]
+
            };
+
            assert_eq!(expected_diff, diff);
+

+
            Ok(())
+
        }
+

+
        #[cfg(feature = "serialize")]
+
        #[test]
+
        fn test_diff_serde() -> Result<(), Error> {
+
            use file_system::*;
+

+
            let diff = Diff {
+
                created: vec![CreateFile{path: unsound::path::new("LICENSE"), diff: FileDiff::Plain { hunks: Hunks::default() }}],
+
                deleted: vec![],
+
                moved: vec![
+
                    MoveFile {
+
                        old_path: unsound::path::new("CONTRIBUTING"),
+
                        new_path: unsound::path::new("CONTRIBUTING.md")
+
                    }
+
                ],
+
                copied: vec![],
+
                modified: vec![ModifiedFile {
+
                    path: Path::with_root(&[unsound::label::new("README.md")]),
+
                    diff: FileDiff::Plain {
+
                        hunks: vec![Hunk {
+
                            header: Line(b"@@ -1 +1,2 @@\n".to_vec()),
+
                            lines: vec![
+
                                LineDiff::deletion(b"This repository is a data source for the Upstream front-end tests.\n".to_vec(), 1),
+
                                LineDiff::addition(b"This repository is a data source for the Upstream front-end tests and the\n".to_vec(), 1),
+
                                LineDiff::addition(b"[`radicle-surf`](https://github.com/radicle-dev/git-platinum) unit tests.\n".to_vec(), 2),
+
                                LineDiff::context(b"\n".to_vec(), 3, 4),
+
                            ]
+
                        }].into()
+
                    },
+
                    eof: None,
+
                }]
+
            };
+

+
            let eof: Option<u8> = None;
+
            let json = serde_json::json!({
+
                "created": [{"path": "LICENSE", "diff": {
+
                        "type": "plain",
+
                        "hunks": []
+
                    },
+
                }],
+
                "deleted": [],
+
                "moved": [{ "oldPath": "CONTRIBUTING", "newPath": "CONTRIBUTING.md" }],
+
                "copied": [],
+
                "modified": [{
+
                    "path": "README.md",
+
                    "diff": {
+
                        "type": "plain",
+
                        "hunks": [{
+
                            "header": "@@ -1 +1,2 @@\n",
+
                            "lines": [
+
                                { "lineNum": 1,
+
                                  "line": "This repository is a data source for the Upstream front-end tests.\n",
+
                                  "type": "deletion"
+
                                },
+
                                { "lineNum": 1,
+
                                  "line": "This repository is a data source for the Upstream front-end tests and the\n",
+
                                  "type": "addition"
+
                                },
+
                                { "lineNum": 2,
+
                                  "line": "[`radicle-surf`](https://github.com/radicle-dev/git-platinum) unit tests.\n",
+
                                  "type": "addition"
+
                                },
+
                                { "lineNumOld": 3, "lineNumNew": 4,
+
                                  "line": "\n",
+
                                  "type": "context"
+
                                }
+
                            ]
+
                        }]
+
                    },
+
                    "eof" : eof,
+
                }]
+
            });
+
            assert_eq!(serde_json::to_value(&diff).unwrap(), json);
+

+
            Ok(())
+
        }
+
    }
+

+
    #[cfg(test)]
+
    mod threading {
+
        use crate::vcs::git::*;
+
        use pretty_assertions::assert_eq;
+
        use std::sync::{Mutex, MutexGuard};
+

+
        #[test]
+
        fn basic_test() -> Result<(), Error> {
+
            let shared_repo = Mutex::new(Repository::new("./data/git-platinum")?);
+
            let locked_repo: MutexGuard<Repository> = shared_repo.lock().unwrap();
+
            let bro = Browser::new(&*locked_repo, Branch::local("master"))?;
+
            let mut branches = bro.list_branches(RefScope::All)?;
+
            branches.sort();
+

+
            assert_eq!(
+
                branches,
+
                vec![
+
                    Branch::remote("HEAD", "origin"),
+
                    Branch::remote("dev", "origin"),
+
                    Branch::local("dev"),
+
                    Branch::remote("master", "origin"),
+
                    Branch::local("master"),
+
                    Branch::remote("orange/pineapple", "banana"),
+
                    Branch::remote("pineapple", "banana"),
+
                ]
+
            );
+

+
            Ok(())
+
        }
+
    }
+
}
added radicle-surf/src/vcs/git/branch.rs
@@ -0,0 +1,193 @@
+
// This file is part of radicle-surf
+
// <https://github.com/radicle-dev/radicle-surf>
+
//
+
// Copyright (C) 2019-2020 The Radicle Team <dev@radicle.xyz>
+
//
+
// This program is free software: you can redistribute it and/or modify
+
// it under the terms of the GNU General Public License version 3 or
+
// later as published by the Free Software Foundation.
+
//
+
// This program is distributed in the hope that it will be useful,
+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+
// GNU General Public License for more details.
+
//
+
// You should have received a copy of the GNU General Public License
+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
+

+
use crate::vcs::git::{self, error::Error, ext, reference::Ref};
+
use std::{cmp::Ordering, convert::TryFrom, fmt, str};
+

+
/// The branch type we want to filter on.
+
#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)]
+
pub enum BranchType {
+
    /// Local branches that are under `refs/heads/*`
+
    Local,
+
    /// Remote branches that are under `refs/remotes/<name>/*` if the name is
+
    /// provided, otherwise `refs/remotes/**/*`.
+
    Remote {
+
        /// Name of the remote.
+
        name: Option<String>,
+
    },
+
}
+

+
impl From<BranchType> for git2::BranchType {
+
    fn from(other: BranchType) -> Self {
+
        match other {
+
            BranchType::Local => git2::BranchType::Local,
+
            BranchType::Remote { .. } => git2::BranchType::Remote,
+
        }
+
    }
+
}
+

+
impl From<git2::BranchType> for BranchType {
+
    fn from(other: git2::BranchType) -> Self {
+
        match other {
+
            git2::BranchType::Local => BranchType::Local,
+
            git2::BranchType::Remote => BranchType::Remote { name: None },
+
        }
+
    }
+
}
+

+
/// A newtype wrapper over `String` to separate out the fact that a caller wants
+
/// to fetch a branch.
+
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
+
pub struct BranchName(pub(crate) String);
+

+
impl fmt::Display for BranchName {
+
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+
        self.0.fmt(f)
+
    }
+
}
+

+
impl TryFrom<&[u8]> for BranchName {
+
    type Error = str::Utf8Error;
+

+
    fn try_from(name: &[u8]) -> Result<Self, Self::Error> {
+
        let name = str::from_utf8(name)?;
+
        let short_name = match git::ext::try_extract_refname(name) {
+
            Ok(stripped) => stripped,
+
            Err(original) => original,
+
        };
+
        Ok(Self(short_name))
+
    }
+
}
+

+
impl BranchName {
+
    /// Create a new `BranchName`.
+
    pub fn new(name: &str) -> Self {
+
        Self(name.into())
+
    }
+

+
    /// Access the string value of the `BranchName`.
+
    pub fn name(&self) -> &str {
+
        &self.0
+
    }
+
}
+

+
/// The static information of a `git2::Branch`.
+
///
+
/// **Note**: The `PartialOrd` and `Ord` implementations compare on `BranchName`
+
/// only.
+
#[derive(Debug, Clone, PartialEq, Eq)]
+
pub struct Branch {
+
    /// Name identifier of the `Branch`.
+
    pub name: BranchName,
+
    /// Whether the `Branch` is `Remote` or `Local`.
+
    pub locality: BranchType,
+
}
+

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

+
impl Ord for Branch {
+
    fn cmp(&self, other: &Branch) -> Ordering {
+
        self.name.cmp(&other.name)
+
    }
+
}
+

+
impl From<Branch> for Ref {
+
    fn from(other: Branch) -> Self {
+
        match other.locality {
+
            BranchType::Local => Self::LocalBranch { name: other.name },
+
            BranchType::Remote { name } => Self::RemoteBranch {
+
                name: other.name,
+
                remote: name.unwrap_or_else(|| "**".to_string()),
+
            },
+
        }
+
    }
+
}
+

+
impl Branch {
+
    /// Helper to create a remote `Branch` with a name
+
    pub fn remote(name: &str, remote: &str) -> Self {
+
        Self {
+
            name: BranchName(name.to_string()),
+
            locality: BranchType::Remote {
+
                name: Some(remote.to_string()),
+
            },
+
        }
+
    }
+

+
    /// Helper to create a remote `Branch` with a name
+
    pub fn local(name: &str) -> Self {
+
        Self {
+
            name: BranchName(name.to_string()),
+
            locality: BranchType::Local,
+
        }
+
    }
+

+
    /// Get the name of the `Branch`.
+
    pub fn name(&self) -> String {
+
        let branch_name = self.name.0.clone();
+
        match self.locality {
+
            BranchType::Local => branch_name,
+
            BranchType::Remote { ref name } => match name {
+
                None => branch_name,
+
                Some(remote_name) => format!("{}/{}", remote_name, branch_name),
+
            },
+
        }
+
    }
+
}
+

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

+
    fn try_from(reference: git2::Reference) -> Result<Self, Self::Error> {
+
        let is_remote = ext::is_remote(&reference);
+
        let is_tag = reference.is_tag();
+
        let is_note = reference.is_note();
+
        let name = BranchName::try_from(reference.name_bytes())?;
+

+
        // Best effort to not return tags or notes. Assuming everything after that is a
+
        // branch.
+
        if is_tag || is_note {
+
            return Err(Error::NotBranch(name));
+
        }
+

+
        if is_remote {
+
            let mut split = name.0.splitn(2, '/');
+
            let remote_name = split
+
                .next()
+
                .ok_or_else(|| Error::ParseRemoteBranch(name.clone()))?;
+
            let name = split
+
                .next()
+
                .ok_or_else(|| Error::ParseRemoteBranch(name.clone()))?;
+

+
            Ok(Self {
+
                name: BranchName(name.to_string()),
+
                locality: BranchType::Remote {
+
                    name: Some(remote_name.to_string()),
+
                },
+
            })
+
        } else {
+
            Ok(Self {
+
                name,
+
                locality: BranchType::Local,
+
            })
+
        }
+
    }
+
}
added radicle-surf/src/vcs/git/commit.rs
@@ -0,0 +1,102 @@
+
// This file is part of radicle-surf
+
// <https://github.com/radicle-dev/radicle-surf>
+
//
+
// Copyright (C) 2019-2020 The Radicle Team <dev@radicle.xyz>
+
//
+
// This program is free software: you can redistribute it and/or modify
+
// it under the terms of the GNU General Public License version 3 or
+
// later as published by the Free Software Foundation.
+
//
+
// This program is distributed in the hope that it will be useful,
+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+
// GNU General Public License for more details.
+
//
+
// You should have received a copy of the GNU General Public License
+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
+

+
use crate::vcs::git::error::Error;
+
use git2::Oid;
+
use std::{convert::TryFrom, str};
+

+
/// `Author` is the static information of a [`git2::Signature`].
+
#[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.
+
    pub time: git2::Time,
+
}
+

+
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<'repo> TryFrom<git2::Signature<'repo>> 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();
+

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

+
/// `Commit` is the static information of a [`git2::Commit`]. To get back the
+
/// original `Commit` in the repository we can use the [`Oid`] to retrieve
+
/// it.
+
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
+
pub struct Commit {
+
    /// Object ID of the Commit, i.e. the SHA1 digest.
+
    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<'repo> TryFrom<git2::Commit<'repo>> for Commit {
+
    type Error = Error;
+

+
    fn try_from(commit: git2::Commit) -> Result<Self, Self::Error> {
+
        let id = commit.id();
+
        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().collect();
+

+
        Ok(Commit {
+
            id,
+
            author,
+
            committer,
+
            message,
+
            summary,
+
            parents,
+
        })
+
    }
+
}
added radicle-surf/src/vcs/git/error.rs
@@ -0,0 +1,126 @@
+
// This file is part of radicle-surf
+
// <https://github.com/radicle-dev/radicle-surf>
+
//
+
// Copyright (C) 2019-2020 The Radicle Team <dev@radicle.xyz>
+
//
+
// This program is free software: you can redistribute it and/or modify
+
// it under the terms of the GNU General Public License version 3 or
+
// later as published by the Free Software Foundation.
+
//
+
// This program is distributed in the hope that it will be useful,
+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+
// GNU General Public License for more details.
+
//
+
// You should have received a copy of the GNU General Public License
+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
+

+
//! Collection of errors and helper instances that can occur when performing
+
//! operations from [`crate::vcs::git`].
+

+
use crate::{
+
    diff,
+
    file_system,
+
    vcs::git::{BranchName, Namespace, TagName},
+
};
+
use std::str;
+
use thiserror::Error;
+

+
/// Enumeration of errors that can occur in operations from [`crate::vcs::git`].
+
#[derive(Debug, PartialEq, Error)]
+
#[non_exhaustive]
+
pub enum Error {
+
    /// The user tried to fetch a branch, but the name provided does not
+
    /// exist as a branch. This could mean that the branch does not exist
+
    /// or that a tag or commit was provided by accident.
+
    #[error("provided branch name does not exist: {0}")]
+
    NotBranch(BranchName),
+
    /// We tried to convert a name into its remote and branch name parts.
+
    #[error("could not parse '{0}' into a remote name and branch name")]
+
    ParseRemoteBranch(BranchName),
+
    /// The user tried to fetch a tag, but the name provided does not
+
    /// exist as a tag. This could mean that the tag does not exist
+
    /// or that a branch or commit was provided by accident.
+
    #[error("provided tag name does not exist: {0}")]
+
    NotTag(TagName),
+
    /// A `revspec` was provided that could not be parsed into a branch, tag, or
+
    /// commit object.
+
    #[error("provided revspec '{rev}' could not be parsed into a git object")]
+
    RevParseFailure {
+
        /// The provided revspec that failed to parse.
+
        rev: String,
+
    },
+
    /// A `revspec` was provided that could not be found in the given
+
    /// `namespace`.
+
    #[error("provided revspec '{rev}' could not be parsed into a git object in the namespace '{namespace}'")]
+
    NamespaceRevParseFailure {
+
        /// The namespace we are in when attempting to fetch the `rev`.
+
        namespace: Namespace,
+
        /// The provided revspec that failed to parse.
+
        rev: String,
+
    },
+
    /// When parsing a namespace we may come across one that was an empty
+
    /// string.
+
    #[error("tried parsing the namespace but it was empty")]
+
    EmptyNamespace,
+
    /// A [`str::Utf8Error`] error, which usually occurs when a git object's
+
    /// name is not in UTF-8 form and parsing of it as such fails.
+
    #[error(transparent)]
+
    Utf8Error(#[from] str::Utf8Error),
+
    /// 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,
+
    /// An error that comes from performing a [`crate::file_system`] operation.
+
    #[error(transparent)]
+
    FileSystem(#[from] file_system::Error),
+
    /// While attempting to calculate a diff for retrieving the
+
    /// [`crate::vcs::git::Browser.last_commit()`], the file path was returned
+
    /// as an `Option::None`.
+
    #[error("last commit has an invalid file path")]
+
    LastCommitException,
+
    /// The requested file was not found.
+
    #[error("path not found for: {0}")]
+
    PathNotFound(file_system::Path),
+
    /// An error that comes from performing a *diff* operations.
+
    #[error(transparent)]
+
    Diff(#[from] diff::git::error::Diff),
+
    /// A wrapper around the generic [`git2::Error`].
+
    #[error(transparent)]
+
    Git(#[from] git2::Error),
+
}
+

+
/// A private enum that captures a recoverable and
+
/// non-recoverable error when walking the git tree.
+
///
+
/// In the case of `NotBlob` we abort the the computation but do
+
/// a check for it and recover.
+
///
+
/// In the of `Git` we abort both computations.
+
#[derive(Debug, Error)]
+
pub(crate) enum TreeWalkError {
+
    #[error("entry is not a blob")]
+
    NotBlob,
+
    #[error("git object is a commit")]
+
    Commit,
+
    #[error(transparent)]
+
    Git(#[from] Error),
+
}
+

+
impl From<git2::Error> for TreeWalkError {
+
    fn from(err: git2::Error) -> Self {
+
        TreeWalkError::Git(err.into())
+
    }
+
}
+

+
impl From<file_system::Error> for TreeWalkError {
+
    fn from(err: file_system::Error) -> Self {
+
        err.into()
+
    }
+
}
+

+
impl From<str::Utf8Error> for TreeWalkError {
+
    fn from(err: str::Utf8Error) -> Self {
+
        err.into()
+
    }
+
}
added radicle-surf/src/vcs/git/ext.rs
@@ -0,0 +1,111 @@
+
// This file is part of radicle-surf
+
// <https://github.com/radicle-dev/radicle-surf>
+
//
+
// Copyright (C) 2019-2020 The Radicle Team <dev@radicle.xyz>
+
//
+
// This program is free software: you can redistribute it and/or modify
+
// it under the terms of the GNU General Public License version 3 or
+
// later as published by the Free Software Foundation.
+
//
+
// This program is distributed in the hope that it will be useful,
+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+
// GNU General Public License for more details.
+
//
+
// You should have received a copy of the GNU General Public License
+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
+

+
/// Try to strip any refs/namespaces, refs/heads, refs/remotes, and
+
/// refs/tags. If this fails we return the original string.
+
pub fn try_extract_refname(spec: &str) -> Result<String, String> {
+
    let re = regex::Regex::new(r"(refs/namespaces/.*?/)*refs/(remotes/(.*?)/)?(heads/|tags/)?(.*)")
+
        .unwrap();
+

+
    re.captures(spec)
+
        .and_then(|c| {
+
            let mut result = String::new();
+
            if let Some(remote) = c.get(3).map(|m| m.as_str()) {
+
                result.push_str(remote);
+
                result.push('/');
+
            }
+
            result.push_str(c.get(5).map(|m| m.as_str())?);
+
            Some(result)
+
        })
+
        .ok_or_else(|| spec.to_string())
+
}
+

+
/// [`git2::Reference::is_tag`] just does a check for the prefix of `tags/`.
+
/// The issue with that is, as soon as we're in 'namespaces' ref that
+
/// is a tag it will say that it's not a tag. Instead we do a regex check on
+
/// `refs/tags/.*`.
+
pub fn is_tag(reference: &git2::Reference) -> bool {
+
    let re = regex::Regex::new(r"refs/tags/.*").unwrap();
+
    // If we couldn't parse the name we say it's not a tag.
+
    match reference.name() {
+
        Some(name) => re.is_match(name),
+
        None => false,
+
    }
+
}
+

+
pub fn is_branch(reference: &git2::Reference) -> bool {
+
    let re = regex::Regex::new(r"refs/heads/.*|refs/remotes/.*/.*").unwrap();
+
    // If we couldn't parse the name we say it's not a branch.
+
    match reference.name() {
+
        Some(name) => re.is_match(name),
+
        None => false,
+
    }
+
}
+

+
pub fn is_remote(reference: &git2::Reference) -> bool {
+
    let re = regex::Regex::new(r"refs/remotes/.*/.*").unwrap();
+
    // If we couldn't parse the name we say it's not a remote branch.
+
    match reference.name() {
+
        Some(name) => re.is_match(name),
+
        None => false,
+
    }
+
}
+

+
#[cfg(test)]
+
mod tests {
+
    use super::*;
+

+
    #[test]
+
    fn test_try_extract_refname() {
+
        assert_eq!(try_extract_refname("refs/heads/dev"), Ok("dev".to_string()));
+

+
        assert_eq!(
+
            try_extract_refname("refs/heads/master"),
+
            Ok("master".to_string())
+
        );
+

+
        assert_eq!(
+
            try_extract_refname("refs/remotes/banana/pineapple"),
+
            Ok("banana/pineapple".to_string())
+
        );
+

+
        assert_eq!(
+
            try_extract_refname("refs/remotes/origin/master"),
+
            Ok("origin/master".to_string())
+
        );
+

+
        assert_eq!(
+
            try_extract_refname("refs/namespaces/golden/refs/heads/banana"),
+
            Ok("banana".to_string())
+
        );
+

+
        assert_eq!(
+
            try_extract_refname("refs/namespaces/golden/refs/tags/v0.1.0"),
+
            Ok("v0.1.0".to_string())
+
        );
+

+
        assert_eq!(
+
            try_extract_refname("refs/namespaces/golden/refs/namespaces/silver/refs/heads/master"),
+
            Ok("master".to_string())
+
        );
+

+
        assert_eq!(
+
            try_extract_refname("refs/namespaces/golden/refs/remotes/kickflip/heads/heelflip"),
+
            Ok("kickflip/heelflip".to_string())
+
        );
+
    }
+
}
added radicle-surf/src/vcs/git/namespace.rs
@@ -0,0 +1,82 @@
+
// This file is part of radicle-surf
+
// <https://github.com/radicle-dev/radicle-surf>
+
//
+
// Copyright (C) 2019-2020 The Radicle Team <dev@radicle.xyz>
+
//
+
// This program is free software: you can redistribute it and/or modify
+
// it under the terms of the GNU General Public License version 3 or
+
// later as published by the Free Software Foundation.
+
//
+
// This program is distributed in the hope that it will be useful,
+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+
// GNU General Public License for more details.
+
//
+
// You should have received a copy of the GNU General Public License
+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
+

+
use crate::vcs::git::error::Error;
+
pub use git2::Oid;
+
use nonempty::NonEmpty;
+
use std::{convert::TryFrom, fmt, str};
+

+
/// A `Namespace` value allows us to switch the git namespace of
+
/// [`super::Browser`].
+
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
+
pub struct Namespace {
+
    /// Since namespaces can be nested we have a vector of strings.
+
    /// This means that the namespaces `"foo/bar"` is represented as
+
    /// `vec!["foo", "bar"]`.
+
    pub(super) values: NonEmpty<String>,
+
}
+

+
impl fmt::Display for Namespace {
+
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+
        let values: Vec<_> = self.values.clone().into();
+
        write!(f, "{}", values.join("/"))
+
    }
+
}
+

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

+
    fn try_from(namespace: &str) -> Result<Self, Self::Error> {
+
        let values = namespace.split('/').map(|n| n.to_string()).collect();
+
        NonEmpty::from_vec(values)
+
            .map(|values| Self { values })
+
            .ok_or(Error::EmptyNamespace)
+
    }
+
}
+

+
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(Namespace::try_from)
+
    }
+
}
+

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

+
    fn try_from(reference: git2::Reference) -> Result<Self, Self::Error> {
+
        let re = regex::Regex::new(r"refs/namespaces/([^/]+)/").unwrap();
+
        let ref_name = str::from_utf8(reference.name_bytes())?;
+
        let values = re
+
            .find_iter(ref_name)
+
            .map(|m| {
+
                String::from(
+
                    m.as_str()
+
                        .trim_start_matches("refs/namespaces/")
+
                        .trim_end_matches('/'),
+
                )
+
            })
+
            .collect::<Vec<_>>();
+

+
        NonEmpty::from_vec(values)
+
            .map(|values| Self { values })
+
            .ok_or(Error::EmptyNamespace)
+
    }
+
}
added radicle-surf/src/vcs/git/reference.rs
@@ -0,0 +1,302 @@
+
// This file is part of radicle-surf
+
// <https://github.com/radicle-dev/radicle-surf>
+
//
+
// Copyright (C) 2019-2020 The Radicle Team <dev@radicle.xyz>
+
//
+
// This program is free software: you can redistribute it and/or modify
+
// it under the terms of the GNU General Public License version 3 or
+
// later as published by the Free Software Foundation.
+
//
+
// This program is distributed in the hope that it will be useful,
+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+
// GNU General Public License for more details.
+
//
+
// You should have received a copy of the GNU General Public License
+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
+

+
use std::{fmt, str};
+

+
use thiserror::Error;
+

+
use crate::vcs::git::{repo::RepositoryRef, BranchName, Namespace, TagName};
+

+
pub(super) mod glob;
+

+
/// A revision within the repository.
+
#[derive(Debug, Clone, PartialEq, Eq)]
+
pub enum Rev {
+
    /// A reference to a branch or tag.
+
    Ref(Ref),
+
    /// A particular commit identifier.
+
    Oid(git2::Oid),
+
}
+

+
impl<R> From<R> for Rev
+
where
+
    R: Into<Ref>,
+
{
+
    fn from(other: R) -> Self {
+
        Self::Ref(other.into())
+
    }
+
}
+

+
impl From<git2::Oid> for Rev {
+
    fn from(other: git2::Oid) -> Self {
+
        Self::Oid(other)
+
    }
+
}
+

+
/// A structured way of referring to a git reference.
+
#[derive(Debug, Clone, PartialEq, Eq)]
+
pub enum Ref {
+
    /// A git tag, which can be found under `.git/refs/tags/`.
+
    Tag {
+
        /// The name of the tag, e.g. `v1.0.0`.
+
        name: TagName,
+
    },
+
    /// A git branch, which can be found under `.git/refs/heads/`.
+
    LocalBranch {
+
        /// The name of the branch, e.g. `master`.
+
        name: BranchName,
+
    },
+
    /// A git branch, which can be found under `.git/refs/remotes/`.
+
    RemoteBranch {
+
        /// The remote name, e.g. `origin`.
+
        remote: String,
+
        /// The name of the branch, e.g. `master`.
+
        name: BranchName,
+
    },
+
    /// A git namespace, which can be found under `.git/refs/namespaces/`.
+
    ///
+
    /// Note that namespaces can be nested.
+
    Namespace {
+
        /// The name value of the namespace.
+
        namespace: String,
+
        /// The reference under that namespace, e.g. The
+
        /// `refs/remotes/origin/master/ portion of `refs/namespaces/
+
        /// moi/refs/remotes/origin/master`.
+
        reference: Box<Ref>,
+
    },
+
}
+

+
impl Ref {
+
    /// Add a [`Namespace`] to a `Ref`.
+
    pub fn namespaced(self, Namespace { values: namespaces }: Namespace) -> Self {
+
        let mut ref_namespace = self;
+
        for namespace in namespaces.into_iter().rev() {
+
            ref_namespace = Self::Namespace {
+
                namespace,
+
                reference: Box::new(ref_namespace.clone()),
+
            };
+
        }
+

+
        ref_namespace
+
    }
+

+
    /// We try to find a [`git2::Reference`] based off of a `Ref` by turning the
+
    /// ref into a fully qualified ref (e.g. refs/remotes/**/master).
+
    pub fn find_ref<'a>(
+
        &self,
+
        repo: &RepositoryRef<'a>,
+
    ) -> Result<git2::Reference<'a>, git2::Error> {
+
        repo.repo_ref.find_reference(&self.to_string())
+
    }
+
}
+

+
impl fmt::Display for Ref {
+
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+
        match self {
+
            Self::Tag { name } => write!(f, "refs/tags/{}", name),
+
            Self::LocalBranch { name } => write!(f, "refs/heads/{}", name),
+
            Self::RemoteBranch { remote, name } => write!(f, "refs/remotes/{}/{}", remote, name),
+
            Self::Namespace {
+
                namespace,
+
                reference,
+
            } => write!(f, "refs/namespaces/{}/{}", namespace, reference),
+
        }
+
    }
+
}
+

+
#[derive(Debug, PartialEq, Eq, Error)]
+
pub enum ParseError {
+
    #[error("the ref provided '{0}' was malformed")]
+
    MalformedRef(String),
+
}
+

+
pub mod parser {
+
    use nom::{bytes, named, tag, IResult};
+

+
    use crate::vcs::git::{BranchName, TagName};
+

+
    use super::Ref;
+

+
    const HEADS: &str = "refs/heads/";
+
    const REMOTES: &str = "refs/remotes/";
+
    const TAGS: &str = "refs/tags/";
+
    const NAMESPACES: &str = "refs/namespaces/";
+

+
    named!(heads, tag!(HEADS));
+
    named!(remotes, tag!(REMOTES));
+
    named!(tags, tag!(TAGS));
+
    named!(namsespaces, tag!(NAMESPACES));
+

+
    type Error<'a> = nom::Err<nom::error::Error<&'a str>>;
+

+
    pub fn component(s: &str) -> IResult<&str, &str> {
+
        bytes::complete::take_till(|c| c == '/')(s).and_then(|(rest, component)| {
+
            bytes::complete::take(1u8)(rest).map(|(rest, _)| (rest, component))
+
        })
+
    }
+

+
    pub fn local(s: &str) -> Result<Ref, Error> {
+
        bytes::complete::tag(HEADS)(s).map(|(name, _)| Ref::LocalBranch {
+
            name: BranchName::new(name),
+
        })
+
    }
+

+
    pub fn remote(s: &str) -> Result<Ref, Error> {
+
        bytes::complete::tag(REMOTES)(s).and_then(|(rest, _)| {
+
            component(rest).map(|(rest, remote)| Ref::RemoteBranch {
+
                remote: remote.to_owned(),
+
                name: BranchName::new(rest),
+
            })
+
        })
+
    }
+

+
    pub fn tag(s: &str) -> Result<Ref, Error> {
+
        bytes::complete::tag(TAGS)(s).map(|(name, _)| Ref::Tag {
+
            name: TagName::new(name),
+
        })
+
    }
+

+
    pub fn namespace(s: &str) -> Result<Ref, Error> {
+
        bytes::complete::tag(NAMESPACES)(s).and_then(|(rest, _)| {
+
            component(rest).and_then(|(rest, namespace)| {
+
                Ok(Ref::Namespace {
+
                    namespace: namespace.to_owned(),
+
                    reference: Box::new(parse(rest)?),
+
                })
+
            })
+
        })
+
    }
+

+
    pub fn parse(s: &str) -> Result<Ref, nom::Err<nom::error::Error<&str>>> {
+
        local(s)
+
            .or_else(|_| remote(s))
+
            .or_else(|_| tag(s))
+
            .or_else(|_| namespace(s))
+
    }
+
}
+

+
impl str::FromStr for Ref {
+
    type Err = ParseError;
+

+
    fn from_str(reference: &str) -> Result<Self, Self::Err> {
+
        parser::parse(reference).map_err(|_| ParseError::MalformedRef(reference.to_owned()))
+
    }
+
}
+

+
#[cfg(test)]
+
mod tests {
+
    use super::*;
+
    use std::str::FromStr;
+

+
    #[test]
+
    fn parse_ref() -> Result<(), ParseError> {
+
        assert_eq!(
+
            Ref::from_str("refs/remotes/origin/master"),
+
            Ok(Ref::RemoteBranch {
+
                remote: "origin".to_string(),
+
                name: BranchName::new("master")
+
            })
+
        );
+

+
        assert_eq!(
+
            Ref::from_str("refs/heads/master"),
+
            Ok(Ref::LocalBranch {
+
                name: BranchName::new("master"),
+
            })
+
        );
+

+
        assert_eq!(
+
            Ref::from_str("refs/heads/i-am-hyphenated"),
+
            Ok(Ref::LocalBranch {
+
                name: BranchName::new("i-am-hyphenated"),
+
            })
+
        );
+

+
        assert_eq!(
+
            Ref::from_str("refs/heads/prefix/i-am-hyphenated"),
+
            Ok(Ref::LocalBranch {
+
                name: BranchName::new("prefix/i-am-hyphenated"),
+
            })
+
        );
+

+
        assert_eq!(
+
            Ref::from_str("refs/tags/v0.0.1"),
+
            Ok(Ref::Tag {
+
                name: TagName::new("v0.0.1")
+
            })
+
        );
+

+
        assert_eq!(
+
            Ref::from_str("refs/namespaces/moi/refs/remotes/origin/master"),
+
            Ok(Ref::Namespace {
+
                namespace: "moi".to_string(),
+
                reference: Box::new(Ref::RemoteBranch {
+
                    remote: "origin".to_string(),
+
                    name: BranchName::new("master")
+
                })
+
            })
+
        );
+

+
        assert_eq!(
+
            Ref::from_str("refs/namespaces/moi/refs/namespaces/toi/refs/tags/v1.0.0"),
+
            Ok(Ref::Namespace {
+
                namespace: "moi".to_string(),
+
                reference: Box::new(Ref::Namespace {
+
                    namespace: "toi".to_string(),
+
                    reference: Box::new(Ref::Tag {
+
                        name: TagName::new("v1.0.0")
+
                    })
+
                })
+
            })
+
        );
+

+
        assert_eq!(
+
            Ref::from_str("refs/namespaces/me/refs/heads/feature/#1194"),
+
            Ok(Ref::Namespace {
+
                namespace: "me".to_string(),
+
                reference: Box::new(Ref::LocalBranch {
+
                    name: BranchName::new("feature/#1194"),
+
                })
+
            })
+
        );
+

+
        assert_eq!(
+
            Ref::from_str("refs/namespaces/me/refs/remotes/fein/heads/feature/#1194"),
+
            Ok(Ref::Namespace {
+
                namespace: "me".to_string(),
+
                reference: Box::new(Ref::RemoteBranch {
+
                    remote: "fein".to_string(),
+
                    name: BranchName::new("heads/feature/#1194"),
+
                })
+
            })
+
        );
+

+
        assert_eq!(
+
            Ref::from_str("refs/remotes/master"),
+
            Err(ParseError::MalformedRef("refs/remotes/master".to_owned())),
+
        );
+

+
        assert_eq!(
+
            Ref::from_str("refs/namespaces/refs/remotes/origin/master"),
+
            Err(ParseError::MalformedRef(
+
                "refs/namespaces/refs/remotes/origin/master".to_owned()
+
            )),
+
        );
+

+
        Ok(())
+
    }
+
}
added radicle-surf/src/vcs/git/reference/glob.rs
@@ -0,0 +1,183 @@
+
// This file is part of radicle-surf
+
// <https://github.com/radicle-dev/radicle-surf>
+
//
+
// Copyright (C) 2019-2020 The Radicle Team <dev@radicle.xyz>
+
//
+
// This program is free software: you can redistribute it and/or modify
+
// it under the terms of the GNU General Public License version 3 or
+
// later as published by the Free Software Foundation.
+
//
+
// This program is distributed in the hope that it will be useful,
+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+
// GNU General Public License for more details.
+
//
+
// You should have received a copy of the GNU General Public License
+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
+

+
use crate::{
+
    git::RefScope,
+
    vcs::git::{error, repo::RepositoryRef},
+
};
+
use either::Either;
+
use std::fmt::{self, Write as _};
+

+
#[derive(Debug, Clone, PartialEq, Eq)]
+
pub enum RefGlob {
+
    /// When calling [`RefGlob::references`] this will return the references via
+
    /// the globs `refs/heads/*` and `refs/remotes/**/*`.
+
    Branch,
+
    /// When calling [`RefGlob::references`] this will return the references via
+
    /// the glob `refs/heads/*`.
+
    LocalBranch,
+
    /// When calling [`RefGlob::references`] this will return the references via
+
    /// either of the following globs:
+
    ///     * `refs/remotes/**/*`
+
    ///     * `refs/remotes/{remote}/*`
+
    RemoteBranch {
+
        /// If `remote` is `None` then the `**` wildcard will be used, otherwise
+
        /// the provided remote name will be used.
+
        remote: Option<String>,
+
    },
+
    /// When calling [`RefGlob::references`] this will return the references via
+
    /// the globs `refs/tags/*` and `refs/remotes/*/tags`
+
    Tag,
+
    /// When calling [`RefGlob::references`] this will return the references via
+
    /// the glob `refs/tags/*`.
+
    LocalTag,
+
    /// When calling [`RefGlob::references`] this will return the references via
+
    /// either of the following globs:
+
    ///     * `refs/remotes/*/tags/*`
+
    ///     * `refs/remotes/{remote}/tags/*`
+
    RemoteTag {
+
        /// If `remote` is `None` then the `*` wildcard will be used, otherwise
+
        /// the provided remote name will be used.
+
        remote: Option<String>,
+
    },
+
    /// refs/namespaces/**
+
    Namespace,
+
}
+

+
/// Iterator chaining multiple [`git2::References`]
+
#[must_use = "iterators are lazy and do nothing unless consumed"]
+
pub struct References<'a> {
+
    inner: Vec<git2::References<'a>>,
+
}
+

+
impl<'a> References<'a> {
+
    pub fn iter(self) -> impl Iterator<Item = Result<git2::Reference<'a>, git2::Error>> {
+
        self.inner.into_iter().flatten()
+
    }
+
}
+

+
impl RefGlob {
+
    pub fn branch(scope: RefScope) -> Self {
+
        match scope {
+
            RefScope::All => Self::Branch,
+
            RefScope::Local => Self::LocalBranch,
+
            RefScope::Remote { name } => Self::RemoteBranch { remote: name },
+
        }
+
    }
+

+
    pub fn tag(scope: RefScope) -> Self {
+
        match scope {
+
            RefScope::All => Self::Tag,
+
            RefScope::Local => Self::LocalTag,
+
            RefScope::Remote { name } => Self::RemoteTag { remote: name },
+
        }
+
    }
+

+
    pub fn references<'a>(&self, repo: &RepositoryRef<'a>) -> Result<References<'a>, error::Error> {
+
        let namespace = repo
+
            .which_namespace()?
+
            .map_or(Either::Left(std::iter::empty()), |namespace| {
+
                Either::Right(namespace.values.into_iter())
+
            });
+
        self.with_namespace_glob(namespace, repo)
+
    }
+

+
    fn with_namespace_glob<'a>(
+
        &self,
+
        namespace: impl Iterator<Item = String>,
+
        repo: &RepositoryRef<'a>,
+
    ) -> Result<References<'a>, error::Error> {
+
        let mut namespace_glob = "".to_string();
+
        for n in namespace {
+
            let _ = write!(namespace_glob, "refs/namespaces/{n}/");
+
        }
+

+
        Ok(match self {
+
            Self::Branch => {
+
                let remotes = repo.repo_ref.references_glob(&format!(
+
                    "{}{}",
+
                    namespace_glob,
+
                    Self::RemoteBranch { remote: None }
+
                ))?;
+

+
                let locals = repo.repo_ref.references_glob(&format!(
+
                    "{}{}",
+
                    namespace_glob,
+
                    &Self::LocalBranch
+
                ))?;
+
                References {
+
                    inner: vec![remotes, locals],
+
                }
+
            },
+
            Self::Tag => {
+
                let remotes = repo.repo_ref.references_glob(&format!(
+
                    "{}{}",
+
                    namespace_glob,
+
                    Self::RemoteTag { remote: None }
+
                ))?;
+

+
                let locals = repo.repo_ref.references_glob(&format!(
+
                    "{}{}",
+
                    namespace_glob,
+
                    &Self::LocalTag
+
                ))?;
+
                References {
+
                    inner: vec![remotes, locals],
+
                }
+
            },
+
            other => References {
+
                inner: vec![repo
+
                    .repo_ref
+
                    .references_glob(&format!("{}{}", namespace_glob, other,))?],
+
            },
+
        })
+
    }
+
}
+

+
impl fmt::Display for RefGlob {
+
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+
        match self {
+
            Self::LocalBranch => write!(f, "refs/heads/*"),
+
            Self::RemoteBranch { remote } => {
+
                write!(f, "refs/remotes/")?;
+
                match remote {
+
                    None => write!(f, "**/*"),
+
                    Some(remote) => write!(f, "{}/*", remote),
+
                }
+
            },
+
            Self::LocalTag => write!(f, "refs/tags/*"),
+
            Self::RemoteTag { remote } => {
+
                let remote = match remote {
+
                    Some(remote) => remote.as_ref(),
+
                    None => "*",
+
                };
+
                write!(f, "refs/remotes/{}/tags/*", remote)
+
            },
+
            // Note: the glob below would be used, but libgit doesn't care for union globs.
+
            // write!(f, "refs/{{remotes/**/*,heads/*}}")
+
            Self::Branch | Self::Tag => {
+
                panic!("{}",
+
                "fatal: `Display` should not be called on `RefGlob::Branch` or `RefGlob::Tag` Since
+
                this `enum` is private to the repository, it should not be called from the outside.
+
                Unfortunately, libgit does not support union of globs otherwise this would display
+
                refs/{remotes/**/*,heads/*}"
+
            )
+
            },
+
            Self::Namespace => write!(f, "refs/namespaces/**"),
+
        }
+
    }
+
}
added radicle-surf/src/vcs/git/repo.rs
@@ -0,0 +1,446 @@
+
// This file is part of radicle-surf
+
// <https://github.com/radicle-dev/radicle-surf>
+
//
+
// Copyright (C) 2019-2020 The Radicle Team <dev@radicle.xyz>
+
//
+
// This program is free software: you can redistribute it and/or modify
+
// it under the terms of the GNU General Public License version 3 or
+
// later as published by the Free Software Foundation.
+
//
+
// This program is distributed in the hope that it will be useful,
+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+
// GNU General Public License for more details.
+
//
+
// You should have received a copy of the GNU General Public License
+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
+

+
use crate::{
+
    diff::*,
+
    file_system,
+
    vcs,
+
    vcs::{
+
        git::{
+
            error::*,
+
            reference::{glob::RefGlob, Ref, Rev},
+
            Branch,
+
            Commit,
+
            Namespace,
+
            RefScope,
+
            Signature,
+
            Tag,
+
        },
+
        Vcs,
+
    },
+
};
+
use git2::Oid;
+
use nonempty::NonEmpty;
+
use std::{collections::HashSet, convert::TryFrom, str};
+

+
/// This is for flagging to the `file_history` function that it should
+
/// stop at the first (i.e. Last) commit it finds for a file.
+
pub(super) enum CommitHistory {
+
    Full,
+
    Last,
+
}
+

+
/// A `History` that uses `git2::Commit` as the underlying artifact.
+
pub type History = vcs::History<Commit>;
+

+
/// Wrapper around the `git2`'s `git2::Repository` type.
+
/// This is to to limit the functionality that we can do
+
/// on the underlying object.
+
pub struct Repository(pub(super) git2::Repository);
+

+
/// A reference-only `Repository`. This means that we cannot mutate the
+
/// underlying `Repository`. Not being able to mutate the `Repository` means
+
/// that the functions defined for `RepositoryRef` should be thread-safe.
+
///
+
/// # Construction
+
///
+
/// Use the `From<&'a git2::Repository>` implementation to construct a
+
/// `RepositoryRef`.
+
pub struct RepositoryRef<'a> {
+
    pub(super) repo_ref: &'a git2::Repository,
+
}
+

+
// RepositoryRef should be safe to transfer across thread boundaries since it
+
// only holds a reference to git2::Repository. git2::Repository is also Send
+
// (see: https://docs.rs/git2/0.13.5/src/git2/repo.rs.html#46)
+
unsafe impl<'a> Send for RepositoryRef<'a> {}
+

+
impl<'a> From<&'a git2::Repository> for RepositoryRef<'a> {
+
    fn from(repo_ref: &'a git2::Repository) -> Self {
+
        RepositoryRef { repo_ref }
+
    }
+
}
+

+
impl<'a> RepositoryRef<'a> {
+
    /// What is the current namespace we're browsing in.
+
    pub fn which_namespace(&self) -> Result<Option<Namespace>, Error> {
+
        self.repo_ref
+
            .namespace_bytes()
+
            .map(Namespace::try_from)
+
            .transpose()
+
    }
+

+
    /// List the branches within a repository, filtering out ones that do not
+
    /// parse correctly.
+
    ///
+
    /// # Errors
+
    ///
+
    /// * [`Error::Git`]
+
    pub fn list_branches(&self, scope: RefScope) -> Result<Vec<Branch>, Error> {
+
        RefGlob::branch(scope)
+
            .references(self)?
+
            .iter()
+
            .try_fold(vec![], |mut acc, reference| {
+
                let branch = Branch::try_from(reference?)?;
+
                acc.push(branch);
+
                Ok(acc)
+
            })
+
    }
+

+
    /// List the tags within a repository, filtering out ones that do not parse
+
    /// correctly.
+
    ///
+
    /// # Errors
+
    ///
+
    /// * [`Error::Git`]
+
    pub fn list_tags(&self, scope: RefScope) -> Result<Vec<Tag>, Error> {
+
        RefGlob::tag(scope)
+
            .references(self)?
+
            .iter()
+
            .try_fold(vec![], |mut acc, reference| {
+
                let tag = Tag::try_from(reference?)?;
+
                acc.push(tag);
+
                Ok(acc)
+
            })
+
    }
+

+
    /// List the namespaces within a repository, filtering out ones that do not
+
    /// parse correctly.
+
    ///
+
    /// # Errors
+
    ///
+
    /// * [`Error::Git`]
+
    pub fn list_namespaces(&self) -> Result<Vec<Namespace>, Error> {
+
        let namespaces: Result<HashSet<Namespace>, Error> = RefGlob::Namespace
+
            .references(self)?
+
            .iter()
+
            .try_fold(HashSet::new(), |mut acc, reference| {
+
                let namespace = Namespace::try_from(reference?)?;
+
                acc.insert(namespace);
+
                Ok(acc)
+
            });
+
        Ok(namespaces?.into_iter().collect())
+
    }
+

+
    pub(super) fn reference<R, P>(&self, reference: R, check: P) -> Result<History, Error>
+
    where
+
        R: Into<Ref>,
+
        P: FnOnce(&git2::Reference) -> Option<Error>,
+
    {
+
        let reference = match self.which_namespace()? {
+
            None => reference.into(),
+
            Some(namespace) => reference.into().namespaced(namespace),
+
        }
+
        .find_ref(self)?;
+

+
        if let Some(err) = check(&reference) {
+
            return Err(err);
+
        }
+

+
        self.to_history(&reference)
+
    }
+

+
    /// Get the [`Diff`] between two commits.
+
    pub fn diff(&self, from: Oid, to: Oid) -> Result<Diff, Error> {
+
        self.diff_commits(None, Some(from), to)
+
            .and_then(|diff| Diff::try_from(diff).map_err(Error::from))
+
    }
+

+
    /// Get the [`Diff`] of a commit with no parents.
+
    pub fn initial_diff(&self, oid: Oid) -> Result<Diff, Error> {
+
        self.diff_commits(None, None, oid)
+
            .and_then(|diff| Diff::try_from(diff).map_err(Error::from))
+
    }
+

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

+
    pub(super) fn rev_to_commit(&self, rev: &Rev) -> Result<git2::Commit, Error> {
+
        match rev {
+
            Rev::Oid(oid) => Ok(self.repo_ref.find_commit(*oid)?),
+
            Rev::Ref(reference) => Ok(reference.find_ref(self)?.peel_to_commit()?),
+
        }
+
    }
+

+
    pub(super) fn switch_namespace(&self, namespace: &str) -> Result<(), Error> {
+
        Ok(self.repo_ref.set_namespace(namespace)?)
+
    }
+

+
    /// Get a particular `Commit`.
+
    pub(super) fn get_commit(&self, oid: Oid) -> Result<git2::Commit<'a>, Error> {
+
        let commit = self.repo_ref.find_commit(oid)?;
+
        Ok(commit)
+
    }
+

+
    /// Build a [`History`] using the `head` reference.
+
    pub(super) fn head(&self) -> Result<History, Error> {
+
        let head = self.repo_ref.head()?;
+
        self.to_history(&head)
+
    }
+

+
    /// Turn a [`git2::Reference`] into a [`History`] by completing
+
    /// a revwalk over the first commit in the reference.
+
    pub(super) fn to_history(&self, history: &git2::Reference<'a>) -> Result<History, Error> {
+
        let head = history.peel_to_commit()?;
+
        self.commit_to_history(head)
+
    }
+

+
    /// Turn a [`git2::Reference`] into a [`History`] by completing
+
    /// a revwalk over the first commit in the reference.
+
    pub(super) fn commit_to_history(&self, head: git2::Commit) -> Result<History, Error> {
+
        let head_id = head.id();
+
        let mut commits = NonEmpty::new(Commit::try_from(head)?);
+
        let mut revwalk = self.repo_ref.revwalk()?;
+

+
        // Set the revwalk to the head commit
+
        revwalk.push(head_id)?;
+

+
        for commit_result_id in revwalk {
+
            // The revwalk iter returns results so
+
            // we unpack these and push them to the history
+
            let commit_id: Oid = commit_result_id?;
+

+
            // Skip the head commit since we have processed it
+
            if commit_id == head_id {
+
                continue;
+
            }
+

+
            let commit = Commit::try_from(self.repo_ref.find_commit(commit_id)?)?;
+
            commits.push(commit);
+
        }
+

+
        Ok(vcs::History(commits))
+
    }
+

+
    /// Extract the signature from a commit
+
    ///
+
    /// # Arguments
+
    ///
+
    /// `commit_oid` - The object ID of the commit
+
    /// `field` - the name of the header field containing the signature block;
+
    ///           pass `None` to extract the default 'gpgsig'
+
    pub(super) fn extract_signature(
+
        &self,
+
        commit_oid: &Oid,
+
        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
+
        match self.repo_ref.extract_signature(commit_oid, field) {
+
            Err(error) => {
+
                if error.code() == git2::ErrorCode::NotFound {
+
                    Ok(None)
+
                } else {
+
                    Err(error.into())
+
                }
+
            },
+
            Ok(sig) => Ok(Some(Signature::from(sig.0))),
+
        }
+
    }
+

+
    pub(crate) fn revision_branches(&self, oid: &Oid) -> Result<Vec<Branch>, Error> {
+
        let local = RefGlob::LocalBranch.references(self)?;
+
        let remote = RefGlob::RemoteBranch { remote: None }.references(self)?;
+
        let mut references = local.iter().chain(remote.iter());
+

+
        let mut contained_branches = vec![];
+

+
        references.try_for_each(|reference| {
+
            let reference = reference?;
+
            self.reachable_from(&reference, oid).and_then(|contains| {
+
                if contains {
+
                    let branch = Branch::try_from(reference)?;
+
                    contained_branches.push(branch);
+
                }
+
                Ok(())
+
            })
+
        })?;
+

+
        Ok(contained_branches)
+
    }
+

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

+
        Ok(other == *oid || is_descendant)
+
    }
+

+
    /// Get the history of the file system where the head of the [`NonEmpty`] is
+
    /// the latest commit.
+
    pub(super) fn file_history(
+
        &self,
+
        path: &file_system::Path,
+
        commit_history: CommitHistory,
+
        commit: Commit,
+
    ) -> Result<Vec<Commit>, Error> {
+
        let mut revwalk = self.repo_ref.revwalk()?;
+
        let mut commits = vec![];
+

+
        // Set the revwalk to the head commit
+
        revwalk.push(commit.id)?;
+

+
        for commit in revwalk {
+
            let parent_id: Oid = commit?;
+
            let parent = self.repo_ref.find_commit(parent_id)?;
+
            let paths = self.diff_commit_and_parents(path, &parent)?;
+
            if let Some(_path) = paths {
+
                commits.push(Commit::try_from(parent)?);
+
                match &commit_history {
+
                    CommitHistory::Last => break,
+
                    CommitHistory::Full => {},
+
                }
+
            }
+
        }
+

+
        Ok(commits)
+
    }
+

+
    fn diff_commit_and_parents(
+
        &self,
+
        path: &file_system::Path,
+
        commit: &git2::Commit,
+
    ) -> Result<Option<file_system::Path>, Error> {
+
        let mut parents = commit.parents();
+
        let parent = parents.next().map(|c| c.id());
+

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

+
    fn diff_commits(
+
        &self,
+
        path: Option<&file_system::Path>,
+
        from: Option<Oid>,
+
        to: Oid,
+
    ) -> Result<git2::Diff, Error> {
+
        let new_tree = self.repo_ref.find_commit(to)?.tree()?;
+
        let old_tree = from.map_or(Ok(None), |oid| {
+
            self.repo_ref.find_commit(oid)?.tree().map(Some)
+
        })?;
+

+
        let mut opts = git2::DiffOptions::new();
+
        if let Some(path) = path {
+
            opts.pathspec(path);
+
            // We're skipping the binary pass because we won't be inspecting deltas.
+
            opts.skip_binary_check(true);
+
        }
+

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

+
        Ok(diff)
+
    }
+
}
+

+
impl<'a> Vcs<Commit, Error> for RepositoryRef<'a> {
+
    type HistoryId = Rev;
+
    type ArtefactId = Oid;
+

+
    fn get_history(&self, history_id: Self::HistoryId) -> Result<History, Error> {
+
        match history_id {
+
            Rev::Ref(reference) => self.reference(reference, |_| None),
+
            Rev::Oid(oid) => {
+
                let commit = self.get_commit(oid)?;
+
                self.commit_to_history(commit)
+
            },
+
        }
+
    }
+

+
    fn get_histories(&self) -> Result<Vec<History>, Error> {
+
        self.repo_ref
+
            .references()
+
            .map_err(Error::from)
+
            .and_then(|mut references| {
+
                references.try_fold(vec![], |mut acc, reference| {
+
                    reference.map_err(Error::from).and_then(|r| {
+
                        let history = self.to_history(&r)?;
+
                        acc.push(history);
+
                        Ok(acc)
+
                    })
+
                })
+
            })
+
    }
+

+
    fn get_identifier(artifact: &Commit) -> Self::ArtefactId {
+
        artifact.id
+
    }
+
}
+

+
impl<'a> std::fmt::Debug for RepositoryRef<'a> {
+
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+
        write!(f, ".git")
+
    }
+
}
+

+
impl Repository {
+
    /// Open a git repository given its URI.
+
    ///
+
    /// # Errors
+
    ///
+
    /// * [`Error::Git`]
+
    pub fn new(repo_uri: impl AsRef<std::path::Path>) -> Result<Self, Error> {
+
        git2::Repository::open(repo_uri)
+
            .map(Repository)
+
            .map_err(Error::from)
+
    }
+

+
    /// Since our operations are read-only when it comes to surfing a repository
+
    /// we have a separate struct called [`RepositoryRef`]. This turns an owned
+
    /// [`Repository`], the one returend by [`Repository::new`], into a
+
    /// [`RepositoryRef`].
+
    pub fn as_ref(&'_ self) -> RepositoryRef<'_> {
+
        RepositoryRef { repo_ref: &self.0 }
+
    }
+
}
+

+
impl<'a> From<&'a Repository> for RepositoryRef<'a> {
+
    fn from(repo: &'a Repository) -> Self {
+
        repo.as_ref()
+
    }
+
}
+

+
impl vcs::GetVcs<Error> for Repository {
+
    type RepoId = String;
+

+
    fn get_repo(repo_id: Self::RepoId) -> Result<Self, Error> {
+
        git2::Repository::open(&repo_id)
+
            .map(Repository)
+
            .map_err(Error::from)
+
    }
+
}
+

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

+
impl std::fmt::Debug for Repository {
+
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+
        write!(f, ".git")
+
    }
+
}
added radicle-surf/src/vcs/git/stats.rs
@@ -0,0 +1,36 @@
+
// This file is part of radicle-surf
+
// <https://github.com/radicle-dev/radicle-surf>
+
//
+
// Copyright (C) 2019-2020 The Radicle Team <dev@radicle.xyz>
+
//
+
// This program is free software: you can redistribute it and/or modify
+
// it under the terms of the GNU General Public License version 3 or
+
// later as published by the Free Software Foundation.
+
//
+
// This program is distributed in the hope that it will be useful,
+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+
// GNU General Public License for more details.
+
//
+
// You should have received a copy of the GNU General Public License
+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
+

+
pub use git2::Oid;
+

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

+
/// Stats for a repository
+
#[cfg_attr(
+
    feature = "serialize",
+
    derive(Serialize),
+
    serde(rename_all = "camelCase")
+
)]
+
pub struct Stats {
+
    /// Number of commits
+
    pub commits: usize,
+
    /// Number of local branches
+
    pub branches: usize,
+
    /// Number of contributors
+
    pub contributors: usize,
+
}
added radicle-surf/src/vcs/git/tag.rs
@@ -0,0 +1,176 @@
+
// This file is part of radicle-surf
+
// <https://github.com/radicle-dev/radicle-surf>
+
//
+
// Copyright (C) 2019-2020 The Radicle Team <dev@radicle.xyz>
+
//
+
// This program is free software: you can redistribute it and/or modify
+
// it under the terms of the GNU General Public License version 3 or
+
// later as published by the Free Software Foundation.
+
//
+
// This program is distributed in the hope that it will be useful,
+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+
// GNU General Public License for more details.
+
//
+
// You should have received a copy of the GNU General Public License
+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
+

+
use crate::vcs::git::{self, error::Error, reference::Ref, Author};
+
use git2::Oid;
+
use std::{convert::TryFrom, fmt, str};
+

+
/// A newtype wrapper over `String` to separate out the fact that a caller wants
+
/// to fetch a tag.
+
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
+
pub struct TagName(String);
+

+
impl fmt::Display for TagName {
+
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+
        self.0.fmt(f)
+
    }
+
}
+

+
impl TryFrom<&[u8]> for TagName {
+
    type Error = str::Utf8Error;
+

+
    fn try_from(name: &[u8]) -> Result<Self, Self::Error> {
+
        let name = str::from_utf8(name)?;
+
        let short_name = match git::ext::try_extract_refname(name) {
+
            Ok(stripped) => stripped,
+
            Err(original) => original,
+
        };
+
        Ok(Self(short_name))
+
    }
+
}
+

+
impl From<TagName> for Ref {
+
    fn from(other: TagName) -> Self {
+
        Self::Tag { name: other }
+
    }
+
}
+

+
impl TagName {
+
    /// Create a new `TagName`.
+
    pub fn new(name: &str) -> Self {
+
        TagName(name.into())
+
    }
+

+
    /// Access the string value of the `TagName`.
+
    pub fn name(&self) -> &str {
+
        &self.0
+
    }
+
}
+

+
/// The static information of a [`git2::Tag`].
+
#[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 name that references this `Tag`.
+
        name: TagName,
+
        /// If the tag is provided this holds the remote’s name.
+
        remote: Option<String>,
+
    },
+
    /// 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_id: Oid,
+
        /// The name that references this `Tag`.
+
        name: TagName,
+
        /// 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>,
+
        /// If the tag is provided this holds the remote’s name.
+
        remote: 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,
+
        }
+
    }
+

+
    /// Get the `TagName` of the tag, regardless of its type.
+
    pub fn name(&self) -> TagName {
+
        match self {
+
            Self::Light { name, .. } => name.clone(),
+
            Self::Annotated { name, .. } => name.clone(),
+
        }
+
    }
+
}
+

+
impl<'repo> TryFrom<git2::Tag<'repo>> for Tag {
+
    type Error = str::Utf8Error;
+

+
    fn try_from(tag: git2::Tag) -> Result<Self, Self::Error> {
+
        let id = tag.id();
+

+
        let target_id = tag.target_id();
+

+
        let name = TagName::try_from(tag.name_bytes())?;
+

+
        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_id,
+
            name,
+
            tagger,
+
            message,
+
            remote: None,
+
        })
+
    }
+
}
+

+
impl<'repo> TryFrom<git2::Reference<'repo>> for Tag {
+
    type Error = Error;
+

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

+
        let (remote, name) = if git::ext::is_remote(&reference) {
+
            let mut split = name.0.splitn(2, '/');
+
            let remote = split.next().map(|x| x.to_owned());
+
            let name = split.next().unwrap();
+
            (remote, TagName(name.to_owned()))
+
        } else {
+
            (None, name)
+
        };
+

+
        match reference.peel_to_tag() {
+
            Ok(tag) => Ok(Tag::try_from(tag)?),
+
            Err(err) => {
+
                // 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.
+
                if err.class() == git2::ErrorClass::Object
+
                    && err.code() == git2::ErrorCode::InvalidSpec
+
                {
+
                    let commit = reference.peel_to_commit()?;
+
                    Ok(Tag::Light {
+
                        id: commit.id(),
+
                        name,
+
                        remote,
+
                    })
+
                } else {
+
                    Err(err.into())
+
                }
+
            },
+
        }
+
    }
+
}