use git2::{Repository, RepositoryOpenFlags};
use std::path::{Path, PathBuf};
pub async fn is_git_repository(repo_path: &Path) -> Result<bool, String> {
match Repository::open_ext(repo_path, RepositoryOpenFlags::empty(), &[] as &[&Path]) {
Ok(_) => Ok(true),
Err(_) => Ok(false),
}
}
pub async fn create_branch(repo_path: &Path, branch_name: &str) -> Result<(), String> {
tokio::task::spawn_blocking({
let repo_path = repo_path.to_owned();
let branch_name = branch_name.to_owned();
move || -> Result<(), String> {
let repo = Repository::open(&repo_path)
.map_err(|e| format!("Failed to open repository: {e}"))?;
let head = repo
.head()
.map_err(|e| format!("Failed to get HEAD: {e}"))?;
let commit = head
.peel_to_commit()
.map_err(|e| format!("Failed to get commit from HEAD: {e}"))?;
repo.branch(&branch_name, &commit, false)
.map_err(|e| format!("Failed to create branch: {e}"))?;
repo.set_head(&format!("refs/heads/{branch_name}"))
.map_err(|e| format!("Failed to checkout branch: {e}"))?;
repo.checkout_head(None)
.map_err(|e| format!("Failed to update working directory: {e}"))?;
Ok(())
}
})
.await
.map_err(|e| format!("Task join error: {e}"))?
}
pub async fn get_status(repo_path: &Path) -> Result<String, String> {
tokio::task::spawn_blocking({
let repo_path = repo_path.to_owned();
move || -> Result<String, String> {
let output = std::process::Command::new("git")
.current_dir(&repo_path)
.arg("status")
.arg("--porcelain")
.output()
.map_err(|e| format!("Failed to execute git status: {e}"))?;
if !output.status.success() {
return Err(format!(
"Failed to get repository status: {}",
String::from_utf8_lossy(&output.stderr)
));
}
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
})
.await
.map_err(|e| format!("Task join error: {e}"))?
}
pub async fn add_all(repo_path: &Path) -> Result<(), String> {
tokio::task::spawn_blocking({
let repo_path = repo_path.to_owned();
move || -> Result<(), String> {
let output = std::process::Command::new("git")
.current_dir(&repo_path)
.arg("add")
.arg("-A")
.output()
.map_err(|e| format!("Failed to execute git add: {e}"))?;
if !output.status.success() {
return Err(format!(
"Failed to add files to index: {}",
String::from_utf8_lossy(&output.stderr)
));
}
Ok(())
}
})
.await
.map_err(|e| format!("Task join error: {e}"))?
}
pub async fn renormalize(repo_path: &Path) -> Result<(), String> {
tokio::task::spawn_blocking({
let repo_path = repo_path.to_owned();
move || -> Result<(), String> {
let output = std::process::Command::new("git")
.current_dir(&repo_path)
.args(["add", "--renormalize", "."])
.output()
.map_err(|e| format!("Failed to execute git add --renormalize: {e}"))?;
if !output.status.success() {
return Err(format!(
"Failed to renormalize files: {}",
String::from_utf8_lossy(&output.stderr)
));
}
Ok(())
}
})
.await
.map_err(|e| format!("Task join error: {e}"))?
}
pub async fn commit(repo_path: &Path, message: &str) -> Result<(), String> {
tokio::task::spawn_blocking({
let repo_path = repo_path.to_owned();
let message = message.to_owned();
move || -> Result<(), String> {
let output = std::process::Command::new("git")
.current_dir(&repo_path)
.arg("commit")
.arg("--no-verify")
.arg("-m")
.arg(&message)
.output()
.map_err(|e| format!("Failed to execute git commit: {e}"))?;
if !output.status.success() {
let combined = format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
if combined.contains("nothing to commit") {
return Ok(());
}
return Err(format!("Failed to create commit: {combined}"));
}
Ok(())
}
})
.await
.map_err(|e| format!("Task join error: {e}"))?
}
pub async fn add_remote(repo_path: &Path, remote_name: &str, url: &str) -> Result<(), String> {
tokio::task::spawn_blocking({
let repo_path = repo_path.to_owned();
let remote_name = remote_name.to_owned();
let url = url.to_owned();
move || -> Result<(), String> {
let repo = Repository::open(&repo_path)
.map_err(|e| format!("Failed to open repository: {e}"))?;
let result = repo.remote(&remote_name, &url);
match result {
Ok(_) => Ok(()),
Err(e) => {
if e.code() == git2::ErrorCode::Exists {
Ok(())
} else {
Err(format!("Failed to add remote: {e}"))
}
}
}
}
})
.await
.map_err(|e| format!("Task join error: {e}"))?
}
pub async fn fetch_branch(
repo_path: &Path,
remote_name: &str,
branch_name: &str,
) -> Result<(), String> {
tokio::task::spawn_blocking({
let repo_path = repo_path.to_owned();
let remote_name = remote_name.to_owned();
let branch_name = branch_name.to_owned();
move || -> Result<(), String> {
let repo = Repository::open(&repo_path)
.map_err(|e| format!("Failed to open repository: {e}"))?;
let remote = repo
.find_remote(&remote_name)
.map_err(|e| format!("Failed to find remote: {e}"))?;
let url = remote
.url()
.ok_or_else(|| "Remote has no URL".to_string())?;
let output = std::process::Command::new("git")
.current_dir(&repo_path)
.arg("fetch")
.arg("--no-recurse-submodules")
.arg(url)
.arg(format!("refs/heads/{branch_name}:refs/heads/{branch_name}"))
.output()
.map_err(|e| format!("Failed to execute git fetch: {e}"))?;
if !output.status.success() {
return Err(format!(
"Failed to fetch changes: {}",
String::from_utf8_lossy(&output.stderr)
));
}
Ok(())
}
})
.await
.map_err(|e| format!("Task join error: {e}"))?
}
pub async fn remove_remote(repo_path: &Path, remote_name: &str) -> Result<(), String> {
tokio::task::spawn_blocking({
let repo_path = repo_path.to_owned();
let remote_name = remote_name.to_owned();
move || -> Result<(), String> {
let repo = Repository::open(&repo_path)
.map_err(|e| format!("Failed to open repository: {e}"))?;
repo.remote_delete(&remote_name)
.map_err(|e| format!("Failed to remove temporary remote: {e}"))?;
Ok(())
}
})
.await
.map_err(|e| format!("Task join error: {e}"))?
}
pub async fn has_commits_not_in_base(
repo_path: &Path,
branch_name: &str,
base_branch: &str,
) -> Result<bool, String> {
tokio::task::spawn_blocking({
let repo_path = repo_path.to_owned();
let branch_name = branch_name.to_owned();
let base_branch = base_branch.to_owned();
move || -> Result<bool, String> {
let repo = Repository::open(&repo_path)
.map_err(|e| format!("Failed to open repository: {e}"))?;
let branch_ref = format!("refs/heads/{branch_name}");
let branch = repo
.find_reference(&branch_ref)
.map_err(|e| format!("Failed to find branch {branch_name}: {e}"))?;
let branch_oid = branch
.target()
.ok_or_else(|| format!("Branch {branch_name} has no target"))?;
let base_ref = format!("refs/heads/{base_branch}");
let base = repo
.find_reference(&base_ref)
.map_err(|e| format!("Failed to find base branch {base_branch}: {e}"))?;
let base_oid = base
.target()
.ok_or_else(|| format!("Base branch {base_branch} has no target"))?;
if branch_oid == base_oid {
return Ok(false);
}
match repo.graph_descendant_of(base_oid, branch_oid) {
Ok(true) => Ok(false), Ok(false) => Ok(true), Err(_) => {
Ok(true)
}
}
}
})
.await
.map_err(|e| format!("Task join error: {e}"))?
}
pub async fn delete_branch(repo_path: &Path, branch_name: &str) -> Result<(), String> {
tokio::task::spawn_blocking({
let repo_path = repo_path.to_owned();
let branch_name = branch_name.to_owned();
move || -> Result<(), String> {
let repo = Repository::open(&repo_path)
.map_err(|e| format!("Failed to open repository: {e}"))?;
let mut branch = repo
.find_branch(&branch_name, git2::BranchType::Local)
.map_err(|e| format!("Failed to find branch {branch_name}: {e}"))?;
branch
.delete()
.map_err(|e| format!("Failed to delete branch {branch_name}: {e}"))?;
Ok(())
}
})
.await
.map_err(|e| format!("Task join error: {e}"))?
}
pub async fn get_current_commit(repo_path: &Path) -> Result<String, String> {
tokio::task::spawn_blocking({
let repo_path = repo_path.to_owned();
move || -> Result<String, String> {
let repo = Repository::open(&repo_path)
.map_err(|e| format!("Failed to open repository: {e}"))?;
let head = repo
.head()
.map_err(|e| format!("Failed to get HEAD: {e}"))?;
let commit = head
.peel_to_commit()
.map_err(|e| format!("Failed to get commit from HEAD: {e}"))?;
Ok(commit.id().to_string())
}
})
.await
.map_err(|e| format!("Task join error: {e}"))?
}
pub async fn create_branch_from_commit(
repo_path: &Path,
branch_name: &str,
commit_sha: &str,
) -> Result<(), String> {
tokio::task::spawn_blocking({
let repo_path = repo_path.to_owned();
let branch_name = branch_name.to_owned();
let commit_sha = commit_sha.to_owned();
move || -> Result<(), String> {
let repo = Repository::open(&repo_path)
.map_err(|e| format!("Failed to open repository: {e}"))?;
let oid =
git2::Oid::from_str(&commit_sha).map_err(|e| format!("Invalid commit SHA: {e}"))?;
let commit = repo
.find_commit(oid)
.map_err(|e| format!("Failed to find commit {commit_sha}: {e}"))?;
repo.branch(&branch_name, &commit, false)
.map_err(|e| format!("Failed to create branch: {e}"))?;
repo.set_head(&format!("refs/heads/{branch_name}"))
.map_err(|e| format!("Failed to checkout branch: {e}"))?;
let mut checkout_opts = git2::build::CheckoutBuilder::new();
checkout_opts.force();
repo.checkout_head(Some(&mut checkout_opts))
.map_err(|e| format!("Failed to update working directory: {e}"))?;
Ok(())
}
})
.await
.map_err(|e| format!("Task join error: {e}"))?
}
pub async fn get_all_non_ignored_files(repo_path: &Path) -> Result<Vec<PathBuf>, String> {
tokio::task::spawn_blocking({
let repo_path = repo_path.to_owned();
move || -> Result<Vec<PathBuf>, String> {
let repo = Repository::open(&repo_path)
.map_err(|e| format!("Failed to open repository: {e}"))?;
let mut opts = git2::StatusOptions::new();
opts.include_untracked(true)
.include_ignored(false)
.include_unmodified(true);
let statuses = repo
.statuses(Some(&mut opts))
.map_err(|e| format!("Failed to get repository status: {e}"))?;
let mut files = Vec::new();
for entry in statuses.iter() {
let status = entry.status();
if let Some(path) = entry.path()
&& !status.is_ignored()
{
files.push(PathBuf::from(path));
}
}
Ok(files)
}
})
.await
.map_err(|e| format!("Task join error: {e}"))?
}
pub async fn validate_branch_accessible(repo_path: &Path, branch_name: &str) -> Result<(), String> {
tokio::task::spawn_blocking({
let repo_path = repo_path.to_owned();
let branch_name = branch_name.to_owned();
move || -> Result<(), String> {
let repo = Repository::open(&repo_path)
.map_err(|e| format!("Failed to open repository: {e}"))?;
let branch_ref = format!("refs/heads/{branch_name}");
let reference = repo
.find_reference(&branch_ref)
.map_err(|e| format!("Branch '{}' not found: {}", branch_name, e))?;
let oid = reference
.target()
.ok_or_else(|| format!("Branch '{}' has no target", branch_name))?;
repo.find_commit(oid).map_err(|e| {
format!(
"Branch '{}' points to inaccessible commit {}: {}",
branch_name, oid, e
)
})?;
Ok(())
}
})
.await
.map_err(|e| format!("Task join error: {e}"))?
}
pub async fn clone_local(source_repo_path: &Path, destination_path: &Path) -> Result<(), String> {
tokio::task::spawn_blocking({
let source_repo_path = source_repo_path.to_owned();
let destination_path = destination_path.to_owned();
move || -> Result<(), String> {
let mut builder = git2::build::RepoBuilder::new();
builder.clone_local(git2::build::CloneLocal::NoLinks);
builder
.clone(
source_repo_path.to_str().ok_or("Invalid source path")?,
&destination_path,
)
.map_err(|e| format!("Failed to clone repository: {e}"))?;
Ok(())
}
})
.await
.map_err(|e| format!("Task join error: {e}"))?
}
pub async fn init_submodules(repo_path: &Path) -> Result<(), String> {
let output = tokio::process::Command::new("git")
.args([
"-c",
"protocol.file.allow=always",
"submodule",
"update",
"--init",
"--recursive",
])
.current_dir(repo_path)
.output()
.await
.map_err(|e| format!("Failed to run git submodule update: {e}"))?;
if output.status.success() {
Ok(())
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
Err(format!("git submodule update failed: {stderr}"))
}
}
pub async fn resolve_branch_commit(repo_path: &Path, branch: &str) -> Result<String, String> {
let ref_name = format!("refs/heads/{branch}");
let output = tokio::process::Command::new("git")
.args(["rev-parse", "--verify", &ref_name])
.current_dir(repo_path)
.output()
.await
.map_err(|e| format!("Failed to run git rev-parse: {e}"))?;
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
} else {
Err(format!(
"Branch '{branch}' not found. Make sure it exists as a local branch.\n\
To create a local tracking branch: git checkout -b {branch} origin/{branch}"
))
}
}
pub async fn get_current_branch(repo_path: &Path) -> Result<Option<String>, String> {
tokio::task::spawn_blocking({
let repo_path = repo_path.to_owned();
move || -> Result<Option<String>, String> {
let repo = Repository::open(&repo_path)
.map_err(|e| format!("Failed to open repository: {e}"))?;
let head = repo
.head()
.map_err(|e| format!("Failed to get HEAD: {e}"))?;
if head.is_branch() {
Ok(head.shorthand().map(|s| s.to_string()))
} else {
Ok(None)
}
}
})
.await
.map_err(|e| format!("Task join error: {e}"))?
}
#[cfg(test)]
mod integration_tests {
use super::*;
use crate::test_utils::TestGitRepository;
#[tokio::test]
async fn test_is_git_repository() {
let non_git_dir = TestGitRepository::new().unwrap();
let is_repo = is_git_repository(non_git_dir.path()).await.unwrap();
assert!(!is_repo, "Non-git directory should return false");
let git_dir = TestGitRepository::new().unwrap();
git_dir.init().unwrap();
let is_repo = is_git_repository(git_dir.path()).await.unwrap();
assert!(is_repo, "Git repository should return true");
let subdir = git_dir.path().join("subdir");
std::fs::create_dir(&subdir).unwrap();
let is_repo = is_git_repository(&subdir).await.unwrap();
assert!(
is_repo,
"Subdirectory inside git repository should return true"
);
}
#[tokio::test]
async fn test_git_operations_with_real_repo() {
let test_repo = TestGitRepository::new().unwrap();
test_repo.init_with_commit().unwrap();
let repo_path = test_repo.path();
let status = get_status(repo_path).await.unwrap();
assert_eq!(status, "");
test_repo.create_file("test.txt", "Hello, world!").unwrap();
let status = get_status(repo_path).await.unwrap();
assert!(status.contains("?? test.txt"));
add_all(repo_path).await.unwrap();
let status = get_status(repo_path).await.unwrap();
assert!(status.contains("A test.txt"));
commit(repo_path, "Add test file").await.unwrap();
let status = get_status(repo_path).await.unwrap();
assert_eq!(status, "");
create_branch(repo_path, "test-branch").await.unwrap();
let branch = test_repo.current_branch().unwrap();
assert_eq!(branch, "test-branch");
}
#[tokio::test]
async fn test_git_operations_remotes() {
let test_repo = TestGitRepository::new().unwrap();
test_repo.init().unwrap();
let repo_path = test_repo.path();
add_remote(repo_path, "origin", "https://github.com/test/repo.git")
.await
.unwrap();
add_remote(repo_path, "origin", "https://github.com/test/repo.git")
.await
.unwrap();
remove_remote(repo_path, "origin").await.unwrap();
}
#[tokio::test]
async fn test_get_current_commit() {
let test_repo = TestGitRepository::new().unwrap();
let initial_sha = test_repo.init_with_commit().unwrap();
let repo_path = test_repo.path();
let commit_sha = get_current_commit(repo_path).await.unwrap();
assert!(!commit_sha.is_empty());
assert_eq!(commit_sha.len(), 40);
assert_eq!(commit_sha, initial_sha);
}
#[tokio::test]
async fn test_create_branch_from_commit() {
let test_repo = TestGitRepository::new().unwrap();
test_repo.init_with_commit().unwrap();
let repo_path = test_repo.path();
test_repo.create_file("file1.txt", "First file").unwrap();
test_repo.stage_all().unwrap();
test_repo.commit("First commit").unwrap();
let first_commit_sha = get_current_commit(repo_path).await.unwrap();
test_repo.create_file("file2.txt", "Second file").unwrap();
test_repo.stage_all().unwrap();
test_repo.commit("Second commit").unwrap();
create_branch_from_commit(repo_path, "feature-from-first", &first_commit_sha)
.await
.unwrap();
let branch = test_repo.current_branch().unwrap();
assert_eq!(branch, "feature-from-first");
let current_sha = get_current_commit(repo_path).await.unwrap();
assert_eq!(current_sha, first_commit_sha);
assert!(!repo_path.join("file2.txt").exists());
assert!(repo_path.join("file1.txt").exists());
}
#[tokio::test]
async fn test_get_current_commit_empty_repository() {
let test_repo = TestGitRepository::new().unwrap();
test_repo.init().unwrap();
let repo_path = test_repo.path();
let result = get_current_commit(repo_path).await;
assert!(
result.is_err(),
"get_current_commit should return error on empty repository"
);
let error_message = result.unwrap_err();
assert!(
error_message.contains("Failed to get HEAD")
|| error_message.contains("Failed to get commit from HEAD"),
"Error should mention HEAD failure, got: {error_message}"
);
}
#[tokio::test]
async fn test_create_branch_empty_repository() {
let test_repo = TestGitRepository::new().unwrap();
test_repo.init().unwrap();
let repo_path = test_repo.path();
let result = create_branch(repo_path, "test-branch").await;
assert!(
result.is_err(),
"create_branch should return error on empty repository"
);
let error_message = result.unwrap_err();
assert!(
error_message.contains("Failed to get commit from HEAD")
|| error_message.contains("Failed to get HEAD")
|| error_message.contains("unborn"),
"Error should mention HEAD or unborn branch, got: {error_message}"
);
}
#[tokio::test]
async fn test_clone_local() {
let source_repo = TestGitRepository::new().unwrap();
source_repo.init_with_commit().unwrap();
source_repo.create_file("file1.txt", "First file").unwrap();
source_repo.stage_all().unwrap();
source_repo.commit("First commit").unwrap();
let first_commit_sha = get_current_commit(source_repo.path()).await.unwrap();
source_repo.create_file("file2.txt", "Second file").unwrap();
source_repo.stage_all().unwrap();
source_repo.commit("Second commit").unwrap();
let second_commit_sha = get_current_commit(source_repo.path()).await.unwrap();
let dest_repo = TestGitRepository::new().unwrap();
let dest_path = dest_repo.path().join("cloned_repo");
clone_local(source_repo.path(), &dest_path).await.unwrap();
assert!(dest_path.exists(), "Cloned repository should exist");
assert!(
dest_path.join(".git").exists(),
"Cloned repository should have .git directory"
);
let cloned_head_sha = get_current_commit(&dest_path).await.unwrap();
assert_eq!(
cloned_head_sha, second_commit_sha,
"Cloned repository should have the same HEAD commit"
);
assert!(
dest_path.join("file1.txt").exists(),
"First file should exist in cloned repo"
);
assert!(
dest_path.join("file2.txt").exists(),
"Second file should exist in cloned repo"
);
let first_commit_oid = git2::Oid::from_str(&first_commit_sha).unwrap();
let cloned_git_repo = git2::Repository::open(&dest_path).unwrap();
let first_commit = cloned_git_repo.find_commit(first_commit_oid).unwrap();
assert_eq!(
first_commit.id().to_string(),
first_commit_sha,
"Should be able to access first commit in cloned repo"
);
let pack_dir = dest_path.join(".git/objects/pack");
if pack_dir.exists() {
let pack_files: Vec<_> = std::fs::read_dir(&pack_dir)
.unwrap()
.filter_map(|entry| entry.ok())
.filter(|entry| {
entry
.path()
.extension()
.and_then(|s| s.to_str())
.map(|s| s == "pack")
.unwrap_or(false)
})
.collect();
assert!(
pack_files.len() <= 2,
"Cloned repository should have at most 2 pack files, found {}",
pack_files.len()
);
}
}
#[tokio::test]
async fn test_get_current_branch_on_branch() {
let test_repo = TestGitRepository::new().unwrap();
test_repo.init_with_commit().unwrap();
let repo_path = test_repo.path();
let branch = get_current_branch(repo_path).await.unwrap();
assert!(branch.is_some());
let branch_name = branch.unwrap();
assert!(
branch_name == "main" || branch_name == "master",
"Expected main or master, got: {}",
branch_name
);
}
#[tokio::test]
async fn test_get_current_branch_custom_branch() {
let test_repo = TestGitRepository::new().unwrap();
test_repo.init_with_commit().unwrap();
let repo_path = test_repo.path();
create_branch(repo_path, "feature-branch").await.unwrap();
let branch = get_current_branch(repo_path).await.unwrap();
assert_eq!(branch, Some("feature-branch".to_string()));
}
#[tokio::test]
async fn test_get_current_branch_detached_head() {
let test_repo = TestGitRepository::new().unwrap();
test_repo.init_with_commit().unwrap();
let repo_path = test_repo.path();
test_repo
.run_git_command(&["checkout", "--detach"])
.unwrap();
let branch = get_current_branch(repo_path).await.unwrap();
assert!(
branch.is_none(),
"Expected None for detached HEAD, got: {:?}",
branch
);
}
#[tokio::test]
async fn test_resolve_branch_commit() {
let test_repo = TestGitRepository::new().unwrap();
test_repo.init_with_main_branch().unwrap();
test_repo.create_file("file.txt", "content").unwrap();
test_repo.stage_all().unwrap();
test_repo.commit("Add file").unwrap();
test_repo
.run_git_command(&["checkout", "-b", "feature"])
.unwrap();
test_repo
.create_file("feature.txt", "feature content")
.unwrap();
test_repo.stage_all().unwrap();
let feature_commit = test_repo.commit("Add feature file").unwrap();
test_repo.run_git_command(&["checkout", "main"]).unwrap();
let result = resolve_branch_commit(test_repo.path(), "feature").await;
assert!(result.is_ok());
assert_eq!(result.unwrap(), feature_commit);
let result = resolve_branch_commit(test_repo.path(), "nonexistent").await;
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
err.contains("not found"),
"Should mention branch not found: {err}"
);
}
}