diff --git a/.forgejo/workflows/push-next.yml b/.forgejo/workflows/push-next.yml index 4f89f11..1c2990d 100644 --- a/.forgejo/workflows/push-next.yml +++ b/.forgejo/workflows/push-next.yml @@ -18,21 +18,21 @@ jobs: uses: actions/checkout@v4 - name: Format - uses: https://git.kemitix.net/kemitix/rust@v0.2.7 + uses: https://git.kemitix.net/kemitix/rust@v0.3.1 with: args: fmt --all -- --check - name: Clippy - uses: https://git.kemitix.net/kemitix/rust@v0.2.7 + uses: https://git.kemitix.net/kemitix/rust@v0.3.1 with: args: clippy -- -D warnings - name: Build - uses: https://git.kemitix.net/kemitix/rust@v0.2.7 + uses: https://git.kemitix.net/kemitix/rust@v0.3.1 with: args: build - name: Test - uses: https://git.kemitix.net/kemitix/rust@v0.2.7 + uses: https://git.kemitix.net/kemitix/rust@v0.3.1 with: args: test diff --git a/crates/config/src/branch_name.rs b/crates/config/src/branch_name.rs index 5086151..92a5bd5 100644 --- a/crates/config/src/branch_name.rs +++ b/crates/config/src/branch_name.rs @@ -1,5 +1,7 @@ +use derive_more::Display; + /// The name of a Branch -#[derive(Clone, Default, Debug, Hash, PartialEq, Eq, derive_more::Display)] +#[derive(Clone, Default, Debug, Hash, PartialEq, Eq, Display, PartialOrd, Ord)] pub struct BranchName(String); impl BranchName { pub fn new(str: impl Into) -> Self { diff --git a/crates/git/Cargo.toml b/crates/git/Cargo.toml index 21df2be..48a794c 100644 --- a/crates/git/Cargo.toml +++ b/crates/git/Cargo.toml @@ -55,6 +55,7 @@ actix = { workspace = true } # Testing assert2 = { workspace = true } rand = { workspace = true } +pretty_assertions = { workspace = true } [lints.clippy] nursery = { level = "warn", priority = -1 } diff --git a/crates/git/src/commit.rs b/crates/git/src/commit.rs index fcbb3f4..39f4c6c 100644 --- a/crates/git/src/commit.rs +++ b/crates/git/src/commit.rs @@ -40,21 +40,19 @@ pub struct Histories { pub mod log { + use git_next_config as config; + pub type Result = core::result::Result; #[derive(Debug, thiserror::Error)] pub enum Error { - #[error("gix: {0}")] - Gix(String), + #[error("branch: {branch}, error: {error}")] + Gix { + branch: config::BranchName, + error: String, + }, #[error("lock")] Lock, } - - impl From for Error { - #[cfg(not(tarpaulin_include))] - fn from(e: String) -> Self { - Self::Gix(e) - } - } } diff --git a/crates/git/src/fetch.rs b/crates/git/src/fetch.rs index 67e0133..ae6780b 100644 --- a/crates/git/src/fetch.rs +++ b/crates/git/src/fetch.rs @@ -1,3 +1,6 @@ +// +pub type Result = core::result::Result; + #[derive(Debug, thiserror::Error)] pub enum Error { #[error("unable to open repo: {0}")] @@ -17,4 +20,12 @@ pub enum Error { #[error("lock")] Lock, + + #[cfg(test)] + #[error("expected failure in test")] + TestFailureExpected, + + #[cfg(test)] + #[error("test")] + TestResult(#[from] Box), } diff --git a/crates/git/src/file.rs b/crates/git/src/file.rs index 34be9d0..ea93e3b 100644 --- a/crates/git/src/file.rs +++ b/crates/git/src/file.rs @@ -37,7 +37,7 @@ pub enum Error { NoTreeInCommit(String), #[error("no .git-next.toml file found in repo")] - NoGitNextToml, + FileNotFound, #[error("find reference: {0}")] FindReference(String), diff --git a/crates/git/src/git_ref.rs b/crates/git/src/git_ref.rs index 8746208..450e8a1 100644 --- a/crates/git/src/git_ref.rs +++ b/crates/git/src/git_ref.rs @@ -1,9 +1,18 @@ +// +use crate as git; +use derive_more::Constructor; + use crate::Commit; -#[derive(Clone, Debug, Hash, PartialEq, Eq, derive_more::Display)] +#[derive(Clone, Constructor, Debug, Hash, PartialEq, Eq, derive_more::Display)] pub struct GitRef(String); impl From for GitRef { fn from(value: Commit) -> Self { Self(value.sha().to_string()) } } +impl From for GitRef { + fn from(value: git::commit::Sha) -> Self { + Self(value.to_string()) + } +} diff --git a/crates/git/src/push.rs b/crates/git/src/push.rs index 1d26279..7c4161d 100644 --- a/crates/git/src/push.rs +++ b/crates/git/src/push.rs @@ -2,7 +2,7 @@ use super::GitRef; use crate as git; use git_next_config as config; -#[derive(Debug)] +#[derive(Debug, PartialEq, Eq)] pub enum Force { No, From(GitRef), @@ -40,6 +40,10 @@ pub enum Error { #[error("gix iter init: {0}")] GixIterInit(#[from] gix::reference::iter::init::Error), + + #[cfg(test)] + #[error("test")] + TestResult(#[from] Box), } pub fn reset( diff --git a/crates/git/src/repo_details.rs b/crates/git/src/repo_details.rs index d993bf7..f0d761d 100644 --- a/crates/git/src/repo_details.rs +++ b/crates/git/src/repo_details.rs @@ -7,9 +7,7 @@ use super::{Generation, GitRemote}; /// The derived information about a repo, used to interact with it #[derive(Clone, Default, Debug, derive_more::Display, derive_with::With)] -#[display("gen-{}:{}:{}/{}:{}@{}/{}@{}", generation, forge.forge_type(), - forge.forge_alias(), repo_alias, forge.user(), forge.hostname(), repo_path, - branch)] +#[display("gen-{}:{}:{}/{}", generation, forge.forge_type(), forge.forge_alias(), repo_alias )] pub struct RepoDetails { pub generation: Generation, pub repo_alias: RepoAlias, diff --git a/crates/git/src/repository/mock.rs b/crates/git/src/repository/mock.rs index a68bf1a..f50008d 100644 --- a/crates/git/src/repository/mock.rs +++ b/crates/git/src/repository/mock.rs @@ -6,19 +6,12 @@ use std::{ sync::{Arc, Mutex}, }; -use crate::{self as git, Repository}; -use crate::{ - repository::{ - open::{OpenRepository, OpenRepositoryLike}, - Direction, RepositoryLike, - }, - GitRemote, RepoDetails, -}; +use crate as git; use git_next_config as config; #[derive(Debug, Default, Clone)] pub struct MockRepository { - open_repos: Arc>>, + open_repos: Arc>>, } impl MockRepository { pub fn new() -> Self { @@ -27,8 +20,11 @@ impl MockRepository { } } - pub fn given_can_be_opened(&mut self, gitdir: &config::GitDir) -> MockOpenRepository { - let open_repo = MockOpenRepository::new(); + pub fn given_can_be_opened( + &mut self, + gitdir: &config::GitDir, + ) -> git::repository::MockOpenRepository { + let open_repo = git::repository::MockOpenRepository::new(); #[allow(clippy::unwrap_used)] self.open_repos .lock() @@ -37,19 +33,25 @@ impl MockRepository { open_repo } - pub fn seal(self) -> (Repository, Self) { - (Repository::Mock(self.clone()), self) + pub fn seal(self) -> (git::Repository, Self) { + (git::Repository::Mock(self.clone()), self) } - pub fn unseal(self, _repository: Repository) -> Self { + pub fn unseal(self, _repository: git::Repository) -> Self { // drop repository to allow same mutable access to mock repository self } + pub fn get(&self, gitdir: &config::GitDir) -> Option { + self.open_repos + .lock() + .map(|or| or.get(gitdir).cloned()) + .unwrap_or(None) + } } -impl RepositoryLike for MockRepository { +impl git::repository::RepositoryLike for MockRepository { fn open( &self, gitdir: &config::GitDir, - ) -> std::result::Result { + ) -> std::result::Result { #[allow(clippy::unwrap_used)] self.open_repos .lock() @@ -62,96 +64,8 @@ impl RepositoryLike for MockRepository { fn git_clone( &self, - _repo_details: &RepoDetails, - ) -> std::result::Result { + _repo_details: &git::RepoDetails, + ) -> std::result::Result { todo!("MockRepository::git_clone") } } - -#[derive(Clone, Debug, Default)] -pub struct MockOpenRepository { - default_push_remote: Arc>>, - default_fetch_remote: Arc>>, -} -impl MockOpenRepository { - pub fn new() -> Self { - Self::default() - } - pub fn given_has_default_remote(&mut self, direction: Direction, remote: Option) { - #[allow(clippy::unwrap_used)] - match direction { - Direction::Push => self - .default_push_remote - .lock() - .map(|mut o| match remote { - Some(gr) => o.replace(gr), - None => o.take(), - }) - .unwrap(), - Direction::Fetch => self - .default_fetch_remote - .lock() - .map(|mut o| match remote { - Some(gr) => o.replace(gr), - None => o.take(), - }) - .unwrap(), - }; - } -} -impl From for OpenRepository { - fn from(value: MockOpenRepository) -> Self { - Self::Mock(value) - } -} - -#[allow(clippy::unwrap_used)] -impl OpenRepositoryLike for MockOpenRepository { - fn remote_branches(&self) -> git::push::Result> { - todo!("MockOpenRepository::remote_branched") - } - fn find_default_remote(&self, direction: Direction) -> Option { - match direction { - Direction::Push => self - .default_push_remote - .lock() - .map(|r| r.clone()) - .unwrap_or(None), - Direction::Fetch => self - .default_fetch_remote - .lock() - .map(|r| r.clone()) - .unwrap_or(None), - } - } - - fn fetch(&self) -> core::result::Result<(), crate::fetch::Error> { - todo!("MockOpenRepository::fetch") - } - - fn push( - &self, - _repo_details: &RepoDetails, - _branch_name: &git_next_config::BranchName, - _to_commit: &crate::GitRef, - _force: &crate::push::Force, - ) -> core::result::Result<(), crate::push::Error> { - todo!("MockOpenRepository::push") - } - - fn commit_log( - &self, - _branch_name: &git_next_config::BranchName, - _find_commits: &[git::Commit], - ) -> core::result::Result, git::commit::log::Error> { - todo!("MockOpenRepository::commit_log") - } - - fn read_file( - &self, - _branch_name: &git_next_config::BranchName, - _file_name: &str, - ) -> git::file::Result { - todo!("MockOpenRepository::read_file") - } -} diff --git a/crates/git/src/repository/mod.rs b/crates/git/src/repository/mod.rs index 8d40348..2c77f30 100644 --- a/crates/git/src/repository/mod.rs +++ b/crates/git/src/repository/mod.rs @@ -1,7 +1,10 @@ // +#[cfg(test)] mod mock; mod open; mod real; +#[cfg(test)] +mod test; #[cfg(test)] mod tests; @@ -9,29 +12,59 @@ mod tests; use git_next_config as config; use git_next_config::GitDir; +#[cfg(test)] pub use mock::MockRepository; +#[cfg(test)] +pub use open::otest::OnFetch; +#[cfg(test)] +pub use open::otest::OnPush; +#[cfg(test)] +pub use open::MockOpenRepository; pub use open::OpenRepository; +pub use open::OpenRepositoryLike; +pub use open::RealOpenRepository; +pub use real::RealRepository; use tracing::info; +#[cfg(test)] +use crate::repository::test::TestRepository; + use crate::validation::repo::validate_repo; use super::RepoDetails; #[derive(Clone, Debug)] +#[allow(clippy::large_enum_variant)] pub enum Repository { Real, + #[cfg(test)] Mock(MockRepository), + #[cfg(test)] + Test(TestRepository), } + pub const fn new() -> Repository { Repository::Real } + +#[cfg(test)] pub fn mock() -> MockRepository { MockRepository::new() - // (Repository::Mock(mock_repository.clone()), mock_repository) } +#[cfg(test)] +pub const fn test(fs: kxio::fs::FileSystem) -> TestRepository { + TestRepository::new(false, fs, vec![], vec![]) +} + +// #[cfg(test)] +// pub const fn test_bare(fs: kxio::fs::FileSystem) -> TestRepository { +// TestRepository::new(true, fs, vec![], vec![]) +// } + /// Opens a repository, cloning if necessary #[tracing::instrument(skip_all)] +#[cfg(not(tarpaulin_include))] // requires network access to either clone new and/or fetch. pub fn open( repository: &Repository, repo_details: &RepoDetails, @@ -59,7 +92,10 @@ impl std::ops::Deref for Repository { fn deref(&self) -> &Self::Target { match self { Self::Real => &real::RealRepository, + #[cfg(test)] Self::Mock(mock_repository) => mock_repository, + #[cfg(test)] + Self::Test(test_repository) => test_repository, } } } diff --git a/crates/git/src/repository/open/mod.rs b/crates/git/src/repository/open/mod.rs index 86f9f10..3fa89f2 100644 --- a/crates/git/src/repository/open/mod.rs +++ b/crates/git/src/repository/open/mod.rs @@ -1,32 +1,89 @@ // +#![allow(dead_code)] + #[cfg(test)] mod tests; pub mod oreal; -use std::sync::{Arc, Mutex}; +#[cfg(test)] +pub mod otest; + +#[cfg(test)] +pub mod omock; + +use std::{ + path::Path, + sync::{Arc, Mutex}, +}; use crate as git; -use git::repository::open::oreal::RealOpenRepository; use git::repository::Direction; use git_next_config as config; +#[cfg(test)] +pub use omock::MockOpenRepository; +pub use oreal::RealOpenRepository; +#[cfg(test)] +pub use otest::TestOpenRepository; #[derive(Clone, Debug)] pub enum OpenRepository { + /// A real git repository. + /// + /// This variant is the normal implementation for use in production code. Real(RealOpenRepository), - Mock(git::repository::mock::MockOpenRepository), // TODO: (#38) contain a mock model of a repo + /// A fake git repository. + /// + /// This variant has no on-disk presense, and only fakes some of + /// the behaviour of a git repository. Once the [Self::LocalOnly] + /// variant is ready for use, tests should be converted to using + /// that instead. + #[cfg(test)] + Mock(git::repository::MockOpenRepository), // TODO: (#38) contain a mock model of a repo + /// A real git repository, but with preprogrammed responses to network access. + /// + /// This variant is for use only in testing. Requests to methods + /// that would require network access, such as to the git remote + /// server will result in an error, unless a predefined change + /// has been scheduled for that request. + #[cfg(test)] + Test(TestOpenRepository), } -impl OpenRepository { - pub fn real(gix_repo: gix::Repository) -> Self { - Self::Real(oreal::RealOpenRepository::new(Arc::new(Mutex::new( - gix_repo, - )))) - } - #[cfg(not(tarpaulin_include))] // don't test mocks - pub const fn mock(mock: git::repository::mock::MockOpenRepository) -> Self { - Self::Mock(mock) - } + +pub fn real(gix_repo: gix::Repository) -> OpenRepository { + OpenRepository::Real(oreal::RealOpenRepository::new(Arc::new(Mutex::new( + gix_repo, + )))) } + +#[cfg(not(tarpaulin_include))] // don't test mocks +#[cfg(test)] +pub const fn mock(mock: git::repository::MockOpenRepository) -> OpenRepository { + OpenRepository::Mock(mock) +} + +#[cfg(not(tarpaulin_include))] // don't test mocks +#[cfg(test)] +pub fn test( + gitdir: &config::GitDir, + fs: kxio::fs::FileSystem, + on_fetch: Vec, + on_push: Vec, +) -> OpenRepository { + OpenRepository::Test(TestOpenRepository::new(gitdir, fs, on_fetch, on_push)) +} + +#[cfg(not(tarpaulin_include))] // don't test mocks +#[cfg(test)] +pub fn test_bare( + gitdir: &config::GitDir, + fs: kxio::fs::FileSystem, + on_fetch: Vec, + on_push: Vec, +) -> OpenRepository { + OpenRepository::Test(TestOpenRepository::new_bare(gitdir, fs, on_fetch, on_push)) +} + pub trait OpenRepositoryLike { fn remote_branches(&self) -> git::push::Result>; fn find_default_remote(&self, direction: Direction) -> Option; @@ -52,7 +109,7 @@ pub trait OpenRepositoryLike { fn read_file( &self, branch_name: &config::BranchName, - file_name: &str, + file_name: &Path, ) -> git::file::Result; } impl std::ops::Deref for OpenRepository { @@ -61,7 +118,10 @@ impl std::ops::Deref for OpenRepository { fn deref(&self) -> &Self::Target { match self { Self::Real(real) => real, + #[cfg(test)] Self::Mock(mock) => mock, + #[cfg(test)] + Self::Test(test) => test, } } } diff --git a/crates/git/src/repository/open/omock.rs b/crates/git/src/repository/open/omock.rs new file mode 100644 index 0000000..be1968e --- /dev/null +++ b/crates/git/src/repository/open/omock.rs @@ -0,0 +1,121 @@ +// +use crate as git; +use git_next_config as config; + +use std::{ + path::Path, + sync::{Arc, Mutex}, +}; + +#[derive(Clone, Debug, Default)] +pub struct MockOpenRepository { + default_push_remote: Arc>>, + default_fetch_remote: Arc>>, + operations: Arc>>, +} +impl MockOpenRepository { + pub fn new() -> Self { + Self::default() + } + pub fn given_has_default_remote( + &mut self, + direction: git::repository::Direction, + remote: Option, + ) { + #[allow(clippy::unwrap_used)] + match direction { + git::repository::Direction::Push => self + .default_push_remote + .lock() + .map(|mut o| match remote { + Some(gr) => o.replace(gr), + None => o.take(), + }) + .unwrap(), + git::repository::Direction::Fetch => self + .default_fetch_remote + .lock() + .map(|mut o| match remote { + Some(gr) => o.replace(gr), + None => o.take(), + }) + .unwrap(), + }; + } + + pub fn operations(&self) -> Vec { + self.operations + .lock() + .map(|operations| operations.clone()) + .unwrap_or_default() + } +} +impl From for git::OpenRepository { + fn from(value: MockOpenRepository) -> Self { + Self::Mock(value) + } +} +#[allow(clippy::unwrap_used)] +impl git::repository::OpenRepositoryLike for MockOpenRepository { + fn remote_branches(&self) -> git::push::Result> { + todo!("MockOpenRepository::remote_branched") + } + fn find_default_remote(&self, direction: git::repository::Direction) -> Option { + match direction { + git::repository::Direction::Push => self + .default_push_remote + .lock() + .map(|r| r.clone()) + .unwrap_or(None), + git::repository::Direction::Fetch => self + .default_fetch_remote + .lock() + .map(|r| r.clone()) + .unwrap_or(None), + } + } + + fn fetch(&self) -> core::result::Result<(), crate::fetch::Error> { + self.operations + .lock() + .map_err(|_| crate::fetch::Error::Lock) + .map(|mut operations| operations.push("fetch".to_string()))?; + Ok(()) + } + + fn push( + &self, + repo_details: &git::RepoDetails, + branch_name: &git_next_config::BranchName, + to_commit: &crate::GitRef, + force: &crate::push::Force, + ) -> core::result::Result<(), crate::push::Error> { + let forge_alias = repo_details.forge.forge_alias(); + let repo_alias = &repo_details.repo_alias; + self.operations + .lock() + .map_err(|_| crate::fetch::Error::Lock) + .map(|mut operations| { + operations.push(format!( + "push fa:{forge_alias} ra:{repo_alias} bn:{branch_name} tc:{to_commit} f:{force}" + )) + })?; + Ok(()) + } + + fn commit_log( + &self, + _branch_name: &git_next_config::BranchName, + _find_commits: &[git::Commit], + ) -> core::result::Result, git::commit::log::Error> { + todo!("MockOpenRepository::commit_log") + } + + fn read_file( + &self, + _branch_name: &git_next_config::BranchName, + _file_name: &Path, + ) -> git::file::Result { + todo!("MockOpenRepository::read_file") + } +} diff --git a/crates/git/src/repository/open/oreal.rs b/crates/git/src/repository/open/oreal.rs index be921f4..58426b8 100644 --- a/crates/git/src/repository/open/oreal.rs +++ b/crates/git/src/repository/open/oreal.rs @@ -1,13 +1,17 @@ // use crate as git; use config::BranchName; +use derive_more::Constructor; use git_next_config as config; use gix::bstr::BStr; -use std::sync::{Arc, Mutex}; +use std::{ + path::Path, + sync::{Arc, Mutex}, +}; use tracing::{info, warn}; -#[derive(Clone, Debug, derive_more::Constructor)] +#[derive(Clone, Debug, Constructor)] pub struct RealOpenRepository(Arc>); impl super::OpenRepositoryLike for RealOpenRepository { fn remote_branches(&self) -> git::push::Result> { @@ -45,6 +49,7 @@ impl super::OpenRepositoryLike for RealOpenRepository { } #[tracing::instrument(skip_all)] + #[cfg(not(tarpaulin_include))] // would require writing to external service fn fetch(&self) -> Result<(), git::fetch::Error> { let Ok(repository) = self.0.lock() else { #[cfg(not(tarpaulin_include))] // don't test mutex lock failure @@ -123,23 +128,40 @@ impl super::OpenRepositoryLike for RealOpenRepository { .lock() .map_err(|_| git::commit::log::Error::Lock) .map(|repo| { - let branch_name = format!("remotes/origin/{branch_name}"); - let branch_name = BStr::new(&branch_name); + let branch = format!("remotes/origin/{branch_name}"); + let branch = BStr::new(&branch); let branch_head = repo - .rev_parse_single(branch_name) - .map_err(|e| e.to_string())?; - let object = branch_head.object().map_err(|e| e.to_string())?; - let commit = object.try_into_commit().map_err(|e| e.to_string())?; + .rev_parse_single(branch) + .map_err(|e| e.to_string()) + .map_err(as_gix_error(branch_name.clone()))?; + let object = branch_head + .object() + .map_err(|e| e.to_string()) + .map_err(as_gix_error(branch_name.clone()))?; + let commit = object + .try_into_commit() + .map_err(|e| e.to_string()) + .map_err(as_gix_error(branch_name.clone()))?; let walk = repo .rev_walk([commit.id]) .all() - .map_err(|e| e.to_string())?; + .map_err(|e| e.to_string()) + .map_err(as_gix_error(branch_name.clone()))?; let mut commits = vec![]; for item in walk.take(limit) { - let item = item.map_err(|e| e.to_string())?; - let commit = item.object().map_err(|e| e.to_string())?; + let item = item + .map_err(|e| e.to_string()) + .map_err(as_gix_error(branch_name.clone()))?; + let commit = item + .object() + .map_err(|e| e.to_string()) + .map_err(as_gix_error(branch_name.clone()))?; let id = commit.id().to_string(); - let message = commit.message_raw().map_err(|e| e.to_string())?.to_string(); + let message = commit + .message_raw() + .map_err(|e| e.to_string()) + .map_err(as_gix_error(branch_name.clone()))? + .to_string(); let commit = git::Commit::new( git::commit::Sha::new(id), git::commit::Message::new(message), @@ -154,11 +176,11 @@ impl super::OpenRepositoryLike for RealOpenRepository { })? } - #[tracing::instrument(skip_all, fields(%branch_name, %file_name))] + #[tracing::instrument(skip_all, fields(%branch_name, ?file_name))] fn read_file( &self, branch_name: &config::BranchName, - file_name: &str, + file_name: &Path, ) -> git::file::Result { self.0 .lock() @@ -171,8 +193,8 @@ impl super::OpenRepositoryLike for RealOpenRepository { let commit = obj.into_commit(); let tree = commit.tree()?; let ent = tree - .find_entry(".git-next.toml") - .ok_or(git::file::Error::NoGitNextToml)?; + .find_entry(file_name.to_string_lossy().to_string()) + .ok_or(git::file::Error::FileNotFound)?; let fobj = ent.object()?; let blob = fobj.into_blob().take_data(); let content = String::from_utf8(blob)?; @@ -181,6 +203,10 @@ impl super::OpenRepositoryLike for RealOpenRepository { } } +fn as_gix_error(branch: BranchName) -> impl FnOnce(String) -> git::commit::log::Error { + |error| git::commit::log::Error::Gix { branch, error } +} + impl From<&gix::Url> for git::GitRemote { fn from(url: &gix::Url) -> Self { let host = url.host().unwrap_or_default(); diff --git a/crates/git/src/repository/open/otest.rs b/crates/git/src/repository/open/otest.rs new file mode 100644 index 0000000..e739bab --- /dev/null +++ b/crates/git/src/repository/open/otest.rs @@ -0,0 +1,185 @@ +// +use crate as git; +use derive_more::Constructor; +use git_next_config as config; + +use std::{ + cell::Cell, + path::Path, + sync::{Arc, Mutex}, +}; + +use assert2::let_assert; + +pub type OnFetchFn = + fn(&config::RepoBranches, &config::GitDir, &kxio::fs::FileSystem) -> git::fetch::Result<()>; +#[derive(Clone, Debug, Constructor)] +pub struct OnFetch { + repo_branches: config::RepoBranches, + gitdir: config::GitDir, + fs: kxio::fs::FileSystem, + action: OnFetchFn, +} +impl OnFetch { + pub fn invoke(&self) -> git::fetch::Result<()> { + (self.action)(&self.repo_branches, &self.gitdir, &self.fs) + } +} + +pub type OnPushFn = fn( + &git::RepoDetails, + &config::BranchName, + &git::GitRef, + &git::push::Force, + &config::RepoBranches, + &config::GitDir, + &kxio::fs::FileSystem, +) -> git::push::Result<()>; +#[derive(Clone, Debug, Constructor)] +pub struct OnPush { + repo_branches: config::RepoBranches, + gitdir: config::GitDir, + fs: kxio::fs::FileSystem, + action: OnPushFn, +} +impl OnPush { + pub fn invoke( + &self, + repo_details: &git::RepoDetails, + branch_name: &config::BranchName, + to_commit: &git::GitRef, + force: &git::push::Force, + ) -> git::push::Result<()> { + (self.action)( + repo_details, + branch_name, + to_commit, + force, + &self.repo_branches, + &self.gitdir, + &self.fs, + ) + } +} + +#[derive(Clone, Debug)] +pub struct TestOpenRepository { + gitdir: config::GitDir, + fs: kxio::fs::FileSystem, + on_fetch: Vec, + fetch_counter: Cell, + on_push: Vec, + push_counter: Cell, + real: git::repository::RealOpenRepository, +} +impl git::repository::OpenRepositoryLike for TestOpenRepository { + fn remote_branches(&self) -> git::push::Result> { + self.real.remote_branches() + } + + fn find_default_remote(&self, direction: git::repository::Direction) -> Option { + self.real.find_default_remote(direction) + } + + fn fetch(&self) -> Result<(), git::fetch::Error> { + let i = self.fetch_counter.get(); + eprintln!("Fetch: {i}"); + self.fetch_counter.set(i + 1); + self.on_fetch.get(i).map(|f| f.invoke()).unwrap_or_else(|| { + unimplemented!("Unexpected fetch"); + }) + } + + fn push( + &self, + repo_details: &git::RepoDetails, + branch_name: &config::BranchName, + to_commit: &git::GitRef, + force: &git::push::Force, + ) -> git::push::Result<()> { + let i = self.push_counter.get(); + self.push_counter.set(i + 1); + self.on_push + .get(i) + .map(|f| f.invoke(repo_details, branch_name, to_commit, force)) + .unwrap_or_else(|| { + unimplemented!("Unexpected push"); + }) + } + + fn commit_log( + &self, + branch_name: &config::BranchName, + find_commits: &[git::Commit], + ) -> git::commit::log::Result> { + self.real.commit_log(branch_name, find_commits) + } + + fn read_file( + &self, + branch_name: &config::BranchName, + file_name: &Path, + ) -> git::file::Result { + self.real.read_file(branch_name, file_name) + } +} +impl TestOpenRepository { + pub fn new( + gitdir: &config::GitDir, + fs: kxio::fs::FileSystem, + on_fetch: Vec, + on_push: Vec, + ) -> Self { + let pathbuf = fs.base().join(gitdir.to_path_buf()); + let_assert!(Ok(gix) = gix::init(pathbuf), "git init"); + Self::write_origin(gitdir, &fs); + Self { + gitdir: gitdir.clone(), + fs, + on_fetch, + fetch_counter: Cell::new(0), + on_push, + push_counter: Cell::new(0), + real: git::repository::RealOpenRepository::new(Arc::new(Mutex::new(gix))), + } + } + pub fn new_bare( + gitdir: &config::GitDir, + fs: kxio::fs::FileSystem, + on_fetch: Vec, + on_push: Vec, + ) -> Self { + let pathbuf = fs.base().join(gitdir.to_path_buf()); + let_assert!(Ok(gix) = gix::init_bare(pathbuf), "git init bare"); + Self::write_origin(gitdir, &fs); + Self { + gitdir: gitdir.clone(), + fs, + on_fetch, + fetch_counter: Cell::new(0), + on_push, + push_counter: Cell::new(0), + real: git::repository::RealOpenRepository::new(Arc::new(Mutex::new(gix))), + } + } + + fn write_origin(gitdir: &config::GitDir, fs: &kxio::fs::FileSystem) { + let config_file = fs.base().join(gitdir.to_path_buf()).join(".git/config"); + #[allow(clippy::expect_used)] + let contents = fs + .file_read_to_string(&config_file) + .expect("read original .git/config"); + let updated_contents = format!( + r#"{contents} +[remote "origin"] + url = git@foo.example,net + fetch = +refs/heads/*:refs/remotes/origin/* + "# + ); + // eprintln!("{config_file:?}:\n{updated_contents}"); + #[allow(clippy::expect_used)] + fs.file_write(&config_file, &updated_contents) + .expect("write updated .git/config"); + eprintln!("write origin to .git/config") + } +} diff --git a/crates/git/src/repository/open/tests.rs b/crates/git/src/repository/open/tests.rs index b7333fa..b05d53b 100644 --- a/crates/git/src/repository/open/tests.rs +++ b/crates/git/src/repository/open/tests.rs @@ -1,22 +1,299 @@ // +use crate as git; +use crate::repository::RepositoryLike as _; +use git::tests::given; +use git::tests::then; +use git_next_config as config; + +use assert2::let_assert; +type TestResult = Result<(), Box>; + +mod server_repo_config { + use super::*; + use std::path::PathBuf; + + use assert2::let_assert; + + use crate::tests::given; + + #[test] + fn should_not_return_repo_config_when_no_branches() { + let main = None; + let next = None; + let dev = None; + let src = + config::ServerRepoConfig::new(given::a_name(), given::a_name(), None, main, next, dev); + + let_assert!(None = src.repo_config()); + } + + #[test] + fn should_return_repo_config_when_branches() { + let main = given::a_name(); + let next = given::a_name(); + let dev = given::a_name(); + let src = config::ServerRepoConfig::new( + given::a_name(), + given::a_name(), + None, + Some(main.clone()), + Some(next.clone()), + Some(dev.clone()), + ); + + let_assert!(Some(rc) = src.repo_config()); + assert_eq!( + rc, + config::RepoConfig::new( + config::RepoBranches::new(main, next, dev), + config::RepoConfigSource::Server + ) + ); + } + #[test] + fn should_return_repo() { + let repo_path = given::a_name(); + let src = config::ServerRepoConfig::new( + repo_path.clone(), + given::a_name(), + None, + Some(given::a_name()), + Some(given::a_name()), + Some(given::a_name()), + ); + + assert_eq!(src.repo(), config::RepoPath::new(repo_path)); + } + #[test] + fn should_return_branch() { + let branch = given::a_name(); + let src = config::ServerRepoConfig::new( + given::a_name(), + branch.clone(), + None, + Some(given::a_name()), + Some(given::a_name()), + Some(given::a_name()), + ); + + assert_eq!(src.branch(), config::BranchName::new(branch)); + } + #[test] + fn should_return_gitdir() { + let gitdir = given::a_name(); + let src = config::ServerRepoConfig::new( + given::a_name(), + given::a_name(), + Some(gitdir.clone().into()), + Some(given::a_name()), + Some(given::a_name()), + Some(given::a_name()), + ); + + assert_eq!( + src.gitdir(), + Some(config::GitDir::new(&PathBuf::default().join(gitdir))) + ); + } +} +mod repo_config { + + use super::*; + + #[test] + fn should_parse_toml() -> TestResult { + let main = given::a_name(); + let next = given::a_name(); + let dev = given::a_name(); + let toml = format!( + r#" + [branches] + main = "{main}" + next = "{next}" + dev = "{dev}" + "# + ); + + let rc = config::RepoConfig::load(toml.as_str())?; + + assert_eq!( + rc, + config::RepoConfig::new( + config::RepoBranches::new(main, next, dev), + config::RepoConfigSource::Repo // reading from repo is the default + ) + ); + + Ok(()) + } + #[test] + fn should_return_branches() { + let main = given::a_name(); + let next = given::a_name(); + let dev = given::a_name(); + let branches = config::RepoBranches::new(main, next, dev); + let repo_config = config::RepoConfig::new(branches.clone(), config::RepoConfigSource::Repo); + + assert_eq!(repo_config.branches(), &branches); + } + #[test] + fn should_return_source() { + let main = given::a_name(); + let next = given::a_name(); + let dev = given::a_name(); + let repo_config = config::RepoConfig::new( + config::RepoBranches::new(main, next, dev), + config::RepoConfigSource::Repo, + ); + + assert_eq!(repo_config.source(), config::RepoConfigSource::Repo); + } +} +mod forge_config { + use super::*; + use std::collections::BTreeMap; + + use secrecy::ExposeSecret; + + use crate::tests::given; + + #[test] + fn should_return_repos() { + let forge_type = config::ForgeType::MockForge; + let hostname = given::a_name(); + let user = given::a_name(); + let token = given::a_name(); + // alphabetical order by key + let red_name = format!("a-{}", given::a_name()); + let blue_name = format!("b-{}", given::a_name()); + let red = config::ServerRepoConfig::new( + red_name.clone(), + given::a_name(), + None, + None, + None, + None, + ); + let blue = config::ServerRepoConfig::new( + blue_name.clone(), + given::a_name(), + None, + None, + None, + None, + ); + let mut repos = BTreeMap::new(); + repos.insert(red_name.clone(), red.clone()); + repos.insert(blue_name.clone(), blue.clone()); + let fc = config::ForgeConfig::new(forge_type, hostname, user, token, repos); + + let returned_repos = fc.repos().collect::>(); + + assert_eq!( + returned_repos, + vec![ + // alphabetical order by key + (config::RepoAlias::new(red_name.as_str()), &red), + (config::RepoAlias::new(blue_name.as_str()), &blue), + ] + ); + } + #[test] + fn should_return_forge_type() { + let forge_type = config::ForgeType::MockForge; + let hostname = given::a_name(); + let user = given::a_name(); + let token = given::a_name(); + let repos = BTreeMap::new(); + let fc = config::ForgeConfig::new(forge_type, hostname, user, token, repos); + + assert_eq!(fc.forge_type(), config::ForgeType::MockForge); + } + #[test] + fn should_return_hostname() { + let forge_type = config::ForgeType::MockForge; + let hostname = given::a_name(); + let user = given::a_name(); + let token = given::a_name(); + let repos = BTreeMap::new(); + let fc = config::ForgeConfig::new(forge_type, hostname.clone(), user, token, repos); + + assert_eq!(fc.hostname(), config::Hostname::new(hostname)); + } + #[test] + fn should_return_user() { + let forge_type = config::ForgeType::MockForge; + let hostname = given::a_name(); + let user = given::a_name(); + let token = given::a_name(); + let repos = BTreeMap::new(); + let fc = config::ForgeConfig::new(forge_type, hostname, user.clone(), token, repos); + + assert_eq!(fc.user(), config::User::new(user)); + } + #[test] + fn should_return_token() { + let forge_type = config::ForgeType::MockForge; + let hostname = given::a_name(); + let user = given::a_name(); + let token = given::a_name(); + let repos = BTreeMap::new(); + let fc = config::ForgeConfig::new(forge_type, hostname, user, token.clone(), repos); + + assert_eq!(fc.token().expose_secret(), token.as_str()); + } + #[test] + fn should_return_repo() { + let forge_type = config::ForgeType::MockForge; + let hostname = given::a_name(); + let user = given::a_name(); + let token = given::a_name(); + let red_name = given::a_name(); + let blue_name = given::a_name(); + let red = config::ServerRepoConfig::new( + red_name.clone(), + given::a_name(), + None, + None, + None, + None, + ); + let blue = config::ServerRepoConfig::new( + blue_name.clone(), + given::a_name(), + None, + None, + None, + None, + ); + let mut repos = BTreeMap::new(); + repos.insert(red_name.clone(), red.clone()); + repos.insert(blue_name, blue); + let fc = config::ForgeConfig::new(forge_type, hostname, user, token, repos); + + let returned_repo = fc.get_repo(red_name.as_str()); + + assert_eq!(returned_repo, Some(&red),); + } +} mod find_default_remote { - use assert2::let_assert; - use git_next_config::{GitDir, Hostname, RepoPath}; + use super::*; + + use assert2::let_assert; - use crate::GitRemote; #[test] fn should_find_default_push_remote() { // uses the current repo let_assert!(Ok(cwd) = std::env::current_dir()); - let gitdir = GitDir::from(cwd.join("../..")); // from ./crate/git directory to the project rook + let gitdir = config::GitDir::from(cwd.join("../..")); // from ./crate/git directory to the project rook let_assert!(Ok(repo) = crate::repository::new().open(&gitdir)); let_assert!(Some(remote) = repo.find_default_remote(crate::repository::Direction::Push)); assert_eq!( remote, - GitRemote::new( - Hostname::new("git.kemitix.net"), - RepoPath::new("kemitix/git-next".to_string()) + git::GitRemote::new( + config::Hostname::new("git.kemitix.net"), + config::RepoPath::new("kemitix/git-next".to_string()) ) ) } @@ -36,3 +313,153 @@ mod fetch { let_assert!(Ok(_) = repo.fetch()); } } + +mod remote_branches { + use super::*; + #[test] + // assumes running in the git-next repo which should have main, next and dev as remote branches + fn should_return_remote_branches() -> TestResult { + let_assert!(Ok(fs) = kxio::fs::temp()); + let gitdir: config::GitDir = fs.base().to_path_buf().into(); + let test_repository = git::repository::test(fs.clone()); + let_assert!(Ok(open_repository) = test_repository.open(&gitdir)); + let repo_config = &given::a_repo_config(); + let branches = repo_config.branches(); + then::create_a_commit_on_branch(&fs, &gitdir, &branches.main())?; + then::create_a_commit_on_branch(&fs, &gitdir, &branches.next())?; + then::create_a_commit_on_branch(&fs, &gitdir, &branches.dev())?; + let_assert!(Ok(remote_branches) = open_repository.remote_branches()); + assert!(remote_branches.contains(&branches.main())); + assert!(remote_branches.contains(&branches.next())); + assert!(remote_branches.contains(&branches.dev())); + Ok(()) + } +} +mod commit_log { + use git::tests::given; + + use crate::tests::then; + + use super::*; + + #[test] + // assumes running in the git-next repo which should have main, next and dev as remote branches + fn should_return_single_item_in_commit_log_when_not_searching() -> TestResult { + let_assert!(Ok(fs) = kxio::fs::temp()); + let gitdir: config::GitDir = fs.base().to_path_buf().into(); + let test_repository = git::repository::test(fs.clone()); + let_assert!(Ok(open_repository) = test_repository.open(&gitdir)); + let repo_config = &given::a_repo_config(); + let branches = repo_config.branches(); + then::create_a_commit_on_branch(&fs, &gitdir, &branches.main())?; + then::create_a_commit_on_branch(&fs, &gitdir, &branches.main())?; + let_assert!(Ok(result) = open_repository.commit_log(&branches.main(), &[])); + assert_eq!(result.len(), 1); + Ok(()) + } + + #[test] + // assumes running in the git-next repo which should have main, next and dev as remote branches + fn should_return_capacity_50_in_commit_log_when_searching_for_garbage() -> TestResult { + let_assert!(Ok(fs) = kxio::fs::temp()); + let branch_name = given::a_branch_name(); + let gitdir: config::GitDir = fs.base().to_path_buf().into(); + let test_repository = git::repository::test(fs.clone()); + let_assert!(Ok(open_repository) = test_repository.open(&gitdir)); + for _ in [0; 60] { + // create 60 commits + then::create_a_commit_on_branch(&fs, &gitdir, &branch_name)?; + } + let_assert!(Ok(result) = open_repository.commit_log(&branch_name, &[given::a_commit()])); + assert_eq!(result.len(), 50); + Ok(()) + } + + #[test] + // assumes running in the git-next repo which should have main, next and dev as remote branches + fn should_return_5_in_commit_log_when_searching_for_5th_item() -> TestResult { + let_assert!(Ok(fs) = kxio::fs::temp(), "create temp directory"); + let branch_name = given::a_branch_name(); + let gitdir: config::GitDir = fs.base().to_path_buf().into(); + let test_repository = git::repository::test(fs.clone()); + let_assert!( + Ok(open_repository) = test_repository.open(&gitdir), + "open repository" + ); + for i in [0; 10] { + eprintln!("create commit {i}"); + then::create_a_commit_on_branch(&fs, &gitdir, &branch_name)?; + } + // search to garbage to get all 10 items + let_assert!( + Ok(long_list) = open_repository.commit_log(&branch_name, &[given::a_commit()]), + "get commit_log" + ); + // pick the 5th item + let search = &long_list[4]; // zero-based + // search for the 25th item + let_assert!( + Ok(result) = open_repository.commit_log(&branch_name, &[search.clone()]), + "get commit log" + ); + // returns + assert_eq!(result.len(), 5); + + Ok(()) + } +} +mod read_file { + + use git::tests::given; + use git::tests::then; + + type TestResult = Result<(), Box>; + + use super::*; + + #[test] + // assumes running in the git-next repo which should have main, next and dev as remote branches + fn should_return_file() -> TestResult { + let_assert!(Ok(fs) = kxio::fs::temp()); + let repo_config = given::a_repo_config(); + let file_name = given::a_pathbuf(); + let contents = given::a_name(); + let gitdir: config::GitDir = fs.base().to_path_buf().into(); + + let test_repository = git::repository::test(fs.clone()); + let_assert!(Ok(open_repository) = test_repository.open(&gitdir)); + then::commit_named_file_to_branch( + &file_name, + &contents, + &fs, + &gitdir, + &repo_config.branches().main(), + )?; + // then::create_a_commit_on_branch(&fs, &gitdir, &repo_config.branches().main())?; + let_assert!( + Ok(result) = open_repository.read_file(&repo_config.branches().main(), &file_name), + "read file" + ); + assert_eq!(result, contents); + Ok(()) + } + + #[test] + // assumes running in the git-next repo which should have main, next and dev as remote branches + fn should_error_on_missing_file() -> TestResult { + let_assert!(Ok(fs) = kxio::fs::temp()); + let gitdir: config::GitDir = fs.base().to_path_buf().into(); + let test_repository = git::repository::test(fs.clone()); + let_assert!(Ok(open_repository) = test_repository.open(&gitdir)); + let repo_config = &given::a_repo_config(); + let branches = repo_config.branches(); + then::create_a_commit_on_branch(&fs, &gitdir, &branches.dev())?; + let_assert!( + Err(err) = open_repository.read_file(&branches.dev(), &given::a_pathbuf()), + "read file" + ); + eprintln!("err: {err:#?}"); + assert!(matches!(err, git::file::Error::FileNotFound)); + Ok(()) + } +} diff --git a/crates/git/src/repository/real.rs b/crates/git/src/repository/real.rs index 3284c12..f3109cb 100644 --- a/crates/git/src/repository/real.rs +++ b/crates/git/src/repository/real.rs @@ -1,22 +1,23 @@ +// use std::sync::atomic::AtomicBool; +use crate as git; use derive_more::Deref as _; use git_next_config::GitDir; -use crate::{ - repository::{open::OpenRepository, Error, RepositoryLike}, - RepoDetails, -}; - pub struct RealRepository; -impl RepositoryLike for RealRepository { - fn open(&self, gitdir: &GitDir) -> Result { +impl git::repository::RepositoryLike for RealRepository { + fn open(&self, gitdir: &GitDir) -> Result { let gix_repo = gix::ThreadSafeRepository::open(gitdir.to_path_buf())?.to_thread_local(); - Ok(OpenRepository::real(gix_repo)) + Ok(git::repository::open::real(gix_repo)) } #[tracing::instrument(skip_all)] - fn git_clone(&self, repo_details: &RepoDetails) -> Result { + #[cfg(not(tarpaulin_include))] // requires external server + fn git_clone( + &self, + repo_details: &git::RepoDetails, + ) -> Result { tracing::info!("creating"); use secrecy::ExposeSecret; let (gix_repo, _outcome) = gix::prepare_clone_bare( @@ -26,6 +27,6 @@ impl RepositoryLike for RealRepository { .fetch_only(gix::progress::Discard, &AtomicBool::new(false))?; tracing::info!("created"); - Ok(OpenRepository::real(gix_repo)) + Ok(git::repository::open::real(gix_repo)) } } diff --git a/crates/git/src/repository/test.rs b/crates/git/src/repository/test.rs new file mode 100644 index 0000000..7331f84 --- /dev/null +++ b/crates/git/src/repository/test.rs @@ -0,0 +1,53 @@ +// +use derive_more::Constructor; + +use crate as git; +use git::repository::RepositoryLike; +use git_next_config as config; + +#[derive(Clone, Debug, Constructor)] +pub struct TestRepository { + is_bare: bool, + fs: kxio::fs::FileSystem, + on_fetch: Vec, + on_push: Vec, +} +impl TestRepository { + pub fn on_fetch(&mut self, on_fetch: git::repository::OnFetch) { + self.on_fetch.push(on_fetch); + } + + pub fn on_push(&mut self, on_push: git::repository::OnPush) { + self.on_push.push(on_push); + } + + pub const fn fs(&self) -> &kxio::fs::FileSystem { + &self.fs + } +} +impl RepositoryLike for TestRepository { + fn open(&self, gitdir: &config::GitDir) -> super::Result { + if self.is_bare { + Ok(git::repository::open::test_bare( + gitdir, + self.fs.clone(), + self.on_fetch.clone(), + self.on_push.clone(), + )) + } else { + Ok(git::repository::open::test( + gitdir, + self.fs.clone(), + self.on_fetch.clone(), + self.on_push.clone(), + )) + } + } + + fn git_clone( + &self, + _repo_details: &crate::RepoDetails, + ) -> super::Result { + todo!() + } +} diff --git a/crates/git/src/tests.rs b/crates/git/src/tests.rs index 6aabdd1..745a8b8 100644 --- a/crates/git/src/tests.rs +++ b/crates/git/src/tests.rs @@ -1,4 +1,5 @@ use crate as git; +use git_next_config as config; mod commit { use super::*; @@ -81,23 +82,59 @@ mod gitremote { } } mod push { - use crate::{commit, push::Force, Commit, GitRef}; + use super::*; + use crate::GitRef; #[test] fn force_no_should_display() { - assert_eq!(Force::No.to_string(), "fast-forward") + assert_eq!(git::push::Force::No.to_string(), "fast-forward") } + #[test] fn force_from_should_display() { - let commit = Commit::new( - commit::Sha::new("sha".to_string()), - commit::Message::new("message".to_string()), - ); + let sha = given::a_name(); + let commit = given::a_commit_with_sha(&git::commit::Sha::new(sha.clone())); assert_eq!( - Force::From(GitRef::from(commit)).to_string(), - "force-if-from:sha" + git::push::Force::From(GitRef::from(commit)).to_string(), + format!("force-if-from:{sha}") ) } + + mod reset { + use super::*; + use crate::{tests::given, OpenRepository}; + use assert2::let_assert; + + #[test] + fn should_perform_a_fetch_then_push() { + let fs = given::a_filesystem(); + let (mock_open_repository, gitdir, mock_repository) = given::an_open_repository(&fs); + let open_repository: OpenRepository = mock_open_repository.into(); + let repo_details = given::repo_details(&fs); + let branch_name = &repo_details.branch; + let commit = given::a_commit(); + let gitref = GitRef::from(commit); + let_assert!( + Ok(_) = git::push::reset( + &open_repository, + &repo_details, + branch_name, + &gitref, + &git::push::Force::No + ) + ); + let_assert!(Some(mock_open_repository) = mock_repository.get(&gitdir)); + let operations = mock_open_repository.operations(); + let forge_alias = repo_details.forge.forge_alias(); + let repo_alias = &repo_details.repo_alias; + let to_commit = gitref; + let force = "fast-forward"; + assert_eq!( + operations, + vec![format!("fetch"), format!("push fa:{forge_alias} ra:{repo_alias} bn:{branch_name} tc:{to_commit} f:{force}")] + ); + } + } } mod repo_details { @@ -172,13 +209,19 @@ mod repo_details { ); } } -mod given { +pub mod given { #![allow(dead_code)] + use std::path::PathBuf; + // - use crate as git; + use crate::{ + self as git, + repository::{MockOpenRepository, MockRepository}, + tests::given, + }; use config::{ BranchName, ForgeAlias, ForgeConfig, ForgeType, GitDir, RepoAlias, RepoBranches, - ServerRepoConfig, WebhookAuth, WebhookId, + RepoConfig, ServerRepoConfig, WebhookAuth, WebhookId, }; use git_next_config as config; @@ -201,7 +244,11 @@ mod given { } pub fn repo_branches() -> RepoBranches { - RepoBranches::new(a_name(), a_name(), a_name()) + RepoBranches::new( + format!("main-{}", a_name()), + format!("next-{}", a_name()), + format!("dev-{}", a_name()), + ) } pub fn a_forge_alias() -> ForgeAlias { @@ -227,6 +274,10 @@ mod given { a_webhook_url(&a_forge_alias(), &a_repo_alias()) } + pub fn a_pathbuf() -> PathBuf { + PathBuf::from(given::a_name()) + } + pub fn a_name() -> String { use rand::Rng; use std::iter; @@ -282,6 +333,10 @@ mod given { ) } + pub fn a_repo_config() -> RepoConfig { + RepoConfig::new(given::repo_branches(), config::RepoConfigSource::Repo) + } + pub fn a_commit() -> git::Commit { git::Commit::new(a_commit_sha(), a_commit_message()) } @@ -335,11 +390,205 @@ mod given { ) } - // pub fn an_open_repository(fs: &kxio::fs::FileSystem) -> (OpenRepository, MockRepository) { - // let (repo, mock) = git::repository::mock(); - // - // let gitdir = a_git_dir(fs); - // let op = repo.open(&gitdir).unwrap(); - // (op, mock) - // } + pub fn an_open_repository( + fs: &kxio::fs::FileSystem, + ) -> (MockOpenRepository, GitDir, MockRepository) { + let mut mock = git::repository::mock(); + let gitdir = a_git_dir(fs); + let or = mock.given_can_be_opened(&gitdir); + (or, gitdir, mock) + } +} +pub mod then { + use std::path::{Path, PathBuf}; + + type TestResult = Result<(), Box>; + + use super::*; + + pub fn commit_named_file_to_branch( + file_name: &Path, + contents: &str, + fs: &kxio::fs::FileSystem, + gitdir: &config::GitDir, + branch_name: &config::BranchName, + ) -> TestResult { + // git checkout ${branch_name} + git_checkout_new_branch(branch_name, gitdir)?; + // echo ${word} > file-${word} + let pathbuf = PathBuf::from(gitdir); + let file = fs.base().join(pathbuf).join(file_name); + #[allow(clippy::expect_used)] + fs.file_write(&file, contents)?; + // git add ${file} + git_add_file(gitdir, &file)?; + // git commit -m"Added ${file}" + git_commit(gitdir, &file)?; + + then::push_branch(fs, gitdir, branch_name)?; + + Ok(()) + } + + pub fn create_a_commit_on_branch( + fs: &kxio::fs::FileSystem, + gitdir: &config::GitDir, + branch_name: &config::BranchName, + ) -> TestResult { + // git checkout ${branch_name} + git_checkout_new_branch(branch_name, gitdir)?; + // echo ${word} > file-${word} + let word = given::a_name(); + let pathbuf = PathBuf::from(gitdir); + let file = fs.base().join(pathbuf).join(&word); + fs.file_write(&file, &word)?; + // git add ${file} + git_add_file(gitdir, &file)?; + // git commit -m"Added ${file}" + git_commit(gitdir, &file)?; + + then::push_branch(fs, gitdir, branch_name)?; + + Ok(()) + } + + fn push_branch( + fs: &kxio::fs::FileSystem, + gitdir: &config::GitDir, + branch_name: &config::BranchName, + ) -> TestResult { + let gitrefs = fs + .base() + .join(gitdir.to_path_buf()) + .join(".git") + .join("refs"); + let local_branch = gitrefs.join("heads").join(branch_name.to_string().as_str()); + let origin_heads = gitrefs.join("remotes").join("origin"); + let remote_branch = origin_heads.join(branch_name.to_string().as_str()); + let contents = fs.file_read_to_string(&local_branch)?; + fs.dir_create_all(&origin_heads)?; + fs.file_write(&remote_branch, &contents)?; + Ok(()) + } + + pub fn git_checkout_new_branch( + branch_name: &git_next_config::BranchName, + gitdir: &git_next_config::GitDir, + ) -> TestResult { + eprintln!("git checkout {branch_name} in {gitdir}"); + ls_la(gitdir)?; + which_git()?; + exec( + format!("git checkout -b {}", branch_name), + std::process::Command::new("/usr/bin/git") + .current_dir(gitdir.to_path_buf()) + .args(["checkout", "-b", branch_name.to_string().as_str()]) + .output(), + )?; + eprintln!("git checkout OKAY"); + Ok(()) + } + + pub fn git_switch( + branch_name: &git_next_config::BranchName, + gitdir: &git_next_config::GitDir, + ) -> TestResult { + exec( + format!("git switch {}", branch_name), + std::process::Command::new("/usr/bin/git") + .current_dir(gitdir.to_path_buf()) + .args(["switch", branch_name.to_string().as_str()]) + .output(), + ) + } + + fn exec(label: String, output: Result) -> TestResult { + eprintln!("== {label}"); + match output { + Ok(output) => { + eprintln!( + "\nstdout:\n{}", + String::from_utf8_lossy(output.stdout.as_slice()) + ); + eprintln!( + "\nstderr:\n{}", + String::from_utf8_lossy(output.stderr.as_slice()) + ); + eprintln!("============================="); + Ok(()) + } + Err(err) => { + eprintln!("ERROR: {err:#?}"); + Ok(Err(err)?) + } + } + } + + fn git_add_file(gitdir: &git_next_config::GitDir, file: &Path) -> TestResult { + exec( + format!("git add {file:?}"), + std::process::Command::new("/usr/bin/git") + .current_dir(gitdir.to_path_buf()) + .args(["add", file.display().to_string().as_str()]) + .output(), + ) + } + + fn git_commit(gitdir: &git_next_config::GitDir, file: &Path) -> TestResult { + exec( + format!(r#"git commit -m"Added {file:?}""#), + std::process::Command::new("/usr/bin/git") + .current_dir(gitdir.to_path_buf()) + .args([ + "commit", + format!(r#"-m"Added {}"#, file.display().to_string().as_str()).as_str(), + ]) + .output(), + ) + } + + pub fn git_log_all(gitdir: &config::GitDir) -> TestResult { + exec( + "git log --all --oneline --decorate --graph".to_string(), + std::process::Command::new("/usr/bin/git") + .current_dir(gitdir.to_path_buf()) + .args(["log", "--all", "--oneline", "--decorate", "--graph"]) + .output(), + ) + } + + #[allow(dead_code)] + pub fn which_git() -> TestResult { + exec( + "which git".to_string(), + std::process::Command::new("which").args(["git"]).output(), + ) + } + + #[allow(dead_code)] + pub fn ls_la(gitdir: &git_next_config::GitDir) -> TestResult { + exec( + "ls -la".to_string(), + std::process::Command::new("ls") + .current_dir(gitdir.to_path_buf()) + .args(["-la"]) + .output(), + ) + } + + pub fn get_sha_for_branch( + fs: &kxio::fs::FileSystem, + gitdir: &git_next_config::GitDir, + branch_name: &git_next_config::BranchName, + ) -> Result> { + let main_ref = fs + .base() + .join(gitdir.to_path_buf()) + .join(".git") + .join("refs") + .join("heads") + .join(branch_name.to_string().as_str()); + let sha = fs.file_read_to_string(&main_ref)?; + Ok(git::commit::Sha::new(sha.trim().to_string())) + } } diff --git a/crates/git/src/validation/mod.rs b/crates/git/src/validation/mod.rs index 6180e68..628b5cf 100644 --- a/crates/git/src/validation/mod.rs +++ b/crates/git/src/validation/mod.rs @@ -1,2 +1,5 @@ pub mod positions; pub mod repo; + +#[cfg(test)] +mod tests; diff --git a/crates/git/src/validation/positions.rs b/crates/git/src/validation/positions.rs index d6db1a6..61ee563 100644 --- a/crates/git/src/validation/positions.rs +++ b/crates/git/src/validation/positions.rs @@ -2,10 +2,11 @@ use crate as git; use git_next_config as config; -use tracing::{debug, error, info, warn}; +use tracing::{error, info, warn}; pub type Result = core::result::Result; +#[derive(Debug)] pub struct Positions { pub main: git::Commit, pub next: git::Commit, @@ -19,133 +20,49 @@ pub fn validate_positions( repo_details: &git::RepoDetails, repo_config: config::RepoConfig, ) -> Result { + let main_branch = repo_config.branches().main(); + let next_branch = repo_config.branches().next(); + let dev_branch = repo_config.branches().dev(); // Collect Commit Histories for `main`, `next` and `dev` branches repository.fetch()?; - let commit_histories = get_commit_histories(repository, &repo_config); - let commit_histories = match commit_histories { - Ok(commit_histories) => commit_histories, - Err(err) => { - error!(?err, "Failed to get commit histories"); - return Err(git::validation::positions::Error::CommitLog(err)); - } - }; - // Validations - let Some(main) = commit_histories.main.first().cloned() else { - warn!( - "No commits on main branch '{}'", - repo_config.branches().main() - ); - return Err(git::validation::positions::Error::BranchHasNoCommits( - repo_config.branches().main(), - )); - }; - // Dev must be on main branch, or user must rebase it - let dev_has_main = commit_histories.dev.iter().any(|commit| commit == &main); - if !dev_has_main { - warn!( - "Dev branch '{}' is not based on main branch '{}' - user should rebase onto main branch '{}'", - repo_config.branches().dev(), - repo_config.branches().main(), - repo_config.branches().main(), - ); - return Err(git::validation::positions::Error::DevBranchNotBasedOn { - dev: repo_config.branches().dev(), - other: repo_config.branches().main(), + let commit_histories = + get_commit_histories(repository, &repo_config).map_err(Error::CommitLog)?; + // branch tips + let main = commit_histories + .main + .first() + .cloned() + .ok_or_else(|| Error::BranchHasNoCommits(main_branch.clone()))?; + let next = commit_histories + .next + .first() + .cloned() + .ok_or_else(|| Error::BranchHasNoCommits(next_branch.clone()))?; + let dev = commit_histories + .dev + .first() + .cloned() + .ok_or_else(|| Error::BranchHasNoCommits(dev_branch.clone()))?; + // Validations: + // Dev must be on main branch, else the USER must rebase it + if is_not_based_on(&commit_histories.dev, &main) { + warn!("Dev '{dev_branch}' not based on main '{main_branch}' - user must rebase",); + return Err(Error::DevBranchNotBasedOn { + dev: dev_branch, + other: main_branch, }); } - // verify that next is an ancestor of dev, and force update to it main if it isn't - let Some(next) = commit_histories.next.first().cloned() else { - warn!( - "No commits on next branch '{}", - repo_config.branches().next() - ); - return Err(git::validation::positions::Error::BranchHasNoCommits( - repo_config.branches().next(), - )); - }; - let next_is_ancestor_of_dev = commit_histories.dev.iter().any(|dev| dev == &next); - if !next_is_ancestor_of_dev { + // verify that next is on main or at most one commit on top of main, else reset it back to main + if is_not_based_on(&commit_histories.next[0..=1], &main) { + info!("Main not on same commit as next, or it's parent - resetting next to main",); + return reset_next_to_main(repository, repo_details, &main, &next, &next_branch); + } + // verify that next is an ancestor of dev, else reset it back to main + if is_not_based_on(&commit_histories.dev, &next) { info!("Next is not an ancestor of dev - resetting next to main"); - if let Err(err) = git::push::reset( - repository, - repo_details, - &repo_config.branches().next(), - &main.into(), - &git::push::Force::From(next.clone().into()), - ) { - warn!(?err, "Failed to reset next to main"); - return Err(git::validation::positions::Error::FailedToResetBranch { - branch: repo_config.branches().next(), - commit: next, - }); - } - return Err(git::validation::positions::Error::NextBranchResetRequired( - repo_config.branches().next(), - )); + return reset_next_to_main(repository, repo_details, &main, &next, &next_branch); } - let next_commits = commit_histories - .next - .into_iter() - .take(2) // next should never be more than one commit ahead of main, so this should be - // either be next and main on two adjacent commits, or next and main on the same commit, - // plus the parent of main. - .collect::>(); - if !next_commits.contains(&main) { - warn!( - "Main branch '{}' is not on the same commit as next branch '{}', or it's parent - resetting next to main", - repo_config.branches().main(), - repo_config.branches().next() - ); - if let Err(err) = git::push::reset( - repository, - repo_details, - &repo_config.branches().next(), - &main.into(), - &git::push::Force::From(next.clone().into()), - ) { - warn!(?err, "Failed to reset next to main"); - return Err(git::validation::positions::Error::FailedToResetBranch { - branch: repo_config.branches().next(), - commit: next, - }); - } - return Err(git::validation::positions::Error::NextBranchResetRequired( - repo_config.branches().next(), - )); - } - let Some(next) = next_commits.first().cloned() else { - warn!( - "No commits on next branch '{}'", - repo_config.branches().next() - ); - return Err(git::validation::positions::Error::BranchHasNoCommits( - repo_config.branches().next(), - )); - }; - let dev_has_next = commit_histories - .dev - .iter() - .any(|commit| commit == &next_commits[0]); - if !dev_has_next { - warn!( - "Dev branch '{}' is not based on next branch '{}' - next branch will be updated shortly", - repo_config.branches().dev(), - repo_config.branches().next() - ); - return Err(git::validation::positions::Error::DevBranchNotBasedOn { - dev: repo_config.branches().dev(), - other: repo_config.branches().next(), - }); // dev is not based on next - } - let Some(dev) = commit_histories.dev.first().cloned() else { - warn!( - "No commits on dev branch '{}'", - repo_config.branches().dev() - ); - return Err(git::validation::positions::Error::BranchHasNoCommits( - repo_config.branches().dev(), - )); - }; + Ok(git::validation::positions::Positions { main, next, @@ -154,6 +71,34 @@ pub fn validate_positions( }) } +fn reset_next_to_main( + repository: &crate::OpenRepository, + repo_details: &crate::RepoDetails, + main: &crate::Commit, + next: &crate::Commit, + next_branch: &config::BranchName, +) -> Result { + git::push::reset( + repository, + repo_details, + next_branch, + &main.clone().into(), + &git::push::Force::From(next.clone().into()), + ) + .map_err(|err| { + warn!(?err, "Failed to reset next to main"); + Error::FailedToResetBranch { + branch: next_branch.clone(), + commit: next.clone(), + } + })?; + Err(Error::NextBranchResetRequired(next_branch.clone())) +} + +fn is_not_based_on(commits: &[crate::commit::Commit], needle: &crate::Commit) -> bool { + commits.iter().filter(|commit| *commit == needle).count() == 0 +} + fn get_commit_histories( repository: &git::repository::OpenRepository, repo_config: &config::RepoConfig, @@ -162,15 +107,10 @@ fn get_commit_histories( let main_head = [main[0].clone()]; let next = repository.commit_log(&repo_config.branches().next(), &main_head)?; let dev = repository.commit_log(&repo_config.branches().dev(), &main_head)?; - debug!( - main = main.len(), - next = next.len(), - dev = dev.len(), - "Commit histories" - ); let histories = git::commit::Histories { main, next, dev }; Ok(histories) } + #[derive(Debug, thiserror::Error)] pub enum Error { #[error("fetch: {0}")] diff --git a/crates/git/src/validation/repo.rs b/crates/git/src/validation/repo.rs index fd770d0..8052838 100644 --- a/crates/git/src/validation/repo.rs +++ b/crates/git/src/validation/repo.rs @@ -7,13 +7,12 @@ pub fn validate_repo( repository: &git::OpenRepository, repo_details: &git::RepoDetails, ) -> Result<()> { - let Some(push_remote) = repository.find_default_remote(git::repository::Direction::Push) else { - return Err(Error::NoDefaultPushRemote); - }; - let Some(fetch_remote) = repository.find_default_remote(git::repository::Direction::Fetch) - else { - return Err(Error::NoDefaultFetchRemote); - }; + let push_remote = repository + .find_default_remote(git::repository::Direction::Push) + .ok_or_else(|| Error::NoDefaultPushRemote)?; + let fetch_remote = repository + .find_default_remote(git::repository::Direction::Fetch) + .ok_or_else(|| Error::NoDefaultFetchRemote)?; let git_remote = repo_details.git_remote(); info!(config = %git_remote, push = %push_remote, fetch = %fetch_remote, "Check remotes match"); if git_remote != push_remote { diff --git a/crates/git/src/validation/tests.rs b/crates/git/src/validation/tests.rs new file mode 100644 index 0000000..5cada53 --- /dev/null +++ b/crates/git/src/validation/tests.rs @@ -0,0 +1,594 @@ +// +use crate as git; + +use git::tests::given; +use git_next_config as config; + +use assert2::let_assert; + +mod repos { + use crate::repository::RepositoryLike as _; + + use super::*; + + #[test] + fn where_repo_has_no_push_remote() { + let_assert!(Ok(fs) = kxio::fs::temp(), "temp fs"); + let gitdir: config::GitDir = fs.base().to_path_buf().into(); + let test_repository = git::repository::test(fs.clone()); + // default has no push or fetch remotes + let repo_details = given::repo_details(&fs); + let_assert!(Ok(repository) = test_repository.open(&gitdir), "open repo"); + + let_assert!(Err(err) = git::validation::repo::validate_repo(&repository, &repo_details)); + assert!(matches!( + err, + git::validation::repo::Error::NoDefaultPushRemote + )); + } +} +mod positions { + use super::*; + + use git::repository::RepositoryLike as _; + + mod validate_positions { + + use git::validation::positions::validate_positions; + + use git::tests::then; + + use super::*; + + #[test] + fn where_fetch_fails_should_error() { + let_assert!(Ok(fs) = kxio::fs::temp(), "temp fs"); + let gitdir: config::GitDir = fs.base().to_path_buf().into(); + let mut test_repository = git::repository::test(fs.clone()); + test_repository.on_fetch(git::repository::OnFetch::new( + given::repo_branches(), + gitdir.clone(), + fs.clone(), + |_, _, _| git::fetch::Result::Err(git::fetch::Error::TestFailureExpected), + )); + let_assert!(Ok(repository) = test_repository.open(&gitdir), "open repo"); + let repo_details = given::repo_details(&fs).with_gitdir(gitdir); + let repo_config = given::a_repo_config(); + + let_assert!( + Err(err) = validate_positions(&repository, &repo_details, repo_config), + "validate" + ); + + assert!(matches!( + err, + git::validation::positions::Error::Fetch(git::fetch::Error::TestFailureExpected) + )); + } + + #[test] + fn where_main_branch_is_missing_or_commit_log_is_empty_should_error() { + let_assert!(Ok(fs) = kxio::fs::temp(), "temp fs"); + let gitdir: config::GitDir = fs.base().to_path_buf().into(); + let mut test_repository = git::repository::test(fs.clone()); + test_repository.on_fetch(git::repository::OnFetch::new( + given::repo_branches(), + gitdir.clone(), + fs.clone(), + |_, _, _| git::fetch::Result::Ok(()), + )); + // test repo is a new bare repo with no commits + let_assert!(Ok(repository) = test_repository.open(&gitdir), "open repo"); + let repo_details = given::repo_details(&fs).with_gitdir(gitdir); + let repo_config = given::a_repo_config(); + + let_assert!( + Err(err) = validate_positions(&repository, &repo_details, repo_config.clone()), + "validate" + ); + let branch_name = repo_config.branches().main(); + let error_message = format!( + r#"The ref partially named "remotes/origin/{branch_name}" could not be found"# + ); + assert!(matches!( + err, + git::validation::positions::Error::CommitLog(git::commit::log::Error::Gix { + branch, + error + }) if branch == branch_name && error == error_message + )); + } + + #[test] + fn where_next_branch_is_missing_or_commit_log_is_empty_should_error() { + //given + let_assert!(Ok(fs) = kxio::fs::temp(), "temp fs"); + let gitdir: config::GitDir = fs.base().to_path_buf().into(); + let mut test_repository = git::repository::test(fs.clone()); + let repo_config = given::a_repo_config(); + test_repository.on_fetch(git::repository::OnFetch::new( + repo_config.branches().clone(), + gitdir.clone(), + fs.clone(), + |branches, gitdir, fs| { + // add a commit to main + then::create_a_commit_on_branch(fs, gitdir, &branches.main())?; + git::fetch::Result::Ok(()) + }, + )); + let_assert!(Ok(repository) = test_repository.open(&gitdir), "open repo"); + let repo_details = given::repo_details(&fs).with_gitdir(gitdir); + + //when + let_assert!( + Err(err) = validate_positions(&repository, &repo_details, repo_config.clone()), + "validate" + ); + + //then + let branch_name = repo_config.branches().next(); + let error_message = format!( + r#"The ref partially named "remotes/origin/{branch_name}" could not be found"# + ); + assert!(matches!( + err, + git::validation::positions::Error::CommitLog(git::commit::log::Error::Gix { + branch, + error + }) if branch == branch_name && error == error_message + )); + } + + #[test] + fn where_dev_branch_is_missing_or_commit_log_is_empty_should_error() { + //given + let_assert!(Ok(fs) = kxio::fs::temp(), "temp fs"); + let gitdir: config::GitDir = fs.base().to_path_buf().into(); + let mut test_repository = git::repository::test(fs.clone()); + let repo_config = given::a_repo_config(); + test_repository.on_fetch(git::repository::OnFetch::new( + repo_config.branches().clone(), + gitdir.clone(), + fs.clone(), + |branches, gitdir, fs| { + // add a commit to main + then::create_a_commit_on_branch(fs, gitdir, &branches.main())?; + // add a commit to next + then::create_a_commit_on_branch(fs, gitdir, &branches.next())?; + git::fetch::Result::Ok(()) + }, + )); + let_assert!(Ok(repository) = test_repository.open(&gitdir), "open repo"); + let repo_details = given::repo_details(&fs).with_gitdir(gitdir); + + //when + let_assert!( + Err(err) = validate_positions(&repository, &repo_details, repo_config.clone()), + "validate" + ); + + //then + let branch_name = repo_config.branches().dev(); + let error_message = format!( + r#"The ref partially named "remotes/origin/{branch_name}" could not be found"# + ); + assert!(matches!( + err, + git::validation::positions::Error::CommitLog(git::commit::log::Error::Gix { + branch, + error + }) if branch == branch_name && error == error_message + )); + } + + #[test] + fn where_dev_branch_is_not_based_on_main_should_error() { + //given + let_assert!(Ok(fs) = kxio::fs::temp(), "temp fs"); + let gitdir: config::GitDir = fs.base().to_path_buf().into(); + let mut test_repository = git::repository::test(fs.clone()); + let repo_config = given::a_repo_config(); + test_repository.on_fetch(git::repository::OnFetch::new( + repo_config.branches().clone(), + gitdir.clone(), + fs.clone(), + |branches, gitdir, fs| { + // /--- 4 next + // 0 --- 1 --- 3 main + // \--- 2 dev + // add a commit to main (0 -> 1) + then::create_a_commit_on_branch(fs, gitdir, &branches.main())?; + // add a commit to dev ( 1 -> 2) + then::create_a_commit_on_branch(fs, gitdir, &branches.dev())?; + // switch back to main + then::git_switch(&branches.main(), gitdir)?; + // add a second commit to main (1 -> 3) + then::git_log_all(gitdir)?; + then::create_a_commit_on_branch(fs, gitdir, &branches.main())?; + // add a commit to next 1 -> 4 + then::create_a_commit_on_branch(fs, gitdir, &branches.next())?; + then::git_log_all(gitdir)?; + git::fetch::Result::Ok(()) + }, + )); + let_assert!(Ok(repository) = test_repository.open(&gitdir), "open repo"); + let repo_details = given::repo_details(&fs).with_gitdir(gitdir); + + //when + let_assert!( + Err(err) = validate_positions(&repository, &repo_details, repo_config.clone()), + "validate" + ); + + //then + assert!(matches!( + err, + git::validation::positions::Error::DevBranchNotBasedOn { dev, other } + if dev == repo_config.branches().dev() && other == repo_config.branches().main() + )); + } + + #[test] + fn where_next_branch_is_not_based_on_main_should_reset_next_branch_to_main_and_then_error() + { + //given + let_assert!(Ok(fs) = kxio::fs::temp(), "temp fs"); + let gitdir: config::GitDir = fs.base().to_path_buf().into(); + let mut test_repository = git::repository::test(fs.clone()); + let repo_config = given::a_repo_config(); + test_repository.on_fetch(git::repository::OnFetch::new( + repo_config.branches().clone(), + gitdir.clone(), + fs.clone(), + |branches, gitdir, fs| { + // /--- 4 dev + // 0 --- 1 --- 3 main + // \--- 2 next + // add a commit to main (0 -> 1) + then::create_a_commit_on_branch(fs, gitdir, &branches.main())?; + // add a commit to next (1 -> 2) + then::create_a_commit_on_branch(fs, gitdir, &branches.next())?; + // switch back to main (1) + then::git_switch(&branches.main(), gitdir)?; + // add a second commit to main (1 -> 3) + then::create_a_commit_on_branch(fs, gitdir, &branches.main())?; + // add a commit to dev (3 -> 4) + then::create_a_commit_on_branch(fs, gitdir, &branches.dev())?; + + then::git_log_all(gitdir)?; + git::fetch::Result::Ok(()) + }, + )); + // second fetch as prep to push + test_repository.on_fetch(git::repository::OnFetch::new( + repo_config.branches().clone(), + gitdir.clone(), + fs.clone(), + |_branches, _gitdir, _fs| { + // don't change anything + git::fetch::Result::Ok(()) + }, + )); + test_repository.on_push(git::repository::OnPush::new( + repo_config.branches().clone(), + gitdir.clone(), + fs.clone(), + |_repo_details, branch_name, gitref, force, repo_branches, gitdir, fs| { + assert_eq!( + branch_name, + &repo_branches.next(), + "branch to reset should be 'next'" + ); + let sha_main = then::get_sha_for_branch(fs, gitdir, &repo_branches.main())?; + assert_eq!( + gitref, + &git::GitRef::from(sha_main), + "should reset to the sha of the 'main' branch" + ); + let sha_next = then::get_sha_for_branch(fs, gitdir, &repo_branches.next())?; + assert_eq!( + force, + &git::push::Force::From(sha_next.into()), + "should force push only if next is on expected sha" + ); + git::push::Result::Ok(()) + }, + )); + let_assert!(Ok(repository) = test_repository.open(&gitdir), "open repo"); + let repo_details = given::repo_details(&fs).with_gitdir(gitdir); + + //when + let_assert!( + Err(err) = validate_positions(&repository, &repo_details, repo_config.clone()), + "validate" + ); + + //then + eprintln!("{err:?}"); + // NOTE: assertions for correct push are in on_push above + assert!(matches!( + err, + git::validation::positions::Error::NextBranchResetRequired(branch) + if branch == repo_config.branches().next() + )); + } + + #[test] + fn where_next_branch_is_not_based_on_main_and_reset_of_next_fails_should_error() { + //given + let_assert!(Ok(fs) = kxio::fs::temp(), "temp fs"); + let gitdir: config::GitDir = fs.base().to_path_buf().into(); + let mut test_repository = git::repository::test(fs.clone()); + let repo_config = given::a_repo_config(); + test_repository.on_fetch(git::repository::OnFetch::new( + repo_config.branches().clone(), + gitdir.clone(), + fs.clone(), + |branches, gitdir, fs| { + // /--- 4 dev + // 0 --- 1 --- 3 main + // \--- 2 next + // add a commit to main (0 -> 1) + then::create_a_commit_on_branch(fs, gitdir, &branches.main())?; + // add a commit to next (1 -> 2) + then::create_a_commit_on_branch(fs, gitdir, &branches.next())?; + // switch back to main (1) + then::git_switch(&branches.main(), gitdir)?; + // add a second commit to main (1 -> 3) + then::create_a_commit_on_branch(fs, gitdir, &branches.main())?; + // add a commit to dev (3 -> 4) + then::create_a_commit_on_branch(fs, gitdir, &branches.dev())?; + + then::git_log_all(gitdir)?; + git::fetch::Result::Ok(()) + }, + )); + // second fetch as prep to push + test_repository.on_fetch(git::repository::OnFetch::new( + repo_config.branches().clone(), + gitdir.clone(), + fs.clone(), + |_branches, _gitdir, _fs| { + // don't change anything + git::fetch::Result::Ok(()) + }, + )); + test_repository.on_push(git::repository::OnPush::new( + repo_config.branches().clone(), + gitdir.clone(), + fs.clone(), + |_repo_details, _branch_name, _gitref, _force, _repo_branches, _gitdir, _fs| { + git::push::Result::Err(git::push::Error::Lock) + }, + )); + let_assert!(Ok(repository) = test_repository.open(&gitdir), "open repo"); + let repo_details = given::repo_details(&fs).with_gitdir(gitdir.clone()); + + //when + let_assert!( + Err(err) = validate_positions(&repository, &repo_details, repo_config.clone()), + "validate" + ); + + //then + eprintln!("{err:?}"); + let_assert!( + Ok(sha_next) = + then::get_sha_for_branch(&fs, &gitdir, &repo_config.branches().next()), + "load next branch sha" + ); + assert!(matches!( + err, + git::validation::positions::Error::FailedToResetBranch{branch, commit} + if branch == repo_config.branches().next() && commit.sha() == &sha_next + )); + } + + #[test] + fn where_dev_branch_is_not_based_on_next_should_reset_next_branch_to_main_and_then_error() { + //given + let_assert!(Ok(fs) = kxio::fs::temp(), "temp fs"); + let gitdir: config::GitDir = fs.base().to_path_buf().into(); + let mut test_repository = git::repository::test(fs.clone()); + let repo_config = given::a_repo_config(); + test_repository.on_fetch(git::repository::OnFetch::new( + repo_config.branches().clone(), + gitdir.clone(), + fs.clone(), + |branches, gitdir, fs| { + // /--- 3 next + // 0 --- 1 main + // \--- 2 dev + // add a commit to main (0 -> 1) + then::create_a_commit_on_branch(fs, gitdir, &branches.main())?; + // add a commit to dev (1 -> 2) + then::create_a_commit_on_branch(fs, gitdir, &branches.dev())?; + // switch back to main (1) + then::git_switch(&branches.main(), gitdir)?; + // add a commit to next (1 -> 3) + then::create_a_commit_on_branch(fs, gitdir, &branches.next())?; + + then::git_log_all(gitdir)?; + git::fetch::Result::Ok(()) + }, + )); + // second fetch as prep to push + test_repository.on_fetch(git::repository::OnFetch::new( + repo_config.branches().clone(), + gitdir.clone(), + fs.clone(), + |_branches, _gitdir, _fs| { + // don't change anything + git::fetch::Result::Ok(()) + }, + )); + test_repository.on_push(git::repository::OnPush::new( + repo_config.branches().clone(), + gitdir.clone(), + fs.clone(), + |_repo_details, branch_name, gitref, force, repo_branches, gitdir, fs| { + assert_eq!( + branch_name, + &repo_branches.next(), + "branch to reset should be 'next'" + ); + let sha_main = then::get_sha_for_branch(fs, gitdir, &repo_branches.main())?; + assert_eq!( + gitref, + &git::GitRef::from(sha_main), + "should reset to the sha of the 'main' branch" + ); + let sha_next = then::get_sha_for_branch(fs, gitdir, &repo_branches.next())?; + assert_eq!( + force, + &git::push::Force::From(sha_next.into()), + "should force push only if next is on expected sha" + ); + git::push::Result::Ok(()) + }, + )); + let_assert!(Ok(repository) = test_repository.open(&gitdir), "open repo"); + let repo_details = given::repo_details(&fs).with_gitdir(gitdir); + + //when + let_assert!( + Err(err) = validate_positions(&repository, &repo_details, repo_config.clone()), + "validate" + ); + + //then + eprintln!("{err:?}"); + // NOTE: assertions for correct push are in on_push above + assert!(matches!( + err, + git::validation::positions::Error::NextBranchResetRequired(branch) + if branch == repo_config.branches().next() + )); + } + + #[test] + fn where_dev_branch_is_not_based_on_next_and_reset_of_next_fails_should_error() { + //given + let_assert!(Ok(fs) = kxio::fs::temp(), "temp fs"); + let gitdir: config::GitDir = fs.base().to_path_buf().into(); + let mut test_repository = git::repository::test(fs.clone()); + let repo_config = given::a_repo_config(); + test_repository.on_fetch(git::repository::OnFetch::new( + repo_config.branches().clone(), + gitdir.clone(), + fs.clone(), + |branches, gitdir, fs| { + // /--- 3 next + // 0 --- 1 main + // \--- 2 dev + // add a commit to main (0 -> 1) + then::create_a_commit_on_branch(fs, gitdir, &branches.main())?; + // add a commit to dev (1 -> 2) + then::create_a_commit_on_branch(fs, gitdir, &branches.dev())?; + // switch back to main (1) + then::git_switch(&branches.main(), gitdir)?; + // add a commit to next (1 -> 3) + then::create_a_commit_on_branch(fs, gitdir, &branches.next())?; + + then::git_log_all(gitdir)?; + git::fetch::Result::Ok(()) + }, + )); + // second fetch as prep to push + test_repository.on_fetch(git::repository::OnFetch::new( + repo_config.branches().clone(), + gitdir.clone(), + fs.clone(), + |_branches, _gitdir, _fs| { + // don't change anything + git::fetch::Result::Ok(()) + }, + )); + test_repository.on_push(git::repository::OnPush::new( + repo_config.branches().clone(), + gitdir.clone(), + fs.clone(), + |_repo_details, _branch_name, _gitref, _force, _repo_branches, _gitdir, _fs| { + git::push::Result::Err(git::push::Error::Lock) + }, + )); + let_assert!(Ok(repository) = test_repository.open(&gitdir), "open repo"); + let repo_details = given::repo_details(&fs).with_gitdir(gitdir.clone()); + + //when + let_assert!( + Err(err) = validate_positions(&repository, &repo_details, repo_config.clone()), + "validate" + ); + + //then + eprintln!("{err:?}"); + let_assert!( + Ok(sha_next) = + then::get_sha_for_branch(&fs, &gitdir, &repo_config.branches().next()), + "load next branch sha" + ); + assert!(matches!( + err, + git::validation::positions::Error::FailedToResetBranch{branch, commit} + if branch == repo_config.branches().next() && commit.sha() == &sha_next + )); + } + + #[test] + fn where_branches_are_all_valid_should_return_positions() { + //given + let_assert!(Ok(fs) = kxio::fs::temp(), "temp fs"); + let gitdir: config::GitDir = fs.base().to_path_buf().into(); + let mut test_repository = git::repository::test(fs.clone()); + let repo_config = given::a_repo_config(); + test_repository.on_fetch(git::repository::OnFetch::new( + repo_config.branches().clone(), + gitdir.clone(), + fs.clone(), + |branches, gitdir, fs| { + // 0 --- 1 main + // \--- 2 next + // \--- dev + // add a commit to main (0 -> 1) + then::create_a_commit_on_branch(fs, gitdir, &branches.main())?; + // add a commit to next (1 -> 2) + then::create_a_commit_on_branch(fs, gitdir, &branches.next())?; + // add a commit to dev (2 -> 3) + then::create_a_commit_on_branch(fs, gitdir, &branches.dev())?; + + then::git_log_all(gitdir)?; + git::fetch::Result::Ok(()) + }, + )); + let_assert!(Ok(repository) = test_repository.open(&gitdir), "open repo"); + let repo_details = given::repo_details(&fs).with_gitdir(gitdir.clone()); + + //when + let_assert!( + Ok(positions) = validate_positions(&repository, &repo_details, repo_config.clone()), + "validate" + ); + + //then + eprintln!("{positions:#?}"); + + let_assert!( + Ok(main_sha) = + then::get_sha_for_branch(&fs, &gitdir, &repo_config.branches().main()) + ); + assert_eq!(positions.main.sha(), &main_sha); + + let_assert!( + Ok(next_sha) = + then::get_sha_for_branch(&fs, &gitdir, &repo_config.branches().next()) + ); + assert_eq!(positions.next.sha(), &next_sha); + + let_assert!( + Ok(dev_sha) = then::get_sha_for_branch(&fs, &gitdir, &repo_config.branches().dev()) + ); + assert_eq!(positions.dev.sha(), &dev_sha); + } + } +} diff --git a/crates/repo-actor/src/lib.rs b/crates/repo-actor/src/lib.rs index fbcd170..3bf1815 100644 --- a/crates/repo-actor/src/lib.rs +++ b/crates/repo-actor/src/lib.rs @@ -93,7 +93,7 @@ impl Actor for RepoActor { pub struct CloneRepo; impl Handler for RepoActor { type Result = (); - #[tracing::instrument(name = "RepoActor::CloneRepo", skip_all, fields(repo = %self.repo_details, gitdir = %self.repo_details.gitdir))] + #[tracing::instrument(name = "RepoActor::CloneRepo", skip_all, fields(repo = %self.repo_details /*, gitdir = %self.repo_details.gitdir */))] fn handle(&mut self, _msg: CloneRepo, ctx: &mut Self::Context) -> Self::Result { let gitdir = self.repo_details.gitdir.clone(); match git::repository::open(&self.repository, &self.repo_details, gitdir) { diff --git a/crates/repo-actor/src/load.rs b/crates/repo-actor/src/load.rs index 510db4a..313e344 100644 --- a/crates/repo-actor/src/load.rs +++ b/crates/repo-actor/src/load.rs @@ -1,3 +1,5 @@ +use std::path::PathBuf; + // use actix::prelude::*; @@ -31,7 +33,7 @@ async fn load( details: &git::RepoDetails, open_repository: &git::OpenRepository, ) -> Result { - let contents = open_repository.read_file(&details.branch, ".git-next.toml")?; + let contents = open_repository.read_file(&details.branch, &PathBuf::from(".git-next.toml"))?; let config = config::RepoConfig::load(&contents)?; let config = validate(config, open_repository).await?; Ok(config)