feat: Abstract Git command execution into injectable enum

Closes kemitix/git-next#36
This commit is contained in:
Paul Campbell 2024-04-12 18:45:32 +01:00
parent fb74879309
commit 5fcf16ea75
9 changed files with 229 additions and 97 deletions

View file

@ -4,6 +4,8 @@ mod server;
use clap::Parser; use clap::Parser;
use kxio::{filesystem, network::Network}; use kxio::{filesystem, network::Network};
use crate::server::git::Git;
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
#[clap(version = clap::crate_version!(), author = clap::crate_authors!(), about = clap::crate_description!())] #[clap(version = clap::crate_version!(), author = clap::crate_authors!(), about = clap::crate_description!())]
struct Commands { struct Commands {
@ -26,6 +28,7 @@ enum Server {
async fn main() { async fn main() {
let fs = filesystem::FileSystem::new_real(None); let fs = filesystem::FileSystem::new_real(None);
let net = Network::new_real(); let net = Network::new_real();
let git = Git::new_real();
let commands = Commands::parse(); let commands = Commands::parse();
match commands.command { match commands.command {
@ -37,7 +40,7 @@ async fn main() {
server::init(fs); server::init(fs);
} }
Server::Start => { Server::Start => {
server::start(fs, net).await; server::start(fs, net, git).await;
} }
}, },
} }

View file

@ -1,16 +1,9 @@
use std::fmt::Display;
use actix::prelude::*; use actix::prelude::*;
use kxio::network; use kxio::network;
use secrecy::ExposeSecret;
use tracing::{error, info, warn}; use tracing::{error, info, warn};
use crate::server::{ use crate::server::{actors::repo::ValidateRepo, config, forge, git::Git, types::ResetForce};
actors::repo::{branch, ValidateRepo},
config::{self, BranchName},
forge,
};
use super::{RepoActor, StartMonitoring}; use super::{RepoActor, StartMonitoring};
@ -19,6 +12,7 @@ pub async fn validate_positions(
repo_details: config::RepoDetails, repo_details: config::RepoDetails,
config: config::RepoConfig, config: config::RepoConfig,
addr: Addr<RepoActor>, addr: Addr<RepoActor>,
git: Git,
net: network::Network, net: network::Network,
) { ) {
let commit_histories = match repo_details.forge.forge_type { let commit_histories = match repo_details.forge.forge_type {
@ -45,9 +39,11 @@ pub async fn validate_positions(
let next_is_ancestor_of_dev = commit_histories.dev.iter().any(|dev| dev == &next); let next_is_ancestor_of_dev = commit_histories.dev.iter().any(|dev| dev == &next);
if !next_is_ancestor_of_dev { if !next_is_ancestor_of_dev {
info!("Next is not an ancestor of dev - resetting next to main"); info!("Next is not an ancestor of dev - resetting next to main");
if branch::reset(
if git
.reset(
&config.branches().next(), &config.branches().next(),
main, main.into(),
ResetForce::Force(next.into()), ResetForce::Force(next.into()),
&repo_details, &repo_details,
) )
@ -118,6 +114,7 @@ pub async fn advance_next(
repo_details: config::RepoDetails, repo_details: config::RepoDetails,
repo_config: config::RepoConfig, repo_config: config::RepoConfig,
addr: Addr<RepoActor>, addr: Addr<RepoActor>,
git: Git,
) { ) {
let next_commit = find_next_commit_on_dev(next, dev_commit_history); let next_commit = find_next_commit_on_dev(next, dev_commit_history);
let Some(commit) = next_commit else { let Some(commit) = next_commit else {
@ -129,9 +126,9 @@ pub async fn advance_next(
return; return;
} }
info!("Advancing next to commit '{}'", commit); info!("Advancing next to commit '{}'", commit);
match reset( match git.reset(
&repo_config.branches().next(), &repo_config.branches().next(),
commit, commit.into(),
ResetForce::None, ResetForce::None,
&repo_details, &repo_details,
) { ) {
@ -184,11 +181,12 @@ pub async fn advance_main(
next: forge::Commit, next: forge::Commit,
repo_details: config::RepoDetails, repo_details: config::RepoDetails,
repo_config: config::RepoConfig, repo_config: config::RepoConfig,
git: Git,
) { ) {
info!("Advancing main to next"); info!("Advancing main to next");
match reset( match git.reset(
&repo_config.branches().main(), &repo_config.branches().main(),
next, next.into(),
ResetForce::None, ResetForce::None,
&repo_details, &repo_details,
) { ) {
@ -201,75 +199,6 @@ pub async fn advance_main(
}; };
} }
#[derive(Clone, Debug)]
pub struct GitRef(pub String);
impl From<forge::Commit> for GitRef {
fn from(value: forge::Commit) -> Self {
Self(value.sha().to_string())
}
}
impl From<BranchName> for GitRef {
fn from(value: BranchName) -> Self {
Self(value.0)
}
}
impl Display for GitRef {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
#[derive(Debug)]
pub enum ResetForce {
None,
Force(GitRef),
}
impl Display for ResetForce {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{:?}", self)
}
}
pub fn reset(
branch: &BranchName,
gitref: impl Into<GitRef>,
reset_force: ResetForce,
repo_details: &config::RepoDetails,
) -> Result<(), std::io::Error> {
let gitref: GitRef = gitref.into();
let user = &repo_details.forge.user;
let hostname = &repo_details.forge.hostname;
let path = &repo_details.repo;
let token = &repo_details.forge.token.expose_secret();
let origin = format!("https://{user}:{token}@{hostname}/{path}.git");
let force = match reset_force {
ResetForce::None => "".to_string(),
ResetForce::Force(old_ref) => format!("--force-with-lease={branch}:{old_ref}"),
};
// INFO: never log the command as it contains the API token
let command = format!("/usr/bin/git push {origin} {gitref}:{branch} {force}");
drop(origin);
info!("Resetting {branch} to {gitref}");
match gix::command::prepare(command)
.with_shell_allow_argument_splitting()
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
{
Ok(mut child) => match child.wait() {
Ok(_) => Ok(()),
Err(err) => {
warn!(?err, "Advance Next Failed (wait)");
Err(err)
}
},
Err(err) => {
warn!(?err, "Advance Next Failed (spawn)");
Err(err)
}
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;

View file

@ -10,6 +10,7 @@ use tracing::{info, warn};
use crate::server::{ use crate::server::{
config::{RepoConfig, RepoDetails}, config::{RepoConfig, RepoDetails},
forge, forge,
git::Git,
}; };
use self::webhook::WebhookId; use self::webhook::WebhookId;
@ -19,14 +20,16 @@ pub struct RepoActor {
config: Option<RepoConfig>, // INFO: if [None] then send [StartRepo] to populate it config: Option<RepoConfig>, // INFO: if [None] then send [StartRepo] to populate it
webhook_id: Option<WebhookId>, // INFO: if [None] then no webhook is configured webhook_id: Option<WebhookId>, // INFO: if [None] then no webhook is configured
net: Network, net: Network,
git: Git,
} }
impl RepoActor { impl RepoActor {
pub(crate) const fn new(details: RepoDetails, net: Network) -> Self { pub(crate) const fn new(details: RepoDetails, net: Network, git: Git) -> Self {
Self { Self {
details, details,
config: None, config: None,
webhook_id: None, webhook_id: None,
net, net,
git,
} }
} }
} }
@ -85,7 +88,8 @@ impl Handler<ValidateRepo> for RepoActor {
let repo_details = self.details.clone(); let repo_details = self.details.clone();
let addr = ctx.address(); let addr = ctx.address();
let net = self.net.clone(); let net = self.net.clone();
branch::validate_positions(repo_details, repo_config, addr, net) let git = self.git.clone();
branch::validate_positions(repo_details, repo_config, addr, git, net)
.into_actor(self) .into_actor(self)
.wait(ctx); .wait(ctx);
} }
@ -115,6 +119,7 @@ impl Handler<StartMonitoring> for RepoActor {
let repo_details = self.details.clone(); let repo_details = self.details.clone();
let addr = ctx.address(); let addr = ctx.address();
let net = self.net.clone(); let net = self.net.clone();
let git = self.git.clone();
if next_ahead_of_main { if next_ahead_of_main {
status::check_next(msg.next, repo_details, addr, net) status::check_next(msg.next, repo_details, addr, net)
@ -127,6 +132,7 @@ impl Handler<StartMonitoring> for RepoActor {
repo_details, repo_details,
repo_config, repo_config,
addr, addr,
git,
) )
.into_actor(self) .into_actor(self)
.wait(ctx); .wait(ctx);
@ -159,7 +165,8 @@ impl Handler<AdvanceMainTo> for RepoActor {
warn!("No config loaded"); warn!("No config loaded");
return; return;
}; };
branch::advance_main(msg.0, repo_details, repo_config) let git = self.git.clone();
branch::advance_main(msg.0, repo_details, repo_config, git)
.into_actor(self) .into_actor(self)
.wait(ctx); .wait(ctx);
} }

View file

@ -205,7 +205,7 @@ impl Display for RepoPath {
write!(f, "{}", self.0) write!(f, "{}", self.0)
} }
} }
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, Hash, PartialEq, Eq)]
pub struct BranchName(pub String); pub struct BranchName(pub String);
impl Display for BranchName { impl Display for BranchName {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {

47
src/server/git/mock.rs Normal file
View file

@ -0,0 +1,47 @@
use std::collections::HashMap;
use crate::server::{
config::{BranchName, RepoDetails},
git::GitResetResult,
types::{GitRef, ResetForce},
};
#[derive(Clone)]
pub struct MockGit {
reset: HashMap<(BranchName, GitRef), GitResetResult>,
}
impl MockGit {
pub(super) fn new() -> Self {
Self {
reset: HashMap::new(),
}
}
#[allow(dead_code)] // TODO: (#38) Add tests
pub const fn into_git(self) -> super::Git {
super::Git::Mock(self)
}
#[allow(dead_code)] // TODO: (#38) Add tests
pub fn expect_reset_result(
&mut self,
branch_name: &BranchName,
gitref: &GitRef,
result: GitResetResult,
) {
self.reset
.insert((branch_name.clone(), gitref.clone()), result);
}
}
impl super::GitLike for MockGit {
fn reset(
&self,
branch: &BranchName,
gitref: GitRef,
_reset_force: ResetForce,
_repo_details: &RepoDetails,
) -> GitResetResult {
self.reset
.get(&(branch.clone(), gitref.clone()))
.map_or_else(|| panic!("unexpected: {} to {}", branch, gitref), |r| *r)
}
}

48
src/server/git/mod.rs Normal file
View file

@ -0,0 +1,48 @@
use std::ops::Deref;
use crate::server::{
config::{BranchName, RepoDetails},
git::{mock::MockGit, real::RealGit},
types::{GitRef, ResetForce},
};
mod mock;
mod real;
#[derive(Clone)]
pub enum Git {
Real(RealGit),
#[allow(dead_code)] // TODO: (#38) Add tests
Mock(MockGit),
}
impl Git {
pub const fn new_real() -> Self {
Self::Real(RealGit::new())
}
#[allow(dead_code)] // TODO: (#38) Add tests
pub fn new_mock() -> MockGit {
MockGit::new()
}
}
impl Deref for Git {
type Target = dyn GitLike;
fn deref(&self) -> &Self::Target {
match self {
Self::Real(git) => git,
Self::Mock(git) => git,
}
}
}
pub trait GitLike: Sync + Send {
fn reset(
&self,
branch: &BranchName,
gitref: GitRef,
reset_force: ResetForce,
repo_details: &RepoDetails,
) -> GitResetResult;
}
pub type GitResetResult = Result<(), ()>;

57
src/server/git/real.rs Normal file
View file

@ -0,0 +1,57 @@
use secrecy::ExposeSecret;
use tracing::{info, warn};
use crate::server::{
config::{BranchName, RepoDetails},
git::GitResetResult,
types::{GitRef, ResetForce},
};
#[derive(Clone)]
pub struct RealGit;
impl RealGit {
pub(super) const fn new() -> Self {
Self
}
}
impl super::GitLike for RealGit {
fn reset(
&self,
branch: &BranchName,
gitref: GitRef,
reset_force: ResetForce,
repo_details: &RepoDetails,
) -> GitResetResult {
let user = &repo_details.forge.user;
let hostname = &repo_details.forge.hostname;
let path = &repo_details.repo;
let token = &repo_details.forge.token.expose_secret();
let origin = format!("https://{user}:{token}@{hostname}/{path}.git");
let force = match reset_force {
ResetForce::None => "".to_string(),
ResetForce::Force(old_ref) => format!("--force-with-lease={branch}:{old_ref}"),
};
// INFO: never log the command as it contains the API token
let command = format!("/usr/bin/git push {origin} {gitref}:{branch} {force}");
drop(origin);
info!("Resetting {branch} to {gitref}");
match gix::command::prepare(command)
.with_shell_allow_argument_splitting()
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
{
Ok(mut child) => match child.wait() {
Ok(_) => Ok(()),
Err(err) => {
warn!(?err, "Failed (wait)");
Err(())
}
},
Err(err) => {
warn!(?err, "Failed (spawn)");
Err(())
}
}
}
}

View file

@ -1,6 +1,8 @@
mod actors; mod actors;
mod config; mod config;
pub mod forge; pub mod forge;
pub mod git;
pub mod types;
use actix::prelude::*; use actix::prelude::*;
use kxio::network::Network; use kxio::network::Network;
@ -11,7 +13,10 @@ use tracing::{error, info, level_filters::LevelFilter};
use crate::{ use crate::{
filesystem::FileSystem, filesystem::FileSystem,
server::config::{Forge, ForgeName, RepoName}, server::{
config::{Forge, ForgeName, RepoName},
git::Git,
},
}; };
use self::{actors::repo::RepoActor, config::Repo}; use self::{actors::repo::RepoActor, config::Repo};
@ -34,7 +39,7 @@ pub fn init(fs: FileSystem) {
} }
} }
pub async fn start(fs: FileSystem, net: Network) { pub async fn start(fs: FileSystem, net: Network, git: Git) {
let Ok(_) = init_logging() else { let Ok(_) = init_logging() else {
eprintln!("Failed to initialize logging."); eprintln!("Failed to initialize logging.");
return; return;
@ -51,7 +56,7 @@ pub async fn start(fs: FileSystem, net: Network) {
info!("Config loaded"); info!("Config loaded");
let addresses = config let addresses = config
.forges() .forges()
.flat_map(|(forge_name, forge)| create_forge_repos(forge, forge_name, &net)) .flat_map(|(forge_name, forge)| create_forge_repos(forge, forge_name, &net, &git))
.map(start_actor) .map(start_actor)
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let _ = actix_rt::signal::ctrl_c().await; let _ = actix_rt::signal::ctrl_c().await;
@ -63,6 +68,7 @@ fn create_forge_repos(
forge: &Forge, forge: &Forge,
forge_name: ForgeName, forge_name: ForgeName,
net: &Network, net: &Network,
git: &Git,
) -> Vec<(ForgeName, RepoName, RepoActor)> { ) -> Vec<(ForgeName, RepoName, RepoActor)> {
let forge = forge.clone(); let forge = forge.clone();
let span = tracing::info_span!("Forge", %forge_name, %forge); let span = tracing::info_span!("Forge", %forge_name, %forge);
@ -70,7 +76,7 @@ fn create_forge_repos(
info!("Creating Forge"); info!("Creating Forge");
forge forge
.repos() .repos()
.map(create_actor(forge_name, forge.clone(), net)) .map(create_actor(forge_name, forge.clone(), net, git))
.collect::<Vec<_>>() .collect::<Vec<_>>()
} }
@ -78,8 +84,10 @@ fn create_actor(
forge_name: ForgeName, forge_name: ForgeName,
forge: config::Forge, forge: config::Forge,
net: &Network, net: &Network,
git: &Git,
) -> impl Fn((RepoName, &Repo)) -> (ForgeName, RepoName, RepoActor) { ) -> impl Fn((RepoName, &Repo)) -> (ForgeName, RepoName, RepoActor) {
let net = net.clone(); let net = net.clone();
let git = git.clone();
move |(repo_name, repo)| { move |(repo_name, repo)| {
let span = tracing::info_span!("Repo", %repo_name, %repo); let span = tracing::info_span!("Repo", %repo_name, %repo);
let _guard = span.enter(); let _guard = span.enter();
@ -87,6 +95,7 @@ fn create_actor(
let actor = actors::repo::RepoActor::new( let actor = actors::repo::RepoActor::new(
config::RepoDetails::new(&repo_name, repo, &forge_name, &forge), config::RepoDetails::new(&repo_name, repo, &forge_name, &forge),
net.clone(), net.clone(),
git.clone(),
); );
info!("Created Repo"); info!("Created Repo");
(forge_name.clone(), repo_name, actor) (forge_name.clone(), repo_name, actor)

32
src/server/types.rs Normal file
View file

@ -0,0 +1,32 @@
use std::fmt::Display;
use crate::server::{config::BranchName, forge};
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
pub struct GitRef(pub String);
impl From<forge::Commit> for GitRef {
fn from(value: forge::Commit) -> Self {
Self(value.sha().to_string())
}
}
impl From<BranchName> for GitRef {
fn from(value: BranchName) -> Self {
Self(value.0)
}
}
impl Display for GitRef {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
#[derive(Debug)]
pub enum ResetForce {
None,
Force(GitRef),
}
impl Display for ResetForce {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{:?}", self)
}
}