refactor: Add GitTrait to allow for easier testing

pull/1/head
sgoudham 2 years ago
parent 7be0788dc7
commit f830f914c5
Signed by: hammy
GPG Key ID: 44E818FD5457EEA4

@ -21,7 +21,8 @@ path = "src/lib.rs"
clap = { version = '3.1.18', features = ["cargo"] }
url = { version = '2.2.2' }
webbrowser = { version = '0.7.1' }
mockall = { version = '0.11.1' }
unicode-segmentation = { version = "1.9.0" }
[dev-dependencies]
test-case = { version = '2.1.0' }
mockall = { version = '0.11.1' }

@ -2,6 +2,7 @@ use std::panic::set_hook;
use clap::{command, crate_authors, crate_description, crate_version, Arg, Command, ErrorKind};
use git_view::GitView;
use git_view::Git;
macro_rules! clap_panic {
($e:expr) => {
@ -69,7 +70,7 @@ fn main() {
matches.is_present("print"),
);
if let Err(app_error) = git_view.view_repository() {
if let Err(app_error) = git_view.view_repository(Git) {
clap_panic!(app_error.error_str);
}
}

@ -1,18 +1,24 @@
use core::fmt;
use mockall::automock;
use std::process::{Command, Output};
use crate::error::AppError;
#[cfg(test)]
use mockall::automock;
pub(crate) enum Git<'a> {
pub(crate) enum GitCommand<'a> {
IsValidRepository,
LocalBranch,
DefaultRemote,
TrackedRemote(&'a str),
UpstreamBranch(&'a str),
IsValidRemote(&'a str),
CurrentTag,
CurrentCommit,
}
#[derive(Default)]
pub struct Git;
#[derive(Debug)]
pub(crate) enum Domain {
GitHub,
@ -26,72 +32,114 @@ pub(crate) struct Url {
pub(crate) path: String,
}
pub(crate) enum GitOutput {
pub enum GitOutput {
Ok(String),
Err(String),
}
#[automock]
pub(crate) trait GitCommand {
fn execute(&self) -> Result<GitOutput, AppError>;
#[cfg_attr(test, automock)]
pub trait GitTrait {
fn is_valid_repository(&self) -> Result<GitOutput, AppError>;
fn get_local_branch(&self) -> Result<GitOutput, AppError>;
fn get_default_remote(&self) -> Result<GitOutput, AppError>;
fn get_tracked_remote(&self, tracked: &str) -> Result<GitOutput, AppError>;
fn get_upstream_branch(&self, branch: &str) -> Result<GitOutput, AppError>;
fn is_valid_remote(&self, remote: &str) -> Result<GitOutput, AppError>;
fn get_current_tag(&self) -> Result<GitOutput, AppError>;
fn get_current_commit(&self) -> Result<GitOutput, AppError>;
}
impl<'a> Git<'a> {
fn command(&self) -> Result<Output, std::io::Error> {
match *self {
Git::IsValidRepository => Command::new("git")
.arg("rev-parse")
.arg("--is-inside-work-tree")
.output(),
Git::LocalBranch => Command::new("git")
.arg("symbolic-ref")
.arg("-q")
.arg("--short")
.arg("HEAD")
.output(),
Git::DefaultRemote => Command::new("git")
.arg("config")
.arg("open.default.remote")
.output(),
Git::TrackedRemote(branch) => Command::new("git")
.arg("config")
.arg(format!("branch.{}.remote", branch))
.output(),
Git::UpstreamBranch(branch) => Command::new("git")
.arg("config")
.arg(format!("branch.{}.merge", branch))
.output(),
Git::IsValidRemote(remote) => Command::new("git")
.arg("ls-remote")
.arg("--get-url")
.arg(remote)
.output(),
}
impl GitTrait for Git {
fn is_valid_repository(&self) -> Result<GitOutput, AppError> {
execute(command(GitCommand::IsValidRepository)?)
}
fn trim(&self, bytes: &[u8]) -> Result<String, AppError> {
let mut utf_8_string = String::from(std::str::from_utf8(bytes)?.trim());
fn get_local_branch(&self) -> Result<GitOutput, AppError> {
execute(command(GitCommand::LocalBranch)?)
}
if utf_8_string.ends_with('\n') {
utf_8_string.pop();
if utf_8_string.ends_with('\r') {
utf_8_string.pop();
}
}
fn get_default_remote(&self) -> Result<GitOutput, AppError> {
execute(command(GitCommand::DefaultRemote)?)
}
fn get_tracked_remote(&self, tracked: &str) -> Result<GitOutput, AppError> {
execute(command(GitCommand::TrackedRemote(tracked))?)
}
fn get_upstream_branch(&self, branch: &str) -> Result<GitOutput, AppError> {
execute(command(GitCommand::UpstreamBranch(branch))?)
}
fn is_valid_remote(&self, remote: &str) -> Result<GitOutput, AppError> {
execute(command(GitCommand::IsValidRemote(remote))?)
}
fn get_current_tag(&self) -> Result<GitOutput, AppError> {
execute(command(GitCommand::CurrentTag)?)
}
Ok(utf_8_string)
fn get_current_commit(&self) -> Result<GitOutput, AppError> {
execute(command(GitCommand::CurrentCommit)?)
}
}
impl<'a> GitCommand for Git<'a> {
fn execute(&self) -> Result<GitOutput, AppError> {
let command = self.command()?;
if command.status.success() {
Ok(GitOutput::Ok(self.trim(&command.stdout)?))
} else {
Ok(GitOutput::Err(self.trim(&command.stderr)?))
fn command(git_command: GitCommand) -> Result<Output, std::io::Error> {
match git_command {
GitCommand::IsValidRepository => Command::new("git")
.arg("rev-parse")
.arg("--is-inside-work-tree")
.output(),
GitCommand::LocalBranch => Command::new("git")
.arg("symbolic-ref")
.arg("-q")
.arg("--short")
.arg("HEAD")
.output(),
GitCommand::DefaultRemote => Command::new("git")
.arg("config")
.arg("open.default.remote")
.output(),
GitCommand::TrackedRemote(branch) => Command::new("git")
.arg("config")
.arg(format!("branch.{}.remote", branch))
.output(),
GitCommand::UpstreamBranch(branch) => Command::new("git")
.arg("config")
.arg(format!("branch.{}.merge", branch))
.output(),
GitCommand::IsValidRemote(remote) => Command::new("git")
.arg("ls-remote")
.arg("--get-url")
.arg(remote)
.output(),
GitCommand::CurrentTag => Command::new("git")
.arg("describe")
.arg("--tags")
.arg("--exact-match")
.output(),
GitCommand::CurrentCommit => Command::new("git").arg("rev-parse").arg("HEAD").output(),
}
}
fn execute(output: Output) -> Result<GitOutput, AppError> {
if output.status.success() {
Ok(GitOutput::Ok(trim(&output.stdout)?))
} else {
Ok(GitOutput::Err(trim(&output.stderr)?))
}
}
fn trim(bytes: &[u8]) -> Result<String, AppError> {
let mut utf_8_string = String::from(std::str::from_utf8(bytes)?.trim());
if utf_8_string.ends_with('\n') {
utf_8_string.pop();
if utf_8_string.ends_with('\r') {
utf_8_string.pop();
}
}
Ok(utf_8_string)
}
impl Url {

@ -4,7 +4,14 @@ mod git;
use std::borrow::Cow;
use error::{AppError, ErrorType};
use git::{Domain, Git, GitCommand, GitOutput, Url};
use git::{Domain, GitOutput, GitTrait, Url};
pub use git::Git;
enum Local<'a> {
Branch(Cow<'a, str>),
NotBranch,
}
#[derive(Debug)]
pub struct GitView<'a> {
@ -32,26 +39,18 @@ impl<'a> GitView<'a> {
}
}
pub fn view_repository(&mut self) -> Result<(), AppError> {
pub fn view_repository(&mut self, git: impl GitTrait) -> Result<(), AppError> {
// Exit out if we're not inside a git repository
self.is_valid_repository(&Git::IsValidRepository)?;
// Retrieve the current branch
let branch = self.populate_branch(&Git::LocalBranch)?;
self.is_valid_repository(&git)?;
// Retrieve the current local ref (branch or not branch)
let local = self.get_local_ref(&git)?;
// Retrieve the remote
let remote = self.populate_remote(&Git::DefaultRemote, &Git::TrackedRemote(&branch))?;
// TODO: Figure out how to default to 'master' or 'main' if branch doesn't exist on remote
//
// Current theory on how to get it working:
// 1. Run "git checkout" to check if there's a remote branch for your current local one (there should be no output if remote branch doesn't exist)
// 2. Then run "git rev-parse --abbrev-ref <remote>/HEAD" and split on the first '/' to get the current default branch
// - Although, I think that this command isn't foolproof, it might be the best option though without trying to use some command line parsers
let remote = self.populate_remote(&local, &git)?;
// Retrieve the remote reference
let remote_ref = self.get_remote_reference(&branch, &Git::UpstreamBranch(&branch))?;
let remote_ref = self.get_remote_reference(&local, &git)?;
// Retrieve the full git_url
// e.g https://github.com/sgoudham/git-view.git
let git_url = self.get_git_url(&remote, &Git::IsValidRemote(&remote))?;
let git_url = self.get_git_url(&remote, &git)?;
// Extract protocol, domain and urlpath
let url = self.parse_git_url(&git_url)?;
// Generate final url to open in the web browser
@ -67,8 +66,8 @@ impl<'a> GitView<'a> {
Ok(())
}
fn is_valid_repository(&self, command: &impl GitCommand) -> Result<(), AppError> {
match command.execute()? {
fn is_valid_repository(&self, git: &impl GitTrait) -> Result<(), AppError> {
match git.is_valid_repository()? {
GitOutput::Ok(_) => Ok(()),
GitOutput::Err(_) => Err(AppError::new(
ErrorType::MissingGitRepository,
@ -77,16 +76,14 @@ impl<'a> GitView<'a> {
}
}
fn populate_branch(&self, command: &impl GitCommand) -> Result<Cow<'_, str>, AppError> {
fn get_local_ref(&self, git: &impl GitTrait) -> Result<Local, AppError> {
if self.branch.is_none() {
match command.execute()? {
GitOutput::Ok(output) => Ok(Cow::Owned(output)),
// Don't error on this, instead make a new enum Branch/NotBranch and match on it
// later
GitOutput::Err(err) => Err(AppError::new(ErrorType::CommandFailed, err)),
match git.get_local_branch()? {
GitOutput::Ok(output) => Ok(Local::Branch(Cow::Owned(output))),
GitOutput::Err(_) => Ok(Local::NotBranch),
}
} else {
Ok(Cow::Borrowed(self.branch.as_ref().unwrap()))
Ok(Local::Branch(Cow::Borrowed(self.branch.as_ref().unwrap())))
}
}
@ -94,20 +91,25 @@ impl<'a> GitView<'a> {
/// User Given Remote -> Default Remote in Config -> Tracked Remote -> 'origin'
fn populate_remote(
&self,
default_remote: &impl GitCommand,
tracked_remote: &impl GitCommand,
local: &Local,
git: &impl GitTrait,
) -> Result<Cow<'_, str>, AppError> {
// Priority goes to user given remote
if self.remote.is_none() {
// Priority then goes to the default remote
match default_remote.execute()? {
GitOutput::Ok(def) => Ok(Cow::Owned(def)),
// Priority then goes to the tracked remote
GitOutput::Err(_) => match tracked_remote.execute()? {
GitOutput::Ok(tracked) => Ok(Cow::Owned(tracked)),
// Default to the 'origin' remote
GitOutput::Err(_) => Ok(Cow::Owned("origin".into())),
},
match local {
Local::Branch(branch) => {
// Priority then goes to the default remote
match git.get_default_remote()? {
GitOutput::Ok(def) => Ok(Cow::Owned(def)),
// Priority then goes to the tracked remote
GitOutput::Err(_) => match git.get_tracked_remote(branch)? {
GitOutput::Ok(tracked) => Ok(Cow::Owned(tracked)),
// Default to the 'origin' remote
GitOutput::Err(_) => Ok(Cow::Owned("origin".into())),
},
}
}
Local::NotBranch => Ok(Cow::Owned("origin".into())),
}
} else {
Ok(Cow::Borrowed(self.remote.as_ref().unwrap()))
@ -116,28 +118,49 @@ impl<'a> GitView<'a> {
fn get_remote_reference(
&self,
branch: &'a str,
command: &impl GitCommand,
local: &'a Local,
git: &impl GitTrait,
) -> Result<Cow<'a, str>, AppError> {
match command.execute()? {
GitOutput::Ok(output) => Ok(Cow::Owned(
output.trim_start_matches("refs/heads/").to_string(),
)),
// So as explained above, branch might not exist either
// Instead of returning branch this early, match on the aforementioned enum above
//
// Branch:
// Just do what you're doing already and `Cow::Borrowed(branch)`
//
// NotBranch:
// Check to see if the user is on a tag or a specific commit hash. If all
// else fails THEN error out
GitOutput::Err(_) => Ok(Cow::Borrowed(branch)),
match local {
Local::Branch(branch) => {
match git.get_upstream_branch(branch)? {
GitOutput::Ok(output) => Ok(Cow::Owned(
output.trim_start_matches("refs/heads/").to_string(),
)),
// If retrieving the upstream_branch fails, that means that there is no valid
// upstream branch (surprise surprise)
//
// When there's no valid remote branch, we should default to the repository's
// default branch, for the vast majority of cases, this will be either "main"
// or "master" but it could be different for whatever INSANE person has changed
// their default to differ from those two terms.
//
// Thankfully, we have a command 'git rev-parse --abbrev-ref <remote>/HEAD'
// to let us retrieve the default branch (we also need to split on the first /
// encountered and take the second split part)
//
// However, it's a bit dodgy so we can't guarantee it will work everytime. If
// the command 'git rev-parse --abbrev-ref <remote>/HEAD' fails, we should just
// default to the local branch and the user will just have to suck it up and
// deal with the 404 error that they will probably get.
GitOutput::Err(_) => Ok(Cow::Borrowed(branch)),
}
}
// Priority is given to the current tag
Local::NotBranch => match git.get_current_tag()? {
GitOutput::Ok(tag) => Ok(Cow::Owned(tag)),
// Priority is then given the current commit
GitOutput::Err(_) => match git.get_current_commit()? {
GitOutput::Ok(commit_hash) => Ok(Cow::Owned(commit_hash)),
// Error out if even the current commit could not be found
GitOutput::Err(err) => Err(AppError::new(ErrorType::CommandFailed, err)),
},
},
}
}
fn get_git_url(&self, remote: &str, command: &impl GitCommand) -> Result<String, AppError> {
match command.execute()? {
fn get_git_url(&self, remote: &str, git: &impl GitTrait) -> Result<String, AppError> {
match git.is_valid_remote(remote)? {
GitOutput::Ok(output) => {
if output != remote {
Ok(output)
@ -200,6 +223,12 @@ impl<'a> GitView<'a> {
}
fn generate_final_url(&self, remote_ref: &str, url: &Url) -> String {
// if self.commit.unwrap() == "latest" {
// ()
// } else {
// ()
// }
let branch_ref = match &url.domain {
Domain::GitHub => {
if self.is_issue {
@ -213,12 +242,6 @@ impl<'a> GitView<'a> {
let mut open_url = format!("{}://{}/{}", url.protocol, url.domain, url.path);
// if self.commit.unwrap() == "latest" {
// ()
// } else {
// ()
// }
if remote_ref == "master" || remote_ref == "main" {
open_url
} else {
@ -264,7 +287,7 @@ mod lib_tests {
mod is_valid_repository {
use crate::{
error::ErrorType,
git::{GitOutput, MockGitCommand},
git::{GitOutput, MockGitTrait},
lib_tests::instantiate_handler,
};
@ -272,8 +295,8 @@ mod lib_tests {
fn yes() {
let handler = instantiate_handler();
let mut mock = MockGitCommand::new();
mock.expect_execute()
let mut mock = MockGitTrait::default();
mock.expect_is_valid_repository()
.returning(|| Ok(GitOutput::Ok("Valid".to_owned())));
let is_valid_repository = handler.is_valid_repository(&mock);
@ -284,8 +307,8 @@ mod lib_tests {
#[test]
fn no() {
let handler = instantiate_handler();
let mut mock = MockGitCommand::new();
mock.expect_execute()
let mut mock = MockGitTrait::default();
mock.expect_is_valid_repository()
.returning(|| Ok(GitOutput::Err("Error".to_owned())));
let is_valid_repository = handler.is_valid_repository(&mock);
@ -301,6 +324,18 @@ mod lib_tests {
}
}
mod get_local_ref {
#[test]
fn user_given_branch() {}
#[test]
fn is_branch() {}
#[test]
fn is_not_branch() {}
}
mod parse_git_url {
use crate::{error::AppError, lib_tests::instantiate_handler};
use test_case::test_case;

Loading…
Cancel
Save