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"), "."), "_", "-");
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 {
pub path: PathBuf,
pub conf: Config,
pub key_sha: OnceLock<Box<[u8]>>,
}
impl Repo {
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.")
}
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(())
}
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 {
println!(
"\n{} files are {}:",
not_encrypted.len().to_string().yellow(),
"NOT encrypted".yellow()
);
for f in ¬_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
))
}
}
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");
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)?;
#[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");
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(())
}
}