git-project 0.2.0

Manage piles of git repositories with ease!
Documentation
use crate::{
    err::Result,
    explore,
    options::CheckOptions,
    util::{self, PathRelativizeExtension},
};
use rayon::prelude::*;
use std::{collections::HashMap, fmt, iter, path};

struct Repository {
    path: String,
    warnings: Vec<Warning>,
}

enum Warning {
    NoRemotes,
    DirtyWorkingDir,
    LocalCommitsNotOnRemote {
        remote: String,
        branch: String,
        ahead_by: usize,
    },
    LocalBranchNotOnRemote {
        remote: String,
        branch: String,
    },
    LocalPathDifferentFromOrigin {
        local_path: path::PathBuf,
        expected_path: path::PathBuf,
        origin: String,
    },
}

#[derive(Default)]
struct Statistics {
    warnings: usize,
    total_repos: usize,
    repos_with_warnings: usize,
    repos_no_warnings: usize,
}

impl fmt::Display for Repository {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        writeln!(f, "{}", self.path)?;

        for warning in &self.warnings {
            writeln!(f, "  - {}", warning)?;
        }

        Ok(())
    }
}

impl fmt::Display for Warning {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            Warning::NoRemotes => write!(f, "no remotes configured"),
            Warning::DirtyWorkingDir => write!(f, "working directory has changes not checked in"),
            Warning::LocalCommitsNotOnRemote {
                remote,
                branch,
                ahead_by,
            } => write!(
                f,
                "local branch {0} ahead of {1}/{0} by {2} commits",
                branch, remote, ahead_by
            ),
            Warning::LocalBranchNotOnRemote { remote, branch } => write!(
                f,
                "local branch {} does not exist on remote {}",
                branch, remote
            ),
            Warning::LocalPathDifferentFromOrigin {
                local_path,
                origin,
                expected_path,
            } => write!(
                f,
                "should be at path {} based on origin url {}, but is at path {}",
                expected_path.display(),
                origin,
                local_path.display(),
            ),
        }
    }
}

impl Repository {
    fn get_stats(&self) -> Statistics {
        let empty = self.warnings.is_empty();
        let repos_with_warnings = if empty { 0 } else { 1 };
        let repos_no_warnings = if empty { 1 } else { 0 };

        Statistics {
            warnings: self.warnings.len(),
            total_repos: 1,
            repos_with_warnings,
            repos_no_warnings,
        }
    }
}

impl iter::Sum<Statistics> for Statistics {
    fn sum<I>(iter: I) -> Self
    where
        I: Iterator<Item = Self>,
    {
        let mut stats: Statistics = Default::default();

        for s in iter {
            stats.warnings += s.warnings;
            stats.total_repos += s.total_repos;
            stats.repos_with_warnings += s.repos_with_warnings;
            stats.repos_no_warnings += s.repos_no_warnings;
        }

        stats
    }
}

impl fmt::Display for Statistics {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        writeln!(f, "---- Summary ---")?;
        writeln!(f, "Warnings: {}", self.warnings)?;
        writeln!(f, "Scanned repositories: {}", self.total_repos)?;
        writeln!(
            f,
            "Repositories with warnings: {}",
            self.repos_with_warnings
        )?;
        writeln!(
            f,
            "Repositories with no warnings: {}",
            self.repos_no_warnings
        )
    }
}

pub fn run(check_opts: &CheckOptions) -> Result<()> {
    let paths = explore::find_git_folders(&check_opts.base.base_dir, check_opts.list.deep_recurse)?;

    let stats: Statistics = paths
        .par_iter()
        .map(|dir| check_git_dir_entry(dir, &check_opts.base.base_dir))
        .filter_map(|result| match result {
            Ok(x) => Some(x),
            Err(e) => {
                eprintln!("Error received: {}", e);
                None
            }
        })
        .map(|repo| {
            if !repo.warnings.is_empty() {
                println!("{}", repo);
            }

            repo.get_stats()
        })
        .sum();

    if check_opts.summarize {
        println!("{}", stats);
    }

    Ok(())
}

fn check_git_dir_entry(git_path: &path::PathBuf, base_dir: &path::Path) -> Result<Repository> {
    let repo = git2::Repository::open(&git_path)?;

    let mut warnings = Vec::new();

    if !is_clean(&repo)? {
        warnings.push(Warning::DirtyWorkingDir);
    }

    let remote_branches = repo.branches(Some(git2::BranchType::Remote))?;
    let local_branches = repo.branches(Some(git2::BranchType::Local))?;

    let remotes = generate_remote_tips(strip_branch_errors(remote_branches))?;
    let local_tips = generate_tips(strip_branch_errors(local_branches))?;

    if repo.remotes()?.is_empty() {
        warnings.push(Warning::NoRemotes);
    }

    for (local_branch, local_sha) in &local_tips {
        for (remote_name, remote_tips) in &remotes {
            match remote_tips.get(local_branch) {
                Some(remote_sha) => {
                    if remote_sha != local_sha {
                        let (ahead_by, _) = repo.graph_ahead_behind(*local_sha, *remote_sha)?;

                        if ahead_by > 0 {
                            warnings.push(Warning::LocalCommitsNotOnRemote {
                                remote: remote_name.clone(),
                                branch: local_branch.clone(),
                                ahead_by,
                            });
                        }
                    }
                }
                None => {
                    warnings.push(Warning::LocalBranchNotOnRemote {
                        remote: remote_name.clone(),
                        branch: local_branch.clone(),
                    });
                }
            }
        }
    }

    if let Ok(origin) = repo.find_remote("origin") {
        if let Some(url) = origin.url() {
            if let Ok(expected_path) = util::find_dir(base_dir, url) {
                if expected_path != *git_path {
                    warnings.push(Warning::LocalPathDifferentFromOrigin {
                        expected_path: expected_path.normalize_relative_to(base_dir),
                        local_path: git_path.normalize_relative_to(base_dir),
                        origin: url.into(),
                    });
                }
            }
        }
    }

    Ok(Repository {
        path: format!("{}", git_path.normalize_relative_to(base_dir).display()),
        warnings,
    })
}

fn is_clean(repo: &git2::Repository) -> Result<bool> {
    let statuses = repo.statuses(Some(git2::StatusOptions::new().include_untracked(true)))?;

    for status in statuses.iter() {
        if status.status() != git2::Status::CURRENT {
            return Ok(false);
        }
    }

    Ok(true)
}

fn strip_branch_errors(branches: git2::Branches) -> impl Iterator<Item = git2::Branch> {
    branches.filter_map(|x| x.map(|(branch, _)| branch).ok())
}

fn generate_remote_tips<'a, I>(branches: I) -> Result<HashMap<String, HashMap<String, git2::Oid>>>
where
    I: Iterator<Item = git2::Branch<'a>>,
{
    let mut map = HashMap::new();

    for branch in branches {
        let name = match branch.name() {
            Ok(Some(n)) => n,
            _ => break,
        };

        let mut name_parts = name.split('/');
        let remote_name = name_parts.next().unwrap();

        let (ref_name, commit) = tip(&branch)?;

        let mut ref_name_parts = ref_name.split('/');
        ref_name_parts.next().unwrap();

        let ref_name = itertools::join(ref_name_parts, "/");

        let branch_items = map
            .entry(remote_name.to_owned())
            .or_insert_with(HashMap::new);
        branch_items.insert(ref_name, commit);
    }

    Ok(map)
}

fn generate_tips<'a, I>(branches: I) -> Result<HashMap<String, git2::Oid>>
where
    I: Iterator<Item = git2::Branch<'a>>,
{
    let mut map = HashMap::new();

    for branch in branches {
        let (ref_name, commit) = tip(&branch)?;
        map.insert(ref_name, commit);
    }

    Ok(map)
}

fn tip<'a>(branch: &git2::Branch<'a>) -> Result<(String, git2::Oid)> {
    let branch_ref = branch.get();

    let ref_name = branch_ref.shorthand().unwrap();
    let ref_commit = branch_ref.peel_to_commit()?;

    Ok((ref_name.to_owned(), ref_commit.id()))
}