From e9dc01ffb547d0fa605bfe38b34672ddd5161be4 Mon Sep 17 00:00:00 2001 From: Max Audron Date: Tue, 7 Jun 2022 12:28:18 +0200 Subject: reorganize file structure and cleanup lints --- src/config/args.rs | 9 +- src/config/mod.rs | 6 +- src/forge/gitlab/mod.rs | 19 ++-- src/forge/mod.rs | 3 +- src/lib.rs | 4 - src/list/mod.rs | 9 +- src/local/;a | 211 ----------------------------------------- src/local/aggregate.rs | 107 --------------------- src/local/mod.rs | 236 ---------------------------------------------- src/local/repostate.rs | 36 ------- src/local/sync.rs | 115 ---------------------- src/local/update.rs | 120 ----------------------- src/main.rs | 24 ++--- src/repo/aggregate.rs | 105 +++++++++++++++++++++ src/repo/mod.rs | 229 ++++++++++++++++++++++++++++++++++++++++++++ src/repo/repostate.rs | 36 +++++++ src/sync/mod.rs | 102 +++++++++++++++++++- src/tests/mod.rs | 59 ++++++++++++ src/update/mod.rs | 114 +++++++++++++++++++++- tests/directory_walker.rs | 55 ----------- 20 files changed, 675 insertions(+), 924 deletions(-) delete mode 100644 src/lib.rs delete mode 100644 src/local/;a delete mode 100644 src/local/aggregate.rs delete mode 100644 src/local/mod.rs delete mode 100644 src/local/repostate.rs delete mode 100644 src/local/sync.rs delete mode 100644 src/local/update.rs create mode 100644 src/repo/aggregate.rs create mode 100644 src/repo/mod.rs create mode 100644 src/repo/repostate.rs create mode 100644 src/tests/mod.rs delete mode 100644 tests/directory_walker.rs diff --git a/src/config/args.rs b/src/config/args.rs index 9c0973f..446c5ce 100644 --- a/src/config/args.rs +++ b/src/config/args.rs @@ -1,9 +1,7 @@ -use clap::{Parser, Subcommand, ArgEnum}; +use clap::{Parser, Subcommand}; #[derive(Parser, Clone, Debug)] -#[clap( - override_usage("gtree [SCOPE]") -)] +#[clap(override_usage("gtree [SCOPE]"))] /// Sync Gitlab Trees pub struct Args { #[clap(subcommand)] @@ -14,8 +12,7 @@ pub struct Args { pub scope: Option, } -#[derive(PartialEq, Clone, Debug)] -#[derive(Subcommand)] +#[derive(PartialEq, Clone, Debug, Subcommand)] pub enum Commands { /// Download new repositories and delete old ones, also update Sync, diff --git a/src/config/mod.rs b/src/config/mod.rs index 26d80f4..781aedf 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -2,7 +2,7 @@ pub mod args; use serde::{Deserialize, Serialize}; -use std::{collections::BTreeMap, ops::Deref, path::Path}; +use std::{collections::BTreeMap, ops::Deref}; use figment::{ providers::{Format, Toml}, @@ -19,7 +19,7 @@ use anyhow::{Context, Result}; // TODO make forge optional pub struct Config { #[serde(flatten)] - config: BTreeMap + config: BTreeMap, } impl Deref for Config { @@ -32,7 +32,7 @@ impl Deref for Config { impl Config { // Allow the configuration to be extracted from any `Provider`. - fn from(provider: T) -> Result { + pub fn from(provider: T) -> Result { Figment::from(provider).extract() } diff --git a/src/forge/gitlab/mod.rs b/src/forge/gitlab/mod.rs index 85efc76..fac60b7 100644 --- a/src/forge/gitlab/mod.rs +++ b/src/forge/gitlab/mod.rs @@ -1,8 +1,7 @@ -use anyhow::{bail, Context, Result}; +use anyhow::Result; use gitlab::AsyncGitlab; use graphql_client::GraphQLQuery; -use tracing::{debug, trace}; pub mod config; @@ -58,18 +57,18 @@ impl super::ForgeTrait for Gitlab { query_path = "graphql/projects_query.graphql", schema_path = "graphql/schema.graphql", response_derives = "Clone,Debug", - variables_derives = "Clone,Debug", + variables_derives = "Clone,Debug" )] pub struct Projects; -impl Into for projects::ProjectsProjectsNodes { - fn into(self) -> super::Project { +impl From for super::Project { + fn from(project: projects::ProjectsProjectsNodes) -> Self { 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, + id: project.id, + name: project.name, + path: project.full_path, + ssh_clone_url: project.ssh_url_to_repo, + http_clone_url: project.http_url_to_repo, } } } diff --git a/src/forge/mod.rs b/src/forge/mod.rs index 3e94812..5a78850 100644 --- a/src/forge/mod.rs +++ b/src/forge/mod.rs @@ -1,6 +1,6 @@ use std::ops::Deref; -use anyhow::{Result, bail}; +use anyhow::{bail, Result}; use serde::{Deserialize, Serialize}; use crate::config::ForgeConfig; @@ -18,6 +18,7 @@ impl Forge { ForgeConfig::Gitlab(config) => { Ok(Forge::Gitlab(gitlab::Gitlab::from_config(config).await?)) } + #[allow(unreachable_patterns)] _ => bail!("wrong forge type found"), } } diff --git a/src/lib.rs b/src/lib.rs deleted file mode 100644 index 7039db7..0000000 --- a/src/lib.rs +++ /dev/null @@ -1,4 +0,0 @@ -pub mod config; -pub mod forge; -pub mod local; -pub mod git; diff --git a/src/list/mod.rs b/src/list/mod.rs index e05a318..ed71e6a 100644 --- a/src/list/mod.rs +++ b/src/list/mod.rs @@ -1,11 +1,10 @@ -use anyhow::{Context, Result}; -use gtree::local::Repos; +use anyhow::Result; + +use crate::repo::Repos; impl crate::GTree { pub async fn list(&self, repos: Repos) -> Result<()> { - repos.iter().for_each(|repo| { - println!("{}", repo) - }); + repos.iter().for_each(|repo| println!("{}", repo)); Ok(()) } diff --git a/src/local/;a b/src/local/;a deleted file mode 100644 index 9d6ada5..0000000 --- a/src/local/;a +++ /dev/null @@ -1,211 +0,0 @@ -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; - -pub struct Repo { - pub name: String, - pub repo: Option, - pub forge: Option, -} - -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 { - if let Some(repo) = &self.repo { - debug!("repo state: {:?}", repo.state()); - let statuses: Vec = 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 { - 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 { - // 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 { - self.name.partial_cmp(&other.name) - } -} - -impl PartialEq for Repo { - fn eq(&self, other: &Self) -> bool { - self.name == other.name - } -} - -impl From 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 deleted file mode 100644 index 21a1dad..0000000 --- a/src/local/aggregate.rs +++ /dev/null @@ -1,107 +0,0 @@ -use std::path::PathBuf; - -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(root: &str, projects: Vec) -> Repos; - async fn aggregate(mut local: Repos, mut remote: Repos) -> Repos; -} - -#[async_trait::async_trait] -impl Aggregator for Repos { - #[tracing::instrument(level = "trace")] - async fn from_local(root: &str, scope: &str) -> Repos { - let mut repos = Vec::new(); - - let path: std::path::PathBuf = [root, scope].iter().collect(); - - if !path.exists() { - return repos; - } - - 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; - } - - #[tracing::instrument(level = "trace")] - async fn from_forge(root: &str, projects: Vec) -> Repos { - projects - .iter() - .map(|project| { - let mut repo: Repo = project.into(); - repo.path = [root, &repo.name].iter().collect(); - return repo; - }) - .collect() - } - - #[tracing::instrument(level = "trace", 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 deleted file mode 100644 index ec8985b..0000000 --- a/src/local/mod.rs +++ /dev/null @@ -1,236 +0,0 @@ -use std::{ - fmt::Debug, - path::{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 sync; -mod update; - -pub use aggregate::*; -pub use repostate::*; -pub use sync::*; -pub use update::*; - -pub type Repos = Vec; - -pub struct Repo { - pub name: String, - pub path: PathBuf, - pub repo: Option, - pub forge: Option, - pub default_branch: String, -} - -impl Repo { - pub fn is_clean(&self) -> Result { - if let Some(repo) = &self.repo { - debug!("repo state: {:?}", repo.state()); - let statuses: Vec = 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, 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(()) - } - - #[tracing::instrument(level = "trace")] - pub fn clone(&self, url: &str) -> Result { - let mut builder = git2::build::RepoBuilder::new(); - builder.fetch_options(crate::git::fetch_options()); - - builder.clone(url, &self.path).map_err(|err| RepoError::GitError(err)) - } - - #[tracing::instrument(level = "trace")] - 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(), - } - } - - #[tracing::instrument(level = "trace", skip(repo, local, upstream))] - pub fn merge( - &self, - repo: &Repository, - local: &mut Branch, - upstream: &Branch, - ) -> Result { - 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 { - self.name.partial_cmp(&other.name) - } -} - -impl PartialEq for Repo { - fn eq(&self, other: &Self) -> bool { - self.name == other.name - } -} - -impl From 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(), - path: 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 deleted file mode 100644 index ea3c5a6..0000000 --- a/src/local/repostate.rs +++ /dev/null @@ -1,36 +0,0 @@ -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/sync.rs b/src/local/sync.rs deleted file mode 100644 index 0ee1b59..0000000 --- a/src/local/sync.rs +++ /dev/null @@ -1,115 +0,0 @@ -use std::fmt::{Debug, Display}; - -use git2::{AnnotatedCommit, Branch, BranchType, Remote, Repository}; -use tracing::{debug, debug_span}; - -use super::{Repo, RepoError}; - -impl Repo { - /// Clone repos from forge and push new repos to forge - #[tracing::instrument(level = "trace")] - pub fn sync(&mut self) -> Result { - let repo_name = self.name.clone(); - - if self.repo.is_some() - && !self - .is_clean() - .map_err(|err| SyncResult::err(repo_name.clone(), err))? - { - return Ok(SyncResult::dirty(repo_name)); - }; - - if self.repo.is_some() && self.forge.is_some() { - Ok(SyncResult::no_changes(repo_name)) - } else if self.repo.is_some() { - // do push stuff - Ok(SyncResult::pushed(repo_name)) - } else if self.forge.is_some() { - let url = self - .forge - .as_ref() - .unwrap() - .ssh_clone_url - .as_ref() - .ok_or(SyncResult::err(self.name.clone(), RepoError::NoRemoteFound))?; - - let repo = self - .clone(&url) - .map_err(|err| SyncResult::err(repo_name.clone(), err))?; - - self.repo = Some(repo); - Ok(SyncResult::cloned(repo_name)) - } else { - Ok(SyncResult::no_changes(repo_name)) - } - } -} - -#[derive(Debug)] -pub enum SyncResult { - NoChanges { - name: String, - }, - Dirty { - name: String, - }, - Cloned { - name: String, - }, - Pushed { - name: String, - }, - Error { - name: String, - error: super::RepoError, - }, -} - -impl SyncResult { - pub fn err(name: String, error: super::RepoError) -> SyncResult { - SyncResult::Error { name, error } - } - - pub fn cloned(name: String) -> SyncResult { - SyncResult::Cloned { name } - } - - pub fn pushed(name: String) -> SyncResult { - SyncResult::Pushed { name } - } - - pub fn dirty(name: String) -> SyncResult { - SyncResult::Dirty { name } - } - - pub fn no_changes(name: String) -> SyncResult { - SyncResult::NoChanges { name } - } -} - -impl Display for SyncResult { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - use ansi_term::Colour::{Blue, Green, Red, Yellow}; - - match self { - SyncResult::NoChanges { name } => { - f.write_fmt(format_args!("{} {}", Blue.paint("NOCHANGE"), name)) - } - SyncResult::Dirty { name } => { - f.write_fmt(format_args!("{} {}", Yellow.paint("DIRTY "), name)) - } - SyncResult::Cloned { name } => { - f.write_fmt(format_args!("{} {}", Green.paint("CLONED "), name)) - } - SyncResult::Pushed { name } => { - f.write_fmt(format_args!("{} {}", Green.paint("PUSHED "), name)) - } - SyncResult::Error { name, error } => f.write_fmt(format_args!( - "{} {} [{}]", - Red.paint("ERROR "), - name, - error - )), - } - } -} diff --git a/src/local/update.rs b/src/local/update.rs deleted file mode 100644 index d9c9f3c..0000000 --- a/src/local/update.rs +++ /dev/null @@ -1,120 +0,0 @@ -use std::fmt::{Debug, Display}; - -use git2::BranchType; -use tracing::debug; - -use super::{Repo, RepoError}; - -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 { - 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 { - 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::(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())) - } - } -} - -#[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 index 97929af..eeec4a3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,27 +2,30 @@ use anyhow::{Context, Result}; use clap::Parser; use derivative::Derivative; -use tracing::{debug, info, metadata::LevelFilter, trace, Level}; +use tracing::{debug, metadata::LevelFilter, Level}; use tracing_subscriber::{fmt::format::FmtSpan, prelude::*, EnvFilter}; -use gtree::{ - config, - local::{Aggregator, Repos}, -}; +use crate::repo::{Aggregator, Repos}; + +pub mod config; +pub mod forge; +pub mod git; +pub mod repo; mod list; mod sync; mod update; +#[cfg(test)] +mod tests; + #[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, + forge: forge::Forge, } impl GTree { @@ -38,16 +41,13 @@ impl GTree { .next() .context("No Forge configured, please setup a forge")?; - let forge = gtree::forge::Forge::new(forge_config).await?; - - let gitconfig = git2::Config::open_default()?; + let forge = forge::Forge::new(forge_config).await?; Ok(GTree { figment, config, args, forge, - gitconfig, }) } diff --git a/src/repo/aggregate.rs b/src/repo/aggregate.rs new file mode 100644 index 0000000..cb4b00d --- /dev/null +++ b/src/repo/aggregate.rs @@ -0,0 +1,105 @@ +use git2::Repository; + +use tracing::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(root: &str, projects: Vec) -> Repos; + async fn aggregate(mut local: Repos, mut remote: Repos) -> Repos; +} + +#[async_trait::async_trait] +impl Aggregator for Repos { + #[tracing::instrument(level = "trace")] + async fn from_local(root: &str, scope: &str) -> Repos { + let mut repos = Vec::new(); + + let path: std::path::PathBuf = [root, scope].iter().collect(); + + if !path.exists() { + return repos; + } + + 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 dir.any(|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; + } + + #[tracing::instrument(level = "trace")] + async fn from_forge(root: &str, projects: Vec) -> Repos { + projects + .iter() + .map(|project| { + let mut repo: Repo = project.into(); + repo.path = [root, &repo.name].iter().collect(); + repo + }) + .collect() + } + + #[tracing::instrument(level = "trace", 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/repo/mod.rs b/src/repo/mod.rs new file mode 100644 index 0000000..e3d1279 --- /dev/null +++ b/src/repo/mod.rs @@ -0,0 +1,229 @@ +use std::{fmt::Debug, path::PathBuf}; + +use thiserror::Error; + +use git2::{Branch, Remote, Repository}; +use tracing::{debug, trace}; + +use crate::forge::Project; + +mod aggregate; +mod repostate; + +pub use aggregate::*; +pub use repostate::*; + +pub type Repos = Vec; + +pub struct Repo { + pub name: String, + pub path: PathBuf, + pub repo: Option, + pub forge: Option, + pub default_branch: String, +} + +impl Repo { + pub fn is_clean(&self) -> Result { + if let Some(repo) = &self.repo { + debug!("repo state: {:?}", repo.state()); + let statuses: Vec = 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, RepoError> { + let remotes = repo.remotes()?; + + let remote = if remotes.iter().any(|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(()) + } + + #[tracing::instrument(level = "trace")] + pub fn clone(&self, url: &str) -> Result { + let mut builder = git2::build::RepoBuilder::new(); + builder.fetch_options(crate::git::fetch_options()); + + builder + .clone(url, &self.path) + .map_err(RepoError::GitError) + } + + #[tracing::instrument(level = "trace")] + 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(), + } + } + + #[tracing::instrument(level = "trace", skip(repo, local, upstream))] + pub fn merge( + &self, + repo: &Repository, + local: &mut Branch, + upstream: &Branch, + ) -> Result { + 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 { + self.name.partial_cmp(&other.name) + } +} + +impl PartialEq for Repo { + fn eq(&self, other: &Self) -> bool { + self.name == other.name + } +} + +impl From 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(), + path: Default::default(), + repo: Default::default(), + forge: Default::default(), + default_branch: "main".to_string(), + } + } +} diff --git a/src/repo/repostate.rs b/src/repo/repostate.rs new file mode 100644 index 0000000..ea3c5a6 --- /dev/null +++ b/src/repo/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/sync/mod.rs b/src/sync/mod.rs index 2edc04c..f707b04 100644 --- a/src/sync/mod.rs +++ b/src/sync/mod.rs @@ -1,4 +1,6 @@ -use gtree::local::Repos; +use std::fmt::{Debug, Display}; + +use crate::repo::{Repo, RepoError, Repos}; impl crate::GTree { pub async fn sync(&self, repos: Repos) { @@ -10,3 +12,101 @@ impl crate::GTree { } } } + +impl Repo { + /// Clone repos from forge and push new repos to forge + #[tracing::instrument(level = "trace")] + pub fn sync(&mut self) -> Result { + let repo_name = self.name.clone(); + + if self.repo.is_some() + && !self + .is_clean() + .map_err(|err| SyncResult::err(repo_name.clone(), err))? + { + return Ok(SyncResult::dirty(repo_name)); + }; + + if self.repo.is_some() && self.forge.is_some() { + Ok(SyncResult::no_changes(repo_name)) + } else if self.repo.is_some() { + // do push stuff + Ok(SyncResult::pushed(repo_name)) + } else if self.forge.is_some() { + let url = self + .forge + .as_ref() + .unwrap() + .ssh_clone_url + .as_ref() + .ok_or_else(|| SyncResult::err(self.name.clone(), RepoError::NoRemoteFound))?; + + let repo = self + .clone(url) + .map_err(|err| SyncResult::err(repo_name.clone(), err))?; + + self.repo = Some(repo); + Ok(SyncResult::cloned(repo_name)) + } else { + Ok(SyncResult::no_changes(repo_name)) + } + } +} + +#[derive(Debug)] +pub enum SyncResult { + NoChanges { name: String }, + Dirty { name: String }, + Cloned { name: String }, + Pushed { name: String }, + Error { name: String, error: RepoError }, +} + +impl SyncResult { + pub fn err(name: String, error: RepoError) -> SyncResult { + SyncResult::Error { name, error } + } + + pub fn cloned(name: String) -> SyncResult { + SyncResult::Cloned { name } + } + + pub fn pushed(name: String) -> SyncResult { + SyncResult::Pushed { name } + } + + pub fn dirty(name: String) -> SyncResult { + SyncResult::Dirty { name } + } + + pub fn no_changes(name: String) -> SyncResult { + SyncResult::NoChanges { name } + } +} + +impl Display for SyncResult { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + use ansi_term::Colour::{Blue, Green, Red, Yellow}; + + match self { + SyncResult::NoChanges { name } => { + f.write_fmt(format_args!("{} {}", Blue.paint("NOCHANGE"), name)) + } + SyncResult::Dirty { name } => { + f.write_fmt(format_args!("{} {}", Yellow.paint("DIRTY "), name)) + } + SyncResult::Cloned { name } => { + f.write_fmt(format_args!("{} {}", Green.paint("CLONED "), name)) + } + SyncResult::Pushed { name } => { + f.write_fmt(format_args!("{} {}", Green.paint("PUSHED "), name)) + } + SyncResult::Error { name, error } => f.write_fmt(format_args!( + "{} {} [{}]", + Red.paint("ERROR "), + name, + error + )), + } + } +} diff --git a/src/tests/mod.rs b/src/tests/mod.rs new file mode 100644 index 0000000..0d32dbf --- /dev/null +++ b/src/tests/mod.rs @@ -0,0 +1,59 @@ +use crate::repo::*; + +use anyhow::Result; +use git2::Repository; + +thread_local! { + static TEST_DIR: std::path::PathBuf = std::env::current_exe() + .unwrap() + .join(std::path::Path::new("../../tmp")); +} + +const REPOS: [&str; 5] = [ + "repos/site/group/repo1", + "repos/site/group/repo2/subrepo1", + "repos/site/group/repo2", + "repos/site/group/subgroup/repo3", + "repos/site/group/subgroup/subsubgroup/repo4", +]; + +fn prepare_repos() -> Result<()> { + REPOS.iter().try_for_each(|repo| { + let path = format!("{:?}/{}", TEST_DIR, repo); + std::fs::create_dir_all(&path)?; + let _repo = Repository::init(&path)?; + + Ok::<(), anyhow::Error>(()) + }) +} + +fn clean_repos() -> Result<()> { + REPOS.iter().try_for_each(|repo| { + let path = format!("{:?}/{}", TEST_DIR, repo); + std::fs::remove_dir_all(&path)?; + + Ok::<(), anyhow::Error>(()) + }) +} + +#[tokio::test] +async fn search_repos() -> Result<()> { + tracing_subscriber::fmt::init(); + + prepare_repos()?; + + let mut left: Vec = vec![ + format!("{:?}/repos/site/group/repo1", TEST_DIR), + format!("{:?}/repos/site/group/repo2", TEST_DIR), + format!("{:?}/repos/site/group/subgroup/repo3", TEST_DIR), + format!("{:?}/repos/site/group/subgroup/subsubgroup/repo4", TEST_DIR), + ]; + let right = Repos::from_local(&format!("{:?}/repos", TEST_DIR), "").await; + + let mut right: Vec<&str> = right.iter().map(|x| x.name.as_str()).collect(); + + assert_eq!(left.sort(), right.sort_unstable()); + + clean_repos()?; + Ok(()) +} diff --git a/src/update/mod.rs b/src/update/mod.rs index 0991b1a..dfc800c 100644 --- a/src/update/mod.rs +++ b/src/update/mod.rs @@ -1,9 +1,14 @@ -use gtree::local::Repos; +use std::fmt::{Debug, Display}; + +use git2::BranchType; +use tracing::debug; + +use crate::repo::{Repo, RepoError, Repos}; impl crate::GTree { pub async fn update(&self, repos: Repos) { for mut repo in repos { - if let Some(_) = repo.repo { + if repo.repo.is_some() { match repo.update() { Ok(u) => println!("{}", u), Err(u) => println!("{}", u), @@ -12,3 +17,108 @@ impl crate::GTree { } } } + +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 { + let repo_name = self.name.clone(); + if self.repo.is_some() { + self.update_inner() + .map_err(|e| UpdateResult::err(repo_name, e)) + } else { + Ok(UpdateResult::err(repo_name, RepoError::NoLocalRepo)) + } + } + + fn update_inner(&mut self) -> Result { + 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::(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())) + } + } +} + +#[derive(Debug)] +pub enum UpdateResult { + NoChanges { name: String }, + Dirty { name: String }, + Merged { name: String }, + Error { name: String, error: RepoError }, +} + +impl UpdateResult { + pub fn err(name: String, error: 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/tests/directory_walker.rs b/tests/directory_walker.rs deleted file mode 100644 index 9fa8d56..0000000 --- a/tests/directory_walker.rs +++ /dev/null @@ -1,55 +0,0 @@ -use gtree::local::*; - -use anyhow::Result; -use git2::Repository; - -const TEST_DIR: &str = env!("CARGO_TARGET_TMPDIR"); - -const REPOS: [&str; 5] = [ - "repos/site/group/repo1", - "repos/site/group/repo2/subrepo1", - "repos/site/group/repo2", - "repos/site/group/subgroup/repo3", - "repos/site/group/subgroup/subsubgroup/repo4", -]; - -fn prepare_repos() -> Result<()> { - REPOS.iter().try_for_each(|repo| { - let path = format!("{}/{}", TEST_DIR, repo); - std::fs::create_dir_all(&path)?; - let _repo = Repository::init(&path)?; - - Ok::<(), anyhow::Error>(()) - }) -} - -fn clean_repos() -> Result<()> { - REPOS.iter().try_for_each(|repo| { - let path = format!("{}/{}", TEST_DIR, repo); - std::fs::remove_dir_all(&path)?; - - Ok::<(), anyhow::Error>(()) - }) -} - -#[tokio::test] -async fn search_repos() -> Result<()> { - tracing_subscriber::fmt::init(); - - prepare_repos()?; - - let mut left: Vec = vec![ - format!("{}/repos/site/group/repo1", TEST_DIR), - format!("{}/repos/site/group/repo2", TEST_DIR), - format!("{}/repos/site/group/subgroup/repo3", TEST_DIR), - format!("{}/repos/site/group/subgroup/subsubgroup/repo4", TEST_DIR), - ]; - let right = Repos::from_local(&format!("{}/repos", TEST_DIR), "").await; - - let mut right: Vec<&str> = right.iter().map(|x| x.name.as_str()).collect(); - - assert_eq!(left.sort(), right.sort()); - - clean_repos()?; - Ok(()) -} -- cgit v1.2.3