diff --git a/Cargo.lock b/Cargo.lock index 057956a7c..ce405c196 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2129,6 +2129,18 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "gitbutler-testsupport" +version = "0.0.0" +dependencies = [ + "anyhow", + "git2", + "gitbutler-core", + "once_cell", + "pretty_assertions", + "tempfile", +] + [[package]] name = "glib" version = "0.15.12" diff --git a/Cargo.toml b/Cargo.toml index e0cb5dd01..ca0a57428 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "crates/gitbutler-tauri", "crates/gitbutler-changeset", "crates/gitbutler-git", + "crates/gitbutler-testsupport", ] resolver = "2" diff --git a/crates/gitbutler-testsupport/Cargo.toml b/crates/gitbutler-testsupport/Cargo.toml new file mode 100644 index 000000000..f87a71bc8 --- /dev/null +++ b/crates/gitbutler-testsupport/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "gitbutler-testsupport" +version = "0.0.0" +edition = "2021" +publish = false + +[lib] +path = "src/lib.rs" +doctest = false +test = false + +[dependencies] +anyhow = "1.0.81" +once_cell = "1.19" +git2.workspace = true +pretty_assertions = "1.4" +tempfile = "3.10.1" +gitbutler-core = { path = "../gitbutler-core" } diff --git a/crates/gitbutler-testsupport/src/lib.rs b/crates/gitbutler-testsupport/src/lib.rs new file mode 100644 index 000000000..8b663812c --- /dev/null +++ b/crates/gitbutler-testsupport/src/lib.rs @@ -0,0 +1,69 @@ +pub const VAR_NO_CLEANUP: &str = "GITBUTLER_TESTS_NO_CLEANUP"; + +mod test_project; +pub use test_project::TestProject; + +mod suite; +pub use suite::*; + +pub mod paths { + use tempfile::TempDir; + + use super::temp_dir; + + pub fn data_dir() -> TempDir { + temp_dir() + } +} + +pub mod virtual_branches { + use gitbutler_core::{ + gb_repository, project_repository, + virtual_branches::{self, VirtualBranchesHandle}, + }; + + use crate::empty_bare_repository; + + pub fn set_test_target( + gb_repo: &gb_repository::Repository, + project_repository: &project_repository::Repository, + ) -> anyhow::Result<()> { + let (remote_repo, _tmp) = empty_bare_repository(); + let mut remote = project_repository + .git_repository + .remote( + "origin", + &remote_repo.path().to_str().unwrap().parse().unwrap(), + ) + .expect("failed to add remote"); + remote.push(&["refs/heads/master:refs/heads/master"], None)?; + + virtual_branches::target::Writer::new( + gb_repo, + VirtualBranchesHandle::new(&project_repository.project().gb_dir()), + )? + .write_default(&virtual_branches::target::Target { + branch: "refs/remotes/origin/master".parse().unwrap(), + remote_url: remote_repo.path().to_str().unwrap().parse().unwrap(), + sha: remote_repo.head().unwrap().target().unwrap(), + }) + .expect("failed to write target"); + + virtual_branches::integration::update_gitbutler_integration(gb_repo, project_repository) + .expect("failed to update integration"); + + Ok(()) + } +} + +pub fn init_opts() -> git2::RepositoryInitOptions { + let mut opts = git2::RepositoryInitOptions::new(); + opts.initial_head("master"); + opts +} + +pub fn init_opts_bare() -> git2::RepositoryInitOptions { + let mut opts = init_opts(); + opts.bare(true); + opts +} diff --git a/crates/gitbutler-testsupport/src/suite.rs b/crates/gitbutler-testsupport/src/suite.rs new file mode 100644 index 000000000..ca9bcef59 --- /dev/null +++ b/crates/gitbutler-testsupport/src/suite.rs @@ -0,0 +1,232 @@ +use std::{ + collections::HashMap, + fs, + path::{Path, PathBuf}, +}; + +use tempfile::{tempdir, TempDir}; + +use crate::{init_opts, init_opts_bare, VAR_NO_CLEANUP}; + +pub struct Suite { + pub local_app_data: Option, + pub storage: gitbutler_core::storage::Storage, + pub users: gitbutler_core::users::Controller, + pub projects: gitbutler_core::projects::Controller, + pub keys: gitbutler_core::keys::Controller, +} + +impl Drop for Suite { + fn drop(&mut self) { + if std::env::var_os(VAR_NO_CLEANUP).is_some() { + let _ = self.local_app_data.take().unwrap().into_path(); + } + } +} + +impl Default for Suite { + fn default() -> Self { + let local_app_data = temp_dir(); + let storage = gitbutler_core::storage::Storage::new(&local_app_data); + let users = gitbutler_core::users::Controller::from_path(&local_app_data); + let projects = gitbutler_core::projects::Controller::from_path(&local_app_data); + let keys = gitbutler_core::keys::Controller::from_path(&local_app_data); + Self { + storage, + local_app_data: Some(local_app_data), + users, + projects, + keys, + } + } +} + +impl Suite { + pub fn local_app_data(&self) -> &Path { + self.local_app_data.as_ref().unwrap().path() + } + pub fn sign_in(&self) -> gitbutler_core::users::User { + let user = gitbutler_core::users::User { + name: Some("test".to_string()), + email: "test@email.com".to_string(), + access_token: "token".to_string(), + ..Default::default() + }; + self.users.set_user(&user).expect("failed to add user"); + user + } + + fn project(&self, fs: HashMap) -> (gitbutler_core::projects::Project, TempDir) { + let (repository, tmp) = test_repository(); + for (path, contents) in fs { + if let Some(parent) = path.parent() { + fs::create_dir_all(repository.path().parent().unwrap().join(parent)) + .expect("failed to create dir"); + } + fs::write( + repository.path().parent().unwrap().join(&path), + contents.as_bytes(), + ) + .expect("failed to write file"); + } + commit_all(&repository); + + ( + self.projects + .add(repository.path().parent().unwrap()) + .expect("failed to add project"), + tmp, + ) + } + + pub fn new_case_with_files(&self, fs: HashMap) -> Case { + let (project, project_tmp) = self.project(fs); + Case::new(self, project, project_tmp) + } + + pub fn new_case(&self) -> Case { + self.new_case_with_files(HashMap::new()) + } +} + +pub struct Case<'a> { + suite: &'a Suite, + pub project: gitbutler_core::projects::Project, + pub project_repository: gitbutler_core::project_repository::Repository, + pub gb_repository: gitbutler_core::gb_repository::Repository, + pub credentials: gitbutler_core::git::credentials::Helper, + /// The directory containing the `project_repository` + project_tmp: Option, +} + +impl Drop for Case<'_> { + fn drop(&mut self) { + if let Some(tmp) = self + .project_tmp + .take() + .filter(|_| std::env::var_os(VAR_NO_CLEANUP).is_some()) + { + let _ = tmp.into_path(); + } + } +} + +impl<'a> Case<'a> { + fn new( + suite: &'a Suite, + project: gitbutler_core::projects::Project, + project_tmp: TempDir, + ) -> Case<'a> { + let project_repository = gitbutler_core::project_repository::Repository::open(&project) + .expect("failed to create project repository"); + let gb_repository = gitbutler_core::gb_repository::Repository::open( + suite.local_app_data(), + &project_repository, + None, + ) + .expect("failed to open gb repository"); + let credentials = + gitbutler_core::git::credentials::Helper::from_path(suite.local_app_data()); + Case { + suite, + project, + gb_repository, + project_repository, + project_tmp: Some(project_tmp), + credentials, + } + } + + pub fn refresh(mut self) -> Self { + let project = self + .suite + .projects + .get(&self.project.id) + .expect("failed to get project"); + let project_repository = gitbutler_core::project_repository::Repository::open(&project) + .expect("failed to create project repository"); + let user = self.suite.users.get_user().expect("failed to get user"); + let credentials = + gitbutler_core::git::credentials::Helper::from_path(self.suite.local_app_data()); + Self { + suite: self.suite, + gb_repository: gitbutler_core::gb_repository::Repository::open( + self.suite.local_app_data(), + &project_repository, + user.as_ref(), + ) + .expect("failed to open gb repository"), + credentials, + project_repository, + project, + project_tmp: self.project_tmp.take(), + } + } +} + +pub fn test_database() -> (gitbutler_core::database::Database, TempDir) { + let tmp = temp_dir(); + let db = gitbutler_core::database::Database::open_in_directory(&tmp).unwrap(); + (db, tmp) +} + +pub fn temp_dir() -> TempDir { + tempdir().unwrap() +} + +pub fn empty_bare_repository() -> (gitbutler_core::git::Repository, TempDir) { + let tmp = temp_dir(); + ( + gitbutler_core::git::Repository::init_opts(&tmp, &init_opts_bare()) + .expect("failed to init repository"), + tmp, + ) +} + +pub fn test_repository() -> (gitbutler_core::git::Repository, TempDir) { + let tmp = temp_dir(); + let repository = gitbutler_core::git::Repository::init_opts(&tmp, &init_opts()) + .expect("failed to init repository"); + let mut index = repository.index().expect("failed to get index"); + let oid = index.write_tree().expect("failed to write tree"); + let signature = gitbutler_core::git::Signature::now("test", "test@email.com").unwrap(); + repository + .commit( + Some(&"refs/heads/master".parse().unwrap()), + &signature, + &signature, + "Initial commit", + &repository.find_tree(oid).expect("failed to find tree"), + &[], + ) + .expect("failed to commit"); + (repository, tmp) +} + +pub fn commit_all(repository: &gitbutler_core::git::Repository) -> gitbutler_core::git::Oid { + let mut index = repository.index().expect("failed to get index"); + index + .add_all(["."], git2::IndexAddOption::DEFAULT, None) + .expect("failed to add all"); + index.write().expect("failed to write index"); + let oid = index.write_tree().expect("failed to write tree"); + let signature = gitbutler_core::git::Signature::now("test", "test@email.com").unwrap(); + let head = repository.head().expect("failed to get head"); + let commit_oid = repository + .commit( + Some(&head.name().unwrap()), + &signature, + &signature, + "some commit", + &repository.find_tree(oid).expect("failed to find tree"), + &[&repository + .find_commit( + repository + .refname_to_id("HEAD") + .expect("failed to get head"), + ) + .expect("failed to find commit")], + ) + .expect("failed to commit"); + commit_oid +} diff --git a/crates/gitbutler-testsupport/src/test_project.rs b/crates/gitbutler-testsupport/src/test_project.rs new file mode 100644 index 000000000..d9e22f9ff --- /dev/null +++ b/crates/gitbutler-testsupport/src/test_project.rs @@ -0,0 +1,346 @@ +use std::path; + +use gitbutler_core::git; +use tempfile::TempDir; + +use crate::{init_opts, VAR_NO_CLEANUP}; + +pub fn temp_dir() -> TempDir { + tempfile::tempdir().unwrap() +} + +pub struct TestProject { + local_repository: git::Repository, + local_tmp: Option, + remote_repository: git::Repository, + remote_tmp: Option, +} + +impl Drop for TestProject { + fn drop(&mut self) { + if std::env::var_os(VAR_NO_CLEANUP).is_some() { + let _ = self.local_tmp.take().unwrap().into_path(); + let _ = self.remote_tmp.take().unwrap().into_path(); + } + } +} + +impl Default for TestProject { + fn default() -> Self { + let local_tmp = temp_dir(); + let local_repository = git::Repository::init_opts(local_tmp.path(), &init_opts()) + .expect("failed to init repository"); + let mut index = local_repository.index().expect("failed to get index"); + let oid = index.write_tree().expect("failed to write tree"); + let signature = git::Signature::now("test", "test@email.com").unwrap(); + local_repository + .commit( + Some(&"refs/heads/master".parse().unwrap()), + &signature, + &signature, + "Initial commit", + &local_repository + .find_tree(oid) + .expect("failed to find tree"), + &[], + ) + .expect("failed to commit"); + + let remote_tmp = temp_dir(); + let remote_repository = git::Repository::init_opts( + remote_tmp.path(), + git2::RepositoryInitOptions::new() + .bare(true) + .external_template(false), + ) + .expect("failed to init repository"); + + { + let mut remote = local_repository + .remote( + "origin", + &remote_repository + .path() + .to_str() + .expect("failed to convert path to str") + .parse() + .unwrap(), + ) + .expect("failed to add remote"); + remote + .push(&["refs/heads/master:refs/heads/master"], None) + .expect("failed to push"); + } + + Self { + local_repository, + local_tmp: Some(local_tmp), + remote_repository, + remote_tmp: Some(remote_tmp), + } + } +} + +impl TestProject { + pub fn path(&self) -> &std::path::Path { + self.local_repository.workdir().unwrap() + } + + pub fn push_branch(&self, branch: &git::LocalRefname) { + let mut origin = self.local_repository.find_remote("origin").unwrap(); + origin.push(&[&format!("{branch}:{branch}")], None).unwrap(); + } + + pub fn push(&self) { + let mut origin = self.local_repository.find_remote("origin").unwrap(); + origin + .push(&["refs/heads/master:refs/heads/master"], None) + .unwrap(); + } + + /// git add -A + /// git reset --hard + pub fn reset_hard(&self, oid: Option) { + let mut index = self.local_repository.index().expect("failed to get index"); + index + .add_all(["."], git2::IndexAddOption::DEFAULT, None) + .expect("failed to add all"); + index.write().expect("failed to write index"); + + let head = self.local_repository.head().unwrap(); + let commit = oid.map_or(head.peel_to_commit().unwrap(), |oid| { + self.local_repository.find_commit(oid).unwrap() + }); + + let head_ref = head.name().unwrap(); + self.local_repository.find_reference(&head_ref).unwrap(); + + self.local_repository + .reset(&commit, git2::ResetType::Hard, None) + .unwrap(); + } + + /// fetch remote into local + pub fn fetch(&self) { + let mut remote = self.local_repository.find_remote("origin").unwrap(); + remote + .fetch(&["+refs/heads/*:refs/remotes/origin/*"], None) + .unwrap(); + } + + pub fn rebase_and_merge(&self, branch_name: &git::Refname) { + let branch_name: git::Refname = match branch_name { + git::Refname::Local(local) => format!("refs/heads/{}", local.branch()).parse().unwrap(), + git::Refname::Remote(remote) => { + format!("refs/heads/{}", remote.branch()).parse().unwrap() + } + _ => "INVALID".parse().unwrap(), // todo + }; + let branch = self.remote_repository.find_branch(&branch_name).unwrap(); + let branch_commit = branch.peel_to_commit().unwrap(); + + let master_branch = { + let name: git::Refname = "refs/heads/master".parse().unwrap(); + self.remote_repository.find_branch(&name).unwrap() + }; + let master_branch_commit = master_branch.peel_to_commit().unwrap(); + + let mut rebase_options = git2::RebaseOptions::new(); + rebase_options.quiet(true); + rebase_options.inmemory(true); + + let mut rebase = self + .remote_repository + .rebase( + Some(branch_commit.id()), + Some(master_branch_commit.id()), + None, + Some(&mut rebase_options), + ) + .unwrap(); + + let mut rebase_success = true; + let mut last_rebase_head = branch_commit.id(); + while let Some(Ok(op)) = rebase.next() { + let commit = self.remote_repository.find_commit(op.id().into()).unwrap(); + let index = rebase.inmemory_index().unwrap(); + if index.has_conflicts() { + rebase_success = false; + break; + } + + if let Ok(commit_id) = rebase.commit(None, &commit.committer().into(), None) { + last_rebase_head = commit_id.into(); + } else { + rebase_success = false; + break; + }; + } + + if rebase_success { + self.remote_repository + .reference( + &"refs/heads/master".parse().unwrap(), + last_rebase_head, + true, + &format!("rebase: {}", branch_name), + ) + .unwrap(); + } else { + rebase.abort().unwrap(); + } + } + + /// works like if we'd open and merge a PR on github. does not update local. + pub fn merge(&self, branch_name: &git::Refname) { + let branch_name: git::Refname = match branch_name { + git::Refname::Local(local) => format!("refs/heads/{}", local.branch()).parse().unwrap(), + git::Refname::Remote(remote) => { + format!("refs/heads/{}", remote.branch()).parse().unwrap() + } + _ => "INVALID".parse().unwrap(), // todo + }; + let branch = self.remote_repository.find_branch(&branch_name).unwrap(); + let branch_commit = branch.peel_to_commit().unwrap(); + + let master_branch = { + let name: git::Refname = "refs/heads/master".parse().unwrap(); + self.remote_repository.find_branch(&name).unwrap() + }; + let master_branch_commit = master_branch.peel_to_commit().unwrap(); + + let merge_base = { + let oid = self + .remote_repository + .merge_base(branch_commit.id(), master_branch_commit.id()) + .unwrap(); + self.remote_repository.find_commit(oid).unwrap() + }; + let merge_tree = { + let mut merge_index = self + .remote_repository + .merge_trees( + &merge_base.tree().unwrap(), + &master_branch.peel_to_tree().unwrap(), + &branch.peel_to_tree().unwrap(), + ) + .unwrap(); + let oid = merge_index.write_tree_to(&self.remote_repository).unwrap(); + self.remote_repository.find_tree(oid).unwrap() + }; + + self.remote_repository + .commit( + Some(&"refs/heads/master".parse().unwrap()), + &branch_commit.author(), + &branch_commit.committer(), + &format!("Merge pull request from {}", branch_name), + &merge_tree, + &[&master_branch_commit, &branch_commit], + ) + .unwrap(); + } + + pub fn find_commit(&self, oid: git::Oid) -> Result { + self.local_repository.find_commit(oid) + } + + pub fn checkout_commit(&self, commit_oid: git::Oid) { + let commit = self.local_repository.find_commit(commit_oid).unwrap(); + let commit_tree = commit.tree().unwrap(); + + self.local_repository.set_head_detached(commit_oid).unwrap(); + self.local_repository + .checkout_tree(&commit_tree) + .force() + .checkout() + .unwrap(); + } + + pub fn checkout(&self, branch: &git::LocalRefname) { + let branch: git::Refname = branch.into(); + let tree = match self.local_repository.find_branch(&branch) { + Ok(branch) => branch.peel_to_tree(), + Err(git::Error::NotFound(_)) => { + let head_commit = self + .local_repository + .head() + .unwrap() + .peel_to_commit() + .unwrap(); + self.local_repository + .reference(&branch, head_commit.id(), false, "new branch") + .unwrap(); + head_commit.tree() + } + Err(error) => Err(error), + } + .unwrap(); + self.local_repository.set_head(&branch).unwrap(); + self.local_repository + .checkout_tree(&tree) + .force() + .checkout() + .unwrap(); + } + + /// takes all changes in the working directory and commits them into local + pub fn commit_all(&self, message: &str) -> git::Oid { + let head = self.local_repository.head().unwrap(); + let mut index = self.local_repository.index().expect("failed to get index"); + index + .add_all(["."], git2::IndexAddOption::DEFAULT, None) + .expect("failed to add all"); + index.write().expect("failed to write index"); + let oid = index.write_tree().expect("failed to write tree"); + let signature = git::Signature::now("test", "test@email.com").unwrap(); + self.local_repository + .commit( + head.name().as_ref(), + &signature, + &signature, + message, + &self + .local_repository + .find_tree(oid) + .expect("failed to find tree"), + &[&self + .local_repository + .find_commit( + self.local_repository + .refname_to_id("HEAD") + .expect("failed to get head"), + ) + .expect("failed to find commit")], + ) + .expect("failed to commit") + } + + pub fn references(&self) -> Vec { + self.local_repository + .references() + .expect("failed to get references") + .collect::, _>>() + .expect("failed to read references") + } + + pub fn add_submodule(&self, url: &git::Url, path: &path::Path) { + let mut submodule = self.local_repository.add_submodule(url, path).unwrap(); + let repo = submodule.open().unwrap(); + + // checkout submodule's master head + repo.find_remote("origin") + .unwrap() + .fetch(&["+refs/heads/*:refs/heads/*"], None, None) + .unwrap(); + let reference = repo.find_reference("refs/heads/master").unwrap(); + let reference_head = repo.find_commit(reference.target().unwrap()).unwrap(); + repo.checkout_tree(reference_head.tree().unwrap().as_object(), None) + .unwrap(); + + // be sure that `HEAD` points to the actual head - `git2` seems to initialize it + // with `init.defaultBranch`, causing failure otherwise. + repo.set_head("refs/heads/master").unwrap(); + submodule.add_finalize().unwrap(); + } +}