use git2::{Repository, Status, StatusOptions, Signature, Oid};
use std::path::{Path, PathBuf};
use tokio::fs;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum GitRepoError {
#[error("Repository operation failed: {0}")]
Operation(#[from] git2::Error),
#[error("File system operation failed: {0}")]
FileSystem(#[from] std::io::Error),
#[error("Path does not exist: {}", .0.display())]
PathNotFound(PathBuf),
#[error("Invalid repository state: {0}")]
InvalidState(String),
}
pub type GitRepoResult<T> = Result<T, GitRepoError>;
pub struct GitRepo {
repo: Repository,
workdir: PathBuf,
}
impl Clone for GitRepo {
fn clone(&self) -> Self {
let repo = Repository::open(&self.workdir).expect("Failed to re-open repository");
Self {
repo,
workdir: self.workdir.clone(),
}
}
}
impl std::fmt::Debug for GitRepo {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("GitRepo")
.field("workdir", &self.workdir)
.finish()
}
}
impl GitRepo {
pub fn open<P: AsRef<Path>>(path: P) -> GitRepoResult<Self> {
let repo = Repository::open(path)?;
let workdir = repo.workdir()
.ok_or_else(|| GitRepoError::InvalidState("Bare repository not supported".to_string()))?
.to_path_buf();
Ok(Self { repo, workdir })
}
pub fn init<P: AsRef<Path>>(path: P) -> GitRepoResult<Self> {
let repo = Repository::init(path)?;
let workdir = repo.workdir()
.ok_or_else(|| GitRepoError::InvalidState("Failed to get workdir".to_string()))?
.to_path_buf();
Ok(Self { repo, workdir })
}
pub async fn find_or_create() -> GitRepoResult<Self> {
Self::find_or_create_in(".").await
}
pub async fn find_or_create_in<P: AsRef<Path>>(path: P) -> GitRepoResult<Self> {
let path = path.as_ref();
match Repository::discover(path) {
Ok(repo) => {
let workdir = repo.workdir()
.ok_or_else(|| GitRepoError::InvalidState("Bare repository not supported".to_string()))?
.to_path_buf();
Ok(Self { repo, workdir })
}
Err(_) => {
Self::init(path)
}
}
}
pub async fn open_or_create<P: AsRef<Path>>(path: P) -> GitRepoResult<Self> {
let path = path.as_ref();
match Self::open(path) {
Ok(repo) => Ok(repo),
Err(_) => Self::init(path),
}
}
pub fn path(&self) -> &Path {
self.repo.path()
}
pub fn workdir(&self) -> &Path {
&self.workdir
}
pub fn git_dir(&self) -> &Path {
self.repo.path()
}
pub fn has_cargocrypt_config(&self) -> bool {
self.workdir.join(".cargocrypt").exists() ||
self.workdir.join("Cargo.toml").exists() }
pub async fn stage_file<P: AsRef<Path>>(&self, path: P) -> GitRepoResult<()> {
let path = path.as_ref();
let relative_path = self.make_relative_path(path)?;
let mut index = self.repo.index()?;
index.add_path(&relative_path)?;
index.write()?;
Ok(())
}
pub async fn unstage_file<P: AsRef<Path>>(&self, path: P) -> GitRepoResult<()> {
let path = path.as_ref();
let relative_path = self.make_relative_path(path)?;
let head = self.repo.head()?.target().unwrap();
let head_commit = self.repo.find_commit(head)?;
let head_tree = head_commit.tree()?;
let mut index = self.repo.index()?;
match head_tree.get_path(&relative_path) {
Ok(_) => {
index.remove_path(&relative_path)?;
let workdir = self.repo.workdir().ok_or_else(||
GitRepoError::InvalidState("No working directory found".to_string()))?;
let full_path = workdir.join(&relative_path);
if full_path.exists() {
index.add_path(&relative_path)?;
}
}
Err(_) => {
index.remove_path(&relative_path)?;
}
}
index.write()?;
Ok(())
}
pub async fn is_staged<P: AsRef<Path>>(&self, path: P) -> GitRepoResult<bool> {
let path = path.as_ref();
let relative_path = self.make_relative_path(path)?;
let statuses = self.repo.statuses(None)?;
for entry in statuses.iter() {
if entry.path() == Some(relative_path.to_str().unwrap()) {
return Ok(entry.status().intersects(
Status::INDEX_NEW | Status::INDEX_MODIFIED | Status::INDEX_DELETED
));
}
}
Ok(false)
}
pub async fn file_status<P: AsRef<Path>>(&self, path: P) -> GitRepoResult<Status> {
let path = path.as_ref();
let relative_path = self.make_relative_path(path)?;
Ok(self.repo.status_file(&relative_path)?)
}
pub fn is_clean(&self) -> GitRepoResult<bool> {
let statuses = self.repo.statuses(None)?;
Ok(statuses.is_empty())
}
pub fn get_modified_files(&self) -> GitRepoResult<Vec<PathBuf>> {
let mut modified = Vec::new();
let statuses = self.repo.statuses(None)?;
for entry in statuses.iter() {
if let Some(path) = entry.path() {
if entry.status().intersects(
Status::WT_MODIFIED | Status::WT_NEW | Status::WT_DELETED |
Status::INDEX_MODIFIED | Status::INDEX_NEW | Status::INDEX_DELETED
) {
modified.push(PathBuf::from(path));
}
}
}
Ok(modified)
}
pub async fn commit(&self, message: &str) -> GitRepoResult<Oid> {
let signature = self.get_signature()?;
let mut index = self.repo.index()?;
let tree_id = index.write_tree()?;
let tree = self.repo.find_tree(tree_id)?;
let parent_commit = match self.repo.head() {
Ok(head) => Some(self.repo.find_commit(head.target().unwrap())?),
Err(_) => None, };
let parents: Vec<&git2::Commit> = parent_commit.iter().collect();
let commit_id = self.repo.commit(
Some("HEAD"),
&signature,
&signature,
message,
&tree,
&parents,
)?;
Ok(commit_id)
}
pub async fn commit_cargocrypt_setup(&self) -> GitRepoResult<Oid> {
let config_files = [
".gitignore",
".gitattributes",
".cargocrypt/config.toml",
".githooks/pre-commit",
".githooks/pre-push",
];
for file in &config_files {
let path = self.workdir.join(file);
if path.exists() {
self.stage_file(&path).await?;
}
}
self.commit("feat: Initialize CargoCrypt git integration\n\n- Add .gitignore patterns for encrypted files\n- Configure git attributes for automatic encryption\n- Install git hooks for secret detection\n- Set up encrypted storage and team key sharing").await
}
fn get_signature(&self) -> GitRepoResult<Signature> {
self.repo.signature()
.or_else(|_| {
Signature::now("CargoCrypt", "cargocrypt@localhost")
})
.map_err(GitRepoError::Operation)
}
fn make_relative_path<P: AsRef<Path>>(&self, path: P) -> GitRepoResult<PathBuf> {
let path = path.as_ref();
if path.is_absolute() {
path.strip_prefix(&self.workdir)
.map(|p| p.to_path_buf())
.map_err(|_| GitRepoError::InvalidState(
format!("Path {} is not within repository", path.display())
))
} else {
Ok(path.to_path_buf())
}
}
pub async fn create_cargocrypt_branch(&self, branch_name: &str) -> GitRepoResult<()> {
let head = self.repo.head()?;
let head_commit = self.repo.find_commit(head.target().unwrap())?;
self.repo.branch(branch_name, &head_commit, false)?;
Ok(())
}
pub async fn checkout_branch(&self, branch_name: &str) -> GitRepoResult<()> {
let (object, reference) = self.repo.revparse_ext(branch_name)?;
self.repo.checkout_tree(&object, None)?;
match reference {
Some(gref) => self.repo.set_head(gref.name().unwrap()),
None => self.repo.set_head_detached(object.id()),
}?;
Ok(())
}
pub fn current_branch(&self) -> GitRepoResult<String> {
let head = self.repo.head()?;
if let Some(name) = head.shorthand() {
Ok(name.to_string())
} else {
Ok("HEAD".to_string()) }
}
pub fn is_dirty(&self) -> GitRepoResult<bool> {
let mut opts = StatusOptions::new();
opts.include_untracked(true);
let statuses = self.repo.statuses(Some(&mut opts))?;
Ok(!statuses.is_empty())
}
pub fn inner(&self) -> &Repository {
&self.repo
}
pub async fn init_cargocrypt_structure(&self) -> GitRepoResult<()> {
let base_dir = self.workdir.join(".cargocrypt");
fs::create_dir_all(&base_dir).await?;
fs::create_dir_all(base_dir.join("keys")).await?;
fs::create_dir_all(base_dir.join("team")).await?;
fs::create_dir_all(base_dir.join("hooks")).await?;
let config_content = r#"# CargoCrypt Configuration
[encryption]
algorithm = "ChaCha20-Poly1305"
key_derivation = "Argon2id"
[git]
auto_encrypt_patterns = ["*.secret", "*.key", "secrets/*"]
ignore_patterns = ["*.cargocrypt", "*.enc"]
[team]
key_sharing_enabled = true
require_signature = true
[hooks]
pre_commit_secret_detection = true
pre_push_validation = true
"#;
fs::write(base_dir.join("config.toml"), config_content).await?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
use std::fs::File;
use std::io::Write;
#[tokio::test]
async fn test_git_repo_creation() {
let temp_dir = TempDir::new().unwrap();
let repo = GitRepo::init(temp_dir.path()).unwrap();
assert!(repo.path().exists());
assert!(repo.workdir().exists());
}
#[tokio::test]
async fn test_find_or_create() {
let temp_dir = TempDir::new().unwrap();
let repo1 = GitRepo::find_or_create_in(temp_dir.path()).await.unwrap();
assert!(repo1.path().exists());
let repo2 = GitRepo::find_or_create_in(temp_dir.path()).await.unwrap();
assert_eq!(repo1.path(), repo2.path());
}
#[tokio::test]
async fn test_file_staging() {
let temp_dir = TempDir::new().unwrap();
let repo = GitRepo::init(temp_dir.path()).unwrap();
let test_file = temp_dir.path().join("test.txt");
let mut file = File::create(&test_file).unwrap();
writeln!(file, "test content").unwrap();
repo.stage_file(&test_file).await.unwrap();
assert!(repo.is_staged(&test_file).await.unwrap());
}
#[tokio::test]
async fn test_cargocrypt_structure_init() {
let temp_dir = TempDir::new().unwrap();
let repo = GitRepo::init(temp_dir.path()).unwrap();
repo.init_cargocrypt_structure().await.unwrap();
let cargocrypt_dir = temp_dir.path().join(".cargocrypt");
assert!(cargocrypt_dir.exists());
assert!(cargocrypt_dir.join("config.toml").exists());
assert!(cargocrypt_dir.join("keys").exists());
assert!(cargocrypt_dir.join("team").exists());
}
#[tokio::test]
async fn test_commit_creation() {
let temp_dir = TempDir::new().unwrap();
let repo = GitRepo::init(temp_dir.path()).unwrap();
let test_file = temp_dir.path().join("test.txt");
let mut file = File::create(&test_file).unwrap();
writeln!(file, "test content").unwrap();
repo.stage_file(&test_file).await.unwrap();
let commit_id = repo.commit("Initial commit").await.unwrap();
assert!(!commit_id.is_zero());
}
}