use anyhow::{Context, Result};
use git2::{IndexAddOption, Repository, Signature};
use std::path::Path;
pub struct GitRepo {
pub repo: Repository,
}
const USERNAME: &str = "oci2git";
const EMAIL: &str = "oci2git@example.com";
impl GitRepo {
pub fn init_with_branch(path: &Path, branch_name: Option<&str>) -> Result<Self> {
let repo = if path.join(".git").exists() {
Repository::open(path).context("Failed to open existing Git repository")?
} else {
Repository::init(path).context("Failed to initialize Git repository")?
};
let mut config = repo.config().context("Failed to get git config")?;
config
.set_str("user.name", USERNAME)
.context("Failed to set git username")?;
config
.set_str("user.email", EMAIL)
.context("Failed to set git email")?;
let git_repo = Self { repo };
if let Some(branch) = branch_name {
git_repo.create_branch(branch, None)?;
}
Ok(git_repo)
}
pub fn create_branch(&self, branch_name: &str, from_commit: Option<git2::Oid>) -> Result<()> {
match from_commit {
Some(commit_oid) => {
let target = self.repo.find_commit(commit_oid)?;
self.repo
.branch(branch_name, &target, false)
.context("Failed to create branch")?;
self.repo
.set_head(&format!("refs/heads/{branch_name}"))
.context("Failed to set HEAD to new branch")?;
self.repo
.reset(
target.as_object(),
git2::ResetType::Hard,
Some(&mut git2::build::CheckoutBuilder::default()),
)
.context("Failed to reset working directory to branch point")?;
}
None => {
self.repo
.set_head(&format!("refs/heads/{branch_name}"))
.context("Failed to set HEAD to new branch")?;
}
}
Ok(())
}
pub fn commit_all_changes(&self, message: &str) -> Result<bool> {
let signature =
Signature::now(USERNAME, EMAIL).context("Failed to create git signature")?;
let mut index = self.repo.index().context("Failed to get git index")?;
index
.add_all(["*"].iter(), IndexAddOption::DEFAULT, None)
.context("Failed to add files to git index")?;
let has_changes = !index.is_empty();
index.write().context("Failed to write git index")?;
let tree_id = index.write_tree().context("Failed to write git tree")?;
let tree = self
.repo
.find_tree(tree_id)
.context("Failed to find git tree")?;
let parent_commits = if let Ok(head) = self.repo.head() {
if let Ok(commit) = head.peel_to_commit() {
vec![commit]
} else {
vec![]
}
} else {
vec![]
};
let parent_commits_refs: Vec<&git2::Commit> = parent_commits.iter().collect();
self.repo
.commit(
Some("HEAD"),
&signature,
&signature,
message,
&tree,
&parent_commits_refs,
)
.context("Failed to create commit")?;
Ok(has_changes)
}
pub fn get_branch_commits(&self, branch_name: &str) -> Result<Vec<git2::Oid>> {
let branch = self
.repo
.find_branch(branch_name, git2::BranchType::Local)
.context("Failed to find branch")?;
let target = branch
.get()
.target()
.ok_or_else(|| anyhow::anyhow!("Branch has no target commit"))?;
let mut revwalk = self.repo.revwalk().context("Failed to create revwalk")?;
revwalk
.push(target)
.context("Failed to push branch target to revwalk")?;
let commits: Result<Vec<_>, _> = revwalk.collect();
let commits = commits.context("Failed to collect commits")?;
Ok(commits.into_iter().rev().collect())
}
pub fn get_all_branches(&self) -> Result<Vec<String>> {
let branches = self.repo.branches(Some(git2::BranchType::Local))?;
let mut branch_names = Vec::new();
for branch_result in branches {
let (branch, _) = branch_result.context("Failed to get branch")?;
if let Some(name) = branch.name().context("Failed to get branch name")? {
branch_names.push(name.to_string());
}
}
Ok(branch_names)
}
pub fn branch_exists(&self, branch_name: &str) -> bool {
self.repo
.find_branch(branch_name, git2::BranchType::Local)
.is_ok()
}
pub fn exists_and_has_commits(&self) -> bool {
if let Ok(branches) = self.get_all_branches() {
!branches.is_empty()
} else {
false
}
}
#[cfg(test)]
pub fn get_commit_count(&self) -> Result<usize> {
let mut revwalk = self.repo.revwalk().context("Failed to create revwalk")?;
revwalk
.push_head()
.context("Failed to push HEAD to revwalk")?;
Ok(revwalk.count())
}
#[cfg(test)]
pub fn get_last_commit_message(&self) -> Result<String> {
let head = self.repo.head().context("Failed to get HEAD reference")?;
let commit = head
.peel_to_commit()
.context("Failed to get commit from HEAD")?;
Ok(commit.message().unwrap_or("").to_string())
}
pub fn read_file_from_commit(&self, commit_oid: git2::Oid, file_path: &str) -> Result<String> {
let commit = self
.repo
.find_commit(commit_oid)
.context("Failed to find commit")?;
let tree = commit.tree().context("Failed to get tree from commit")?;
let entry = tree.get_name(file_path);
match entry {
Some(entry) => {
let blob = self
.repo
.find_blob(entry.id())
.context("Failed to find file blob")?;
let content =
std::str::from_utf8(blob.content()).context("File contains invalid UTF-8")?;
Ok(content.to_string())
}
None => {
Err(anyhow::anyhow!("File '{file_path}' not found in commit"))
}
}
}
pub fn get_commit_successors(&self, commit_oid: Option<git2::Oid>) -> Result<Vec<git2::Oid>> {
let mut successors = Vec::new();
let branches = self.get_all_branches()?;
match commit_oid {
Some(target_commit) => {
for branch_name in branches {
if let Ok(commits) = self.get_branch_commits(&branch_name) {
for (i, ¤t_commit) in commits.iter().enumerate() {
if current_commit == target_commit && i + 1 < commits.len() {
successors.push(commits[i + 1]);
}
}
}
}
}
None => {
for branch_name in branches {
if let Ok(commits) = self.get_branch_commits(&branch_name) {
if let Some(&root_commit) = commits.first() {
successors.push(root_commit);
}
}
}
}
}
successors.sort();
successors.dedup();
Ok(successors)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn test_init_repo() {
let temp_dir = tempdir().unwrap();
let result = GitRepo::init_with_branch(temp_dir.path(), Some("main"));
assert!(result.is_ok());
let repo = result.unwrap();
assert!(temp_dir.path().join(".git").exists());
let config = repo.repo.config().unwrap();
assert_eq!(config.get_string("user.name").unwrap(), "oci2git");
assert_eq!(
config.get_string("user.email").unwrap(),
"oci2git@example.com"
);
}
#[test]
fn test_commit_file() {
let temp_dir = tempdir().unwrap();
let repo = GitRepo::init_with_branch(temp_dir.path(), Some("main")).unwrap();
let test_file_path = temp_dir.path().join("test.txt");
fs::write(&test_file_path, "test content").unwrap();
let result = repo.commit_all_changes("Add test file");
assert!(result.is_ok());
assert!(result.unwrap());
assert_eq!(repo.get_commit_count().unwrap(), 1);
assert_eq!(repo.get_last_commit_message().unwrap(), "Add test file");
}
#[test]
fn test_empty_commit() {
let temp_dir = tempdir().unwrap();
let repo = GitRepo::init_with_branch(temp_dir.path(), Some("main")).unwrap();
let result = repo.commit_all_changes("Empty commit");
assert!(result.is_ok());
assert!(!result.unwrap());
assert_eq!(repo.get_commit_count().unwrap(), 1);
assert_eq!(repo.get_last_commit_message().unwrap(), "Empty commit");
let result = repo.commit_all_changes("Another empty commit");
assert!(result.is_ok());
assert!(!result.unwrap());
assert_eq!(repo.get_commit_count().unwrap(), 2);
assert_eq!(
repo.get_last_commit_message().unwrap(),
"Another empty commit"
);
}
#[test]
fn test_commit_all_changes() {
let temp_dir = tempdir().unwrap();
let repo = GitRepo::init_with_branch(temp_dir.path(), Some("main")).unwrap();
let test_file_path = temp_dir.path().join("initial.txt");
fs::write(&test_file_path, "initial content").unwrap();
repo.commit_all_changes("Initial commit").unwrap();
let file1_path = temp_dir.path().join("file1.txt");
let file2_path = temp_dir.path().join("file2.txt");
fs::write(&file1_path, "file1 content").unwrap();
fs::write(&file2_path, "file2 content").unwrap();
let result = repo.commit_all_changes("Commit all changes");
assert!(result.is_ok());
assert!(result.unwrap());
assert_eq!(repo.get_commit_count().unwrap(), 2);
assert_eq!(
repo.get_last_commit_message().unwrap(),
"Commit all changes"
);
let result = repo.commit_all_changes("No changes");
assert!(result.is_ok());
let commit_count = repo.get_commit_count().unwrap();
assert!(
commit_count >= 2,
"Expected at least 2 commits, got {commit_count}"
);
}
#[test]
fn test_init_with_custom_branch() {
let temp_dir = tempdir().unwrap();
let branch_name = "hello-world#latest#1234567890ab";
let repo = GitRepo::init_with_branch(temp_dir.path(), Some(branch_name)).unwrap();
assert!(temp_dir.path().join(".git").exists());
let config = repo.repo.config().unwrap();
assert_eq!(config.get_string("user.name").unwrap(), "oci2git");
assert_eq!(
config.get_string("user.email").unwrap(),
"oci2git@example.com"
);
let test_file = temp_dir.path().join("test.txt");
fs::write(&test_file, "test").unwrap();
repo.commit_all_changes("Test commit").unwrap();
let head = repo.repo.head().unwrap();
let branch_ref = head.shorthand().unwrap();
assert_eq!(branch_ref, branch_name);
assert!(head.target().is_some());
assert_eq!(repo.get_commit_count().unwrap(), 1);
}
#[test]
fn test_branch_exists() {
let temp_dir = tempdir().unwrap();
let repo = GitRepo::init_with_branch(temp_dir.path(), Some("main")).unwrap();
assert!(!repo.branch_exists("main"));
let test_file = temp_dir.path().join("test.txt");
fs::write(&test_file, "test").unwrap();
repo.commit_all_changes("Test commit").unwrap();
assert!(repo.branch_exists("main"));
assert!(!repo.branch_exists("non-existent-branch"));
}
}