git-simple-encrypt 3.0.0

Encrypt/decrypt files in your git repo using only one password
Documentation
use std::{
    path::{Path, PathBuf},
    sync::{Mutex, OnceLock},
};

use anyhow::{Result, anyhow, ensure};
use assert2::assert;
use colored::Colorize;
use config_file2::LoadConfigFile;
use log::{info, warn};
use rayon::prelude::*;

use crate::{
    config::{CONFIG_FILE_NAME, Config},
    utils::{create_progress_bar, is_file_encrypted, prompt_password, resolve_target_files},
};

pub const GIT_CONFIG_PREFIX: &str =
    const_str::replace!(concat!(env!("CARGO_CRATE_NAME"), "."), "_", "-");

/// The pre-commit hook content that runs `git-se check` before every commit.
const PRE_COMMIT_HOOK: &[u8] = br#"#!/bin/sh
# Auto-generated by git-se install
git-se check
if [ $? -ne 0 ]; then
    echo "Please run 'git-se e' to encrypt them before committing."
    exit 1
fi
"#;

#[derive(Debug, Clone, Default)]
pub struct Repo {
    /// The absolute path of the opened repo.
    pub path: PathBuf,
    pub conf: Config,
    pub key_sha: OnceLock<Box<[u8]>>,
}

impl Repo {
    /// open a repo. The [`path`] param must be absolute path.
    pub fn open(path: impl AsRef<Path>) -> Result<Self> {
        debug_assert!(path.as_ref().is_absolute(), "given path must be absolute");
        let mut repo_path = path.as_ref().to_path_buf();
        assert!(
            repo_path.exists(),
            "Repo not found: {}",
            repo_path.display()
        );
        assert!(
            repo_path.is_dir(),
            "Not a directory: {}",
            repo_path.display()
        );
        if repo_path
            .file_name()
            .ok_or_else(|| anyhow!("Filename not found"))?
            == ".git"
        {
            repo_path.pop();
        }
        info!("Open repo: {}", repo_path.display());
        let config_file_path = repo_path.join(CONFIG_FILE_NAME);
        if !config_file_path.exists() {
            warn!(
                "Config file not found: `{}`, using default config instead...",
                config_file_path.display()
            );
        }
        let conf = Config::load_or_default(&config_file_path)?.with_repo_path(&repo_path);
        Ok(Self {
            path: repo_path,
            conf,
            key_sha: OnceLock::new(),
        })
    }

    pub fn path(&self) -> &Path {
        &self.path
    }

    pub fn to_absolute_path(&self, path: impl AsRef<Path>) -> PathBuf {
        self.path.join(path.as_ref())
    }

    pub fn get_key(&self) -> String {
        self.get_config("key")
            .expect("Key not found, please exec `git-se p` first.")
    }

    /// set the key interactively
    pub fn set_key_interactive(&self) -> Result<()> {
        let key = prompt_password("Please input your key: ")?;
        self.set_config("key", &key)?;
        info!("Set key: `{key}`");
        Ok(())
    }

    /// Check if all files in the crypt list (or given paths) are encrypted.
    ///
    /// Returns `Ok(())` if all files are encrypted, or an error summarizing
    /// which files are not encrypted. The process exits with a non-zero code
    /// when files are not encrypted, suitable for CI usage.
    pub fn check(&self, paths: &[PathBuf]) -> Result<()> {
        let target_files = resolve_target_files(paths, &self.conf.crypt_list, self.path());
        ensure!(!target_files.is_empty(), "No file to check");

        println!(
            "\n{} {} {}",
            "Checking encryption status".bold(),
            format!("({} files)", target_files.len()).cyan(),
            ":".dimmed()
        );

        let pb = create_progress_bar(target_files.len(), "Check");
        let not_encrypted: Mutex<Vec<PathBuf>> = Mutex::new(Vec::new());

        target_files.par_iter().try_for_each(|f| -> Result<()> {
            if !is_file_encrypted(f)?
                && let Ok(mut list) = not_encrypted.lock()
            {
                let relative = pathdiff::diff_paths(f, &self.path).unwrap_or_else(|| f.clone());
                list.push(relative);
            }
            pb.inc(1);
            Ok(())
        })?;

        pb.finish_and_clear();

        let not_encrypted = not_encrypted.into_inner().unwrap();
        let total = target_files.len();
        let encrypted_count = total - not_encrypted.len();

        if not_encrypted.is_empty() {
            println!(
                "\n{}: All {} files are encrypted.",
                "Check complete".bold(),
                total.to_string().green(),
            );
            Ok(())
        } else {
            // Print the list of unencrypted files
            println!(
                "\n{} files are {}:",
                not_encrypted.len().to_string().yellow(),
                "NOT encrypted".yellow()
            );
            for f in &not_encrypted {
                println!("  - {}", f.display());
            }

            println!(
                "\n{}: {}/{} files encrypted",
                "Check complete".bold(),
                encrypted_count.to_string().green(),
                total,
            );

            Err(anyhow!(
                "{} out of {} files are not encrypted",
                not_encrypted.len(),
                total
            ))
        }
    }

    /// Install a pre-commit hook that runs `git-se check` before each commit.
    ///
    /// Creates `<repo>/.git/hooks/pre-commit` with the check script.
    /// Fails if a hook already exists and is not managed by git-se.
    pub fn install_hook(&self) -> Result<()> {
        let hooks_dir = self.path.join(".git").join("hooks");
        std::fs::create_dir_all(&hooks_dir)?;

        let hook_path = hooks_dir.join("pre-commit");

        // Check if hook already exists
        if hook_path.exists() {
            return Err(anyhow!(
                "A pre-commit hook already exists at {}. \
                     Please remove it manually before installing.",
                hook_path.display()
            ));
        }

        std::fs::write(&hook_path, PRE_COMMIT_HOOK)?;

        // Set executable permission on unix
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            let mut perms = std::fs::metadata(&hook_path)?.permissions();
            perms.set_mode(0o755);
            std::fs::set_permissions(&hook_path, perms)?;
        }

        println!(
            "{} pre-commit hook at {}",
            "Installed".green().bold(),
            hook_path.display()
        );
        Ok(())
    }
}

pub trait GitCommand {
    fn run(&self, args: &[&str]) -> Result<()>;
    fn run_with_output(&self, args: &[&str]) -> Result<String>;
    fn set_config(&self, key: &str, value: &str) -> Result<()>;
    fn get_config(&self, key: &str) -> Result<String>;
}

impl GitCommand for Repo {
    fn run(&self, args: &[&str]) -> Result<()> {
        let output = std::process::Command::new("git")
            .current_dir(&self.path)
            .args(args)
            .output()?;
        if !output.status.success() {
            return Err(anyhow!(
                "Git command failed: {}",
                String::from_utf8_lossy(&output.stderr)
            ));
        }
        Ok(())
    }
    fn run_with_output(&self, args: &[&str]) -> Result<String> {
        let mut cmd = std::process::Command::new("git");

        // we need to check English output in test
        if cfg!(test) {
            cmd.env("LC_ALL", "C.UTF-8").env("LANGUAGE", "C.UTF-8");
        }

        let output = cmd.current_dir(&self.path).args(args).output()?;
        if !output.status.success() {
            return Err(anyhow!(
                "Git command failed: {}",
                String::from_utf8_lossy(&output.stderr)
            ));
        }
        Ok(String::from_utf8(output.stdout)?)
    }
    fn set_config(&self, key: &str, value: &str) -> Result<()> {
        let temp = String::from(GIT_CONFIG_PREFIX) + key;
        self.run(&["config", "--local", &temp, value.trim()])
    }
    fn get_config(&self, key: &str) -> Result<String> {
        let temp = String::from(GIT_CONFIG_PREFIX) + key;
        self.run_with_output(&["config", "--get", &temp])
            .map(|x| x.trim().to_string())
    }
}

#[cfg(test)]
mod tests {
    use path_absolutize::Absolutize;

    use super::*;

    #[test]
    fn test_repo_open() -> Result<()> {
        let repo = Repo::open(Path::new(".").absolutize()?)?;
        assert_eq!(repo.path().file_name().unwrap(), "git-simple-encrypt");
        let repo = Repo::open(Path::new("./.git").absolutize()?)?;
        assert_eq!(repo.path().file_name().unwrap(), "git-simple-encrypt");
        Ok(())
    }
}