copy radicle-surf and radicle-source into this repo.

Signed-off-by: Han Xu <keepsimple@gmail.com>
This commit is contained in:
Han Xu
2022-09-16 22:17:49 -07:00
parent ce7dc3890a
commit 58d69ebf7f
49 changed files with 9783 additions and 1 deletions
+11
View File
@@ -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
+4
View File
@@ -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
+2
View File
@@ -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",
+52 -1
View File
@@ -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
+31
View File
@@ -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"
+108
View File
@@ -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 })
}
+243
View File
@@ -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 })
}
+46
View File
@@ -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),
}
+51
View File
@@ -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};
+76
View File
@@ -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()
}
}
+218
View File
@@ -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),
}
}
}
+177
View File
@@ -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,
})
}
+44
View File
@@ -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
}
}
+28
View File
@@ -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,
}
+175
View File
@@ -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),
}
}
+87
View File
@@ -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,
}
}
+60
View File
@@ -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)
}
+52
View File
@@ -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
+83
View File
@@ -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`.
+74
View File
@@ -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")),
]);
```
+45
View File
@@ -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);
+81
View File
@@ -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(())
}
+21
View File
@@ -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
Binary file not shown.
+12
View File
@@ -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
+265
View File
@@ -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)
```
+176
View File
@@ -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,
})
}
}
+767
View File
@@ -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)
}
*/
}
+306
View File
@@ -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));
}
}
+118
View File
@@ -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::*};
+722
View File
@@ -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));
}
}
}
+79
View File
@@ -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,
}
+470
View File
@@ -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")
)
);
}
}
}
@@ -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")
}
}
+93
View File
@@ -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;
+33
View File
@@ -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)
},
}
}
File diff suppressed because it is too large Load Diff
+238
View File
@@ -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;
}
File diff suppressed because it is too large Load Diff
+193
View File
@@ -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,
})
}
}
}
+102
View File
@@ -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,
})
}
}
+126
View File
@@ -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()
}
}
+111
View File
@@ -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())
);
}
}
+82
View File
@@ -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)
}
}
+302
View File
@@ -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(())
}
}
+183
View File
@@ -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/**"),
}
}
}
+446
View File
@@ -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")
}
}
+36
View File
@@ -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,
}
+176
View File
@@ -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())
}
},
}
}
}