pub mod backend;
pub mod branch;
pub mod cache;
pub mod cherry_pick;
pub mod gc;
pub mod git2_backend;
pub mod remote;
pub mod status;
pub use branch::*;
pub use cache::{invalidate_status_cache, GitStatusCache, STATUS_CACHE};
pub use remote::*;
pub use status::*;
use crate::util::log_cmd;
use git2::Repository;
use std::path::Path;
use std::process::Command;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum GitError {
#[error("Git error: {0}")]
Git(#[from] git2::Error),
#[error("Repository not found: {0}")]
NotFound(String),
#[error("Not a git repository: {0}")]
NotARepo(String),
#[error("Branch not found: {0}")]
BranchNotFound(String),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Operation failed: {0}")]
OperationFailed(String),
#[error("Reference error: {0}")]
Reference(String),
#[error("Object error: {0}")]
Object(String),
#[error("Repository locked: {0}")]
RepositoryLocked(String),
}
pub fn open_repo<P: AsRef<Path>>(path: P) -> Result<Repository, GitError> {
Repository::open(path.as_ref())
.map_err(|e| GitError::NotARepo(format!("{}: {}", path.as_ref().display(), e)))
}
pub fn is_git_repo<P: AsRef<Path>>(path: P) -> bool {
Repository::open(path.as_ref()).is_ok()
}
pub fn path_exists<P: AsRef<Path>>(path: P) -> bool {
path.as_ref().exists()
}
pub fn git_lock_exists<P: AsRef<Path>>(path: P) -> Option<std::path::PathBuf> {
let lock_path = path.as_ref().join(".git").join("index.lock");
if lock_path.exists() {
Some(lock_path)
} else {
None
}
}
pub fn wait_for_git_lock<P: AsRef<Path>>(path: P) -> Result<(), GitError> {
let path = path.as_ref();
let max_attempts: u32 = 5;
let initial_delay_ms: u64 = 200;
let max_delay_ms: u64 = 5000;
for attempt in 0..max_attempts {
if git_lock_exists(path).is_none() {
return Ok(());
}
let delay_ms = (initial_delay_ms * 2u64.pow(attempt)).min(max_delay_ms);
std::thread::sleep(std::time::Duration::from_millis(delay_ms));
}
if git_lock_exists(path).is_none() {
return Ok(());
}
Err(GitError::RepositoryLocked(format!(
"{}: .git/index.lock exists — another git process may be running",
path.display()
)))
}
pub fn clone_repo<P: AsRef<Path>>(
url: &str,
path: P,
branch: Option<&str>,
) -> Result<Repository, GitError> {
let path = path.as_ref();
let path_str = path.to_str().unwrap_or(".");
if let Some(b) = branch {
let args = vec!["clone", "-b", b, url, path_str];
let mut cmd = Command::new("git");
cmd.args(&args);
log_cmd(&cmd);
let output = cmd
.output()
.map_err(|e| GitError::OperationFailed(e.to_string()))?;
if output.status.success() {
return open_repo(path);
}
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("Remote branch") && stderr.contains("not found") {
} else {
return Err(GitError::OperationFailed(format!(
"git clone failed: {}",
stderr
)));
}
}
let args = vec!["clone", url, path_str];
let mut cmd = Command::new("git");
cmd.args(&args);
log_cmd(&cmd);
let output = cmd
.output()
.map_err(|e| GitError::OperationFailed(e.to_string()))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(GitError::OperationFailed(format!(
"git clone failed: {}",
stderr
)));
}
open_repo(path)
}
pub fn get_workdir(repo: &Repository) -> &Path {
repo.workdir().unwrap_or_else(|| repo.path())
}
pub fn get_current_branch(repo: &Repository) -> Result<String, GitError> {
let head = repo
.head()
.map_err(|e| GitError::Reference(e.to_string()))?;
if head.is_branch() {
let name = head.shorthand().unwrap_or("HEAD");
Ok(name.to_string())
} else {
let oid = head
.target()
.ok_or_else(|| GitError::Reference("HEAD has no target".to_string()))?;
Ok(format!("(HEAD detached at {})", &oid.to_string()[..7]))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::process::Command;
use tempfile::TempDir;
#[test]
fn test_is_git_repo() {
let temp = TempDir::new().unwrap();
assert!(!is_git_repo(temp.path()));
Repository::init(temp.path()).unwrap();
assert!(is_git_repo(temp.path()));
}
#[test]
fn test_path_exists() {
let temp = TempDir::new().unwrap();
assert!(path_exists(temp.path()));
assert!(!path_exists(temp.path().join("nonexistent")));
}
#[test]
fn test_open_repo() {
let temp = TempDir::new().unwrap();
assert!(open_repo(temp.path()).is_err());
Repository::init(temp.path()).unwrap();
assert!(open_repo(temp.path()).is_ok());
}
fn git(dir: &std::path::Path, args: &[&str]) {
let output = Command::new("git")
.current_dir(dir)
.args(args)
.output()
.unwrap_or_else(|e| panic!("failed to run git {:?}: {}", args, e));
assert!(
output.status.success(),
"git {:?} failed in {}: {}",
args,
dir.display(),
String::from_utf8_lossy(&output.stderr)
);
}
fn setup_bare_remote() -> (TempDir, String) {
let temp = TempDir::new().unwrap();
let bare_path = temp.path().join("remote.git");
let output = Command::new("git")
.args(["init", "--bare", "-b", "main", bare_path.to_str().unwrap()])
.current_dir(temp.path())
.output()
.unwrap();
assert!(
output.status.success(),
"git init --bare failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let staging = temp.path().join("staging");
fs::create_dir_all(&staging).unwrap();
git(&staging, &["init", "-b", "main"]);
git(&staging, &["config", "user.email", "test@example.com"]);
git(&staging, &["config", "user.name", "Test User"]);
fs::write(staging.join("README.md"), "# Test").unwrap();
git(&staging, &["add", "README.md"]);
git(&staging, &["commit", "-m", "Initial commit"]);
git(
&staging,
&[
"remote",
"add",
"origin",
&format!("file://{}", bare_path.display()),
],
);
git(&staging, &["push", "-u", "origin", "main"]);
(temp, format!("file://{}", bare_path.display()))
}
#[test]
fn test_clone_repo_invalid_url_fails() {
let temp = TempDir::new().unwrap();
let dest = temp.path().join("dest");
let result = clone_repo("file:///does-not-exist/repo.git", &dest, Some("main"));
assert!(result.is_err(), "expected clone to fail for bad URL");
}
#[test]
fn test_clone_repo_falls_back_when_branch_missing() {
let (_temp, remote_url) = setup_bare_remote();
let dest_root = TempDir::new().unwrap();
let dest = dest_root.path().join("dest");
let result = clone_repo(&remote_url, &dest, Some("does-not-exist"));
assert!(
result.is_ok(),
"expected clone to fall back to default branch"
);
let repo = open_repo(&dest).expect("open repo");
let branch = get_current_branch(&repo).expect("current branch");
assert_eq!(branch, "main");
}
#[test]
fn test_git_lock_exists_none() {
let temp = TempDir::new().unwrap();
Repository::init(temp.path()).unwrap();
assert!(git_lock_exists(temp.path()).is_none());
}
#[test]
fn test_git_lock_exists_some() {
let temp = TempDir::new().unwrap();
Repository::init(temp.path()).unwrap();
let lock_path = temp.path().join(".git").join("index.lock");
fs::write(&lock_path, "").unwrap();
assert!(git_lock_exists(temp.path()).is_some());
}
#[test]
fn test_wait_for_git_lock_no_lock() {
let temp = TempDir::new().unwrap();
Repository::init(temp.path()).unwrap();
assert!(wait_for_git_lock(temp.path()).is_ok());
}
#[test]
fn test_wait_for_git_lock_persistent_lock() {
let temp = TempDir::new().unwrap();
Repository::init(temp.path()).unwrap();
let lock_path = temp.path().join(".git").join("index.lock");
fs::write(&lock_path, "").unwrap();
let result = wait_for_git_lock(temp.path());
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.contains("index.lock"));
}
}