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