aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/config/args.rs23
-rw-r--r--src/config/mod.rs87
-rw-r--r--src/forge/gitlab/config.rs26
-rw-r--r--src/forge/gitlab/mod.rs73
-rw-r--r--src/forge/mod.rs48
-rw-r--r--src/git/mod.rs27
-rw-r--r--src/lib.rs4
-rw-r--r--src/list/mod.rs12
-rw-r--r--src/local/;a211
-rw-r--r--src/local/aggregate.rs91
-rw-r--r--src/local/mod.rs272
-rw-r--r--src/local/repostate.rs36
-rw-r--r--src/local/update.rs57
-rw-r--r--src/main.rs98
-rw-r--r--src/update/mod.rs14
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),
+ };
+ }
+ }
+ }
+}