mirror of
https://seed.radicle.dev/rad:z6cFWeWpnZNHh9rUW8phgA3b5yGt.git
synced 2026-10-24 13:50:36 +08:00
copy radicle-surf and radicle-source into this repo.
Signed-off-by: Han Xu <keepsimple@gmail.com>
This commit is contained in:
@@ -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,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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
®ions[..],
|
||||
syntect::html::IncludeBackground::No,
|
||||
&mut html,
|
||||
);
|
||||
}
|
||||
Some(html)
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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`.
|
||||
@@ -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")),
|
||||
]);
|
||||
```
|
||||
@@ -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);
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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.
@@ -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
|
||||
@@ -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)
|
||||
```
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
*/
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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::*};
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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/**"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user