diff options
| author | Max Audron <audron@cocaine.farm> | 2022-06-07 12:28:18 +0200 |
|---|---|---|
| committer | Maximilian Manz <maximilian.manz@de.clara.net> | 2022-06-20 11:33:04 +0200 |
| commit | f869c7f52d8fd1f1ef61e218bbec4d0dac27673d (patch) | |
| tree | 8029b33976fc897a4ae81785e622a7e61ad2eb67 /src/local | |
init
Diffstat (limited to 'src/local')
| -rw-r--r-- | src/local/;a | 211 | ||||
| -rw-r--r-- | src/local/aggregate.rs | 91 | ||||
| -rw-r--r-- | src/local/mod.rs | 272 | ||||
| -rw-r--r-- | src/local/repostate.rs | 36 | ||||
| -rw-r--r-- | src/local/update.rs | 57 |
5 files changed, 667 insertions, 0 deletions
diff --git a/src/local/;a b/src/local/;a new file mode 100644 index 0000000..9d6ada5 --- /dev/null +++ b/src/local/;a @@ -0,0 +1,211 @@ +use std::{fmt::Debug, path::PathBuf}; + +use thiserror::Error; + +use git2::{AnnotatedCommit, Remote, Repository}; +use tracing::{debug, trace}; + +use crate::forge::Project; + +mod aggregate; +mod repostate; + +pub use aggregate::*; +pub use repostate::*; + +pub type Repos = Vec<Repo>; + +pub struct Repo { + pub name: String, + pub repo: Option<Repository>, + pub forge: Option<Project>, +} + +impl Repo { + /// Fetch any new state from the remote, + /// we get the default branch in the same run. + /// + /// Then check if the repo is to be considered clean, + /// no stale uncommitted changes, no in progress merges etc + #[tracing::instrument(level = "trace")] + pub fn update(&self) -> Result<(), RepoError> { + let mut remote = self.main_remote()?; + + let fetch_head = self.fetch(&mut remote)?; + + let default_branch = remote.default_branch()?.as_str().unwrap().to_string(); + debug!("default branch: {}", default_branch); + + if self.is_clean()? { + debug!("repo is clean"); + + self.merge(&default_branch, &fetch_head)?; + }; + + Ok(()) + } + + pub fn is_clean(&self) -> Result<bool, RepoError> { + if let Some(repo) = &self.repo { + debug!("repo state: {:?}", repo.state()); + let statuses: Vec<git2::Status> = repo + .statuses(None)? + .iter() + .filter_map(|status| { + if status.status().is_ignored() { + None + } else { + Some(status.status()) + } + }) + .collect(); + + debug!("got repo statuses: {:?}", statuses); + + if repo.state() == git2::RepositoryState::Clean && statuses.is_empty() { + Ok(true) + } else { + Ok(false) + } + } else { + Err(RepoError::NoLocalRepo) + } + } + + pub fn main_remote(&self) -> Result<git2::Remote, RepoError> { + if let Some(repo) = &self.repo { + let remotes = repo.remotes()?; + + let remote = if let Some(_) = remotes.iter().find(|x| *x == Some("origin")) { + "origin" + } else { + if let Some(remote) = remotes.get(0) { + remote + } else { + return Err(RepoError::NoRemoteFound); + } + }; + + return Ok(repo.find_remote(remote)?); + } else { + return Err(RepoError::NoLocalRepo); + } + } + + #[tracing::instrument(level = "trace", skip(remote))] + pub fn fetch(&self, remote: &mut Remote) -> Result<AnnotatedCommit, RepoError> { + // Pass an empty array as the refspec to fetch to fetch the "default" refspecs + // Type annotation is needed because type can't be guessed from the empty array + remote.fetch::<&str>( + &[], + Some(&mut crate::git::fetch_options()), + Some("gtree fetch"), + )?; + + let repo = self.repo.as_ref().unwrap(); + + let fetch_head = repo.find_reference("FETCH_HEAD")?; + + Ok(repo.reference_to_annotated_commit(&fetch_head)?) + // Ok(remote.default_branch()?.as_str().unwrap().to_string()) + } + + pub fn checkout(&self) -> Result<(), RepoError> { + if let Some(repo) = &self.repo { + repo.checkout_head(None).map_err(|e| e.into()) + } else { + Err(RepoError::NoLocalRepo) + } + } + + pub fn merge(&self, refname: &str, fetch_commit: &AnnotatedCommit) -> Result<(), RepoError> { + let repo = self.repo.as_ref().unwrap(); + + let analysis = repo.merge_analysis(&[fetch_commit])?; + + if analysis.0.is_fast_forward() { + trace!("Doing a fast forward"); + match repo.find_reference(&refname) { + Ok(mut r) => { + let name = match r.name() { + Some(s) => s.to_string(), + None => String::from_utf8_lossy(r.name_bytes()).to_string(), + }; + let msg = format!("gtree: update repo branch: {} to {}", name, fetch_commit.id()); + debug!("{}", msg); + + r.set_target(fetch_commit.id(), &msg)?; + repo.set_head(&name)?; + repo.checkout_head(Some(git2::build::CheckoutBuilder::default().force()))?; + } + Err(_) => (), + }; + } + + Ok(()) + } +} + +#[derive(Error, Debug)] +pub enum RepoError { + #[error("repo is not cloned locally")] + NoLocalRepo, + #[error("local git repo does not have a remote")] + NoRemoteFound, + #[error("error during git operation {0}")] + GitError(#[from] git2::Error), + #[error("unknown repo error")] + Unknown, +} + +impl Ord for Repo { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.name.cmp(&other.name) + } +} + +impl Eq for Repo {} + +impl PartialOrd for Repo { + fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> { + self.name.partial_cmp(&other.name) + } +} + +impl PartialEq for Repo { + fn eq(&self, other: &Self) -> bool { + self.name == other.name + } +} + +impl From<Project> for Repo { + fn from(project: Project) -> Self { + Self { + name: project.path.clone(), + repo: None, + forge: Some(project), + } + } +} + +impl From<&Project> for Repo { + fn from(project: &Project) -> Self { + Self { + name: project.path.clone(), + repo: None, + forge: Some(project.to_owned()), + } + } +} + +impl std::fmt::Display for Repo { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_fmt(format_args!("{} {}", RepoState::from(self), self.name)) + } +} + +impl Debug for Repo { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Repo").field("path", &self.name).finish() + } +} diff --git a/src/local/aggregate.rs b/src/local/aggregate.rs new file mode 100644 index 0000000..e52a18a --- /dev/null +++ b/src/local/aggregate.rs @@ -0,0 +1,91 @@ +use git2::Repository; + +use tracing::{debug, error}; +use walkdir::WalkDir; + +use crate::forge::Project; + +use super::{Repo, Repos}; + +#[async_trait::async_trait] +pub trait Aggregator { + async fn from_local(root: &str, scope: &str) -> Repos; + async fn from_forge(projects: Vec<Project>) -> Repos; + async fn aggregate(mut local: Repos, mut remote: Repos) -> Repos; +} + +#[async_trait::async_trait] +impl Aggregator for Repos { + async fn from_local(root: &str, scope: &str) -> Repos { + let mut repos = Vec::new(); + + let path: std::path::PathBuf = [root, scope].iter().collect(); + let mut walker = WalkDir::new(path).into_iter(); + + loop { + let entry = match walker.next() { + None => break, + Some(Err(err)) => panic!("ERROR: {}", err), + Some(Ok(entry)) => entry, + }; + + if entry.file_type().is_dir() { + let mut dir = std::fs::read_dir(entry.path()).unwrap(); + + if let Some(_) = dir.find(|dir| { + if let Ok(dir) = dir { + dir.file_name() == ".git" + } else { + false + } + }) { + walker.skip_current_dir(); + + match Repository::open(entry.path()) { + Ok(repo) => repos.push(Repo { + name: entry + .path() + .strip_prefix(root) + .unwrap() + .to_str() + .unwrap() + .to_string(), + // path: entry.path().to_path_buf(), + repo: Some(repo), + ..Repo::default() + }), + Err(err) => error!("could not open repository: {}", err), + } + } else { + continue; + } + } + } + + return repos; + } + + async fn from_forge(projects: Vec<Project>) -> Repos { + projects.iter().map(|project| project.into()).collect() + } + + #[tracing::instrument(level = "debug", skip(local, remote))] + async fn aggregate(mut local: Repos, mut remote: Repos) -> Repos { + local = local + .into_iter() + .map(|mut left| { + if let Some(i) = remote.iter().position(|right| *right == left) { + let right = remote.remove(i); + left.forge = right.forge; + } + + left + }) + .collect(); + + local.append(&mut remote); + local.sort(); + + return local; + } +} diff --git a/src/local/mod.rs b/src/local/mod.rs new file mode 100644 index 0000000..e2f4a9d --- /dev/null +++ b/src/local/mod.rs @@ -0,0 +1,272 @@ +use std::{fmt::Debug, path::PathBuf}; + +use thiserror::Error; + +use git2::{AnnotatedCommit, Branch, BranchType, Remote, Repository}; +use tracing::{debug, trace}; + +use crate::forge::Project; + +mod aggregate; +mod repostate; +mod update; + +pub use aggregate::*; +pub use repostate::*; +pub use update::*; + +pub type Repos = Vec<Repo>; + +pub struct Repo { + pub name: String, + pub repo: Option<Repository>, + pub forge: Option<Project>, + pub default_branch: String, +} + +impl Repo { + /// Fetch any new state from the remote and fast forward merge changes into local branches + #[tracing::instrument(level = "trace")] + pub fn update(&mut self) -> Result<UpdateResult, UpdateResult> { + let repo_name = self.name.clone(); + if self.repo.is_some() { + self.update_inner() + .map_err(|e| UpdateResult::err(repo_name, e.into())) + } else { + Ok(UpdateResult::err(repo_name, RepoError::NoLocalRepo)) + } + } + + fn update_inner(&mut self) -> Result<UpdateResult, RepoError> { + let repo = self.repo.as_ref().unwrap(); + let mut remote = self.main_remote(repo)?; + + self.fetch(&mut remote)?; + + self.default_branch = remote.default_branch()?.as_str().unwrap().to_string(); + + debug!("default branch: {}", self.default_branch); + + if self.is_clean()? { + debug!("repo is clean"); + + let merged = repo.branches(Some(BranchType::Local))? + .filter_map(|x| x.ok()) + .try_fold(false, |mut merged, (mut branch, _)| { + let name = format!("refs/heads/{}", Repo::branch_name(&branch)); + + if branch.upstream().is_ok() { + let upstream = branch.upstream().unwrap(); + + debug!("branch: {}", name); + + merged |= self.merge(repo, &mut branch, &upstream)?; + Ok::<bool, RepoError>(merged) + } else { + debug!("not updating branch: {}: branch does not have upstream tracking branch set", name); + Ok(merged) + } + })?; + + if merged { + Ok(UpdateResult::merged(self.name.clone())) + } else { + Ok(UpdateResult::no_changes(self.name.clone())) + } + } else { + Ok(UpdateResult::dirty(self.name.clone())) + } + } + + pub fn is_clean(&self) -> Result<bool, RepoError> { + if let Some(repo) = &self.repo { + debug!("repo state: {:?}", repo.state()); + let statuses: Vec<git2::Status> = repo + .statuses(None)? + .iter() + .filter_map(|status| { + if status.status().is_ignored() { + None + } else { + Some(status.status()) + } + }) + .collect(); + + debug!("got repo statuses: {:?}", statuses); + + if repo.state() == git2::RepositoryState::Clean && statuses.is_empty() { + Ok(true) + } else { + Ok(false) + } + } else { + Err(RepoError::NoLocalRepo) + } + } + + pub fn main_remote<'a>(&self, repo: &'a Repository) -> Result<git2::Remote<'a>, RepoError> { + let remotes = repo.remotes()?; + + let remote = if let Some(_) = remotes.iter().find(|x| *x == Some("origin")) { + "origin" + } else { + if let Some(remote) = remotes.get(0) { + remote + } else { + return Err(RepoError::NoRemoteFound); + } + }; + + return Ok(repo.find_remote(remote)?); + } + + #[tracing::instrument(level = "trace", skip(remote))] + pub fn fetch<'a>(&self, remote: &mut Remote) -> Result<(), RepoError> { + // Pass an empty array as the refspec to fetch to fetch the "default" refspecs + // Type annotation is needed because type can't be guessed from the empty array + remote.fetch::<&str>( + &[], + Some(&mut crate::git::fetch_options()), + Some("gtree fetch"), + )?; + + Ok(()) + } + + pub fn checkout(&self) -> Result<(), RepoError> { + if let Some(repo) = &self.repo { + repo.checkout_head(None).map_err(|e| e.into()) + } else { + Err(RepoError::NoLocalRepo) + } + } + + pub fn branch_name(branch: &Branch) -> String { + match branch.name().unwrap() { + Some(s) => s.to_string(), + None => String::from_utf8_lossy(branch.name_bytes().unwrap()).to_string(), + } + } + + pub fn merge( + &self, + repo: &Repository, + local: &mut Branch, + upstream: &Branch, + ) -> Result<bool, RepoError> { + let local_name = Repo::branch_name(&local); + let upstream_name = Repo::branch_name(&upstream); + + let local_ref = local.get_mut(); + let upstream_ref = upstream.get(); + + let analysis = repo.merge_analysis_for_ref( + local_ref, + &[&repo.reference_to_annotated_commit(upstream_ref)?], + )?; + + if analysis.0.is_fast_forward() { + trace!("Doing a fast forward"); + + let msg = format!( + "gtree: update repo branch: {} to {}", + local_name, upstream_name + ); + debug!("{}", msg); + + // sets the branch to target the new commit + // of the remote branch it's tracking + local_ref.set_target(upstream_ref.target().unwrap(), &msg)?; + // Apply these changes in the working dir if the branch is currently checked out. + if format!("refs/heads/{}", local_name) == self.default_branch { + repo.checkout_head(Some(git2::build::CheckoutBuilder::default().force()))? + } + + Ok(true) + } else if analysis.0.is_up_to_date() { + Ok(false) + } else { + Err(RepoError::NoFF) + } + } +} + +#[derive(Error, Debug)] +pub enum RepoError { + #[error("repo is not cloned locally")] + NoLocalRepo, + #[error("local git repo does not have a remote")] + NoRemoteFound, + #[error("repository is dirty")] + Dirty, + #[error("fast-forward merge was not possible")] + NoFF, + #[error("error during git operation {0}")] + GitError(#[from] git2::Error), + #[error("unknown repo error")] + Unknown, +} + +impl Ord for Repo { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.name.cmp(&other.name) + } +} + +impl Eq for Repo {} + +impl PartialOrd for Repo { + fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> { + self.name.partial_cmp(&other.name) + } +} + +impl PartialEq for Repo { + fn eq(&self, other: &Self) -> bool { + self.name == other.name + } +} + +impl From<Project> for Repo { + fn from(project: Project) -> Self { + Self { + name: project.path.clone(), + forge: Some(project), + ..Repo::default() + } + } +} + +impl From<&Project> for Repo { + fn from(project: &Project) -> Self { + Self { + name: project.path.clone(), + forge: Some(project.to_owned()), + ..Repo::default() + } + } +} + +impl std::fmt::Display for Repo { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_fmt(format_args!("{} {}", RepoState::from(self), self.name)) + } +} + +impl Debug for Repo { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Repo").field("path", &self.name).finish() + } +} + +impl Default for Repo { + fn default() -> Self { + Self { + name: Default::default(), + repo: Default::default(), + forge: Default::default(), + default_branch: "main".to_string(), + } + } +} diff --git a/src/local/repostate.rs b/src/local/repostate.rs new file mode 100644 index 0000000..ea3c5a6 --- /dev/null +++ b/src/local/repostate.rs @@ -0,0 +1,36 @@ +use super::Repo; + +#[derive(Clone, Debug)] +pub enum RepoState { + Local, + Remote, + Synced, + Unknown, +} + +impl std::fmt::Display for RepoState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + use ansi_term::Colour::{Blue, Green, Red, Yellow}; + + match self { + RepoState::Local => f.write_str(&Yellow.paint("LOCAL ").to_string()), + RepoState::Remote => f.write_str(&Blue.paint("REMOTE ").to_string()), + RepoState::Synced => f.write_str(&Green.paint("SYNCED ").to_string()), + RepoState::Unknown => f.write_str(&Red.paint("UNKNOWN").to_string()), + } + } +} + +impl From<&Repo> for RepoState { + fn from(repo: &Repo) -> Self { + if repo.repo.is_some() && repo.forge.is_some() { + RepoState::Synced + } else if repo.repo.is_some() { + RepoState::Local + } else if repo.forge.is_some() { + RepoState::Remote + } else { + RepoState::Unknown + } + } +} diff --git a/src/local/update.rs b/src/local/update.rs new file mode 100644 index 0000000..95057a4 --- /dev/null +++ b/src/local/update.rs @@ -0,0 +1,57 @@ +use std::fmt::Display; + +#[derive(Debug)] +pub enum UpdateResult { + NoChanges { + name: String, + }, + Dirty { + name: String, + }, + Merged { + name: String, + }, + Error { + name: String, + error: super::RepoError, + }, +} + +impl UpdateResult { + pub fn err(name: String, error: super::RepoError) -> UpdateResult { + UpdateResult::Error { name, error } + } + + pub fn merged(name: String) -> UpdateResult { + UpdateResult::Merged { name } + } + + pub fn dirty(name: String) -> UpdateResult { + UpdateResult::Dirty { name } + } + + pub fn no_changes(name: String) -> UpdateResult { + UpdateResult::NoChanges { name } + } +} + +impl Display for UpdateResult { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + use ansi_term::Colour::{Blue, Green, Red, Yellow}; + + match self { + UpdateResult::NoChanges { name } => { + f.write_fmt(format_args!("{} {}", Blue.paint("FETCHED"), name)) + } + UpdateResult::Dirty { name } => { + f.write_fmt(format_args!("{} {}", Yellow.paint("DIRTY "), name)) + } + UpdateResult::Merged { name } => { + f.write_fmt(format_args!("{} {}", Green.paint("PULLED "), name)) + } + UpdateResult::Error { name, error } => { + f.write_fmt(format_args!("{} {} [{}]", Red.paint("ERROR "), name, error)) + } + } + } +} |
