diff --git a/Cargo.toml b/Cargo.toml index 0d0baed..f519ee4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -63,7 +63,7 @@ tokio = { version = "1.37", features = ["full"] } # Testing assert2 = "0.3" pretty_assertions = "1.4" -test-log = "0.2" +test-log = { version = "0.2", features = ["env_logger", "trace"] } anyhow = "1.0" [package.metadata.bin] diff --git a/src/server/config/mod.rs b/src/server/config/mod.rs index 74a2141..225f6cf 100644 --- a/src/server/config/mod.rs +++ b/src/server/config/mod.rs @@ -181,7 +181,6 @@ pub struct ServerRepoConfig { dev: Option, } impl ServerRepoConfig { - #[allow(dead_code)] pub fn repo(&self) -> RepoPath { RepoPath(self.repo.clone()) } @@ -369,6 +368,36 @@ impl RepoDetails { let origin = format!("https://{user}:{token}@{hostname}/{repo_path}.git"); origin.into() } + + #[allow(dead_code)] + pub fn validate_repo(&self) -> Result<(), RepoValidationError> { + self.gitdir.validate(self) + } + + pub fn find_default_push_remote(&self) -> Result { + let repository = gix::open(self.gitdir.clone()) + .map_err(|e| RepoValidationError::UnableToOpenRepo(e.to_string()))?; + let Some(Ok(remote)) = repository.find_default_remote(gix::remote::Direction::Push) else { + return Err(RepoValidationError::NoDefaultPushRemote); + }; + let Some(url) = remote.url(gix::remote::Direction::Push) else { + return Err(RepoValidationError::NoUrlForDefaultPushRemote); + }; + let Some(host) = url.host() else { + return Err(RepoValidationError::NoHostnameForDefaultPushRemote); + }; + let path = url.path.to_string(); + let path = path.strip_prefix('/').map_or(path.as_str(), |path| path); + let path = path.strip_suffix(".git").map_or(path, |path| path); + Ok(GitRemote::new( + Hostname(host.to_string()), + RepoPath(path.to_string()), + )) + } + + pub fn git_remote(&self) -> GitRemote { + GitRemote::new(self.forge.hostname.clone(), self.repo_path.clone()) + } } impl Display for RepoDetails { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { @@ -386,6 +415,34 @@ impl Display for RepoDetails { } } +#[derive(Debug)] +pub enum RepoValidationError { + NoDefaultPushRemote, + NoUrlForDefaultPushRemote, + NoHostnameForDefaultPushRemote, + UnableToOpenRepo(String), + Io(std::io::Error), + MismatchDefaultPushRemote { + found: GitRemote, + configured: GitRemote, + }, +} +impl Display for RepoValidationError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::NoDefaultPushRemote => write!(f, "There is no default push remote"), + Self::NoUrlForDefaultPushRemote => write!(f, "The default push remote has no url"), + Self::NoHostnameForDefaultPushRemote => { + write!(f, "The default push remote has no hostname") + } + Self::UnableToOpenRepo(err) => write!(f, "Unable to open the git dir: {err}"), + Self::Io(err) => write!(f, "IO Error: {err:?}"), + Self::MismatchDefaultPushRemote{found, configured} => + write!(f, "The default push remote for the git dir doesn't match the configuration: found: {found}, expected: {configured}") + } + } +} + /// Identifier for the type of Forge #[derive(Clone, Debug, PartialEq, Eq, Deserialize)] pub enum ForgeType { @@ -415,6 +472,15 @@ impl GitDir { pub const fn pathbuf(&self) -> &PathBuf { &self.0 } + + pub fn validate(&self, repo_details: &RepoDetails) -> Result<(), RepoValidationError> { + let configured = repo_details.git_remote(); + let found = repo_details.find_default_push_remote()?; + if configured != found { + return Err(RepoValidationError::MismatchDefaultPushRemote { found, configured }); + } + Ok(()) + } } impl Deref for GitDir { type Target = PathBuf; @@ -433,6 +499,33 @@ impl From<&str> for GitDir { Self(value.into()) } } +impl From for PathBuf { + fn from(value: GitDir) -> Self { + value.0 + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GitRemote { + host: Hostname, + repo_path: RepoPath, +} +impl GitRemote { + pub const fn new(host: Hostname, repo_path: RepoPath) -> Self { + Self { host, repo_path } + } + pub const fn host(&self) -> &Hostname { + &self.host + } + pub const fn repo_path(&self) -> &RepoPath { + &self.repo_path + } +} +impl Display for GitRemote { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{}:{}", self.host, self.repo_path) + } +} #[cfg(test)] mod tests; diff --git a/src/server/config/tests.rs b/src/server/config/tests.rs index ae12bfc..1cc7a62 100644 --- a/src/server/config/tests.rs +++ b/src/server/config/tests.rs @@ -1,6 +1,10 @@ +use assert2::let_assert; use pretty_assertions::assert_eq; -use crate::filesystem::FileSystem; +use crate::{ + filesystem::FileSystem, + server::gitforge::tests::common, /* server::gitforge::tests::common */ +}; use super::*; @@ -142,3 +146,82 @@ fn gitdir_should_display_as_pathbuf() { //then assert_eq!(result, "foo/dir"); } + +#[test] +// NOTE: this test assumes it is being run in a cloned worktree from the project's home repo: +// git.kemitix.net:kemitix/git-next +// If the default push remote is something else, then this test will fail +fn repo_details_find_default_push_remote_finds_correct_remote() -> Result<(), RepoValidationError> { + let cwd = std::env::current_dir().map_err(RepoValidationError::Io)?; + let mut repo_details = common::repo_details( + 1, + common::forge_details(1, ForgeType::MockForge), + None, + GitDir::new(&cwd), // Server GitDir - should be ignored + ); + repo_details.forge.hostname = Hostname("git.kemitix.net".to_string()); + repo_details.repo_path = RepoPath("kemitix/git-next".to_string()); + let found_git_remote = repo_details.find_default_push_remote()?; + let config_git_remote = repo_details.git_remote(); + + assert_eq!( + found_git_remote, config_git_remote, + "Default Push Remote must match config" + ); + + Ok(()) +} + +#[test] +fn gitdir_validate_should_pass_a_valid_git_repo() -> Result<(), RepoValidationError> { + let cwd = std::env::current_dir().map_err(RepoValidationError::Io)?; + let mut repo_details = common::repo_details( + 1, + common::forge_details(1, ForgeType::MockForge), + None, + GitDir::new(&cwd), // Server GitDir - should be ignored + ); + repo_details.forge.hostname = Hostname("git.kemitix.net".to_string()); + repo_details.repo_path = RepoPath("kemitix/git-next".to_string()); + let git_dir = &repo_details.gitdir; + git_dir.validate(&repo_details)?; + + Ok(()) +} + +#[test] +fn gitdir_validate_should_fail_a_non_git_dir() { + let_assert!(Ok(fs) = kxio::filesystem::FileSystem::new_temp()); + let cwd = fs.cwd(); + let repo_details = common::repo_details( + 1, + common::forge_details(1, ForgeType::MockForge), + None, + GitDir::new(cwd), // Server GitDir - should be ignored + ); + let git_dir = &repo_details.gitdir; + let_assert!(Err(_) = git_dir.validate(&repo_details)); +} + +#[test] +fn gitdir_validate_should_fail_a_git_repo_with_wrong_remote() { + let_assert!(Ok(cwd) = std::env::current_dir().map_err(RepoValidationError::Io)); + let mut repo_details = common::repo_details( + 1, + common::forge_details(1, ForgeType::MockForge), + None, + GitDir::new(&cwd), // Server GitDir - should be ignored + ); + repo_details.forge.hostname = Hostname("localhost".to_string()); + repo_details.repo_path = RepoPath("hello/world".to_string()); + let git_dir = &repo_details.gitdir; + let_assert!(Err(_) = git_dir.validate(&repo_details)); +} + +#[test] +fn git_remote_to_string_is_as_expected() { + let git_remote = GitRemote::new(Hostname("foo".to_string()), RepoPath("bar".to_string())); + let as_string = git_remote.to_string(); + + assert_eq!(as_string, "foo:bar"); +} diff --git a/src/server/gitforge/errors.rs b/src/server/gitforge/errors.rs index 6e85a5a..9530e51 100644 --- a/src/server/gitforge/errors.rs +++ b/src/server/gitforge/errors.rs @@ -43,16 +43,20 @@ impl std::fmt::Display for ForgeBranchError { #[derive(Debug)] pub enum RepoCloneError { InvalidGitDir(GitDir), + Io(std::io::Error), Wait(std::io::Error), Spawn(std::io::Error), + Validation(String), } impl std::error::Error for RepoCloneError {} impl std::fmt::Display for RepoCloneError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::InvalidGitDir(gitdir) => write!(f, "Invalid Git dir: {:?}", gitdir), + Self::Io(err) => write!(f, "IO error: {:?}", err), Self::Wait(err) => write!(f, "Waiting for command: {:?}", err), Self::Spawn(err) => write!(f, "Spawning comming: {:?}", err), + Self::Validation(err) => write!(f, "Validation: {}", err), } } } diff --git a/src/server/gitforge/forgejo/mod.rs b/src/server/gitforge/forgejo/mod.rs index a78d471..4638af0 100644 --- a/src/server/gitforge/forgejo/mod.rs +++ b/src/server/gitforge/forgejo/mod.rs @@ -101,7 +101,13 @@ impl super::ForgeLike for ForgeJoEnv { } fn repo_clone(&self, gitdir: GitDir) -> Result<(), RepoCloneError> { - repo::clone(&self.repo_details, gitdir) + if gitdir.exists() { + gitdir + .validate(&self.repo_details) + .map_err(|e| RepoCloneError::Validation(e.to_string())) + } else { + repo::clone(&self.repo_details, gitdir) + } } } diff --git a/src/server/gitforge/forgejo/repo/clone.rs b/src/server/gitforge/forgejo/repo/clone.rs index 194eefc..e6604d9 100644 --- a/src/server/gitforge/forgejo/repo/clone.rs +++ b/src/server/gitforge/forgejo/repo/clone.rs @@ -6,8 +6,6 @@ use crate::server::{ }; pub fn clone(repo_details: &RepoDetails, gitdir: GitDir) -> Result<(), RepoCloneError> { - // TODO: (#60) If directory already exists, validate it is git repo (e.g. is a git repo, - // matches the same origin) let origin = repo_details.origin(); // INFO: never log the command as it contains the API token within the 'origin' use secrecy::ExposeSecret; diff --git a/src/server/gitforge/mod.rs b/src/server/gitforge/mod.rs index 1b6765d..973fe9c 100644 --- a/src/server/gitforge/mod.rs +++ b/src/server/gitforge/mod.rs @@ -94,4 +94,4 @@ impl std::ops::Deref for Forge { } #[cfg(test)] -mod tests; +pub mod tests; diff --git a/src/server/gitforge/tests/mod.rs b/src/server/gitforge/tests/mod.rs index 71c268b..eb07eca 100644 --- a/src/server/gitforge/tests/mod.rs +++ b/src/server/gitforge/tests/mod.rs @@ -1,6 +1,6 @@ use super::*; -mod common; +pub mod common; #[cfg(feature = "forgejo")] mod forgejo;