bgit 0.4.2

User-friendly Git wrapper for beginners, automating essential tasks like adding, committing, and pushing changes. It includes smart rules to avoid common pitfalls, such as accidentally adding sensitive files or directories and has exclusive support for portable hooks!
use colored::Colorize;
use git2::{Config, Repository};
use std::env;
use std::path::PathBuf;

use crate::{
    bgit_error::{BGitError, BGitErrorWorkflowType, NO_RULE, NO_STEP},
    config::global::BGitGlobalConfig,
    hook_executor::execute_hook_util,
    rules::Rule,
    util::find_hook_with_extension,
};
pub mod git_add;
pub mod git_branch;
mod git_checkout;
mod git_clean;
pub mod git_clone;
pub mod git_commit;
pub mod git_config;
mod git_filter_repo;
pub mod git_init;
pub mod git_log;
pub mod git_pull;
pub mod git_push;
pub mod git_restore;
pub mod git_stash;
pub mod git_status;

const PENGUIN_EMOJI: &str = "🐧";

pub(crate) enum HookType {
    PreEvent,
    PostEvent,
}

/// Sample struct
/// struct GitAdd {
///     name: String,
///     action_description: String,
///     pre_check_rules: Vec<Box<dyn Rule + Send + Sync>>
/// }
/// List of various Git Events to be called with git2-rs library
pub(crate) trait AtomicEvent<'a> {
    fn new(global_config: &'a BGitGlobalConfig) -> Self
    where
        Self: Sized;
    fn get_name(&self) -> &str;

    #[allow(unused)]
    fn get_action_description(&self) -> &str;
    fn add_pre_check_rule(&mut self, rule: Box<dyn Rule + Send + Sync>);
    fn get_pre_check_rule(&self) -> &Vec<Box<dyn Rule + Send + Sync>>;
    // Plain execute the event, without any checks and hook
    fn raw_execute(&self) -> Result<bool, Box<BGitError>>;

    // Hooks
    fn pre_execute_hook(&self) -> Result<bool, Box<BGitError>> {
        let event_hook_file_name: String = format!("pre_{}", self.get_name());
        let bgit_ok = self.execute_hook(&event_hook_file_name, HookType::PreEvent)?;
        if !bgit_ok {
            return Ok(false);
        }

        if self.get_name() == "git_commit" {
            self.execute_standard_git_hook("pre-commit", HookType::PreEvent)?;
        }

        Ok(true)
    }

    fn post_execute_hook(&self) -> Result<bool, Box<BGitError>> {
        let post_event_hook_file_name: String = format!("post_{}", self.get_name());
        let bgit_ok = self.execute_hook(&post_event_hook_file_name, HookType::PostEvent)?;
        if !bgit_ok {
            return Ok(false);
        }

        if self.get_name() == "git_commit" {
            self.execute_standard_git_hook("post-commit", HookType::PostEvent)?;
        }

        Ok(true)
    }

    /// Run hooks inside `{RepositoryBase}/.bgit/hooks/[pre|post]-{hook_name}`
    /// TODO: Implement for Windows and other OS using custom toml for custom runtime like
    /// shell and languages
    fn execute_hook(
        &self,
        event_hook_file_name: &str,
        hook_type: HookType,
    ) -> Result<bool, Box<BGitError>> {
        let cwd = env::current_dir().expect("Failed to get current directory");
        let git_repo = Repository::discover(&cwd);
        let bgit_hooks_path = match git_repo.is_ok() {
            true => {
                let git_repo = git_repo.unwrap();
                let git_repo_path = git_repo
                    .path()
                    .parent()
                    .expect("Failed to crawl to parent directory of .git folder");
                git_repo_path.join(".bgit").join("hooks")
            }
            false => cwd.join(".bgit").join("hooks"),
        };

        let event_hook_path = bgit_hooks_path.join(event_hook_file_name);
        match find_hook_with_extension(&event_hook_path) {
            None => Ok(true),
            Some(hook_path) => {
                let hook_type_str = match hook_type {
                    HookType::PreEvent => "pre",
                    HookType::PostEvent => "post",
                };
                eprintln!(
                    "{} Running {}-event hook for {}",
                    PENGUIN_EMOJI,
                    hook_type_str,
                    self.get_name().cyan().bold()
                );
                execute_hook_util(&hook_path, self.get_name())
            }
        }
    }

    /// Execute standard Git hooks (e.g., pre-commit, post-commit) if present.
    /// Resolves core.hooksPath (local, then global) and falls back to .git/hooks.
    fn execute_standard_git_hook(
        &self,
        hook_name: &str,
        hook_type: HookType,
    ) -> Result<bool, Box<BGitError>> {
        if let Some(hooks_dir) = Self::resolve_standard_hooks_dir() {
            let hook_path = hooks_dir.join(hook_name);
            if hook_path.exists() {
                let hook_type_str = match hook_type {
                    HookType::PreEvent => "pre",
                    HookType::PostEvent => "post",
                };
                eprintln!(
                    "{} Running standard Git {}-hook: {}",
                    PENGUIN_EMOJI,
                    hook_type_str,
                    hook_name.cyan().bold()
                );
                return execute_hook_util(&hook_path, hook_name);
            }
        }
        Ok(true)
    }

    /// Find the directory where standard Git hooks live.
    /// Priority: repo core.hooksPath -> global core.hooksPath -> .git/hooks
    fn resolve_standard_hooks_dir() -> Option<PathBuf> {
        let cwd = env::current_dir().ok()?;
        let repo = Repository::discover(&cwd).ok()?;

        if let Ok(cfg) = repo.config()
            && let Ok(val) = cfg.get_string("core.hooksPath")
        {
            let dir = Self::normalize_hooks_path(Self::repo_root_path(&repo)?, &val);
            return Some(dir);
        }

        if let Ok(global) = Config::open_default()
            && let Ok(val) = global.get_string("core.hooksPath")
        {
            let dir = Self::normalize_hooks_path(Self::repo_root_path(&repo)?, &val);
            return Some(dir);
        }

        Some(repo.path().join("hooks"))
    }

    fn repo_root_path(repo: &Repository) -> Option<PathBuf> {
        if let Some(workdir) = repo.workdir() {
            Some(workdir.to_path_buf())
        } else {
            repo.path().parent().map(|p| p.to_path_buf())
        }
    }

    fn normalize_hooks_path(repo_root: PathBuf, configured: &str) -> PathBuf {
        let expanded = if let Some(rest) = configured.strip_prefix("~/") {
            if let Some(home_dir) = home::home_dir() {
                home_dir.join(rest)
            } else {
                PathBuf::from(configured)
            }
        } else {
            PathBuf::from(configured)
        };

        if expanded.is_absolute() {
            expanded
        } else {
            repo_root.join(expanded)
        }
    }

    // Check against set of rules before running the event
    fn check_rules(&self) -> Result<bool, Box<BGitError>> {
        let rules = self.get_pre_check_rule();
        if rules.is_empty() {
            return Ok(true);
        }
        eprintln!(
            "{} Running pre-check rules for {}",
            PENGUIN_EMOJI,
            self.get_name().cyan().bold()
        );
        for rule in rules.iter() {
            let rule_passed = rule.execute()?;
            if !rule_passed {
                return Err(Box::new(BGitError::new(
                    "Pre-check Rule failed",
                    rule.get_description(),
                    BGitErrorWorkflowType::AtomicEvent,
                    NO_STEP,
                    self.get_name(),
                    rule.get_name(),
                )));
            }
        }
        Ok(true)
    }

    fn execute(&self) -> Result<bool, Box<BGitError>> {
        eprintln!("Running event: {}", self.get_name());
        let rule_check_status = self.check_rules()?;
        if !rule_check_status {
            return Ok(false);
        }
        let event_hook_status = self.pre_execute_hook()?;
        if !event_hook_status {
            return Err(Box::new(BGitError::new(
                "Pre-event hook failed",
                "Pre-event hook failed!",
                BGitErrorWorkflowType::AtomicEvent,
                NO_STEP,
                self.get_name(),
                NO_RULE,
            )));
        }

        eprintln!(
            "{} Running executor for event {}",
            PENGUIN_EMOJI,
            self.get_name().cyan().bold()
        );
        let raw_executor_status = self.raw_execute()?;

        let post_event_hook_status = self.post_execute_hook()?;
        if !post_event_hook_status {
            return Err(Box::new(BGitError::new(
                "Post-event hook failed",
                "Post-event hook failed!",
                BGitErrorWorkflowType::AtomicEvent,
                NO_STEP,
                self.get_name(),
                NO_RULE,
            )));
        }
        Ok(raw_executor_status)
    }

    fn to_bgit_error(&self, message: &str) -> Box<BGitError> {
        Box::new(BGitError::new(
            "BGitError",
            message,
            BGitErrorWorkflowType::AtomicEvent,
            NO_STEP,
            self.get_name(),
            NO_RULE,
        ))
    }
}