bitbucket_server_cli 0.4.5

A CLI for cloning and updating entire projects from bitbucket server
Documentation
use crate::bitbucket::types::Repo;
use crate::types::GitOpts;
use crate::util::{bail, exec};
use generic_error::Result;
use std::path::Path;
use std::process::Output;

#[derive(Clone)]
pub struct SingleGit<'a, 'b> {
    repo: &'a Repo,
    opts: &'b GitOpts,
}

impl SingleGit<'_, '_> {
    pub fn new<'a, 'b>(repo: &'a Repo, opts: &'b GitOpts) -> SingleGit<'a, 'b> {
        SingleGit { repo, opts }
    }

    pub async fn clone_or_update(&self) -> Result<()> {
        if self.dir_exists() {
            if self.opts.reset_state {
                self.git_reset().await?;
            }
            self.git_update().await?;
        } else {
            self.git_clone().await?;
        }
        Ok(())
    }

    async fn git_clone(&self) -> Result<()> {
        let string_path = format!("{}/{}", self.opts.output_directory, self.repo.project_key);
        let path = Path::new(&string_path);

        let extra_conf = if self.opts.ssl_allow_anything {
            "-c http.sslVerify=false "
        } else {
            ""
        };
        self.exec_resolve(
            &format!("git clone into {}", self.opts.output_directory),
            &format!(
                "git {}clone {} {}",
                extra_conf, self.repo.git, self.repo.name
            ),
            path,
        )
        .await?;
        Ok(())
    }

    async fn git_update(&self) -> Result<()> {
        let string_path = self.path();
        let path = Path::new(&string_path);
        let current_branch_raw: Vec<u8> =
            exec("git rev-parse --abbrev-ref HEAD", path).await?.stdout;
        let current_branch: &str = std::str::from_utf8(&current_branch_raw)?.trim();
        let main_branch: String = self.get_git_main().await?;

        if main_branch == "(unknown)" {
            self.generate_repo_err("get main branch", "main branch unknown")?;
        } else if current_branch == main_branch.as_str() {
            self.exec_resolve("git pull", "git pull --autostash --ff-only --rebase", path)
                .await?;
        } else {
            self.exec_resolve(
                "git fetch",
                &format!("git fetch origin {}:{}", main_branch, main_branch),
                path,
            )
            .await?;
            if self
                .exec_resolve("git pull", "git pull --autostash --ff-only --rebase", path)
                .await
                .is_err()
            {
                // Do nothing
            }
            self.exec_resolve(
                &format!("checkout {}", main_branch),
                &format!("git checkout {}", main_branch),
                path,
            )
            .await?;
        }
        self.clean_merged_branches(&main_branch, path).await?;
        Ok(())
    }

    async fn clean_merged_branches(&self, main_branch: &str, path: &Path) -> Result<()> {
        let out_raw: Vec<u8> = self
            .exec_resolve("list merged branches", "git branch --merged", path)
            .await?
            .stdout;
        let out: &str = std::str::from_utf8(&out_raw)?;
        for line in out.lines() {
            if !line.starts_with("* ") && line != format!("  {}", main_branch) {
                self.exec_resolve(
                    "remove branch",
                    &format!("git branch -D {}", line.trim()),
                    path,
                )
                .await?;
            }
        }
        Ok(())
    }

    async fn exec_resolve(&self, action: &str, cmd: &str, path: &Path) -> Result<Output> {
        match exec(cmd, path).await {
            Ok(o) if o.status.success() => Ok(o),
            Ok(o) if !o.status.success() => {
                self.generate_repo_err_from_output(action, o.stdout, o.stderr)
            }
            Err(e) => self.generate_repo_err(action, &e.msg),
            _ => self.generate_repo_err(action, "unknown"),
        }
    }

    async fn get_git_main(&self) -> Result<String> {
        let path_string = self.path();
        let path = Path::new(&path_string);
        let raw: Vec<u8> = self
            .exec_resolve("git remote show origin", "git remote show origin", path)
            .await?
            .stdout;
        let stdout: &str = std::str::from_utf8(&raw)?;
        self.get_git_main_from_remote_info(stdout)
    }

    fn get_git_main_from_remote_info(&self, remote_info: &str) -> Result<String> {
        let opt: Option<String> = remote_info
            .lines()
            .find(|s| s.starts_with("  HEAD branch: "))
            .map(|s| String::from(&s[15..]));
        match opt {
            Some(s) => Ok(s),
            None => self.generate_repo_err("list branches", "unable to filter main branch"),
        }
    }

    async fn git_reset(&self) -> Result<()> {
        let string_path = self.path();
        let path = Path::new(&string_path);
        match exec("git reset --hard", path).await {
            Ok(_) => {
                let main_branch: String = self.get_git_main().await?;
                match exec(
                    &format!("git checkout {} --quiet --force", main_branch),
                    path,
                )
                .await
                {
                    Err(e) => self.generate_repo_err(&format!("checkout {}", main_branch), &e.msg),
                    Ok(_) => Ok(()),
                }
            }
            Err(e) => self.generate_repo_err("resetting repo", &e.msg),
        }
    }

    fn generate_repo_err_from_output<T>(
        &self,
        suffix: &str,
        cause_out: Vec<u8>,
        cause_err: Vec<u8>,
    ) -> Result<T> {
        let cause = match (cause_to_str(cause_err), cause_to_str(cause_out)) {
            (Some(e), _) => format!("Err: '{}'", e.trim()),
            (_, Some(o)) => format!("Output: '{}'", o.trim()),
            (None, None) => "no output".to_string(),
        };
        self.generate_repo_err(suffix, &cause)
    }

    fn path(&self) -> String {
        return format!(
            "{}/{}/{}",
            &self.opts.output_directory, &self.repo.project_key, &self.repo.name
        );
    }

    fn dir_exists(&self) -> bool {
        Path::new(&self.path()).exists()
    }

    fn generate_repo_err<T>(&self, action: &str, cause: &str) -> Result<T> {
        bail(&format!(
            "{}/{} failed {}. Cause: {}",
            &self.repo.project_key,
            self.repo.name,
            action,
            cause.lines().next().unwrap_or("unknown")
        ))
    }
}

fn cause_to_str(cause: Vec<u8>) -> Option<String> {
    if cause.is_empty() {
        None
    } else if let Ok(cause) = std::str::from_utf8(cause.as_slice()) {
        Some(cause.to_owned())
    } else {
        None
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[cfg(not(target_os = "windows"))]
    const RM_STR: &str = "rm -rf test_repo";
    #[cfg(target_os = "windows")]
    const RM_STR: &str = "rmdir /Q /S test_repo";

    fn repo(project_key: &str, name: &str) -> Repo {
        Repo {
            project_key: String::from(project_key),
            git: String::from("https://github.com/jensim/bitbucket_server_cli.git"),
            name: String::from(name),
        }
    }

    #[tokio::test]
    async fn test_git_clone_and_update() {
        let repo_path = "/tmp/test_project/test_repo";
        let repo = repo("test_project", "test_repo");
        let project_path = "/tmp/test_project";
        let output_directory = "/tmp";
        let opts = GitOpts {
            reset_state: false,
            concurrency: 1,
            quiet: false,
            output_directory: output_directory.to_owned(),
            ssl_allow_anything: false,
        };
        std::fs::create_dir_all(project_path).unwrap();
        assert!(
            Path::new(project_path).exists(),
            "Project dir should exist."
        );
        if let Err(e) = exec(RM_STR, project_path).await {
            eprintln!("Failed removing {} due to {:?}", repo_path, e)
        }
        assert!(!Path::new(repo_path).exists(), "Repo dir should not exist.");
        let single = SingleGit::new(&repo, &opts);

        single.git_clone().await.unwrap();
        assert!(Path::new(repo_path).exists(), "Failed cloning");

        single.git_update().await.unwrap();

        if let Err(e) = exec(RM_STR, project_path).await {
            eprintln!("Failed removing {} due to {:?}", repo_path, e)
        }
        assert!(!Path::new(repo_path).exists(), "Failed cleaning away dir.");
    }

    #[test]
    fn test_get_git_main_from_remote_info() {
        //let repo_path = "/tmp/test_project/test_repo";
        let repo = repo("test_project", "test_repo");
        let project_path = "/tmp/test_project";
        let output_directory = "/tmp";
        let opts = GitOpts {
            reset_state: false,
            concurrency: 1,
            quiet: false,
            output_directory: output_directory.to_owned(),
            ssl_allow_anything: false,
        };
        std::fs::create_dir_all(project_path).unwrap();

        let single = SingleGit::new(&repo, &opts);
        let s = single.get_git_main_from_remote_info(
            "* remote origin
  Fetch URL: ssh://git@github.com/jensim/foo-bar-baz.git
  Push  URL: ssh://git@github.com/jensim/foo-bar-baz.git
  HEAD branch: master
  Remote branches:
    master                                                       tracked
  Local branches configured for 'git pull':
    master                                                       merges with remote master
  Local refs configured for 'git push':
    master                                                       pushes to master                                                       (up to date)
",
        ).unwrap();

        assert_eq!(s, "master")
    }
}