feat: validate repo if it already exists
All checks were successful
ci/woodpecker/push/push-next Pipeline was successful
ci/woodpecker/push/cron-docker-builder Pipeline was successful
ci/woodpecker/push/tag-created Pipeline was successful
ci/woodpecker/cron/push-next Pipeline was successful
ci/woodpecker/cron/tag-created Pipeline was successful
ci/woodpecker/cron/cron-docker-builder Pipeline was successful

Closes kemitix/git-next#60
This commit is contained in:
Paul Campbell 2024-04-24 07:08:03 +01:00
parent 7b46045737
commit ff6e61b0ee
8 changed files with 192 additions and 8 deletions

View file

@ -63,7 +63,7 @@ tokio = { version = "1.37", features = ["full"] }
# Testing # Testing
assert2 = "0.3" assert2 = "0.3"
pretty_assertions = "1.4" pretty_assertions = "1.4"
test-log = "0.2" test-log = { version = "0.2", features = ["env_logger", "trace"] }
anyhow = "1.0" anyhow = "1.0"
[package.metadata.bin] [package.metadata.bin]

View file

@ -181,7 +181,6 @@ pub struct ServerRepoConfig {
dev: Option<String>, dev: Option<String>,
} }
impl ServerRepoConfig { impl ServerRepoConfig {
#[allow(dead_code)]
pub fn repo(&self) -> RepoPath { pub fn repo(&self) -> RepoPath {
RepoPath(self.repo.clone()) RepoPath(self.repo.clone())
} }
@ -369,6 +368,36 @@ impl RepoDetails {
let origin = format!("https://{user}:{token}@{hostname}/{repo_path}.git"); let origin = format!("https://{user}:{token}@{hostname}/{repo_path}.git");
origin.into() origin.into()
} }
#[allow(dead_code)]
pub fn validate_repo(&self) -> Result<(), RepoValidationError> {
self.gitdir.validate(self)
}
pub fn find_default_push_remote(&self) -> Result<GitRemote, RepoValidationError> {
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 { impl Display for RepoDetails {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { 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 /// Identifier for the type of Forge
#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
pub enum ForgeType { pub enum ForgeType {
@ -415,6 +472,15 @@ impl GitDir {
pub const fn pathbuf(&self) -> &PathBuf { pub const fn pathbuf(&self) -> &PathBuf {
&self.0 &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 { impl Deref for GitDir {
type Target = PathBuf; type Target = PathBuf;
@ -433,6 +499,33 @@ impl From<&str> for GitDir {
Self(value.into()) Self(value.into())
} }
} }
impl From<GitDir> 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)] #[cfg(test)]
mod tests; mod tests;

View file

@ -1,6 +1,10 @@
use assert2::let_assert;
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
use crate::filesystem::FileSystem; use crate::{
filesystem::FileSystem,
server::gitforge::tests::common, /* server::gitforge::tests::common */
};
use super::*; use super::*;
@ -142,3 +146,82 @@ fn gitdir_should_display_as_pathbuf() {
//then //then
assert_eq!(result, "foo/dir"); 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");
}

View file

@ -43,16 +43,20 @@ impl std::fmt::Display for ForgeBranchError {
#[derive(Debug)] #[derive(Debug)]
pub enum RepoCloneError { pub enum RepoCloneError {
InvalidGitDir(GitDir), InvalidGitDir(GitDir),
Io(std::io::Error),
Wait(std::io::Error), Wait(std::io::Error),
Spawn(std::io::Error), Spawn(std::io::Error),
Validation(String),
} }
impl std::error::Error for RepoCloneError {} impl std::error::Error for RepoCloneError {}
impl std::fmt::Display for RepoCloneError { impl std::fmt::Display for RepoCloneError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self { match self {
Self::InvalidGitDir(gitdir) => write!(f, "Invalid Git dir: {:?}", gitdir), 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::Wait(err) => write!(f, "Waiting for command: {:?}", err),
Self::Spawn(err) => write!(f, "Spawning comming: {:?}", err), Self::Spawn(err) => write!(f, "Spawning comming: {:?}", err),
Self::Validation(err) => write!(f, "Validation: {}", err),
} }
} }
} }

View file

@ -101,9 +101,15 @@ impl super::ForgeLike for ForgeJoEnv {
} }
fn repo_clone(&self, gitdir: GitDir) -> Result<(), RepoCloneError> { fn repo_clone(&self, gitdir: GitDir) -> Result<(), RepoCloneError> {
if gitdir.exists() {
gitdir
.validate(&self.repo_details)
.map_err(|e| RepoCloneError::Validation(e.to_string()))
} else {
repo::clone(&self.repo_details, gitdir) repo::clone(&self.repo_details, gitdir)
} }
} }
}
#[derive(Debug, serde::Deserialize)] #[derive(Debug, serde::Deserialize)]
pub struct CombinedStatus { pub struct CombinedStatus {

View file

@ -6,8 +6,6 @@ use crate::server::{
}; };
pub fn clone(repo_details: &RepoDetails, gitdir: GitDir) -> Result<(), RepoCloneError> { 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(); let origin = repo_details.origin();
// INFO: never log the command as it contains the API token within the 'origin' // INFO: never log the command as it contains the API token within the 'origin'
use secrecy::ExposeSecret; use secrecy::ExposeSecret;

View file

@ -94,4 +94,4 @@ impl std::ops::Deref for Forge {
} }
#[cfg(test)] #[cfg(test)]
mod tests; pub mod tests;

View file

@ -1,6 +1,6 @@
use super::*; use super::*;
mod common; pub mod common;
#[cfg(feature = "forgejo")] #[cfg(feature = "forgejo")]
mod forgejo; mod forgejo;