git-repo-manager 0.10.0

Manage multiple git repositories. You configure the git repositories in a file, the program does the rest!
Documentation
use std::fmt::{self, Write};

use camino::{Utf8Path as Path, Utf8PathBuf as PathBuf};
use comfy_table::{Cell, Table};
use thiserror::Error;

use super::{
    config, path,
    repo::{self, WorktreeName, WorktreeSetup},
    repo::{RepoHandle, RepoName, WorktreeRepoHandle},
    tree,
};

#[derive(Debug, Error)]
pub enum Error {
    #[error(transparent)]
    Lib(#[from] crate::Error),
    #[error(transparent)]
    Config(#[from] config::Error),
    #[error("Repo error: {0}")]
    Repo(#[from] repo::Error),
    #[error("Worktreeerror: {0}")]
    Worktree(#[from] repo::WorktreeError),
    #[error("Directory is not a git directory")]
    NotAGitDirectory,
    #[error("Worktree {worktree:?} does not have a directory")]
    WorktreeWithoutDirectory { worktree: WorktreeName },
    #[error(transparent)]
    Path(#[from] path::Error),
    #[error(transparent)]
    Fmt(#[from] fmt::Error),
    #[error("Found {path}, which is not a valid worktree directory!")]
    InvalidWorktreeDirectory { path: PathBuf },
    #[error("Repository \"{name}\" does not exist. Run sync?")]
    RepoDoesNotExist { name: RepoName },
    #[error("No git repository for \"{name}\" found. Run sync?")]
    RepoNotGit { name: RepoName },
    #[error("Opening repository \"{name}\" failed: {message}")]
    RepoOpenFailed { name: RepoName, message: String },
    #[error("Couldn't add repo status for \"{name}\": {message}")]
    RepoStatusFailed { name: RepoName, message: String },
}

fn add_table_header(table: &mut Table) {
    table
        .load_preset(comfy_table::presets::UTF8_FULL)
        .apply_modifier(comfy_table::modifiers::UTF8_ROUND_CORNERS)
        .set_header([
            Cell::new("Repo"),
            Cell::new("Worktree"),
            Cell::new("Status"),
            Cell::new("Branches"),
            Cell::new("HEAD"),
            Cell::new("Remotes"),
        ]);
}

fn add_repo_status(
    table: &mut Table,
    repo_name: Option<&RepoName>,
    repo_handle: &RepoHandle,
    worktree_setup: WorktreeSetup,
) -> Result<(), Error> {
    let repo_status = repo_handle.status(worktree_setup).map_err(Error::Repo)?;

    let branch_info = {
        let mut acc = String::new();
        for (branch_name, remote_branch) in repo_status.branches {
            writeln!(
                &mut acc,
                "branch: {}{}",
                &branch_name,
                &match remote_branch {
                    None => String::from(" <!local>"),
                    Some((remote_branch_name, remote_tracking_status)) => {
                        format!(
                            " <{}>{}",
                            remote_branch_name,
                            &match remote_tracking_status {
                                repo::RemoteTrackingStatus::UpToDate => String::from(" \u{2714}"),
                                repo::RemoteTrackingStatus::Ahead(d) => format!(" [+{}]", &d),
                                repo::RemoteTrackingStatus::Behind(d) => format!(" [-{}]", &d),
                                repo::RemoteTrackingStatus::Diverged(d1, d2) =>
                                    format!(" [+{}/-{}]", &d1, &d2),
                            }
                        )
                    }
                }
            )?;
        }
        acc.trim().to_owned()
    };

    let remote_status = {
        let mut acc = String::new();
        for remote in repo_status.remotes {
            writeln!(&mut acc, "{remote}")?;
        }

        acc.trim().to_owned()
    };

    table.add_row([
        match repo_name {
            Some(name) => name.as_str(),
            None => "unknown",
        },
        if worktree_setup.is_worktree() {
            "\u{2714}"
        } else {
            ""
        },
        &if worktree_setup.is_worktree() {
            String::new()
        } else {
            match repo_status.changes {
                Some(changes) => {
                    let mut out = Vec::new();
                    if changes.files_new > 0 {
                        out.push(format!("New: {}\n", changes.files_new));
                    }
                    if changes.files_modified > 0 {
                        out.push(format!("Modified: {}\n", changes.files_modified));
                    }
                    if changes.files_deleted > 0 {
                        out.push(format!("Deleted: {}\n", changes.files_deleted));
                    }
                    out.into_iter().collect::<String>().trim().to_owned()
                }
                None => String::from("\u{2714}"),
            }
        },
        &branch_info,
        &if worktree_setup.is_worktree() {
            String::new()
        } else {
            match repo_status.head {
                Some(head) => head.into_string(),
                None => String::from("Empty"),
            }
        },
        &remote_status,
    ]);

    Ok(())
}

// Don't return table, return a type that implements Display(?)
pub fn get_worktree_status_table(
    repo: &WorktreeRepoHandle,
    directory: &Path,
) -> Result<(impl std::fmt::Display, Vec<Error>), Error> {
    let worktrees = repo.get_worktrees()?;
    let mut table = Table::new();

    let mut errors = Vec::new();

    add_worktree_table_header(&mut table);
    for worktree in &worktrees {
        let worktree_dir = &directory.join(worktree.name().as_str());
        if worktree_dir.exists() {
            let repo = match RepoHandle::open(worktree_dir) {
                Ok(repo) => repo,
                Err(error) => {
                    errors.push(error.into());
                    continue;
                }
            };
            if let Err(error) = add_worktree_status(&mut table, worktree, &repo) {
                errors.push(error);
            }
        } else {
            errors.push(Error::WorktreeWithoutDirectory {
                worktree: worktree.name().clone(),
            });
        }
    }
    for worktree in WorktreeRepoHandle::find_unmanaged_worktrees(repo, directory)? {
        errors.push(Error::InvalidWorktreeDirectory { path: worktree });
    }
    Ok((table, errors))
}

pub fn get_status_table(trees: Vec<tree::Tree>) -> Result<(Vec<Table>, Vec<Error>), Error> {
    let mut errors = Vec::new();
    let mut tables = Vec::new();

    for tree in trees {
        let repos = tree.repos;

        let root_path = path::expand_path(tree.root.as_path())?;

        let mut table = Table::new();
        add_table_header(&mut table);

        for repo in &repos {
            let repo_path = root_path.join(repo.name.as_str());

            if !repo_path.exists() {
                errors.push(Error::RepoDoesNotExist {
                    name: repo.name.clone(),
                });
                continue;
            }

            let repo_handle = RepoHandle::open_with_worktree_setup(&repo_path, repo.worktree_setup);

            let repo_handle = match repo_handle {
                Ok(repo) => repo,
                Err(error) => {
                    if matches!(error, repo::Error::RepoNotFound) {
                        errors.push(Error::RepoNotGit {
                            name: repo.name.clone(),
                        });
                    } else {
                        errors.push(Error::RepoOpenFailed {
                            name: repo.name.clone(),
                            message: error.to_string(),
                        });
                    }
                    continue;
                }
            };

            if let Err(err) = add_repo_status(
                &mut table,
                Some(&repo.name),
                &repo_handle,
                repo.worktree_setup,
            ) {
                errors.push(Error::RepoStatusFailed {
                    name: repo.name.clone(),
                    message: err.to_string(),
                });
            }
        }

        tables.push(table);
    }

    Ok((tables, errors))
}

fn add_worktree_table_header(table: &mut Table) {
    table
        .load_preset(comfy_table::presets::UTF8_FULL)
        .apply_modifier(comfy_table::modifiers::UTF8_ROUND_CORNERS)
        .set_header([
            Cell::new("Worktree"),
            Cell::new("Status"),
            Cell::new("Branch"),
            Cell::new("Remote branch"),
        ]);
}

fn add_worktree_status(
    table: &mut Table,
    worktree: &repo::Worktree,
    repo: &RepoHandle,
) -> Result<(), Error> {
    let repo_status = repo
        .status(WorktreeSetup::NoWorktree)
        .map_err(Error::Repo)?;

    let local_branch = repo.head_branch().map_err(Error::Repo)?;

    let upstream_output = match local_branch.upstream()? {
        Some(remote_branch) => {
            let remote_branch_name = remote_branch.name().map_err(Error::Repo)?;

            let (ahead, behind) = repo
                .graph_ahead_behind(&local_branch, &remote_branch)
                .map_err(Error::Repo)?;

            format!(
                "{}{}\n",
                &remote_branch_name,
                &match (ahead, behind) {
                    (0, 0) => String::new(),
                    (d, 0) => format!(" [+{}]", &d),
                    (0, d) => format!(" [-{}]", &d),
                    (d1, d2) => format!(" [+{}/-{}]", &d1, &d2),
                },
            )
        }
        None => String::new(),
    };

    table.add_row([
        worktree.name().as_str(),
        &match repo_status.changes {
            Some(changes) => {
                let mut out = Vec::new();
                if changes.files_new > 0 {
                    out.push(format!("New: {}\n", changes.files_new));
                }
                if changes.files_modified > 0 {
                    out.push(format!("Modified: {}\n", changes.files_modified));
                }
                if changes.files_deleted > 0 {
                    out.push(format!("Deleted: {}\n", changes.files_deleted));
                }
                out.into_iter().collect::<String>().trim().to_owned()
            }
            None => String::from("\u{2714}"),
        },
        local_branch.name().map_err(Error::Repo)?.as_str(),
        &upstream_output,
    ]);

    Ok(())
}

pub fn show_single_repo_status(
    path: &Path,
) -> Result<(impl std::fmt::Display, Vec<String>), Error> {
    let mut table = Table::new();
    let mut warnings = Vec::new();

    let worktree_setup = WorktreeSetup::detect(path);
    add_table_header(&mut table);

    let repo_handle = RepoHandle::open_with_worktree_setup(path, worktree_setup);

    if let Err(error) = repo_handle {
        if matches!(error, repo::Error::RepoNotFound) {
            return Err(Error::NotAGitDirectory);
        } else {
            return Err(error.into());
        }
    }

    let repo_name = match path.file_name() {
        None => {
            warnings.push(format!(
                "Cannot detect repo name for path {path}. Are you working in /?"
            ));
            None
        }
        Some(file_name) => Some(RepoName::new(file_name.to_owned())),
    };

    add_repo_status(
        &mut table,
        repo_name.as_ref(),
        &repo_handle?,
        worktree_setup,
    )?;

    Ok((table, warnings))
}