add integration tests for core git abstraction library

This commit is contained in:
Josh Junon 2024-01-18 16:26:11 +01:00
parent cc44e2c8ff
commit 897fd8b217
10 changed files with 335 additions and 14 deletions

View File

@ -16,3 +16,6 @@ git2 = { workspace = true, optional = true }
thiserror = { workspace = true, optional = true }
serde = { workspace = true, optional = true }
tokio = { workspace = true, optional = true, features = ["process"]}
[dev-dependencies]
tokio = { workspace = true, features = ["rt", "rt-multi-thread"]}

View File

@ -9,3 +9,23 @@ pub use self::{executor::GitExecutor, repository::Repository};
#[cfg(feature = "tokio")]
pub use self::executor::tokio;
#[cfg(test)]
mod tests {
use super::*;
async fn make_repo(test_name: String) -> impl crate::Repository {
let repo_path = std::env::temp_dir()
.join("gitbutler-tests")
.join("git")
.join("cli")
.join(test_name);
let _ = std::fs::remove_dir_all(&repo_path);
std::fs::create_dir_all(&repo_path).unwrap();
Repository::open_or_init(executor::tokio::TokioExecutor, repo_path)
.await
.unwrap()
}
crate::gitbutler_git_integration_tests!(make_repo);
}

View File

@ -1,5 +1,6 @@
//! A [Tokio](https://tokio.rs)-based [`GitExecutor`] implementation.
use std::collections::HashMap;
use tokio::process::Command;
/// A [`GitExecutor`] implementation using the `git` command-line tool
@ -17,8 +18,87 @@ impl super::GitExecutor for TokioExecutor {
Ok((
output.status.code().unwrap_or(127) as usize,
String::from_utf8_lossy(&output.stdout).into(),
String::from_utf8_lossy(&output.stderr).into(),
String::from_utf8_lossy(&output.stdout).trim().into(),
String::from_utf8_lossy(&output.stderr).trim().into(),
))
}
}
/// A [`GitExecutor`] implementation using the `git` command-line tool
/// via [`tokio::process::Command`], with the given environment variables.
pub struct TokioExecutorEnv {
env: HashMap<String, String>,
}
impl super::GitExecutor for TokioExecutorEnv {
type Error = std::io::Error;
async fn execute(&self, args: &[&str]) -> Result<(usize, String, String), Self::Error> {
let mut cmd = Command::new("git");
cmd.args(args);
cmd.envs(&self.env);
let output = cmd.output().await?;
Ok((
output.status.code().unwrap_or(127) as usize,
String::from_utf8_lossy(&output.stdout).trim().into(),
String::from_utf8_lossy(&output.stderr).trim().into(),
))
}
}
/// Allows executors to create (or modify) a [`TokioExecutorEnv`],
/// with added/modified environment variables, set for each execution
/// of `git`.
pub trait WithEnv: Sized {
/// Sets the given environment variable.
fn with_env<K: AsRef<str>, V: AsRef<str>>(self, key: K, value: V) -> TokioExecutorEnv;
/// Creates a new [`TokioExecutorEnv`] with the given additional environment variables.
fn with_envs<K: AsRef<str>, V: AsRef<str>, I: IntoIterator<Item = (K, V)>>(
self,
envs: I,
) -> TokioExecutorEnv;
}
impl WithEnv for TokioExecutor {
fn with_env<K: AsRef<str>, V: AsRef<str>>(self, key: K, value: V) -> TokioExecutorEnv {
TokioExecutorEnv {
env: [(key.as_ref().into(), value.as_ref().into())]
.iter()
.cloned()
.collect(),
}
}
fn with_envs<K: AsRef<str>, V: AsRef<str>, I: IntoIterator<Item = (K, V)>>(
self,
envs: I,
) -> TokioExecutorEnv {
TokioExecutorEnv {
env: envs
.into_iter()
.map(|(k, v)| (k.as_ref().into(), v.as_ref().into()))
.collect(),
}
}
}
impl WithEnv for TokioExecutorEnv {
fn with_env<K: AsRef<str>, V: AsRef<str>>(mut self, key: K, value: V) -> TokioExecutorEnv {
self.env.insert(key.as_ref().into(), value.as_ref().into());
self
}
fn with_envs<K: AsRef<str>, V: AsRef<str>, I: IntoIterator<Item = (K, V)>>(
mut self,
envs: I,
) -> TokioExecutorEnv {
self.env.extend(
envs.into_iter()
.map(|(k, v)| (k.as_ref().into(), v.as_ref().into())),
);
self
}
}

View File

@ -1,5 +1,7 @@
//! NOTE: Doesn't support `no_std` yet.
use std::path::Path;
use super::executor::GitExecutor;
use crate::ConfigScope;
@ -18,13 +20,40 @@ pub enum Error<E: core::error::Error + core::fmt::Debug + Send + Sync + 'static>
/// and the given [`GitExecutor`] implementation.
pub struct Repository<E: GitExecutor> {
exec: E,
path: String,
}
impl<E: GitExecutor> Repository<E> {
/// Creates a new repository using the given [`GitExecutor`].
/// Opens a repository using the given [`GitExecutor`].
///
/// Note that this **does not** check if the repository exists,
/// but assumes it does.
#[inline]
pub fn new(exec: E) -> Self {
Self { exec }
pub fn open_unchecked<P: AsRef<Path>>(exec: E, path: P) -> Self {
Self {
exec,
path: path.as_ref().to_str().unwrap().to_string(),
}
}
/// (Re-)initializes a repository at the given path
/// using the given [`GitExecutor`].
pub async fn open_or_init<P: AsRef<Path>>(exec: E, path: P) -> Result<Self, Error<E::Error>> {
let path = path.as_ref().to_str().unwrap().to_string();
let args = vec!["init", "--quiet", &path];
let (exit_code, stdout, stderr) = exec.execute(&args).await.map_err(Error::Exec)?;
if exit_code == 0 {
Ok(Self { exec, path })
} else {
Err(Error::Failed(
exit_code,
args.into_iter().map(Into::into).collect(),
stdout,
stderr,
))
}
}
}
@ -36,13 +65,20 @@ impl<E: GitExecutor + 'static> crate::Repository for Repository<E> {
key: &str,
scope: ConfigScope,
) -> Result<Option<String>, Self::Error> {
let mut args = vec!["config", "--get"];
let mut args = vec!["-C", &self.path, "config", "--get"];
// NOTE(qix-): See source comments for ConfigScope to explain
// NOTE(qix-): the `#[cfg(not(test))]` attributes.
match scope {
#[cfg(not(test))]
ConfigScope::Auto => {}
ConfigScope::Local => args.push("--local"),
#[cfg(not(test))]
ConfigScope::System => args.push("--system"),
#[cfg(not(test))]
ConfigScope::Global => args.push("--global"),
}
args.push(key);
let (exit_code, stdout, stderr) = self.exec.execute(&args).await.map_err(Error::Exec)?;
@ -67,13 +103,20 @@ impl<E: GitExecutor + 'static> crate::Repository for Repository<E> {
value: &str,
scope: ConfigScope,
) -> Result<(), Self::Error> {
let mut args = vec!["config", "--set"];
let mut args = vec!["-C", &self.path, "config", "--replace-all"];
// NOTE(qix-): See source comments for ConfigScope to explain
// NOTE(qix-): the `#[cfg(not(test))]` attributes.
match scope {
#[cfg(not(test))]
ConfigScope::Auto => {}
ConfigScope::Local => args.push("--local"),
#[cfg(not(test))]
ConfigScope::System => args.push("--system"),
#[cfg(not(test))]
ConfigScope::Global => args.push("--global"),
}
args.push(key);
args.push(value);

View File

@ -6,3 +6,21 @@
mod repository;
pub use self::repository::Repository;
#[cfg(test)]
mod tests {
use super::*;
async fn make_repo(test_name: String) -> impl crate::Repository {
let repo_path = std::env::temp_dir()
.join("gitbutler-tests")
.join("git")
.join("git2")
.join(test_name);
let _ = std::fs::remove_dir_all(&repo_path);
std::fs::create_dir_all(&repo_path).unwrap();
Repository::open_or_init(&repo_path).unwrap()
}
crate::gitbutler_git_integration_tests!(make_repo);
}

View File

@ -8,7 +8,61 @@ pub struct Repository {
}
impl Repository {
/// Initializes a repository at the given path.
///
/// Errors if the repository is already initialized.
#[inline]
pub fn init<P: AsRef<Path>>(path: P) -> Result<Self, git2::Error> {
Ok(Self {
repo: git2::Repository::init_opts(
path,
git2::RepositoryInitOptions::new().no_reinit(true),
)?,
})
}
/// Opens a repository at the given path, or initializes it if it doesn't exist.
#[inline]
pub fn open_or_init<P: AsRef<Path>>(path: P) -> Result<Self, git2::Error> {
Ok(Self {
repo: git2::Repository::init_opts(
path,
git2::RepositoryInitOptions::new().no_reinit(false),
)?,
})
}
/// Initializes a bare repository at the given path.
///
/// Errors if the repository is already initialized.
#[inline]
pub fn init_bare<P: AsRef<Path>>(path: P) -> Result<Self, git2::Error> {
Ok(Self {
repo: git2::Repository::init_opts(
path,
git2::RepositoryInitOptions::new()
.no_reinit(true)
.bare(true),
)?,
})
}
/// Opens a repository at the given path, or initializes a new bare repository
/// if it doesn't exist.
#[inline]
pub fn open_or_init_bare<P: AsRef<Path>>(path: P) -> Result<Self, git2::Error> {
Ok(Self {
repo: git2::Repository::init_opts(
path,
git2::RepositoryInitOptions::new()
.no_reinit(false)
.bare(true),
)?,
})
}
/// Opens a repository at the given path.
/// Will error if there's no existing repository at the given path.
#[inline]
pub fn open<P: AsRef<Path>>(path: P) -> Result<Self, git2::Error> {
Ok(Self {
@ -23,16 +77,24 @@ impl crate::Repository for Repository {
async fn config_get(
&self,
key: &str,
scope: ConfigScope,
#[cfg_attr(test, allow(unused_variables))] scope: ConfigScope,
) -> Result<Option<String>, Self::Error> {
let config = self.repo.config()?;
#[cfg(test)]
let scope = ConfigScope::Local;
// NOTE(qix-): See source comments for ConfigScope to explain
// NOTE(qix-): the `#[cfg(not(test))]` attributes.
let res = match scope {
#[cfg(not(test))]
ConfigScope::Auto => config.get_string(key),
ConfigScope::Local => config.open_level(git2::ConfigLevel::Local)?.get_string(key),
#[cfg(not(test))]
ConfigScope::System => config
.open_level(git2::ConfigLevel::System)?
.get_string(key),
#[cfg(not(test))]
ConfigScope::Global => config
.open_level(git2::ConfigLevel::Global)?
.get_string(key),
@ -51,18 +113,27 @@ impl crate::Repository for Repository {
&self,
key: &str,
value: &str,
scope: ConfigScope,
#[cfg_attr(test, allow(unused_variables))] scope: ConfigScope,
) -> Result<(), Self::Error> {
#[cfg_attr(test, allow(unused_mut))]
let mut config = self.repo.config()?;
#[cfg(test)]
let scope = ConfigScope::Local;
// NOTE(qix-): See source comments for ConfigScope to explain
// NOTE(qix-): the `#[cfg(not(test))]` attributes.
match scope {
#[cfg(not(test))]
ConfigScope::Auto => config.set_str(key, value),
ConfigScope::Local => config
.open_level(git2::ConfigLevel::Local)?
.set_str(key, value),
#[cfg(not(test))]
ConfigScope::System => config
.open_level(git2::ConfigLevel::System)?
.set_str(key, value),
#[cfg(not(test))]
ConfigScope::Global => config
.open_level(git2::ConfigLevel::Global)?
.set_str(key, value),

View File

@ -0,0 +1,63 @@
/// To use in a backend, create a function that initializes
/// an empty repository, whatever that looks like, and returns
/// something that implements the `Repository` trait.
///
/// Include this file via
/// `include!(concat!(env!("CARGO_MANIFEST_DIR"), "/integration-tests.rs"));`
///
/// Then, pass the function to `gitbutler_git_integration_tests!(fn)`, like so:
///
/// ```
/// #[cfg(test)]
/// mod tests {
/// async fn make_repo(test_name: String) -> impl crate::Repository {
/// // Use `test_name` to create a unique repository, if needed.
/// todo!();
/// }
///
/// crate::gitbutler_git_integration_tests!(make_repo);
/// }
/// ```
macro_rules! gitbutler_git_integration_tests {
($create_repo:expr) => {
$crate::gitbutler_git_integration_tests! {
$create_repo,
async fn create_repo_selftest(_repo) {
// Do-nothing, just a selftest.
}
async fn check_utmost_discretion(repo) {
assert_eq!(crate::ops::has_utmost_discretion(&repo).await.unwrap(), false);
crate::ops::set_utmost_discretion(&repo, true).await.unwrap();
assert_eq!(crate::ops::has_utmost_discretion(&repo).await.unwrap(), true);
crate::ops::set_utmost_discretion(&repo, false).await.unwrap();
assert_eq!(crate::ops::has_utmost_discretion(&repo).await.unwrap(), false);
}
}
};
// Don't use this one from your backend. This is an internal macro.
($create_repo:expr, $(async fn $name:ident($repo:ident) { $($body:tt)* })*) => {
$(
#[test]
fn $name() {
::tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.unwrap()
.block_on(async {
let $repo = $create_repo({
let mod_name = ::std::module_path!();
let test_name = ::std::stringify!($name);
format!("{mod_name}::{test_name}")
}).await;
$($body)*
})
}
)*
}
}
pub(crate) use gitbutler_git_integration_tests;

View File

@ -12,6 +12,11 @@
#[cfg(not(feature = "std"))]
extern crate alloc;
#[cfg(test)]
mod integration_tests;
#[cfg(test)]
pub(crate) use integration_tests::*;
mod backend;
pub mod ops;
mod repository;

View File

@ -14,16 +14,17 @@ use crate::{ConfigScope, Repository};
/// utmost discretion enabled.
pub async fn has_utmost_discretion<R: Repository>(repo: &R) -> Result<bool, R::Error> {
let config = repo
.config_get("gitbutler.utmostDiscretion", ConfigScope::Auto)
.config_get("gitbutler.utmostDiscretion", ConfigScope::default())
.await?;
Ok(config == Some("true".to_string()))
Ok(config.map(|v| v == "1").unwrap_or(false))
}
/// Sets whether or not the repository has GitButler's utmost discretion.
pub async fn set_utmost_discretion<R: Repository>(repo: &R, value: bool) -> Result<(), R::Error> {
repo.config_set(
"gitbutler.utmostDiscretion",
if value { "true" } else { "false" },
if value { "1" } else { "0" },
ConfigScope::Local,
)
.await

View File

@ -3,18 +3,35 @@ use crate::prelude::*;
/// The scope from/to which a configuration value is read/written.
#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(
all(not(test), feature = "serde"),
derive(serde::Serialize, serde::Deserialize)
)]
pub enum ConfigScope {
// NOTE(qix-): We disable all but `Local` when testing.
// NOTE(qix-): This is not a standard practice, and you shouldn't
// NOTE(qix-): do this in almost any other case. However, we do
// NOTE(qix-): this here because most backends for Git do not have
// NOTE(qix-): a way to override global/system/etc config locations,
// NOTE(qix-): and we don't want to accidentally modify the user's
// NOTE(qix-): global config when running tests or have them influence
// NOTE(qix-): the tests in any way. Thus, we force test writers to use
// NOTE(qix-): `Local` scope when testing. This is not ideal, but it's
// NOTE(qix-): the best we can do for now. Sorry for the mess.
/// Pull from the most appropriate scope.
/// This is the default, and will fall back to a higher
/// scope if the value is not initially found.
#[default]
#[cfg(not(test))]
#[cfg_attr(not(test), default)]
Auto = 0,
/// Pull from the local scope (`.git/config`) _only_.
#[cfg_attr(test, default)]
Local = 1,
/// Pull from the system-wide scope (`${prefix}/etc/gitconfig`) _only_.
#[cfg(not(test))]
System = 2,
/// Pull from the global (user) scope (typically `~/.gitconfig`) _only_.
#[cfg(not(test))]
Global = 3,
}