feat: Abstract Git command execution into injectable enum
Closes kemitix/git-next#36
This commit is contained in:
parent
fb74879309
commit
5fcf16ea75
9 changed files with 229 additions and 97 deletions
|
@ -4,6 +4,8 @@ mod server;
|
|||
use clap::Parser;
|
||||
use kxio::{filesystem, network::Network};
|
||||
|
||||
use crate::server::git::Git;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[clap(version = clap::crate_version!(), author = clap::crate_authors!(), about = clap::crate_description!())]
|
||||
struct Commands {
|
||||
|
@ -26,6 +28,7 @@ enum Server {
|
|||
async fn main() {
|
||||
let fs = filesystem::FileSystem::new_real(None);
|
||||
let net = Network::new_real();
|
||||
let git = Git::new_real();
|
||||
let commands = Commands::parse();
|
||||
|
||||
match commands.command {
|
||||
|
@ -37,7 +40,7 @@ async fn main() {
|
|||
server::init(fs);
|
||||
}
|
||||
Server::Start => {
|
||||
server::start(fs, net).await;
|
||||
server::start(fs, net, git).await;
|
||||
}
|
||||
},
|
||||
}
|
||||
|
|
|
@ -1,16 +1,9 @@
|
|||
use std::fmt::Display;
|
||||
|
||||
use actix::prelude::*;
|
||||
|
||||
use kxio::network;
|
||||
use secrecy::ExposeSecret;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use crate::server::{
|
||||
actors::repo::{branch, ValidateRepo},
|
||||
config::{self, BranchName},
|
||||
forge,
|
||||
};
|
||||
use crate::server::{actors::repo::ValidateRepo, config, forge, git::Git, types::ResetForce};
|
||||
|
||||
use super::{RepoActor, StartMonitoring};
|
||||
|
||||
|
@ -19,6 +12,7 @@ pub async fn validate_positions(
|
|||
repo_details: config::RepoDetails,
|
||||
config: config::RepoConfig,
|
||||
addr: Addr<RepoActor>,
|
||||
git: Git,
|
||||
net: network::Network,
|
||||
) {
|
||||
let commit_histories = match repo_details.forge.forge_type {
|
||||
|
@ -45,13 +39,15 @@ pub async fn validate_positions(
|
|||
let next_is_ancestor_of_dev = commit_histories.dev.iter().any(|dev| dev == &next);
|
||||
if !next_is_ancestor_of_dev {
|
||||
info!("Next is not an ancestor of dev - resetting next to main");
|
||||
if branch::reset(
|
||||
&config.branches().next(),
|
||||
main,
|
||||
ResetForce::Force(next.into()),
|
||||
&repo_details,
|
||||
)
|
||||
.is_ok()
|
||||
|
||||
if git
|
||||
.reset(
|
||||
&config.branches().next(),
|
||||
main.into(),
|
||||
ResetForce::Force(next.into()),
|
||||
&repo_details,
|
||||
)
|
||||
.is_ok()
|
||||
{
|
||||
// TODO : (#18) sleep and restart while we don't have webhooks
|
||||
tokio::time::sleep(std::time::Duration::from_secs(10)).await;
|
||||
|
@ -118,6 +114,7 @@ pub async fn advance_next(
|
|||
repo_details: config::RepoDetails,
|
||||
repo_config: config::RepoConfig,
|
||||
addr: Addr<RepoActor>,
|
||||
git: Git,
|
||||
) {
|
||||
let next_commit = find_next_commit_on_dev(next, dev_commit_history);
|
||||
let Some(commit) = next_commit else {
|
||||
|
@ -129,9 +126,9 @@ pub async fn advance_next(
|
|||
return;
|
||||
}
|
||||
info!("Advancing next to commit '{}'", commit);
|
||||
match reset(
|
||||
match git.reset(
|
||||
&repo_config.branches().next(),
|
||||
commit,
|
||||
commit.into(),
|
||||
ResetForce::None,
|
||||
&repo_details,
|
||||
) {
|
||||
|
@ -184,11 +181,12 @@ pub async fn advance_main(
|
|||
next: forge::Commit,
|
||||
repo_details: config::RepoDetails,
|
||||
repo_config: config::RepoConfig,
|
||||
git: Git,
|
||||
) {
|
||||
info!("Advancing main to next");
|
||||
match reset(
|
||||
match git.reset(
|
||||
&repo_config.branches().main(),
|
||||
next,
|
||||
next.into(),
|
||||
ResetForce::None,
|
||||
&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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
|
@ -10,6 +10,7 @@ use tracing::{info, warn};
|
|||
use crate::server::{
|
||||
config::{RepoConfig, RepoDetails},
|
||||
forge,
|
||||
git::Git,
|
||||
};
|
||||
|
||||
use self::webhook::WebhookId;
|
||||
|
@ -19,14 +20,16 @@ pub struct RepoActor {
|
|||
config: Option<RepoConfig>, // INFO: if [None] then send [StartRepo] to populate it
|
||||
webhook_id: Option<WebhookId>, // INFO: if [None] then no webhook is configured
|
||||
net: Network,
|
||||
git: Git,
|
||||
}
|
||||
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 {
|
||||
details,
|
||||
config: None,
|
||||
webhook_id: None,
|
||||
net,
|
||||
git,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -85,7 +88,8 @@ impl Handler<ValidateRepo> for RepoActor {
|
|||
let repo_details = self.details.clone();
|
||||
let addr = ctx.address();
|
||||
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)
|
||||
.wait(ctx);
|
||||
}
|
||||
|
@ -115,6 +119,7 @@ impl Handler<StartMonitoring> for RepoActor {
|
|||
let repo_details = self.details.clone();
|
||||
let addr = ctx.address();
|
||||
let net = self.net.clone();
|
||||
let git = self.git.clone();
|
||||
|
||||
if next_ahead_of_main {
|
||||
status::check_next(msg.next, repo_details, addr, net)
|
||||
|
@ -127,6 +132,7 @@ impl Handler<StartMonitoring> for RepoActor {
|
|||
repo_details,
|
||||
repo_config,
|
||||
addr,
|
||||
git,
|
||||
)
|
||||
.into_actor(self)
|
||||
.wait(ctx);
|
||||
|
@ -159,7 +165,8 @@ impl Handler<AdvanceMainTo> for RepoActor {
|
|||
warn!("No config loaded");
|
||||
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)
|
||||
.wait(ctx);
|
||||
}
|
||||
|
|
|
@ -205,7 +205,7 @@ impl Display for RepoPath {
|
|||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
|
||||
pub struct BranchName(pub String);
|
||||
impl Display for BranchName {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
|
|
47
src/server/git/mock.rs
Normal file
47
src/server/git/mock.rs
Normal 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
48
src/server/git/mod.rs
Normal 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
57
src/server/git/real.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,6 +1,8 @@
|
|||
mod actors;
|
||||
mod config;
|
||||
pub mod forge;
|
||||
pub mod git;
|
||||
pub mod types;
|
||||
|
||||
use actix::prelude::*;
|
||||
use kxio::network::Network;
|
||||
|
@ -11,7 +13,10 @@ use tracing::{error, info, level_filters::LevelFilter};
|
|||
|
||||
use crate::{
|
||||
filesystem::FileSystem,
|
||||
server::config::{Forge, ForgeName, RepoName},
|
||||
server::{
|
||||
config::{Forge, ForgeName, RepoName},
|
||||
git::Git,
|
||||
},
|
||||
};
|
||||
|
||||
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 {
|
||||
eprintln!("Failed to initialize logging.");
|
||||
return;
|
||||
|
@ -51,7 +56,7 @@ pub async fn start(fs: FileSystem, net: Network) {
|
|||
info!("Config loaded");
|
||||
let addresses = config
|
||||
.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)
|
||||
.collect::<Vec<_>>();
|
||||
let _ = actix_rt::signal::ctrl_c().await;
|
||||
|
@ -63,6 +68,7 @@ fn create_forge_repos(
|
|||
forge: &Forge,
|
||||
forge_name: ForgeName,
|
||||
net: &Network,
|
||||
git: &Git,
|
||||
) -> Vec<(ForgeName, RepoName, RepoActor)> {
|
||||
let forge = forge.clone();
|
||||
let span = tracing::info_span!("Forge", %forge_name, %forge);
|
||||
|
@ -70,7 +76,7 @@ fn create_forge_repos(
|
|||
info!("Creating Forge");
|
||||
forge
|
||||
.repos()
|
||||
.map(create_actor(forge_name, forge.clone(), net))
|
||||
.map(create_actor(forge_name, forge.clone(), net, git))
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
|
||||
|
@ -78,8 +84,10 @@ fn create_actor(
|
|||
forge_name: ForgeName,
|
||||
forge: config::Forge,
|
||||
net: &Network,
|
||||
git: &Git,
|
||||
) -> impl Fn((RepoName, &Repo)) -> (ForgeName, RepoName, RepoActor) {
|
||||
let net = net.clone();
|
||||
let git = git.clone();
|
||||
move |(repo_name, repo)| {
|
||||
let span = tracing::info_span!("Repo", %repo_name, %repo);
|
||||
let _guard = span.enter();
|
||||
|
@ -87,6 +95,7 @@ fn create_actor(
|
|||
let actor = actors::repo::RepoActor::new(
|
||||
config::RepoDetails::new(&repo_name, repo, &forge_name, &forge),
|
||||
net.clone(),
|
||||
git.clone(),
|
||||
);
|
||||
info!("Created Repo");
|
||||
(forge_name.clone(), repo_name, actor)
|
||||
|
|
32
src/server/types.rs
Normal file
32
src/server/types.rs
Normal 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)
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue