diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/config/args.rs | 23 | ||||
| -rw-r--r-- | src/config/mod.rs | 87 | ||||
| -rw-r--r-- | src/forge/gitlab/config.rs | 26 | ||||
| -rw-r--r-- | src/forge/gitlab/mod.rs | 73 | ||||
| -rw-r--r-- | src/forge/mod.rs | 48 | ||||
| -rw-r--r-- | src/git/mod.rs | 27 | ||||
| -rw-r--r-- | src/lib.rs | 4 | ||||
| -rw-r--r-- | src/list/mod.rs | 12 | ||||
| -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 | ||||
| -rw-r--r-- | src/main.rs | 98 | ||||
| -rw-r--r-- | src/update/mod.rs | 14 |
15 files changed, 1079 insertions, 0 deletions
diff --git a/src/config/args.rs b/src/config/args.rs new file mode 100644 index 0000000..1b0683d --- /dev/null +++ b/src/config/args.rs @@ -0,0 +1,23 @@ +use structopt::StructOpt; + +#[derive(StructOpt, Clone, Debug)] +/// Sync Gitlab Trees +pub struct Args { + #[structopt(subcommand)] + pub command: Commands, + + /// Only operate on this subtree + pub scope: Option<String>, +} + +#[derive(PartialEq, Clone, Debug)] +#[derive(StructOpt)] +#[structopt(about = "the stupid content tracker")] +pub enum Commands { + /// Download new repositories and delete old ones, also update + Sync, + /// Pull and Push new commits to and from the cloned repos + Update, + /// List Directories + List, +} diff --git a/src/config/mod.rs b/src/config/mod.rs new file mode 100644 index 0000000..26d80f4 --- /dev/null +++ b/src/config/mod.rs @@ -0,0 +1,87 @@ +pub mod args; + +use serde::{Deserialize, Serialize}; + +use std::{collections::BTreeMap, ops::Deref, path::Path}; + +use figment::{ + providers::{Format, Toml}, + value::{Dict, Map}, + Error, Figment, Metadata, Profile, Provider, +}; + +use anyhow::{Context, Result}; + +/// Configuration for the Bot +#[derive(Clone, Debug, Deserialize, Serialize)] +// pub struct Config(); + +// TODO make forge optional +pub struct Config { + #[serde(flatten)] + config: BTreeMap<String, ForgeConfig> +} + +impl Deref for Config { + type Target = BTreeMap<String, ForgeConfig>; + + fn deref(&self) -> &Self::Target { + &self.config + } +} + +impl Config { + // Allow the configuration to be extracted from any `Provider`. + fn from<T: Provider>(provider: T) -> Result<Config, Error> { + Figment::from(provider).extract() + } + + // Provide a default provider, a `Figment`. + pub fn figment() -> Result<Figment> { + use figment::providers::Env; + + let dirs = xdg::BaseDirectories::with_prefix(env!("CARGO_PKG_NAME")).unwrap(); + + Ok(Figment::from(Toml::file( + dirs.place_config_file("config.toml") + .context("failed to create config directory")?, + )) + .merge(Toml::file( + dirs.place_config_file("config.yaml") + .context("failed to create config directory")?, + )) + .merge(Env::prefixed("GTREE_"))) + } +} + +// Make `Config` a provider itself for composability. +impl Provider for Config { + fn metadata(&self) -> Metadata { + Metadata::named("Library Config") + } + + fn data(&self) -> Result<Map<Profile, Dict>, Error> { + figment::providers::Serialized::defaults(self).data() + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(tag = "type")] +pub enum ForgeConfig { + #[serde(alias = "gitlab")] + Gitlab(crate::forge::gitlab::config::Gitlab), +} + +pub trait ForgeConfigTrait { + fn root(&self) -> &str; +} + +impl Deref for ForgeConfig { + type Target = dyn ForgeConfigTrait; + + fn deref(&self) -> &Self::Target { + match self { + ForgeConfig::Gitlab(conf) => conf, + } + } +} diff --git a/src/forge/gitlab/config.rs b/src/forge/gitlab/config.rs new file mode 100644 index 0000000..37186e8 --- /dev/null +++ b/src/forge/gitlab/config.rs @@ -0,0 +1,26 @@ +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +use crate::config::ForgeConfigTrait; + +#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Deserialize, Serialize)] +pub struct Gitlab { + // pub url: url::Url, + pub host: String, + pub token: String, + pub directory: PathBuf, + #[serde(default = "default_tls")] + pub tls: bool, + #[serde(default)] + pub auto_create_branches: bool, +} + +const fn default_tls() -> bool { + true +} + +impl ForgeConfigTrait for Gitlab { + fn root(&self) -> &str { + self.directory.to_str().unwrap() + } +} diff --git a/src/forge/gitlab/mod.rs b/src/forge/gitlab/mod.rs new file mode 100644 index 0000000..a32c367 --- /dev/null +++ b/src/forge/gitlab/mod.rs @@ -0,0 +1,73 @@ +use anyhow::{bail, Context, Result}; +use gitlab::AsyncGitlab; + +use graphql_client::GraphQLQuery; +use tracing::{debug, trace}; + +pub mod config; + +#[derive(Clone, Debug)] +pub struct Gitlab { + client: AsyncGitlab, +} + +impl Gitlab { + pub async fn new(host: &str, token: &str, tls: bool) -> Result<Gitlab> { + let mut gitlab = gitlab::GitlabBuilder::new(host, token); + + if !tls { + gitlab.insecure(); + } + + let gitlab = gitlab.build_async().await?; + + Ok(Gitlab { client: gitlab }) + } + + pub async fn from_config(forge: &config::Gitlab) -> Result<Gitlab> { + Gitlab::new(&forge.host, &forge.token, forge.tls).await + } +} + +#[async_trait::async_trait] +impl super::ForgeTrait for Gitlab { + async fn projects(&self, scope: &str) -> Result<Vec<super::Project>> { + let query = Projects::build_query(projects::Variables { + scope: scope.to_owned(), + }); + debug!("query: {:#?}", query); + let res = self.client.graphql::<Projects>(&query).await?; + + let res = res + .projects + .unwrap() + .nodes + .unwrap() + .into_iter() + .filter(|x| x.is_some()) + .map(|x| x.unwrap().into()) + .collect(); + + Ok(res) + } +} + +#[derive(GraphQLQuery)] +#[graphql( + query_path = "graphql/projects_query.graphql", + schema_path = "graphql/schema.graphql", + response_derives = "Clone, Debug" +)] +pub struct Projects; + +impl Into<super::Project> for projects::ProjectsProjectsNodes { + fn into(self) -> super::Project { + super::Project { + id: self.id, + name: self.name, + path: self.full_path, + ssh_clone_url: self.ssh_url_to_repo, + http_clone_url: self.http_url_to_repo, + } + } +} diff --git a/src/forge/mod.rs b/src/forge/mod.rs new file mode 100644 index 0000000..3e94812 --- /dev/null +++ b/src/forge/mod.rs @@ -0,0 +1,48 @@ +use std::ops::Deref; + +use anyhow::{Result, bail}; +use serde::{Deserialize, Serialize}; + +use crate::config::ForgeConfig; + +pub mod gitlab; + +#[derive(Clone, Debug)] +pub enum Forge { + Gitlab(self::gitlab::Gitlab), +} + +impl Forge { + pub async fn new(config: &ForgeConfig) -> Result<Forge> { + match config { + ForgeConfig::Gitlab(config) => { + Ok(Forge::Gitlab(gitlab::Gitlab::from_config(config).await?)) + } + _ => bail!("wrong forge type found"), + } + } +} + +#[async_trait::async_trait] +pub trait ForgeTrait { + async fn projects(&self, scope: &str) -> Result<Vec<Project>>; +} + +impl Deref for Forge { + type Target = dyn ForgeTrait; + + fn deref(&self) -> &Self::Target { + match self { + Forge::Gitlab(forge) => forge, + } + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct Project { + pub id: String, + pub name: String, + pub path: String, + pub ssh_clone_url: Option<String>, + pub http_clone_url: Option<String>, +} diff --git a/src/git/mod.rs b/src/git/mod.rs new file mode 100644 index 0000000..60d0b46 --- /dev/null +++ b/src/git/mod.rs @@ -0,0 +1,27 @@ +pub fn git_credentials_callback( + _user: &str, + user_from_url: Option<&str>, + _cred: git2::CredentialType, +) -> Result<git2::Cred, git2::Error> { + if let Some(user) = user_from_url { + git2::Cred::ssh_key_from_agent(user) + } else { + Err(git2::Error::from_str("no url username found")) + } +} + +pub fn callbacks<'g>() -> git2::RemoteCallbacks<'g> { + let mut callbacks = git2::RemoteCallbacks::new(); + callbacks.credentials(git_credentials_callback); + + callbacks +} + +#[tracing::instrument(level = "trace")] +pub fn fetch_options<'g>() -> git2::FetchOptions<'g> { + let mut opts = git2::FetchOptions::new(); + opts.remote_callbacks(callbacks()); + opts.download_tags(git2::AutotagOption::All); + + opts +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..7039db7 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,4 @@ +pub mod config; +pub mod forge; +pub mod local; +pub mod git; diff --git a/src/list/mod.rs b/src/list/mod.rs new file mode 100644 index 0000000..e05a318 --- /dev/null +++ b/src/list/mod.rs @@ -0,0 +1,12 @@ +use anyhow::{Context, Result}; +use gtree::local::Repos; + +impl crate::GTree { + pub async fn list(&self, repos: Repos) -> Result<()> { + repos.iter().for_each(|repo| { + println!("{}", repo) + }); + + Ok(()) + } +} 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)) + } + } + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..748f50d --- /dev/null +++ b/src/main.rs @@ -0,0 +1,98 @@ +use anyhow::{Context, Result}; +use structopt::StructOpt; +use derivative::Derivative; + +use tracing::{debug, info, metadata::LevelFilter, trace, Level}; +use tracing_subscriber::{fmt::format::FmtSpan, prelude::*, EnvFilter}; + +use gtree::{ + config, + local::{Aggregator, Repos}, +}; + +mod list; +mod update; + +#[derive(Derivative)] +#[derivative(Debug)] +struct GTree { + figment: figment::Figment, + config: config::Config, + args: config::args::Args, + forge: gtree::forge::Forge, + #[derivative(Debug="ignore")] + gitconfig: git2::Config, +} + +impl GTree { + pub async fn new() -> Result<GTree> { + let args = config::args::Args::from_args(); + + let figment = config::Config::figment()?; + let config: config::Config = figment.extract()?; + + let (_name, forge_config) = config + .iter() + .next() + .context("No Forge configured, please setup a forge")?; + + let forge = gtree::forge::Forge::new(forge_config).await?; + + let gitconfig = git2::Config::open_default()?; + + Ok(GTree { + figment, + config, + args, + forge, + gitconfig, + }) + } + + pub async fn run(self) -> Result<()> { + let scope = self.args.scope.as_ref().map_or("", |x| x); + + let (_name, forge) = self + .config + .iter() + .next() + .context("No Forge configured, please setup a forge")?; + + let (local, remote) = tokio::join!( + Repos::from_local(forge.root(), scope), + Repos::from_forge(self.forge.projects(scope).await?) + ); + + let repos = Repos::aggregate(local, remote).await; + + match self.args.command { + config::args::Commands::Sync => todo!(), + config::args::Commands::Update => self.update(repos).await, + config::args::Commands::List => self.list(repos).await?, + }; + + Ok(()) + } +} + +#[tokio::main] +async fn main() -> Result<()> { + let filter = tracing_subscriber::filter::Targets::new() + .with_default(Level::TRACE) + .with_target("hyper", LevelFilter::OFF) + .with_target("reqwest", LevelFilter::OFF); + + let env_filter = EnvFilter::from_default_env(); + + tracing_subscriber::registry() + .with(tracing_subscriber::fmt::layer().with_span_events(FmtSpan::ACTIVE)) + .with(filter) + .with(env_filter) + .init(); + + debug!("starting"); + + let gtree = GTree::new().await?; + + gtree.run().await +} diff --git a/src/update/mod.rs b/src/update/mod.rs new file mode 100644 index 0000000..0991b1a --- /dev/null +++ b/src/update/mod.rs @@ -0,0 +1,14 @@ +use gtree::local::Repos; + +impl crate::GTree { + pub async fn update(&self, repos: Repos) { + for mut repo in repos { + if let Some(_) = repo.repo { + match repo.update() { + Ok(u) => println!("{}", u), + Err(u) => println!("{}", u), + }; + } + } + } +} |
