diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index c3ebf90..ad8011f 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -82,6 +82,13 @@ jobs: continue-on-error: ${{ matrix.toolchain == 'nightly' }} steps: - uses: actions/checkout@master + with: + fetch-depth: 0 + # The 'main' branch is needed for radicle-surf vcs::git::tests::test_submodule_failure, + # however actions/checkout uses the default branch 'master' at this moment. + # We will improve the test soon to remove this need. + - run: git branch main + - run: git branch -u origin/main main - uses: actions-rs/toolchain@v1 with: profile: minimal @@ -111,6 +118,10 @@ jobs: needs: build-macos steps: - uses: actions/checkout@master + with: + fetch-depth: 0 + - run: git branch main + - run: git branch -u origin/main main - uses: actions-rs/toolchain@v1 with: profile: minimal diff --git a/.gitignore b/.gitignore index 4942acc..3b8a63d 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,7 @@ tags Cargo.lock # These are backup files generated by rustfmt **/*.rs.bk + +# This is an inner git repo unpacked (if necessary) from the archive. +# Git doesn't like nested repos, and we don't really want to use submodules. +radicle-surf/data/git-platinum diff --git a/Cargo.toml b/Cargo.toml index 87f473b..2d36867 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,9 @@ members = [ "radicle-git-ext", "radicle-git-types", "radicle-macros", + "radicle-source", "radicle-std-ext", + "radicle-surf", # TODO: port gitd-lib over # "cli/gitd-lib", "test", diff --git a/deny.toml b/deny.toml index c740252..1ec00b0 100644 --- a/deny.toml +++ b/deny.toml @@ -47,12 +47,63 @@ allow = [ "CC0-1.0", "GPL-3.0", "MIT", + "Unicode-DFS-2016", "Unlicense", ] # List of explictly disallowed licenses # See https://spdx.org/licenses/ for list of possible licenses # [possible values: any SPDX 3.7 short identifier (+ optional exception)]. -deny = [] +deny = [ + # As per https://www.gnu.org/licenses/license-list.html#GPLIncompatibleLicenses + "AGPL-1.0", +# fails to parse: +# "AFL-1.0", +# "AFL-1.2", +# "AFL-2.0", +# "AFL-2.1", +# "AFL-3.0", + "Apache-1.0", + "Apache-1.1", + "APSL-2.0", + "BitTorrent-1.0", + "BitTorrent-1.1", + "BSD-4-Clause", + "CECILL-B", + "CECILL-C", + "CDDL-1.0", + "CDDL-1.1", + "CNRI-Python", + "CPAL-1.0", + "CPL-1.0", + "Condor-1.1", + "EPL-1.0", + "EPL-2.0", + "EUPL-1.1", + "EUPL-1.2", + "gnuplot", + "IPL-1.0", + "LPPL-1.3a", + "LPPL-1.2", + "LPL-1.02", + "MS-PL", + "MS-RL", + "MPL-1.1", + "NOSL", + "NPL-1.0", + "NPL-1.1", + "Nokia", + "OpenSSL", + "PHP-3.01", + "QPL-1.0", + "RPSL-1.0", + "SISSL", + "SPL-1.0", + "xinetd", + "YPL-1.1", + "Zend-2.0", + "Zimbra-1.3", + "ZPL-1.1" +] # Lint level for licenses considered copyleft copyleft = "allow" # Blanket approval or denial for OSI-approved or FSF Free/Libre licenses diff --git a/radicle-source/Cargo.toml b/radicle-source/Cargo.toml new file mode 100644 index 0000000..166dcac --- /dev/null +++ b/radicle-source/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "radicle-source" +description = "A high level API for browsing source files" +version = "0.4.0" +authors = ["The Radicle Team "] +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" diff --git a/radicle-source/src/branch.rs b/radicle-source/src/branch.rs new file mode 100644 index 0000000..7dfbbeb --- /dev/null +++ b/radicle-source/src/branch.rs @@ -0,0 +1,108 @@ +// This file is part of radicle-surf +// +// +// Copyright (C) 2019-2020 The Radicle Team +// +// 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 . + +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 for Branch { + fn from(name: String) -> Self { + Self(name) + } +} + +impl From 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, Error> { + let mut branches = browser + .list_branches(filter)? + .into_iter() + .map(|b| Branch(b.name.name().to_string())) + .collect::>(); + + branches.sort(); + + Ok(branches) +} + +/// Information about a locally checked out repository. +#[derive(Deserialize, Serialize)] +pub struct LocalState { + /// List of branches. + branches: Vec, +} + +/// 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 { + 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::>(); + + branches.sort(); + + Ok(LocalState { branches }) +} diff --git a/radicle-source/src/commit.rs b/radicle-source/src/commit.rs new file mode 100644 index 0000000..308e31e --- /dev/null +++ b/radicle-source/src/commit.rs @@ -0,0 +1,243 @@ +// This file is part of radicle-surf +// +// +// Copyright (C) 2019-2020 The Radicle Team +// +// 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 . + +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, +} + +/// 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(&self, serializer: S) -> Result + 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
, + /// 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 { + 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 { + 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

( + browser: &mut Browser<'_>, + maybe_revision: Option>, +) -> Result +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 }) +} diff --git a/radicle-source/src/error.rs b/radicle-source/src/error.rs new file mode 100644 index 0000000..05eb822 --- /dev/null +++ b/radicle-source/src/error.rs @@ -0,0 +1,46 @@ +// This file is part of radicle-surf +// +// +// Copyright (C) 2019-2020 The Radicle Team +// +// 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 . + +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), +} diff --git a/radicle-source/src/lib.rs b/radicle-source/src/lib.rs new file mode 100644 index 0000000..c23000c --- /dev/null +++ b/radicle-source/src/lib.rs @@ -0,0 +1,51 @@ +// This file is part of radicle-surf +// +// +// Copyright (C) 2019-2020 The Radicle Team +// +// 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 . + +//! 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}; diff --git a/radicle-source/src/object.rs b/radicle-source/src/object.rs new file mode 100644 index 0000000..1343320 --- /dev/null +++ b/radicle-source/src/object.rs @@ -0,0 +1,76 @@ +// This file is part of radicle-surf +// +// +// Copyright (C) 2019-2020 The Radicle Team +// +// 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 . + +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(&self, serializer: S) -> Result + 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, +} + +impl Serialize for Info { + fn serialize(&self, serializer: S) -> Result + 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() + } +} diff --git a/radicle-source/src/object/blob.rs b/radicle-source/src/object/blob.rs new file mode 100644 index 0000000..6eb81cf --- /dev/null +++ b/radicle-source/src/object/blob.rs @@ -0,0 +1,218 @@ +// This file is part of radicle-surf +// +// +// Copyright (C) 2019-2020 The Radicle Team +// +// 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 . + +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(&self, serializer: S) -> Result + 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), +} + +impl Serialize for BlobContent { + fn serialize(&self, serializer: S) -> Result + 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

( + browser: &mut Browser, + maybe_revision: Option>, + path: &str, +) -> Result +where + P: ToString, +{ + make_blob(browser, maybe_revision, path, content) +} + +fn make_blob( + browser: &mut Browser, + maybe_revision: Option>, + path: &str, + content: C, +) -> Result +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

( + browser: &mut Browser, + maybe_revision: Option>, + path: &str, + theme: Option<&str>, + ) -> Result + 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), + } + } +} diff --git a/radicle-source/src/object/tree.rs b/radicle-source/src/object/tree.rs new file mode 100644 index 0000000..f052974 --- /dev/null +++ b/radicle-source/src/object/tree.rs @@ -0,0 +1,177 @@ +// This file is part of radicle-surf +// +// +// Copyright (C) 2019-2020 The Radicle Team +// +// 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 . + +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, + /// Extra info for the tree object. + pub info: Info, +} + +impl Serialize for Tree { + fn serialize(&self, serializer: S) -> Result + 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(&self, serializer: S) -> Result + 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

( + browser: &mut Browser<'_>, + maybe_revision: Option>, + maybe_prefix: Option, +) -> Result +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, 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, + }) +} diff --git a/radicle-source/src/oid.rs b/radicle-source/src/oid.rs new file mode 100644 index 0000000..e295a1a --- /dev/null +++ b/radicle-source/src/oid.rs @@ -0,0 +1,44 @@ +// This file is part of radicle-surf +// +// +// Copyright (C) 2019-2020 The Radicle Team +// +// 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 . + +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 { + value.parse().map(Oid) + } +} + +impl From for String { + fn from(oid: Oid) -> Self { + oid.0.to_string() + } +} + +impl From for git2::Oid { + fn from(oid: Oid) -> Self { + oid.0 + } +} diff --git a/radicle-source/src/person.rs b/radicle-source/src/person.rs new file mode 100644 index 0000000..b4b886f --- /dev/null +++ b/radicle-source/src/person.rs @@ -0,0 +1,28 @@ +// This file is part of radicle-surf +// +// +// Copyright (C) 2019-2020 The Radicle Team +// +// 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 . + +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, +} diff --git a/radicle-source/src/revision.rs b/radicle-source/src/revision.rs new file mode 100644 index 0000000..08abc08 --- /dev/null +++ b/radicle-source/src/revision.rs @@ -0,0 +1,175 @@ +// This file is part of radicle-surf +// +// +// Copyright (C) 2019-2020 The Radicle Team +// +// 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 . + +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 { + 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

{ + /// 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

, + }, + /// Select a SHA1 under the name provided. + #[serde(rename_all = "camelCase")] + Sha { + /// The SHA1 value. + sha: Oid, + }, +} + +impl

TryFrom> for Rev +where + P: ToString, +{ + type Error = Error; + + fn try_from(other: Revision

) -> Result { + 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 { + /// 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, + /// List of [`git::Tag`]. + pub tags: Vec, +} + +/// 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( + browser: &Browser, + peer_id: P, + user: U, +) -> Result>, 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(browser: &Browser, peer_id: P, user: U) -> Result>, 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( + browser: &Browser, + peer: Category, +) -> Result>, 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), + } +} diff --git a/radicle-source/src/syntax.rs b/radicle-source/src/syntax.rs new file mode 100644 index 0000000..b572a35 --- /dev/null +++ b/radicle-source/src/syntax.rs @@ -0,0 +1,87 @@ +// This file is part of radicle-surf +// +// +// Copyright (C) 2019-2020 The Radicle Team +// +// 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 . + +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 { + 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, + } +} diff --git a/radicle-source/src/tag.rs b/radicle-source/src/tag.rs new file mode 100644 index 0000000..1cfed94 --- /dev/null +++ b/radicle-source/src/tag.rs @@ -0,0 +1,60 @@ +// This file is part of radicle-surf +// +// +// Copyright (C) 2019-2020 The Radicle Team +// +// 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 . + +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 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, Error> { + let tag_names = browser.list_tags(RefScope::Local)?; + let mut tags: Vec = tag_names + .into_iter() + .map(|tag_name| Tag(tag_name.name().to_string())) + .collect(); + + tags.sort(); + + Ok(tags) +} diff --git a/radicle-surf/Cargo.toml b/radicle-surf/Cargo.toml new file mode 100644 index 0000000..9c48ec7 --- /dev/null +++ b/radicle-surf/Cargo.toml @@ -0,0 +1,52 @@ +[package] +name = "radicle-surf" +description = "A code surfing library for VCS file systems" +readme = "README.md" +version = "0.8.0" +authors = ["The Radicle Team "] +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 diff --git a/radicle-surf/DEVELOPMENT.md b/radicle-surf/DEVELOPMENT.md new file mode 100644 index 0000000..fff78fc --- /dev/null +++ b/radicle-surf/DEVELOPMENT.md @@ -0,0 +1,83 @@ + +# Radicle Surfing πŸ„ + +Thanks for wanting to contribute to `radicle-surf`! + +# Licensing + +We are [GPL-3.0-or-later](./LICENSE) licensed project. To keep in compliance with this we must +add a [license header](./.license-header) to any new files added. This is checked on each run of CI. + +## Building & Testing πŸ—οΈ + +We try to make development as seemless as possible so we can get down to the real work. We supply +the toolchain via the `rust-toolchain` file, and the formatting rules `.rustmt.toml` file. + +For the [Nix](https://nixos.org/) inclined there is a `default.nix` file to get all the necessary +dependencies and it also uses the `rust-toolchain` file to pin to that version of Rust. + +You can build the project the usual way: +``` +cargo build +``` + +To run all the tests: +``` +cargo test +``` + +For the full list of checks that get executed in CI you can checkout the [ci/run](./ci/run) script. + +If any of this _isn't_ working, then let's work through it together and get it Working on Your +Machineβ„’. + +## Structure πŸ›οΈ + +The design of `radicle-surf` is to have an in-memory representation of a project's directory which +can be generated by a VCS's backend. The directory system is modeled under `file_system`, the VCS +functionality is naturally under `vcs`, and `diff` logic is held under `diff`. + +``` +src/ +β”œβ”€β”€ diff +β”œβ”€β”€ file_system +└── vcs +``` + +## Testing & Documentation πŸ“š + +We ensure that the crate is well documented. `cargo clippy` will argue with you anytime a public +facing piece of the library is undocumented. We should always provide an explanation of what +something is or does, and also provide examples to allow our users to get up and running as quick +and easy as possible. + +When writing documentation we should try provide one or two examples (if they make sense). This +provides us with some simple unit tests as well as something our users can copy and paste for ease +of development. + +If more tests are needed then we should add them under `mod tests` in the relevant module. We strive +to find properties of our programs so that we can use tools like `proptest` to extensively prove our +programs are correct. As well as this, we add unit tests to esnure the examples in our heads are +correct, and testing out the ergonomics of our API first-hand. + +## CI files πŸ€– + +Our CI infrastructure runs on Buildkite. The build process is run for every commit which is pushed +to GitHub. + +All relevant configuration can be found here: + +``` +radicle-surf/.buildkite/ +β”œβ”€β”€ docker +β”‚Β Β  β”œβ”€β”€ build +β”‚Β Β  β”‚Β Β  └── Dockerfile +β”‚Β Β  └── rust-nightly +β”‚Β Β  └── Dockerfile +└── pipeline.yaml +``` + +## Releases πŸ“… + +TODO: Once we get the API into a good shape we will keep track of releases via a `CHANGELOG.md` and +tag the releases via `git tag`. diff --git a/radicle-surf/README.md b/radicle-surf/README.md new file mode 100644 index 0000000..76aa7d8 --- /dev/null +++ b/radicle-surf/README.md @@ -0,0 +1,74 @@ +# radicle-surf + +A code surfing library for VCS file systems πŸ„β€β™€οΈπŸ„β€β™‚οΈ + +Welcome to `radicle-surf`! + +`radicle-surf` is a system to describe a file-system in a VCS world. +We have the concept of files and directories, but these objects can change over time while people iterate on them. +Thus, it is a file-system within history and we, the user, are viewing the file-system at a particular snapshot. +Alongside this, we will wish to take two snapshots and view their differences. + +## Contributing + +To get started on contributing you can check out our [developing guide](../DEVELOPMENT.md), and also +our [LICENSE](../LICENSE) file. + +## The Community + +Join our community disccussions at [radicle.community](https://radicle.community)! + +# Example + +To a taste for the capabilities of `radicle-surf` we provide an example below, but we also +keep our documentation and doc-tests up to date. + +```rust +use radicle_surf::vcs::git; +use radicle_surf::file_system::{Label, Path, SystemType}; +use radicle_surf::file_system::unsound; +use pretty_assertions::assert_eq; +use std::str::FromStr; + +// We're going to point to this repo. +let repo = git::Repository::new("./data/git-platinum")?; + +// Here we initialise a new Broswer for a the git repo. +let mut browser = git::Browser::new(&repo, "master")?; + +// Set the history to a particular commit +let commit = git::Oid::from_str("80ded66281a4de2889cc07293a8f10947c6d57fe")?; +browser.commit(commit)?; + +// Get the snapshot of the directory for our current HEAD of history. +let directory = browser.get_directory()?; + +// Let's get a Path to the memory.rs file +let memory = unsound::path::new("src/memory.rs"); + +// And assert that we can find it! +assert!(directory.find_file(memory).is_some()); + +let root_contents = directory.list_directory(); + +assert_eq!(root_contents, vec![ + SystemType::file(unsound::label::new(".i-am-well-hidden")), + SystemType::file(unsound::label::new(".i-too-am-hidden")), + SystemType::file(unsound::label::new("README.md")), + SystemType::directory(unsound::label::new("bin")), + SystemType::directory(unsound::label::new("src")), + SystemType::directory(unsound::label::new("text")), + SystemType::directory(unsound::label::new("this")), +]); + +let src = directory + .find_directory(Path::new(unsound::label::new("src"))) + .expect("failed to find src"); +let src_contents = src.list_directory(); + +assert_eq!(src_contents, vec![ + SystemType::file(unsound::label::new("Eval.hs")), + SystemType::file(unsound::label::new("Folder.svelte")), + SystemType::file(unsound::label::new("memory.rs")), +]); +``` diff --git a/radicle-surf/benches/last_commit.rs b/radicle-surf/benches/last_commit.rs new file mode 100644 index 0000000..2655a51 --- /dev/null +++ b/radicle-surf/benches/last_commit.rs @@ -0,0 +1,45 @@ +// This file is part of radicle-surf +// +// +// Copyright (C) 2019-2020 The Radicle Team +// +// 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 . + +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); diff --git a/radicle-surf/build.rs b/radicle-surf/build.rs new file mode 100644 index 0000000..8d1711d --- /dev/null +++ b/radicle-surf/build.rs @@ -0,0 +1,81 @@ +// This file is part of radicle-surf +// +// +// Copyright (C) 2019-2020 The Radicle Team +// +// 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 . + +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 { + 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, target: impl AsRef) -> 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(()) +} diff --git a/radicle-surf/data/README.md b/radicle-surf/data/README.md new file mode 100644 index 0000000..45cb638 --- /dev/null +++ b/radicle-surf/data/README.md @@ -0,0 +1,21 @@ +# Updating [git-platinum][] + +1. Push your changes to [`radicle-dev/git-platinum`][git-platinum] and/or update + `surf/data/mock-branches.txt`. +2. Run `scripts/update-git-platinum.sh` from the repo root. This updates + `surf/data/git-platinum.tgz`. +3. Run `cargo build` to unpack the updated repo. +4. Run the tests +5. Commit your changes. We provide a template below so that we can easily + identify changes to `git-platinum`. Please fill in the details that follow a + comment (`#`): + ``` + data/git-platinum: # short reason for updating + + # provide a longer reason for making changes to git-platinum + # as well as what has changed. + ``` + + + +[git-platinum]: https://github.com/radicle-dev/git-platinum diff --git a/radicle-surf/data/git-platinum.tgz b/radicle-surf/data/git-platinum.tgz new file mode 100644 index 0000000..50cf9f2 Binary files /dev/null and b/radicle-surf/data/git-platinum.tgz differ diff --git a/radicle-surf/data/mock-branches.txt b/radicle-surf/data/mock-branches.txt new file mode 100644 index 0000000..6400bf8 --- /dev/null +++ b/radicle-surf/data/mock-branches.txt @@ -0,0 +1,12 @@ +refs/namespaces/golden/refs/heads/master,refs/heads/master +refs/namespaces/golden/refs/heads/banana,refs/heads/dev +refs/namespaces/golden/refs/tags/v0.1.0,refs/tags/v0.1.0 +refs/namespaces/golden/refs/tags/v0.2.0,refs/tags/v0.2.0 +refs/namespaces/golden/refs/remotes/kickflip/heads/heelflip,refs/heads/dev +refs/namespaces/golden/refs/remotes/kickflip/heads/fakie/bigspin,refs/heads/dev +refs/namespaces/golden/refs/remotes/kickflip/tags/v0.1.0,refs/tags/v0.1.0 +refs/namespaces/golden/refs/namespaces/silver/refs/heads/master,refs/heads/dev +refs/remotes/banana/pineapple,refs/remotes/origin/master +refs/remotes/banana/orange/pineapple,refs/remotes/origin/master +refs/namespaces/me/refs/heads/feature/#1194,refs/heads/master +refs/namespaces/me/refs/remotes/fein/heads/feature/#1194,refs/heads/dev diff --git a/radicle-surf/docs/denotational-design.md b/radicle-surf/docs/denotational-design.md new file mode 100644 index 0000000..88a2c41 --- /dev/null +++ b/radicle-surf/docs/denotational-design.md @@ -0,0 +1,265 @@ +# Design Documentation + +In this document we will describe the design of `radicle-surf`. The design of the system will rely +heavily on [denotational design](todo) and use Haskell syntax (because types are easy to reason about, I'm sorry). + +`radicle-surf` is a system to describe a file-system in a VCS world. We have the concept of files and directories, +but these objects can change over time while people iterate on them. Thus, it is a file-system within history and +we, the user, are viewing the file-system at a particular snapshot. Alongside this, we will wish to take two snapshots +and view their differences. + +The stream of consciousness that gave birth to this document started with thinking how the user would interact with +the system, identifying the key components. This is captured in [User Flow](#user-flow). From there we found nouns that +represent objects in our system and verbs that represent functions over those objects. This iteratively informed us as +to what other actions we would need to supply. We would occassionally look at [GitHub](todo) and [Pijul Nest](todo) for +inspiration, since we would like to imitate the features that they supply, and we ultimately want use one or both of +these for our backends. + +## User Flow + +For the user flow we imagined what it would be like if the user was using a [REPL](todo) to interact with `radicle-surf`. +The general concept was that the user would enter the repository, build a view of the directory structure, and then +interact with the directories and files from there (called `browse`). +```haskell +repl :: IO () +repl = do + repo <- getRepo + history <- getHistory label repo -- head is SHA1, tail is rest + directory <- buildDirectory history + + forever browse directory +``` + +But then we thought about what happens when we are in `browse` but we would like to change the history and see that +file or directory at a different snapshot. This was captured in the pseudo-code below: +```haskell + src_foo_bar <- find... + history' <- historyOf src_foo_bar +``` + +This information was enough for us to begin the [denotational design](#denotational-design) below. + +## Denotational Design + +```haskell +-- A Label is a name for a directory or a file +type Label +ΞΌ Label = Text + +-- A Directory captures its own Label followed by 1 or more +-- artefacts which can either be sub-directories or files. +-- +-- An example of "foo/bar.hs" structure: +-- foo +-- |-- bar.hs +-- +-- Would look like: +-- @("foo", Right ("bar.hs", "module Banana ...") :| [])@ +type Directory +ΞΌ Directory = (Label, NonEmpty (Either Directory File)) + +-- DirectoryContents can either be the special IsRepo object, +-- a Directory, or a File. +type DirectoryContents +ΞΌ DirectoryContents = IsRepo | Directory | File + +-- Opaque representation of repository state directories (e.g. `.git`, `.pijul`) +-- Those are not browseable, but have to be present at the repo root 'Directory'. +type IsRepo + +-- A Directory captures its own Label followed by 1 or more DirectoryContents +-- +-- An example of "foo/bar.hs" structure: +-- foo +-- |-- bar.hs +-- +-- Would look like: +-- @("~", IsRepo :| [Directory ("foo", File ("bar.hs", "module Banana ..") :| [])] +-- where IsRepo is the implicit root of the repository. +type Directory +ΞΌ Directory = (Label, NonEmpty DirectoryContents) + +-- A File is its Label and its contents +type File +ΞΌ File = (Label, ByteString) + +-- An enumeration of what file-system artefact we're looking at. +-- Useful for listing a directory and denoting what the label is +-- corresponding to. +type SystemType +ΞΌ SystemType + = IsFile + | IsDirectory + +-- A Chnage is an enumeration of how a file has changed. +-- This is simply used for getting the difference between two +-- directories. +type Change + +-- Constructors of Change - think GADT +AddLineToFile :: NonEmpty Label -> Location -> ByteString -> Change +RemoveLineFromFile :: NonEmpty Label -> Location -> Change +MoveFile :: NonEmpty Label -> NonEmpty Label -> Change +CreateFile :: NonEmpty Label -> Change +DeleteFile :: NonEmpty Label -> Change + +-- A Diff is a set of Changes that were made +type Diff +ΞΌ Diff = [Change] + +-- History is an ordered set of @a@s. The reason for it being +-- polymorphic is that it allows us to choose what set artefact we +-- want to carry around. +-- +-- For example: +-- * In `git` this would be a `Commit`. +-- * In `pijul` it would be a `Patch`. +type History a +ΞΌ History = NonEmpty a + +-- A Repo is a collection of multiple histories. +-- This would essentially boil down to branches and tags. +type Repo +ΞΌ Repo a = [History a] + +-- A Snapshot is a way of converting a History into a Directory. +-- In other words it gives us a snapshot of the history in the form of a directory. +type Snapshot a +ΞΌ Snapshot a = History a -> Directory + +-- For example, we have a `git` snapshot or a `pjul` snapshot. +type Commit +type GitSnapshot = Snapshot Commit + +type Patch +type PijulSnapshot = Snapshot Patch + +-- This is piece de resistance of the design! It turns out, +-- everything is just a Monad after all. +-- +-- Our code Browser is a stateful computation of what History +-- we are currently working with and how to get a Snapshot of it. +type Browser a b +ΞΌ type Browser a b = ReaderT (Snapshot a) (State (History a) b) + +-- A function that will retrieve a repository given an +-- identifier. In our case the identifier is opaque to the system. +getRepo :: Repo -> Repo + +-- Find a particular History in the Repo. Again, how these things +-- are equated and found is opaque, but we can think of them as +-- branch or tag labels. +getHistory :: Eq a => History a -> Repo a -> Maybe (History a) +ΞΌ getHistory history repo = + find history (ΞΌ repo) + +-- Find if a particular artefact occurs in 0 or more histories. +findInHistories :: a -> [History a] -> [History a] +ΞΌ findInHistories a histories = + filterMaybe (findInHistory a) histories + +-- Find a particular artefact is in a history. +findInHistory :: Eq a => a -> History a -> Maybe a +ΞΌ findInHistory a history = find (== a) (ΞΌ history) + +-- A special Label that guarantees a starting point, i.e. ~ +rootLabel :: Label +ΞΌ rootLabel = "~" + +emptyRepoRoot :: Directory +ΞΌ emptyRepoRoot = (rootLabel, IsRepo :| []) + +-- Get the difference between two directory views. +diff :: Directory -> Directory -> Diff + +-- List the current file or directories in a given Directory view. +listDirectory :: Directory -> [Label, SystemType] +ΞΌ listDirectory directory = foldMap toLabel $ snd (ΞΌ directory) + where + toLabel content = case content of + File (label, _) -> [(label, IsFile)] + Directory (label, _) -> [(label, IsDirectory)] + IsRepo -> [] + +fileName :: File -> Label +ΞΌ fileName file = fst (ΞΌ file) + +findFile :: NonEmpty Label -> Directory -> Maybe File +ΞΌ findFile (label :| labels) (Directory (label', contents)) = + if label == label' then go labels contents else Nothing + where + findFileWithLabel :: Foldable f => Label -> f DirectoryContents -> Maybe File + findFileWithLabel label = find (\artefact -> case content of + File (fileLabel, _) -> fileLabel == label + Directory _ -> False + IsRepo -> False) + + go :: [Label] -> NonEmpty DirectoryContents -> Just File + go [] _ = Nothing + go [label] contents = findMaybe (fileWithLabel label) contents + go (label:labels) contents = (go labels . snd) <$> find ((label ==) . fst) onlyDirectories contents + +onlyDirectories :: Foldable f => f DirectoryContents -> [Directory] +ΞΌ onlyDirectories = fmapMaybe (\content -> case content of + d@(Directory _) -> Just d + File _ -> Nothing + IsRepo -> Nothing) . toList + +getSubDirectories :: Directory -> [Directory] +ΞΌ getSubDirectories directory = foldMap f $ snd (ΞΌ directory) + where + f :: DirectoryContents -> [Directory] + f = \case + d@(Directory _) -> [d] + File _ -> [] + IsRepo -> [] + +-- Definition elided +findDirectory :: NonEmpty Label -> Directory -> Maybe Directory + +-- Definition elided +fuzzyFind :: Label -> [Directory] + +-- A Git Snapshot is grabbing the HEAD commit of your History +-- and turning it into a Directory +gitSnapshot :: Snapshot Commit +ΞΌ gitSnapshot commits = second (\root -> root <> getDirectoryPtr $ Nel.head commits) emptyRepoRoot + +-- Opaque and defined by the backend +getDirectoryPtr :: Commit -> Directory + +-- A Pijul history is semantically applying the patches in a +-- topological order and achieving the Directory view. +pijulHistory :: Snapshot Patch +ΞΌ pijulHistory = foldl pijulMagic emptyRepoRoot + +-- Opaque and defined by the backend +pijulMagic :: Patch -> Directory -> Directory + +-- Get the current History we are working with. +getHistory :: Browser a (History a) +ΞΌ getHistory = get + +setHistory :: History a -> Browser a () +ΞΌ setHistory = put + +-- Get the current Directory in the Browser +getDirectory :: Browser a Directory +ΞΌ getDirectory = do + hist <- get + applySnapshot <- ask + pure $ applySnapshot hist + +-- We modify the history by changing the internal history state. +switchHistory :: (History a -> History a) -> Browser a b +ΞΌ switchHistory f = modify f + +-- | Find the suffix of a History. +findSuffix :: Eq a => a -> History a -> Maybe (History a) +ΞΌ findSuffix a = nonEmpty . Nel.dropWhile (/= a) + +-- View the history up to a given point by supplying a function to modify +-- the state. If this operation fails, then the default value is used. +viewAt :: (History a -> Maybe (History a)) -> History a -> Browser a b +ΞΌ viewAt f def = switchHistory (fromMaybe def . f) +``` diff --git a/radicle-surf/examples/diff.rs b/radicle-surf/examples/diff.rs new file mode 100644 index 0000000..3f932fa --- /dev/null +++ b/radicle-surf/examples/diff.rs @@ -0,0 +1,176 @@ +// This file is part of radicle-surf +// +// +// Copyright (C) 2019-2020 The Radicle Team +// +// 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 . + +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 { + let args: Vec = args.collect(); + if args.len() != 4 { + return Err(format!( + "Usage: {} \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, + }) + } +} diff --git a/radicle-surf/src/diff.rs b/radicle-surf/src/diff.rs new file mode 100644 index 0000000..5ef20a4 --- /dev/null +++ b/radicle-surf/src/diff.rs @@ -0,0 +1,767 @@ +// This file is part of radicle-surf +// +// +// Copyright (C) 2019-2020 The Radicle Team +// +// 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 . + +#![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, + pub deleted: Vec, + pub moved: Vec, + pub copied: Vec, + pub modified: Vec, +} + +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, +} + +/// 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, +} + +/// A set of [`Hunk`]s. +#[cfg_attr(feature = "serialize", derive(Serialize))] +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Hunks(pub Vec); + +pub struct IterHunks<'a> { + inner: slice::Iter<'a, Hunk>, +} + +impl Hunks { + pub fn iter(&self) -> IterHunks<'_> { + IterHunks { + inner: self.0.iter(), + } + } +} + +impl From> for Hunks { + fn from(hunks: Vec) -> Self { + Self(hunks) + } +} + +impl<'a> Iterator for IterHunks<'a> { + type Item = &'a Hunk; + + fn next(&mut self) -> Option { + self.inner.next() + } +} + +impl TryFrom> for Hunks { + type Error = git::error::Hunk; + + fn try_from(patch: git2::Patch) -> Result { + 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 = 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); + +impl From> for Line { + fn from(v: Vec) -> Self { + Self(v) + } +} + +impl From for Line { + fn from(s: String) -> Self { + Self(s.into_bytes()) + } +} + +#[cfg(feature = "serialize")] +impl Serialize for Line { + fn serialize(&self, serializer: S) -> Result + 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_num: u32) -> Self { + Self::Addition { + line: line.into(), + line_num, + } + } + + pub fn deletion(line: impl Into, line_num: u32) -> Self { + Self::Deletion { + line: line.into(), + line_num, + } + } + + pub fn context(line: impl Into, 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>, + 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( + entry: &DirectoryContents, + parent_path: &Rc>, + mapper: F, + ) -> Vec + 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(dir: &Directory, parent_path: &Rc>, mapper: F) -> Vec + where + F: Fn(Path) -> T + Copy, + { + let mut files: Vec = 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>, + mapper: F, + files: &mut Vec, + ) 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, + eof: Option, + ) { + // 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>) { + let mut new_files: Vec = + 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>) { + let mut new_files: Vec = + 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) + } + */ +} diff --git a/radicle-surf/src/diff/git.rs b/radicle-surf/src/diff/git.rs new file mode 100644 index 0000000..b0bd7bd --- /dev/null +++ b/radicle-surf/src/diff/git.rs @@ -0,0 +1,306 @@ +// This file is part of radicle-surf +// +// +// Copyright (C) 2019-2020 The Radicle Team +// +// 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 . + +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> for LineDiff { + type Error = error::LineDiff; + + fn try_from(line: git2::DiffLine) -> Result { + 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> for Diff { + type Error = error::Diff; + + fn try_from(git_diff: git2::Diff) -> Result { + 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 = 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 = 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)); + } +} diff --git a/radicle-surf/src/file_system.rs b/radicle-surf/src/file_system.rs new file mode 100644 index 0000000..f492d78 --- /dev/null +++ b/radicle-surf/src/file_system.rs @@ -0,0 +1,118 @@ +// This file is part of radicle-surf +// +// +// Copyright (C) 2019-2020 The Radicle Team +// +// 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 . + +//! 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::*}; diff --git a/radicle-surf/src/file_system/directory.rs b/radicle-surf/src/file_system/directory.rs new file mode 100644 index 0000000..0b64494 --- /dev/null +++ b/radicle-surf/src/file_system/directory.rs @@ -0,0 +1,722 @@ +// This file is part of radicle-surf +// +// +// Copyright (C) 2019-2020 The Radicle Team +// +// 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 . + +//! 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, + 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, +} + +/// `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> for DirectoryContents { + fn from(sub_tree: SubTree) -> 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 + '_ { + 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 { + 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.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 { + 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>) -> 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 { + // ASCII regex, excluding '/' because of posix file paths + "[ -.|0-~]+".prop_map(|label| unsound::label::new(&label)) + } + + fn path_strategy(max_size: usize) -> impl Strategy { + ( + label_strategy(), + collection::vec(label_strategy(), 0..max_size), + ) + .prop_map(|(label, labels)| Path((label, labels).into())) + } + + fn file_strategy() -> impl Strategy { + // 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>> { + 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>, + ) -> 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)); + } + } +} diff --git a/radicle-surf/src/file_system/error.rs b/radicle-surf/src/file_system/error.rs new file mode 100644 index 0000000..20cc2a3 --- /dev/null +++ b/radicle-surf/src/file_system/error.rs @@ -0,0 +1,79 @@ +// This file is part of radicle-surf +// +// +// Copyright (C) 2019-2020 The Radicle Team +// +// 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 . + +//! 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, +} diff --git a/radicle-surf/src/file_system/path.rs b/radicle-surf/src/file_system/path.rs new file mode 100644 index 0000000..a86556f --- /dev/null +++ b/radicle-surf/src/file_system/path.rs @@ -0,0 +1,470 @@ +// This file is part of radicle-surf +// +// +// Copyright (C) 2019-2020 The Radicle Team +// +// 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 . + +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 { + 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 { + 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

` into a [`RefScope`]. If the `P` is present then +/// this is set as the remote of the `RefScope`. Otherwise, it's local +/// branch. +impl

From> for RefScope +where + P: ToString, +{ + fn from(peer_id: Option

) -> Self { + peer_id.map_or(RefScope::Local, |peer_id| RefScope::Remote { + // We qualify the remotes as the PeerId + heads, otherwise we would grab the tags too. + name: Some(format!("{}/heads", peer_id.to_string())), + }) + } +} + +/// A [`crate::vcs::Browser`] that uses [`Repository`] as the underlying +/// repository backend, [`git2::Commit`] as the artifact, and [`Error`] for +/// error reporting. +pub type Browser<'a> = vcs::Browser, Commit, Error>; + +impl<'a> Browser<'a> { + /// Create a new browser to interact with. + /// + /// The `revspec` provided will be used to kick off the [`History`] for this + /// `Browser`. + /// + /// # Errors + /// + /// * [`error::Error::Git`] + /// + /// # Examples + /// + /// ``` + /// use radicle_surf::vcs::git::{Browser, Branch, Repository}; + /// # use std::error::Error; + /// + /// # fn main() -> Result<(), Box> { + /// let repo = Repository::new("./data/git-platinum")?; + /// let browser = Browser::new(&repo, Branch::local("master"))?; + /// # + /// # Ok(()) + /// # } + /// ``` + pub fn new( + repository: impl Into>, + rev: impl Into, + ) -> Result { + let repository = repository.into(); + let history = repository.get_history(rev.into())?; + Ok(Self::init(repository, history)) + } + + /// Create a new `Browser` that starts in a given `namespace`. + /// + /// # Errors + /// + /// * [`error::Error::Git`] + /// + /// # Examples + /// + /// ``` + /// use radicle_surf::vcs::git::{Browser, Repository, Branch, RefScope, BranchName, Namespace}; + /// use std::convert::TryFrom; + /// # use std::error::Error; + /// + /// # fn main() -> Result<(), Box> { + /// let repo = Repository::new("./data/git-platinum")?; + /// let browser = Browser::new_with_namespace( + /// &repo, + /// &Namespace::try_from("golden")?, + /// Branch::local("master") + /// )?; + /// + /// let mut branches = browser.list_branches(RefScope::Local)?; + /// branches.sort(); + /// + /// assert_eq!( + /// branches, + /// vec![ + /// Branch::local("banana"), + /// Branch::local("master"), + /// ] + /// ); + /// # + /// # Ok(()) + /// # } + /// ``` + pub fn new_with_namespace( + repository: impl Into>, + namespace: &Namespace, + rev: impl Into, + ) -> Result { + let repository = repository.into(); + // This is a bit weird, the references don't seem to all be present unless we + // make a call to `references` o_O. + let _ = repository.repo_ref.references()?; + repository.switch_namespace(&namespace.to_string())?; + let history = repository.get_history(rev.into())?; + Ok(Self::init(repository, history)) + } + + fn init(repository: RepositoryRef<'a>, history: History) -> Self { + let snapshot = Box::new(|repository: &RepositoryRef<'a>, history: &History| { + let tree = Self::get_tree(repository.repo_ref, history.0.first())?; + Ok(directory::Directory::from_hash_map(tree)) + }); + vcs::Browser { + snapshot, + history, + repository, + } + } + + /// Switch the namespace you are browsing in. This will consume the previous + /// `Browser` and give you back a new `Browser` for that particular + /// namespace. The `revision` provided will kick-off the history for + /// this `Browser`. + pub fn switch_namespace( + self, + namespace: &Namespace, + rev: impl Into, + ) -> Result { + self.repository.switch_namespace(&namespace.to_string())?; + let history = self.get_history(Rev::from(rev))?; + Ok(Browser { + snapshot: self.snapshot, + repository: self.repository, + history, + }) + } + + /// What is the current namespace we're browsing in. + pub fn which_namespace(&self) -> Result, Error> { + self.repository + .repo_ref + .namespace_bytes() + .map(Namespace::try_from) + .transpose() + } + + /// Set the current `Browser` history to the `HEAD` commit of the underlying + /// repository. + /// + /// # Errors + /// + /// * [`error::Error::Git`] + /// + /// # Examples + /// + /// ``` + /// use radicle_surf::vcs::git::{Browser, Repository, Branch}; + /// # use std::error::Error; + /// + /// # fn main() -> Result<(), Box> { + /// let repo = Repository::new("./data/git-platinum")?; + /// let mut browser = Browser::new(&repo, Branch::local("master"))?; + /// + /// // ensure we're at HEAD + /// browser.head(); + /// + /// let directory = browser.get_directory(); + /// + /// // We are able to render the directory + /// assert!(directory.is_ok()); + /// # + /// # Ok(()) + /// # } + /// ``` + pub fn head(&mut self) -> Result<(), Error> { + let history = self.repository.head()?; + self.set(history); + Ok(()) + } + + /// Set the current `Browser`'s [`History`] to the given [`BranchName`] + /// provided. + /// + /// # Errors + /// + /// * [`error::Error::Git`] + /// * [`error::Error::NotBranch`] + /// + /// # Examples + /// + /// ``` + /// use radicle_surf::vcs::git::{Branch, Browser, Repository}; + /// # use std::error::Error; + /// + /// # fn main() -> Result<(), Box> { + /// let repo = Repository::new("./data/git-platinum")?; + /// let mut browser = Browser::new(&repo, Branch::local("master"))?; + /// + /// // ensure we're on 'master' + /// browser.branch(Branch::local("master")); + /// + /// let directory = browser.get_directory(); + /// + /// // We are able to render the directory + /// assert!(directory.is_ok()); + /// # + /// # Ok(()) + /// # } + /// ``` + /// + /// ``` + /// use radicle_surf::vcs::git::{Branch, Browser, Repository}; + /// use radicle_surf::file_system::{Label, Path, SystemType}; + /// use radicle_surf::file_system::unsound; + /// # use std::error::Error; + /// + /// # fn main() -> Result<(), Box> { + /// let repo = Repository::new("./data/git-platinum")?; + /// let mut browser = Browser::new(&repo, Branch::local("master"))?; + /// browser.branch(Branch::remote("dev", "origin"))?; + /// + /// let directory = browser.get_directory()?; + /// let mut directory_contents = directory.list_directory(); + /// directory_contents.sort(); + /// + /// assert!(directory_contents.contains( + /// &SystemType::file(unsound::label::new("here-we-are-on-a-dev-branch.lol")) + /// )); + /// # + /// # Ok(()) + /// # } + /// ``` + pub fn branch(&mut self, branch: Branch) -> Result<(), Error> { + let name = BranchName(branch.name()); + self.set(self.repository.reference(branch, |reference| { + let is_branch = ext::is_branch(reference) || reference.is_remote(); + if !is_branch { + Some(Error::NotBranch(name)) + } else { + None + } + })?); + Ok(()) + } + + /// Set the current `Browser`'s [`History`] to the [`TagName`] provided. + /// + /// # Errors + /// + /// * [`error::Error::Git`] + /// * [`error::Error::NotTag`] + /// + /// # Examples + /// + /// ``` + /// use nonempty::NonEmpty; + /// use radicle_surf::vcs::History; + /// use radicle_surf::vcs::git::{TagName, Branch, Browser, Oid, Repository}; + /// # use std::error::Error; + /// + /// # fn main() -> Result<(), Box> { + /// let repo = Repository::new("./data/git-platinum")?; + /// let mut browser = Browser::new(&repo, Branch::local("master"))?; + /// + /// // Switch to "v0.3.0" + /// browser.tag(TagName::new("v0.3.0"))?; + /// + /// let expected_history = History(NonEmpty::from(( + /// Oid::from_str("19bec071db6474af89c866a1bd0e4b1ff76e2b97")?, + /// vec![ + /// Oid::from_str("f3a089488f4cfd1a240a9c01b3fcc4c34a4e97b2")?, + /// Oid::from_str("2429f097664f9af0c5b7b389ab998b2199ffa977")?, + /// Oid::from_str("d3464e33d75c75c99bfb90fa2e9d16efc0b7d0e3")?, + /// ] + /// ))); + /// + /// let history_ids = browser.get().map(|commit| commit.id); + /// + /// // We are able to render the directory + /// assert_eq!(history_ids, expected_history); + /// # + /// # Ok(()) + /// # } + /// ``` + pub fn tag(&mut self, tag_name: TagName) -> Result<(), Error> { + let name = tag_name.clone(); + self.set(self.repository.reference(tag_name, |reference| { + if !ext::is_tag(reference) { + Some(Error::NotTag(name)) + } else { + None + } + })?); + Ok(()) + } + + /// Set the current `Browser`'s [`History`] to the [`Oid`] (SHA digest) + /// provided. + /// + /// # Errors + /// + /// * [`error::Error::Git`] + /// + /// # Examples + /// + /// ``` + /// use radicle_surf::file_system::{Label, SystemType}; + /// use radicle_surf::file_system::unsound; + /// use radicle_surf::vcs::git::{Branch, Browser, Oid, Repository}; + /// use std::str::FromStr; + /// # use std::error::Error; + /// + /// # fn main() -> Result<(), Box> { + /// let repo = Repository::new("./data/git-platinum")?; + /// let mut browser = Browser::new(&repo, Branch::local("master"))?; + /// + /// // Set to the initial commit + /// let commit = Oid::from_str("e24124b7538658220b5aaf3b6ef53758f0a106dc")?; + /// + /// browser.commit(commit)?; + /// + /// let directory = browser.get_directory()?; + /// let mut directory_contents = directory.list_directory(); + /// + /// assert_eq!( + /// directory_contents, + /// vec![ + /// SystemType::file(unsound::label::new("README.md")), + /// SystemType::directory(unsound::label::new("bin")), + /// SystemType::directory(unsound::label::new("src")), + /// SystemType::directory(unsound::label::new("this")), + /// ] + /// ); + /// # + /// # Ok(()) + /// # } + /// ``` + pub fn commit(&mut self, oid: Oid) -> Result<(), Error> { + self.set(self.get_history(Rev::Oid(oid))?); + Ok(()) + } + + /// Set a `Browser`'s [`History`] based on a [revspec](https://git-scm.com/docs/git-rev-parse.html#_specifying_revisions). + /// + /// # Errors + /// + /// * [`error::Error::Git`] + /// * [`error::Error::RevParseFailure`] + /// + /// # Examples + /// + /// ``` + /// use radicle_surf::file_system::{Label, SystemType}; + /// use radicle_surf::file_system::unsound; + /// use radicle_surf::vcs::git::{Browser, Branch, Oid, Repository}; + /// use std::str::FromStr; + /// # use std::error::Error; + /// + /// # fn main() -> Result<(), Box> { + /// let repo = Repository::new("./data/git-platinum")?; + /// let mut browser = Browser::new(&repo, Branch::local("master"))?; + /// + /// browser.rev(Branch::remote("dev", "origin"))?; + /// + /// let directory = browser.get_directory()?; + /// let mut directory_contents = directory.list_directory(); + /// directory_contents.sort(); + /// + /// assert!(directory_contents.contains( + /// &SystemType::file(unsound::label::new("here-we-are-on-a-dev-branch.lol")) + /// )); + /// # + /// # Ok(()) + /// # } + /// ``` + pub fn rev(&mut self, rev: impl Into) -> Result<(), Error> { + let history = self.get_history(rev.into())?; + self.set(history); + Ok(()) + } + + /// Parse an [`Oid`] from the given string. This is useful if we have a + /// shorthand version of the `Oid`, as opposed to the full one. + /// + /// # Examples + /// + /// ``` + /// use radicle_surf::vcs::git::{Branch, Browser, Oid, Repository}; + /// use std::str::FromStr; + /// # use std::error::Error; + /// + /// # fn main() -> Result<(), Box> { + /// let repo = Repository::new("./data/git-platinum")?; + /// let mut browser = Browser::new(&repo, Branch::local("master"))?; + /// + /// // Set to the initial commit + /// let commit = Oid::from_str("e24124b7538658220b5aaf3b6ef53758f0a106dc")?; + /// + /// assert_eq!( + /// commit, + /// browser.oid("e24124b")?, + /// ); + /// # + /// # Ok(()) + /// # } + /// ``` + pub fn oid(&self, oid: &str) -> Result { + self.repository.oid(oid) + } + + /// Get the [`Diff`] between two commits. + pub fn diff(&self, from: Oid, to: Oid) -> Result { + self.repository.diff(from, to) + } + + /// Get the [`Diff`] of a commit with no parents. + pub fn initial_diff(&self, oid: Oid) -> Result { + self.repository.initial_diff(oid) + } + + /// List the names of the _branches_ that are contained in the underlying + /// [`Repository`]. + /// + /// # Errors + /// + /// * [`error::Error::Git`] + /// + /// # Examples + /// + /// ``` + /// use radicle_surf::vcs::git::{Branch, RefScope, BranchName, Browser, Namespace, Repository}; + /// use std::convert::TryFrom; + /// # use std::error::Error; + /// + /// # fn main() -> Result<(), Box> { + /// let repo = Repository::new("./data/git-platinum")?; + /// let mut browser = Browser::new(&repo, Branch::local("master"))?; + /// + /// let branches = browser.list_branches(RefScope::All)?; + /// + /// // 'master' exists in the list of branches + /// assert!(branches.contains(&Branch::local("master"))); + /// + /// // Filter the branches by `Remote` 'origin'. + /// let mut branches = browser.list_branches(RefScope::Remote { + /// name: Some("origin".to_string()) + /// })?; + /// branches.sort(); + /// + /// assert_eq!(branches, vec![ + /// Branch::remote("HEAD", "origin"), + /// Branch::remote("dev", "origin"), + /// Branch::remote("master", "origin"), + /// ]); + /// + /// // Filter the branches by all `Remote`s. + /// let mut branches = browser.list_branches(RefScope::Remote { + /// name: None + /// })?; + /// branches.sort(); + /// + /// assert_eq!(branches, vec![ + /// Branch::remote("HEAD", "origin"), + /// Branch::remote("dev", "origin"), + /// Branch::remote("master", "origin"), + /// Branch::remote("orange/pineapple", "banana"), + /// Branch::remote("pineapple", "banana"), + /// ]); + /// + /// // We can also switch namespaces and list the branches in that namespace. + /// let golden = browser.switch_namespace(&Namespace::try_from("golden")?, Branch::local("master"))?; + /// + /// let mut branches = golden.list_branches(RefScope::Local)?; + /// branches.sort(); + /// + /// assert_eq!(branches, vec![ + /// Branch::local("banana"), + /// Branch::local("master"), + /// ]); + /// # + /// # Ok(()) + /// # } + /// ``` + pub fn list_branches(&self, filter: RefScope) -> Result, Error> { + self.repository.list_branches(filter) + } + + /// List the names of the _tags_ that are contained in the underlying + /// [`Repository`]. + /// + /// # Errors + /// + /// * [`error::Error::Git`] + /// + /// # Examples + /// + /// ``` + /// use radicle_surf::vcs::git::{Branch, RefScope, Browser, Namespace, Oid, Repository, Tag, TagName, Author, Time}; + /// use std::convert::TryFrom; + /// # use std::error::Error; + /// + /// # fn main() -> Result<(), Box> { + /// let repo = Repository::new("./data/git-platinum")?; + /// let mut browser = Browser::new(&repo, Branch::local("master"))?; + /// + /// let tags = browser.list_tags(RefScope::Local)?; + /// + /// assert_eq!( + /// tags, + /// vec![ + /// Tag::Light { + /// id: Oid::from_str("d3464e33d75c75c99bfb90fa2e9d16efc0b7d0e3")?, + /// name: TagName::new("v0.1.0"), + /// remote: None, + /// }, + /// Tag::Light { + /// id: Oid::from_str("2429f097664f9af0c5b7b389ab998b2199ffa977")?, + /// name: TagName::new("v0.2.0"), + /// remote: None, + /// }, + /// Tag::Light { + /// id: Oid::from_str("19bec071db6474af89c866a1bd0e4b1ff76e2b97")?, + /// name: TagName::new("v0.3.0"), + /// remote: None, + /// }, + /// Tag::Light { + /// id: Oid::from_str("91b69e00cd8e5a07e20942e9e4457d83ce7a3ff1")?, + /// name: TagName::new("v0.4.0"), + /// remote: None, + /// }, + /// Tag::Light { + /// id: Oid::from_str("80ded66281a4de2889cc07293a8f10947c6d57fe")?, + /// name: TagName::new("v0.5.0"), + /// remote: None, + /// }, + /// Tag::Annotated { + /// id: Oid::from_str("4d1f4af2703074d37cb877f4fdbe36322c8e541d")?, + /// target_id: Oid::from_str("d6880352fc7fda8f521ae9b7357668b17bb5bad5")?, + /// name: TagName::new("v0.6.0"), + /// remote: None, + /// tagger: Some(Author { + /// name: "Thomas Scholtes".to_string(), + /// email: "thomas@monadic.xyz".to_string(), + /// time: Time::new(1620740737, 120), + /// }), + /// message: Some("An annotated tag message for v0.6.0\n".to_string()) + /// }, + /// ] + /// ); + /// + /// // We can also switch namespaces and list the branches in that namespace. + /// let golden = browser.switch_namespace(&Namespace::try_from("golden")?, Branch::local("master"))?; + /// + /// let branches = golden.list_tags(RefScope::Local)?; + /// assert_eq!(branches, vec![ + /// Tag::Light { + /// id: Oid::from_str("d3464e33d75c75c99bfb90fa2e9d16efc0b7d0e3")?, + /// name: TagName::new("v0.1.0"), + /// remote: None, + /// }, + /// Tag::Light { + /// id: Oid::from_str("2429f097664f9af0c5b7b389ab998b2199ffa977")?, + /// name: TagName::new("v0.2.0"), + /// remote: None, + /// }, + /// ]); + /// let golden = golden.switch_namespace(&Namespace::try_from("golden")?, Branch::local("master"))?; + /// + /// let branches = golden.list_tags(RefScope::Remote { name: Some("kickflip".to_string()) })?; + /// assert_eq!(branches, vec![ + /// Tag::Light { + /// id: Oid::from_str("d3464e33d75c75c99bfb90fa2e9d16efc0b7d0e3")?, + /// name: TagName::new("v0.1.0"), + /// remote: Some("kickflip".to_string()), + /// }, + /// ]); + /// # + /// # Ok(()) + /// # } + /// ``` + pub fn list_tags(&self, scope: RefScope) -> Result, Error> { + self.repository.list_tags(scope) + } + + /// List the namespaces within a `Browser`, filtering out ones that do not + /// parse correctly. + /// + /// # Errors + /// + /// * [`Error::Git`] + /// + /// # Examples + /// + /// ``` + /// use radicle_surf::vcs::git::{Branch, BranchType, BranchName, Browser, Namespace, Repository}; + /// use std::convert::TryFrom; + /// # use std::error::Error; + /// + /// # fn main() -> Result<(), Box> { + /// let repo = Repository::new("./data/git-platinum")?; + /// let mut browser = Browser::new(&repo, Branch::local("master"))?; + /// + /// let mut namespaces = browser.list_namespaces()?; + /// namespaces.sort(); + /// + /// assert_eq!(namespaces, vec![ + /// Namespace::try_from("golden")?, + /// Namespace::try_from("golden/silver")?, + /// Namespace::try_from("me")?, + /// ]); + /// + /// + /// # + /// # Ok(()) + /// # } + /// ``` + pub fn list_namespaces(&self) -> Result, Error> { + self.repository.list_namespaces() + } + + /// Given a [`crate::file_system::Path`] to a file, return the last + /// [`Commit`] that touched that file or directory. + /// + /// # Errors + /// + /// * [`error::Error::Git`] + /// * [`error::Error::LastCommitException`] + /// + /// # Examples + /// + /// ``` + /// use radicle_surf::vcs::git::{Branch, Browser, Oid, Repository}; + /// use radicle_surf::file_system::{Label, Path, SystemType}; + /// use radicle_surf::file_system::unsound; + /// use std::str::FromStr; + /// # use std::error::Error; + /// + /// # fn main() -> Result<(), Box> { + /// let repo = Repository::new("./data/git-platinum")?; + /// let mut browser = Browser::new(&repo, Branch::local("master"))?; + /// + /// // Clamp the Browser to a particular commit + /// let commit = Oid::from_str("d6880352fc7fda8f521ae9b7357668b17bb5bad5")?; + /// browser.commit(commit)?; + /// + /// let head_commit = browser.get().first().clone(); + /// let expected_commit = Oid::from_str("d3464e33d75c75c99bfb90fa2e9d16efc0b7d0e3")?; + /// + /// let readme_last_commit = browser + /// .last_commit(Path::with_root(&[unsound::label::new("README.md")]))? + /// .map(|commit| commit.id); + /// + /// assert_eq!(readme_last_commit, Some(expected_commit)); + /// + /// let expected_commit = Oid::from_str("e24124b7538658220b5aaf3b6ef53758f0a106dc")?; + /// + /// let memory_last_commit = browser + /// .last_commit(Path::with_root(&[unsound::label::new("src"), unsound::label::new("memory.rs")]))? + /// .map(|commit| commit.id); + /// + /// assert_eq!(memory_last_commit, Some(expected_commit)); + /// # + /// # Ok(()) + /// # } + /// ``` + pub fn last_commit(&self, path: file_system::Path) -> Result, Error> { + let file_history = self.repository.file_history( + &path, + repo::CommitHistory::Last, + self.get().first().clone(), + )?; + Ok(file_history.first().cloned()) + } + + /// Get the commit history for a file _or_ directory. + /// + /// # Examples + /// + /// ``` + /// use nonempty::NonEmpty; + /// use radicle_surf::vcs::git::{Branch, Browser, Oid, Repository}; + /// use radicle_surf::file_system::{Label, Path, SystemType}; + /// use radicle_surf::file_system::unsound; + /// use std::str::FromStr; + /// # use std::error::Error; + /// + /// # fn main() -> Result<(), Box> { + /// let repo = Repository::new("./data/git-platinum")?; + /// let mut browser = Browser::new(&repo, Branch::local("master"))?; + /// + /// // Clamp the Browser to a particular commit + /// let commit = Oid::from_str("223aaf87d6ea62eef0014857640fd7c8dd0f80b5")?; + /// browser.commit(commit)?; + /// + /// let root_commits: Vec = browser + /// .file_history(unsound::path::new("~"))? + /// .into_iter() + /// .map(|commit| commit.id) + /// .collect(); + /// + /// assert_eq!(root_commits, + /// vec![ + /// Oid::from_str("223aaf87d6ea62eef0014857640fd7c8dd0f80b5")?, + /// Oid::from_str("80bacafba303bf0cdf6142921f430ff265f25095")?, + /// Oid::from_str("a57846bbc8ced6587bf8329fc4bce970eb7b757e")?, + /// Oid::from_str("3873745c8f6ffb45c990eb23b491d4b4b6182f95")?, + /// Oid::from_str("80ded66281a4de2889cc07293a8f10947c6d57fe")?, + /// Oid::from_str("91b69e00cd8e5a07e20942e9e4457d83ce7a3ff1")?, + /// Oid::from_str("1820cb07c1a890016ca5578aa652fd4d4c38967e")?, + /// Oid::from_str("1e0206da8571ca71c51c91154e2fee376e09b4e7")?, + /// Oid::from_str("e24124b7538658220b5aaf3b6ef53758f0a106dc")?, + /// Oid::from_str("19bec071db6474af89c866a1bd0e4b1ff76e2b97")?, + /// Oid::from_str("f3a089488f4cfd1a240a9c01b3fcc4c34a4e97b2")?, + /// Oid::from_str("2429f097664f9af0c5b7b389ab998b2199ffa977")?, + /// Oid::from_str("d3464e33d75c75c99bfb90fa2e9d16efc0b7d0e3")?, + /// ] + /// ); + /// + /// let eval_commits: Vec = browser + /// .file_history(unsound::path::new("~/src/Eval.hs"))? + /// .into_iter() + /// .map(|commit| commit.id) + /// .collect(); + /// + /// assert_eq!(eval_commits, + /// vec![ + /// Oid::from_str("3873745c8f6ffb45c990eb23b491d4b4b6182f95")?, + /// Oid::from_str("e24124b7538658220b5aaf3b6ef53758f0a106dc")?, + /// ] + /// ); + /// # + /// # Ok(()) + /// # } + /// ``` + pub fn file_history(&self, path: file_system::Path) -> Result, Error> { + self.repository + .file_history(&path, repo::CommitHistory::Full, self.get().first().clone()) + } + + /// Extract the signature for a commit + /// + /// # Arguments + /// + /// * `commit` - The commit to extract the signature for + /// * `field` - the name of the header field containing the signature block; + /// pass `None` to extract the default 'gpgsig' + /// + /// # Examples + /// + /// ``` + /// use radicle_surf::vcs::git::{Branch, Browser, Repository, Oid, error}; + /// # use std::error::Error; + /// + /// # fn main() -> Result<(), Box> { + /// let repo = Repository::new("./data/git-platinum")?; + /// let mut browser = Browser::new(&repo, Branch::local("master"))?; + /// + /// let commit_with_signature_oid = Oid::from_str( + /// "e24124b7538658220b5aaf3b6ef53758f0a106dc" + /// )?; + /// + /// browser.commit(commit_with_signature_oid)?; + /// let history = browser.get(); + /// let commit_with_signature = history.first(); + /// let signature = browser.extract_signature(commit_with_signature, None)?; + /// + /// // We have a signature + /// assert!(signature.is_some()); + /// + /// let commit_without_signature_oid = Oid::from_str( + /// "80bacafba303bf0cdf6142921f430ff265f25095" + /// )?; + /// + /// browser.commit(commit_without_signature_oid)?; + /// let history = browser.get(); + /// let commit_without_signature = history.first(); + /// let signature = browser.extract_signature(commit_without_signature, None)?; + /// + /// // There is no signature + /// assert!(signature.is_none()); + /// # + /// # Ok(()) + /// # } + /// ``` + pub fn extract_signature( + &self, + commit: &Commit, + field: Option<&str>, + ) -> Result, Error> { + self.repository.extract_signature(&commit.id, field) + } + + /// List the [`Branch`]es, which contain the provided [`Commit`]. + /// + /// # Errors + /// + /// * [`error::Error::Git`] + /// + /// # Examples + /// + /// ``` + /// use radicle_surf::vcs::git::{Browser, Repository, Branch, BranchName, Namespace, Oid}; + /// use std::convert::TryFrom; + /// # use std::error::Error; + /// + /// # fn main() -> Result<(), Box> { + /// let repo = Repository::new("./data/git-platinum")?; + /// let browser = Browser::new(&repo, Branch::local("master"))?; + /// + /// + /// let branches = browser.revision_branches(Oid::from_str("27acd68c7504755aa11023300890bb85bbd69d45")?)?; + /// assert_eq!( + /// branches, + /// vec![ + /// Branch::local("dev"), + /// Branch::remote("dev", "origin"), + /// ] + /// ); + /// + /// // TODO(finto): I worry that this test will fail as other branches get added + /// let mut branches = browser.revision_branches(Oid::from_str("1820cb07c1a890016ca5578aa652fd4d4c38967e")?)?; + /// branches.sort(); + /// assert_eq!( + /// branches, + /// vec![ + /// Branch::remote("HEAD", "origin"), + /// Branch::local("dev"), + /// Branch::remote("dev", "origin"), + /// Branch::local("master"), + /// Branch::remote("master", "origin"), + /// Branch::remote("orange/pineapple", "banana"), + /// Branch::remote("pineapple", "banana"), + /// ] + /// ); + /// + /// let golden_browser = browser.switch_namespace(&Namespace::try_from("golden")?, + /// Branch::local("master"))?; + /// + /// let branches = golden_browser.revision_branches(Oid::from_str("27acd68c7504755aa11023300890bb85bbd69d45")?)?; + /// assert_eq!( + /// branches, + /// vec![ + /// Branch::local("banana"), + /// Branch::remote("fakie/bigspin", "kickflip"), + /// Branch::remote("heelflip", "kickflip"), + /// ] + /// ); + /// # + /// # Ok(()) + /// # } + /// ``` + pub fn revision_branches(&self, rev: impl Into) -> Result, Error> { + let commit = self.repository.rev_to_commit(&rev.into())?; + self.repository.revision_branches(&commit.id()) + } + + /// Get the [`Stats`] of the underlying [`Repository`]. + /// + /// # Errors + /// + /// * [`error::Error::Git`] + /// + /// # Examples + /// + /// ``` + /// use radicle_surf::vcs::git::{Branch, Browser, Repository}; + /// # use std::error::Error; + /// + /// # fn main() -> Result<(), Box> { + /// let repo = Repository::new("./data/git-platinum")?; + /// let mut browser = Browser::new(&repo, Branch::local("master"))?; + /// + /// let stats = browser.get_stats()?; + /// + /// assert_eq!(stats.branches, 2); + /// + /// assert_eq!(stats.commits, 15); + /// + /// assert_eq!(stats.contributors, 4); + /// + /// # Ok(()) + /// # } + /// ``` + pub fn get_stats(&self) -> Result { + let branches = self.list_branches(RefScope::Local)?.len(); + let commits = self.history.len(); + + let contributors = self + .history + .iter() + .cloned() + .map(|commit| (commit.author.name, commit.author.email)) + .collect::>(); + + Ok(Stats { + branches, + commits, + contributors: contributors.len(), + }) + } + + /// Do a pre-order TreeWalk of the given commit. This turns a Tree + /// into a HashMap of Paths and a list of Files. We can then turn that + /// into a Directory. + fn get_tree( + repo: &git2::Repository, + commit: &Commit, + ) -> Result>, Error> + { + let mut file_paths_or_error: Result< + HashMap>, + Error, + > = Ok(HashMap::new()); + + let commit = repo.find_commit(commit.id)?; + let tree = commit.as_object().peel_to_tree()?; + + tree.walk( + git2::TreeWalkMode::PreOrder, + |s, entry| match Self::tree_entry_to_file_and_path(repo, s, entry) { + Ok((path, name, file)) => { + match file_paths_or_error.as_mut() { + Ok(files) => Self::update_file_map(path, name, file, files), + + // We don't need to update, we want to keep the error. + Err(_err) => {}, + } + git2::TreeWalkResult::Ok + }, + Err(err) => match err { + // We want to continue if the entry was not a Blob. + TreeWalkError::NotBlob => git2::TreeWalkResult::Ok, + + // We found a ObjectType::Commit (likely a submodule) and + // so we can skip it. + TreeWalkError::Commit => git2::TreeWalkResult::Ok, + + // But we want to keep the error and abort otherwise. + TreeWalkError::Git(err) => { + file_paths_or_error = Err(err); + git2::TreeWalkResult::Abort + }, + }, + }, + )?; + + file_paths_or_error + } + + /// Find the best common ancestor between two commits if it exists. + /// + /// See [`git2::Repository::merge_base`] for details. + pub fn merge_base(&self, one: Oid, two: Oid) -> Result, Error> { + match self.repository.repo_ref.merge_base(one, two) { + Ok(merge_base) => Ok(Some(merge_base)), + Err(err) => { + if err.code() == git2::ErrorCode::NotFound { + Ok(None) + } else { + Err(Error::Git(err)) + } + }, + } + } + + fn update_file_map( + path: file_system::Path, + name: file_system::Label, + file: directory::File, + files: &mut HashMap>, + ) { + files + .entry(path) + .and_modify(|entries| entries.push((name.clone(), file.clone()))) + .or_insert_with(|| NonEmpty::new((name, file))); + } + + fn tree_entry_to_file_and_path( + repo: &git2::Repository, + tree_path: &str, + entry: &git2::TreeEntry, + ) -> Result<(file_system::Path, file_system::Label, directory::File), TreeWalkError> { + // Account for the "root" of git being the empty string + let path = if tree_path.is_empty() { + Ok(file_system::Path::root()) + } else { + file_system::Path::try_from(tree_path) + }?; + + // We found a Commit object in the Tree, likely a submodule. + // We will skip this entry. + if let Some(git2::ObjectType::Commit) = entry.kind() { + return Err(TreeWalkError::Commit); + } + + let object = entry.to_object(repo)?; + let blob = object.as_blob().ok_or(TreeWalkError::NotBlob)?; + let name = str::from_utf8(entry.name_bytes())?; + + let name = file_system::Label::try_from(name).map_err(Error::FileSystem)?; + + Ok(( + path, + name, + directory::File { + contents: blob.content().to_owned(), + size: blob.size(), + }, + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(not(feature = "gh-actions"))] + #[test] + // An issue with submodules, see: https://github.com/radicle-dev/radicle-surf/issues/54 + fn test_submodule_failure() { + let repo = Repository::new("..").unwrap(); + let browser = Browser::new(&repo, Branch::local("main")).unwrap(); + + browser.get_directory().unwrap(); + } + + #[cfg(test)] + mod namespace { + use super::*; + use pretty_assertions::{assert_eq, assert_ne}; + + #[test] + fn switch_to_banana() -> Result<(), Error> { + let repo = Repository::new("./data/git-platinum")?; + let mut browser = Browser::new_with_namespace( + &repo, + &Namespace::try_from("golden")?, + Branch::local("master"), + )?; + let history = browser.history.clone(); + + browser.branch(Branch::local("banana"))?; + + assert_ne!(history, browser.history); + + Ok(()) + } + + #[test] + fn me_namespace() -> Result<(), Error> { + let repo = Repository::new("./data/git-platinum")?; + let browser = Browser::new(&repo, Branch::local("master"))?; + let history = browser.history.clone(); + + assert_eq!(browser.which_namespace(), Ok(None)); + + let browser = browser + .switch_namespace(&Namespace::try_from("me")?, Branch::local("feature/#1194"))?; + + assert_eq!( + browser.which_namespace(), + Ok(Some(Namespace::try_from("me")?)) + ); + assert_eq!(history, browser.history); + + let expected_branches: Vec = vec![Branch::local("feature/#1194")]; + let mut branches = browser.list_branches(RefScope::Local)?; + branches.sort(); + + assert_eq!(expected_branches, branches); + + let expected_branches: Vec = vec![Branch::remote("feature/#1194", "fein")]; + let mut branches = browser.list_branches(RefScope::Remote { + name: Some("fein".to_string()), + })?; + branches.sort(); + + assert_eq!(expected_branches, branches); + + Ok(()) + } + + #[test] + fn golden_namespace() -> Result<(), Error> { + let repo = Repository::new("./data/git-platinum")?; + let browser = Browser::new(&repo, Branch::local("master"))?; + let history = browser.history.clone(); + + assert_eq!(browser.which_namespace(), Ok(None)); + + let golden_browser = browser + .switch_namespace(&Namespace::try_from("golden")?, Branch::local("master"))?; + + assert_eq!( + golden_browser.which_namespace(), + Ok(Some(Namespace::try_from("golden")?)) + ); + assert_eq!(history, golden_browser.history); + + let expected_branches: Vec = + vec![Branch::local("banana"), Branch::local("master")]; + let mut branches = golden_browser.list_branches(RefScope::Local)?; + branches.sort(); + + assert_eq!(expected_branches, branches); + + let expected_branches: Vec = vec![ + Branch::remote("fakie/bigspin", "kickflip"), + Branch::remote("heelflip", "kickflip"), + Branch::remote("v0.1.0", "kickflip"), + ]; + let mut branches = golden_browser.list_branches(RefScope::Remote { + name: Some("kickflip".to_string()), + })?; + branches.sort(); + + assert_eq!(expected_branches, branches); + + Ok(()) + } + + #[test] + fn silver_namespace() -> Result<(), Error> { + let repo = Repository::new("./data/git-platinum")?; + let browser = Browser::new(&repo, Branch::local("master"))?; + let history = browser.history.clone(); + + assert_eq!(browser.which_namespace(), Ok(None)); + + let silver_browser = browser.switch_namespace( + &Namespace::try_from("golden/silver")?, + Branch::local("master"), + )?; + + assert_eq!( + silver_browser.which_namespace(), + Ok(Some(Namespace::try_from("golden/silver")?)) + ); + assert_ne!(history, silver_browser.history); + + let expected_branches: Vec = vec![Branch::local("master")]; + let mut branches = silver_browser.list_branches(RefScope::All)?; + branches.sort(); + + assert_eq!(expected_branches, branches); + + Ok(()) + } + } + + #[cfg(test)] + mod rev { + use super::{Branch, Browser, Error, Oid, Repository, TagName}; + + // **FIXME**: This seems to break occasionally on + // buildkite. For some reason the commit + // 3873745c8f6ffb45c990eb23b491d4b4b6182f95, which is on master + // (currently HEAD), is not found. It seems to load the history + // with d6880352fc7fda8f521ae9b7357668b17bb5bad5 as the HEAD. + // + // To temporarily fix this, we need to select "New Build" from the build kite + // build page that's failing. + // * Under "Message" put whatever you want. + // * Under "Branch" put in the branch you're working on. + // * Expand "Options" and select "clean checkout". + #[test] + fn _master() -> Result<(), Error> { + let repo = Repository::new("./data/git-platinum")?; + let mut browser = Browser::new(&repo, Branch::local("master"))?; + browser.rev(Branch::remote("master", "origin"))?; + + let commit1 = Oid::from_str("3873745c8f6ffb45c990eb23b491d4b4b6182f95")?; + assert!( + browser + .history + .find(|commit| if commit.id == commit1 { + Some(commit.clone()) + } else { + None + }) + .is_some(), + "commit_id={}, history =\n{:#?}", + commit1, + browser.history + ); + + let commit2 = Oid::from_str("d6880352fc7fda8f521ae9b7357668b17bb5bad5")?; + assert!( + browser + .history + .find(|commit| if commit.id == commit2 { + Some(commit.clone()) + } else { + None + }) + .is_some(), + "commit_id={}, history =\n{:#?}", + commit2, + browser.history + ); + + Ok(()) + } + + #[test] + fn commit() -> Result<(), Error> { + let repo = Repository::new("./data/git-platinum")?; + let mut browser = Browser::new(&repo, Branch::local("master"))?; + browser.rev(Oid::from_str("3873745c8f6ffb45c990eb23b491d4b4b6182f95")?)?; + + let commit1 = Oid::from_str("3873745c8f6ffb45c990eb23b491d4b4b6182f95")?; + assert!(browser + .history + .find(|commit| if commit.id == commit1 { + Some(commit.clone()) + } else { + None + }) + .is_some()); + + Ok(()) + } + + #[test] + fn commit_parents() -> Result<(), Error> { + let repo = Repository::new("./data/git-platinum")?; + let mut browser = Browser::new(&repo, Branch::local("master"))?; + browser.rev(Oid::from_str("3873745c8f6ffb45c990eb23b491d4b4b6182f95")?)?; + let commit = browser.history.first(); + + assert_eq!( + commit.parents, + vec![Oid::from_str("d6880352fc7fda8f521ae9b7357668b17bb5bad5")?] + ); + + Ok(()) + } + + #[test] + fn commit_short() -> Result<(), Error> { + let repo = Repository::new("./data/git-platinum")?; + let mut browser = Browser::new(&repo, Branch::local("master"))?; + browser.rev(browser.oid("3873745c8")?)?; + + let commit1 = Oid::from_str("3873745c8f6ffb45c990eb23b491d4b4b6182f95")?; + assert!(browser + .history + .find(|commit| if commit.id == commit1 { + Some(commit.clone()) + } else { + None + }) + .is_some()); + + Ok(()) + } + + #[test] + fn tag() -> Result<(), Error> { + let repo = Repository::new("./data/git-platinum")?; + let mut browser = Browser::new(&repo, Branch::local("master"))?; + browser.rev(TagName::new("v0.2.0"))?; + + let commit1 = Oid::from_str("2429f097664f9af0c5b7b389ab998b2199ffa977")?; + assert_eq!(browser.history.first().id, commit1); + + Ok(()) + } + } + + #[cfg(test)] + mod last_commit { + use crate::{ + file_system::{unsound, Path}, + vcs::git::{Branch, Browser, Oid, Repository}, + }; + + #[test] + fn readme_missing_and_memory() { + let repo = Repository::new("./data/git-platinum") + .expect("Could not retrieve ./data/git-platinum as git repository"); + let mut browser = + Browser::new(&repo, Branch::local("master")).expect("Could not initialise Browser"); + + // Set the browser history to the initial commit + let commit = Oid::from_str("d3464e33d75c75c99bfb90fa2e9d16efc0b7d0e3") + .expect("Failed to parse SHA"); + browser.commit(commit).unwrap(); + + let head_commit = browser.get().0.first().clone(); + + // memory.rs is commited later so it should not exist here. + let memory_last_commit = browser + .last_commit(Path::with_root(&[ + unsound::label::new("src"), + unsound::label::new("memory.rs"), + ])) + .expect("Failed to get last commit") + .map(|commit| commit.id); + + assert_eq!(memory_last_commit, None); + + // README.md exists in this commit. + let readme_last_commit = browser + .last_commit(Path::with_root(&[unsound::label::new("README.md")])) + .expect("Failed to get last commit") + .map(|commit| commit.id); + + assert_eq!(readme_last_commit, Some(head_commit.id)); + } + + #[test] + fn folder_svelte() { + let repo = Repository::new("./data/git-platinum") + .expect("Could not retrieve ./data/git-platinum as git repository"); + let mut browser = + Browser::new(&repo, Branch::local("master")).expect("Could not initialise Browser"); + + // Check that last commit is the actual last commit even if head commit differs. + let commit = Oid::from_str("19bec071db6474af89c866a1bd0e4b1ff76e2b97") + .expect("Could not parse SHA"); + browser.commit(commit).unwrap(); + + let expected_commit_id = + Oid::from_str("f3a089488f4cfd1a240a9c01b3fcc4c34a4e97b2").unwrap(); + + let folder_svelte = browser + .last_commit(unsound::path::new("~/examples/Folder.svelte")) + .expect("Failed to get last commit") + .map(|commit| commit.id); + + assert_eq!(folder_svelte, Some(expected_commit_id)); + } + + #[test] + fn nest_directory() { + let repo = Repository::new("./data/git-platinum") + .expect("Could not retrieve ./data/git-platinum as git repository"); + let mut browser = + Browser::new(&repo, Branch::local("master")).expect("Could not initialise Browser"); + + // Check that last commit is the actual last commit even if head commit differs. + let commit = Oid::from_str("19bec071db6474af89c866a1bd0e4b1ff76e2b97") + .expect("Failed to parse SHA"); + browser.commit(commit).unwrap(); + + let expected_commit_id = + Oid::from_str("2429f097664f9af0c5b7b389ab998b2199ffa977").unwrap(); + + let nested_directory_tree_commit_id = browser + .last_commit(unsound::path::new( + "~/this/is/a/really/deeply/nested/directory/tree", + )) + .expect("Failed to get last commit") + .map(|commit| commit.id); + + assert_eq!(nested_directory_tree_commit_id, Some(expected_commit_id)); + } + + #[test] + #[cfg(not(windows))] + fn can_get_last_commit_for_special_filenames() { + let repo = Repository::new("./data/git-platinum") + .expect("Could not retrieve ./data/git-platinum as git repository"); + let mut browser = + Browser::new(&repo, Branch::local("master")).expect("Could not initialise Browser"); + + // Check that last commit is the actual last commit even if head commit differs. + let commit = Oid::from_str("a0dd9122d33dff2a35f564d564db127152c88e02") + .expect("Failed to parse SHA"); + browser.commit(commit).unwrap(); + + let expected_commit_id = + Oid::from_str("a0dd9122d33dff2a35f564d564db127152c88e02").unwrap(); + + let backslash_commit_id = browser + .last_commit(unsound::path::new("~/special/faux\\path")) + .expect("Failed to get last commit") + .map(|commit| commit.id); + assert_eq!(backslash_commit_id, Some(expected_commit_id)); + + let ogre_commit_id = browser + .last_commit(unsound::path::new("~/special/πŸ‘ΉπŸ‘ΉπŸ‘Ή")) + .expect("Failed to get last commit") + .map(|commit| commit.id); + assert_eq!(ogre_commit_id, Some(expected_commit_id)); + } + + #[test] + fn root() { + let repo = Repository::new("./data/git-platinum") + .expect("Could not retrieve ./data/git-platinum as git repository"); + let browser = + Browser::new(&repo, Branch::local("master")).expect("Could not initialise Browser"); + + let root_last_commit_id = browser + .last_commit(Path::root()) + .expect("Failed to get last commit") + .map(|commit| commit.id); + + assert_eq!(root_last_commit_id, Some(browser.get().first().id)); + } + } + + #[cfg(test)] + mod diff { + use crate::{diff::*, vcs::git::*}; + + #[test] + fn test_initial_diff() -> Result<(), Error> { + use file_system::*; + use pretty_assertions::assert_eq; + + let oid = Oid::from_str("d3464e33d75c75c99bfb90fa2e9d16efc0b7d0e3")?; + let repo = Repository::new("./data/git-platinum")?; + let commit = repo.0.find_commit(oid).unwrap(); + + assert!(commit.parents().count() == 0); + assert!(commit.parent(0).is_err()); + + let bro = Browser::new(&repo, Branch::local("master"))?; + let diff = bro.initial_diff(oid)?; + + let expected_diff = Diff { + created: vec![CreateFile { + path: Path::with_root(&[unsound::label::new("README.md")]), + diff: FileDiff::Plain { + hunks: vec![Hunk { + header: Line(b"@@ -0,0 +1 @@\n".to_vec()), + lines: vec![ + LineDiff::addition(b"This repository is a data source for the Upstream front-end tests.\n".to_vec(), 1), + ] + }].into() + }, + }], + deleted: vec![], + moved: vec![], + copied: vec![], + modified: vec![], + }; + assert_eq!(expected_diff, diff); + + Ok(()) + } + + #[test] + fn test_diff() -> Result<(), Error> { + use file_system::*; + use pretty_assertions::assert_eq; + + let repo = Repository::new("./data/git-platinum")?; + let commit = repo + .0 + .find_commit(Oid::from_str("80bacafba303bf0cdf6142921f430ff265f25095")?) + .unwrap(); + let parent = commit.parent(0)?; + + let bro = Browser::new(&repo, Branch::local("master"))?; + + let diff = bro.diff(parent.id(), commit.id())?; + + let expected_diff = Diff { + created: vec![], + deleted: vec![], + moved: vec![], + copied: vec![], + modified: vec![ModifiedFile { + path: Path::with_root(&[unsound::label::new("README.md")]), + diff: FileDiff::Plain { + hunks: vec![Hunk { + header: Line(b"@@ -1 +1,2 @@\n".to_vec()), + lines: vec![ + LineDiff::deletion(b"This repository is a data source for the Upstream front-end tests.\n".to_vec(), 1), + LineDiff::addition(b"This repository is a data source for the Upstream front-end tests and the\n".to_vec(), 1), + LineDiff::addition(b"[`radicle-surf`](https://github.com/radicle-dev/git-platinum) unit tests.\n".to_vec(), 2), + ] + }].into() + }, + eof: None, + }] + }; + assert_eq!(expected_diff, diff); + + Ok(()) + } + + #[cfg(feature = "serialize")] + #[test] + fn test_diff_serde() -> Result<(), Error> { + use file_system::*; + + let diff = Diff { + created: vec![CreateFile{path: unsound::path::new("LICENSE"), diff: FileDiff::Plain { hunks: Hunks::default() }}], + deleted: vec![], + moved: vec![ + MoveFile { + old_path: unsound::path::new("CONTRIBUTING"), + new_path: unsound::path::new("CONTRIBUTING.md") + } + ], + copied: vec![], + modified: vec![ModifiedFile { + path: Path::with_root(&[unsound::label::new("README.md")]), + diff: FileDiff::Plain { + hunks: vec![Hunk { + header: Line(b"@@ -1 +1,2 @@\n".to_vec()), + lines: vec![ + LineDiff::deletion(b"This repository is a data source for the Upstream front-end tests.\n".to_vec(), 1), + LineDiff::addition(b"This repository is a data source for the Upstream front-end tests and the\n".to_vec(), 1), + LineDiff::addition(b"[`radicle-surf`](https://github.com/radicle-dev/git-platinum) unit tests.\n".to_vec(), 2), + LineDiff::context(b"\n".to_vec(), 3, 4), + ] + }].into() + }, + eof: None, + }] + }; + + let eof: Option = None; + let json = serde_json::json!({ + "created": [{"path": "LICENSE", "diff": { + "type": "plain", + "hunks": [] + }, + }], + "deleted": [], + "moved": [{ "oldPath": "CONTRIBUTING", "newPath": "CONTRIBUTING.md" }], + "copied": [], + "modified": [{ + "path": "README.md", + "diff": { + "type": "plain", + "hunks": [{ + "header": "@@ -1 +1,2 @@\n", + "lines": [ + { "lineNum": 1, + "line": "This repository is a data source for the Upstream front-end tests.\n", + "type": "deletion" + }, + { "lineNum": 1, + "line": "This repository is a data source for the Upstream front-end tests and the\n", + "type": "addition" + }, + { "lineNum": 2, + "line": "[`radicle-surf`](https://github.com/radicle-dev/git-platinum) unit tests.\n", + "type": "addition" + }, + { "lineNumOld": 3, "lineNumNew": 4, + "line": "\n", + "type": "context" + } + ] + }] + }, + "eof" : eof, + }] + }); + assert_eq!(serde_json::to_value(&diff).unwrap(), json); + + Ok(()) + } + } + + #[cfg(test)] + mod threading { + use crate::vcs::git::*; + use pretty_assertions::assert_eq; + use std::sync::{Mutex, MutexGuard}; + + #[test] + fn basic_test() -> Result<(), Error> { + let shared_repo = Mutex::new(Repository::new("./data/git-platinum")?); + let locked_repo: MutexGuard = shared_repo.lock().unwrap(); + let bro = Browser::new(&*locked_repo, Branch::local("master"))?; + let mut branches = bro.list_branches(RefScope::All)?; + branches.sort(); + + assert_eq!( + branches, + vec![ + Branch::remote("HEAD", "origin"), + Branch::remote("dev", "origin"), + Branch::local("dev"), + Branch::remote("master", "origin"), + Branch::local("master"), + Branch::remote("orange/pineapple", "banana"), + Branch::remote("pineapple", "banana"), + ] + ); + + Ok(()) + } + } +} diff --git a/radicle-surf/src/vcs/git/branch.rs b/radicle-surf/src/vcs/git/branch.rs new file mode 100644 index 0000000..d6dcabc --- /dev/null +++ b/radicle-surf/src/vcs/git/branch.rs @@ -0,0 +1,193 @@ +// This file is part of radicle-surf +// +// +// Copyright (C) 2019-2020 The Radicle Team +// +// 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 . + +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//*` if the name is + /// provided, otherwise `refs/remotes/**/*`. + Remote { + /// Name of the remote. + name: Option, + }, +} + +impl From for git2::BranchType { + fn from(other: BranchType) -> Self { + match other { + BranchType::Local => git2::BranchType::Local, + BranchType::Remote { .. } => git2::BranchType::Remote, + } + } +} + +impl From 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 { + 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 { + Some(self.cmp(other)) + } +} + +impl Ord for Branch { + fn cmp(&self, other: &Branch) -> Ordering { + self.name.cmp(&other.name) + } +} + +impl From 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> for Branch { + type Error = Error; + + fn try_from(reference: git2::Reference) -> Result { + 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, + }) + } + } +} diff --git a/radicle-surf/src/vcs/git/commit.rs b/radicle-surf/src/vcs/git/commit.rs new file mode 100644 index 0000000..15e53d5 --- /dev/null +++ b/radicle-surf/src/vcs/git/commit.rs @@ -0,0 +1,102 @@ +// This file is part of radicle-surf +// +// +// Copyright (C) 2019-2020 The Radicle Team +// +// 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 . + +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> for Author { + type Error = str::Utf8Error; + + fn try_from(signature: git2::Signature) -> Result { + 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, +} + +impl<'repo> TryFrom> for Commit { + type Error = Error; + + fn try_from(commit: git2::Commit) -> Result { + 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, + }) + } +} diff --git a/radicle-surf/src/vcs/git/error.rs b/radicle-surf/src/vcs/git/error.rs new file mode 100644 index 0000000..5e4c23d --- /dev/null +++ b/radicle-surf/src/vcs/git/error.rs @@ -0,0 +1,126 @@ +// This file is part of radicle-surf +// +// +// Copyright (C) 2019-2020 The Radicle Team +// +// 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 . + +//! 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 for TreeWalkError { + fn from(err: git2::Error) -> Self { + TreeWalkError::Git(err.into()) + } +} + +impl From for TreeWalkError { + fn from(err: file_system::Error) -> Self { + err.into() + } +} + +impl From for TreeWalkError { + fn from(err: str::Utf8Error) -> Self { + err.into() + } +} diff --git a/radicle-surf/src/vcs/git/ext.rs b/radicle-surf/src/vcs/git/ext.rs new file mode 100644 index 0000000..6994b63 --- /dev/null +++ b/radicle-surf/src/vcs/git/ext.rs @@ -0,0 +1,111 @@ +// This file is part of radicle-surf +// +// +// Copyright (C) 2019-2020 The Radicle Team +// +// 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 . + +/// 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 { + 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()) + ); + } +} diff --git a/radicle-surf/src/vcs/git/namespace.rs b/radicle-surf/src/vcs/git/namespace.rs new file mode 100644 index 0000000..1435d59 --- /dev/null +++ b/radicle-surf/src/vcs/git/namespace.rs @@ -0,0 +1,82 @@ +// This file is part of radicle-surf +// +// +// Copyright (C) 2019-2020 The Radicle Team +// +// 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 . + +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, +} + +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 { + 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 { + str::from_utf8(namespace) + .map_err(Error::from) + .and_then(Namespace::try_from) + } +} + +impl TryFrom> for Namespace { + type Error = Error; + + fn try_from(reference: git2::Reference) -> Result { + 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::>(); + + NonEmpty::from_vec(values) + .map(|values| Self { values }) + .ok_or(Error::EmptyNamespace) + } +} diff --git a/radicle-surf/src/vcs/git/reference.rs b/radicle-surf/src/vcs/git/reference.rs new file mode 100644 index 0000000..314b0e6 --- /dev/null +++ b/radicle-surf/src/vcs/git/reference.rs @@ -0,0 +1,302 @@ +// This file is part of radicle-surf +// +// +// Copyright (C) 2019-2020 The Radicle Team +// +// 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 . + +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 From for Rev +where + R: Into, +{ + fn from(other: R) -> Self { + Self::Ref(other.into()) + } +} + +impl From 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, + }, +} + +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::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>; + + 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 { + bytes::complete::tag(HEADS)(s).map(|(name, _)| Ref::LocalBranch { + name: BranchName::new(name), + }) + } + + pub fn remote(s: &str) -> Result { + 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 { + bytes::complete::tag(TAGS)(s).map(|(name, _)| Ref::Tag { + name: TagName::new(name), + }) + } + + pub fn namespace(s: &str) -> Result { + 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>> { + 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 { + 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(()) + } +} diff --git a/radicle-surf/src/vcs/git/reference/glob.rs b/radicle-surf/src/vcs/git/reference/glob.rs new file mode 100644 index 0000000..9b85508 --- /dev/null +++ b/radicle-surf/src/vcs/git/reference/glob.rs @@ -0,0 +1,183 @@ +// This file is part of radicle-surf +// +// +// Copyright (C) 2019-2020 The Radicle Team +// +// 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 . + +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, + }, + /// 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, + }, + /// refs/namespaces/** + Namespace, +} + +/// Iterator chaining multiple [`git2::References`] +#[must_use = "iterators are lazy and do nothing unless consumed"] +pub struct References<'a> { + inner: Vec>, +} + +impl<'a> References<'a> { + pub fn iter(self) -> impl Iterator, 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, 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, + repo: &RepositoryRef<'a>, + ) -> Result, 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/**"), + } + } +} diff --git a/radicle-surf/src/vcs/git/repo.rs b/radicle-surf/src/vcs/git/repo.rs new file mode 100644 index 0000000..f3e38f5 --- /dev/null +++ b/radicle-surf/src/vcs/git/repo.rs @@ -0,0 +1,446 @@ +// This file is part of radicle-surf +// +// +// Copyright (C) 2019-2020 The Radicle Team +// +// 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 . + +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; + +/// 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, 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, 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, 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, Error> { + let namespaces: Result, 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(&self, reference: R, check: P) -> Result + where + R: Into, + P: FnOnce(&git2::Reference) -> Option, + { + 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 { + 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 { + 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 { + Ok(self.repo_ref.revparse_single(oid)?.id()) + } + + pub(super) fn rev_to_commit(&self, rev: &Rev) -> Result { + 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, Error> { + let commit = self.repo_ref.find_commit(oid)?; + Ok(commit) + } + + /// Build a [`History`] using the `head` reference. + pub(super) fn head(&self) -> Result { + 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 { + 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 { + 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, 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, 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 { + 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, 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, 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, + to: Oid, + ) -> Result { + 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 for RepositoryRef<'a> { + type HistoryId = Rev; + type ArtefactId = Oid; + + fn get_history(&self, history_id: Self::HistoryId) -> Result { + 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, 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) -> Result { + 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 for Repository { + type RepoId = String; + + fn get_repo(repo_id: Self::RepoId) -> Result { + git2::Repository::open(&repo_id) + .map(Repository) + .map_err(Error::from) + } +} + +impl From 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") + } +} diff --git a/radicle-surf/src/vcs/git/stats.rs b/radicle-surf/src/vcs/git/stats.rs new file mode 100644 index 0000000..25f4e71 --- /dev/null +++ b/radicle-surf/src/vcs/git/stats.rs @@ -0,0 +1,36 @@ +// This file is part of radicle-surf +// +// +// Copyright (C) 2019-2020 The Radicle Team +// +// 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 . + +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, +} diff --git a/radicle-surf/src/vcs/git/tag.rs b/radicle-surf/src/vcs/git/tag.rs new file mode 100644 index 0000000..755dc64 --- /dev/null +++ b/radicle-surf/src/vcs/git/tag.rs @@ -0,0 +1,176 @@ +// This file is part of radicle-surf +// +// +// Copyright (C) 2019-2020 The Radicle Team +// +// 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 . + +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 { + 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 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, + }, + /// 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, + /// The message with this `Tag`, if the `Tag` was annotated. + message: Option, + /// If the tag is provided this holds the remote’s name. + remote: Option, + }, +} + +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> for Tag { + type Error = str::Utf8Error; + + fn try_from(tag: git2::Tag) -> Result { + 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> for Tag { + type Error = Error; + + fn try_from(reference: git2::Reference) -> Result { + 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()) + } + }, + } + } +}