mirror of
https://github.com/gitbutlerapp/gitbutler.git
synced 2024-11-28 13:26:16 +03:00
add integration tests for core git abstraction library
This commit is contained in:
parent
cc44e2c8ff
commit
897fd8b217
@ -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"]}
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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),
|
||||
|
63
gitbutler-git/src/integration_tests.rs
Normal file
63
gitbutler-git/src/integration_tests.rs
Normal 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;
|
@ -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;
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user