use std::path::{Path, PathBuf};
use std::sync::atomic::AtomicBool;
use gix::progress::Discard;
use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog};
use gix::refs::Target;
use gix::remote::Direction;
use super::error::GitError;
use super::{ClonedRepo, GitBackend};
use crate::fs::ScopedLock;
#[derive(Debug, Default)]
pub struct GixBackend;
impl GixBackend {
#[must_use]
pub const fn new() -> Self {
Self
}
}
impl GitBackend for GixBackend {
fn name(&self) -> &'static str {
"gix"
}
fn clone(&self, url: &str, dest: &Path, r#ref: Option<&str>) -> Result<ClonedRepo, GitError> {
with_repo_lock(dest, || {
ensure_dest_empty(dest)?;
let repo = run_clone(url, dest, r#ref)?;
let head_sha = read_head_sha(&repo)?;
Ok(ClonedRepo { path: dest.to_path_buf(), head_sha })
})
}
fn fetch(&self, dest: &Path) -> Result<(), GitError> {
with_repo_lock(dest, || fetch_locked(dest))
}
fn checkout(&self, dest: &Path, r#ref: &str) -> Result<(), GitError> {
with_repo_lock(dest, || {
let repo = open_repo(dest)?;
ensure_clean_worktree(&repo, dest)?;
let target = resolve_ref(&repo, r#ref)?;
update_head_detached(&repo, r#ref, target)?;
materialise_tree(&repo, r#ref, target)
})
}
fn head_sha(&self, dest: &Path) -> Result<String, GitError> {
let repo = open_repo(dest)?;
read_head_sha(&repo)
}
}
fn repo_lock_path(dest: &Path) -> PathBuf {
let parent = dest.parent().map_or_else(|| PathBuf::from("."), Path::to_path_buf);
let stem = dest
.file_name()
.map_or_else(|| std::ffi::OsString::from("repo"), std::ffi::OsStr::to_os_string);
let mut name = std::ffi::OsString::from(".grex-backend-");
name.push(&stem);
name.push(".lock");
parent.join(name)
}
fn with_repo_lock<T, F>(dest: &Path, op: F) -> Result<T, GitError>
where
F: FnOnce() -> Result<T, GitError>,
{
let lock_path = repo_lock_path(dest);
let mut lock = ScopedLock::open(&lock_path)
.map_err(|e| GitError::Internal(format!("open repo lock {}: {e}", lock_path.display())))?;
let _guard = lock.acquire().map_err(|e| {
GitError::Internal(format!("acquire repo lock {}: {e}", lock_path.display()))
})?;
op()
}
fn fetch_locked(dest: &Path) -> Result<(), GitError> {
let repo = open_repo(dest)?;
let remote = repo
.find_default_remote(Direction::Fetch)
.ok_or_else(|| {
GitError::FetchFailed(dest.to_path_buf(), "no default remote configured".into())
})?
.map_err(|e| GitError::FetchFailed(dest.to_path_buf(), e.to_string()))?;
let connection = remote
.connect(Direction::Fetch)
.map_err(|e| GitError::FetchFailed(dest.to_path_buf(), e.to_string()))?;
let interrupt = AtomicBool::new(false);
let prepare = connection
.prepare_fetch(Discard, gix::remote::ref_map::Options::default())
.map_err(|e| GitError::FetchFailed(dest.to_path_buf(), e.to_string()))?;
prepare
.receive(Discard, &interrupt)
.map_err(|e| GitError::FetchFailed(dest.to_path_buf(), e.to_string()))?;
Ok(())
}
fn ensure_dest_empty(dest: &Path) -> Result<(), GitError> {
if !dest.exists() {
return Ok(());
}
let mut iter = std::fs::read_dir(dest)
.map_err(|e| GitError::Internal(format!("read_dir({}): {e}", dest.display())))?;
if iter.next().is_some() {
return Err(GitError::DestinationNotEmpty(dest.to_path_buf()));
}
Ok(())
}
fn run_clone(url: &str, dest: &Path, r#ref: Option<&str>) -> Result<gix::Repository, GitError> {
let mut prepare = gix::prepare_clone(url, dest)
.map_err(|e| GitError::CloneFailed { url: url.to_string(), detail: e.to_string() })?;
if let Some(name) = r#ref {
prepare = prepare
.with_ref_name(Some(name))
.map_err(|e| GitError::CloneFailed { url: url.to_string(), detail: e.to_string() })?;
}
let interrupt = AtomicBool::new(false);
let (mut checkout, _) = prepare
.fetch_then_checkout(Discard, &interrupt)
.map_err(|e| GitError::CloneFailed { url: url.to_string(), detail: e.to_string() })?;
let (repo, _) = checkout
.main_worktree(Discard, &interrupt)
.map_err(|e| GitError::CloneFailed { url: url.to_string(), detail: e.to_string() })?;
Ok(repo)
}
fn open_repo(dest: &Path) -> Result<gix::Repository, GitError> {
gix::open(dest).map_err(|_| GitError::NotARepository(dest.to_path_buf()))
}
fn ensure_clean_worktree(repo: &gix::Repository, dest: &Path) -> Result<(), GitError> {
match repo.is_dirty() {
Ok(false) => Ok(()),
Ok(true) => Err(GitError::DirtyWorkingTree(dest.to_path_buf())),
Err(e) => Err(GitError::Internal(format!("is_dirty({}): {e}", dest.display()))),
}
}
fn resolve_ref(repo: &gix::Repository, r#ref: &str) -> Result<gix::ObjectId, GitError> {
repo.rev_parse_single(r#ref)
.map(|id| id.detach())
.map_err(|_| GitError::RefNotFound(r#ref.to_string()))
}
fn update_head_detached(
repo: &gix::Repository,
r#ref: &str,
target: gix::ObjectId,
) -> Result<(), GitError> {
let edit = RefEdit {
change: Change::Update {
log: LogChange {
mode: RefLog::AndReference,
force_create_reflog: false,
message: format!("grex: checkout {ref_name}", ref_name = r#ref).into(),
},
expected: PreviousValue::Any,
new: Target::Object(target),
},
name: "HEAD".try_into().expect("HEAD is a valid ref name"),
deref: false,
};
repo.edit_reference(edit)
.map(|_| ())
.map_err(|e| GitError::CheckoutFailed { r#ref: r#ref.to_string(), detail: e.to_string() })
}
fn materialise_tree(
repo: &gix::Repository,
r#ref: &str,
target: gix::ObjectId,
) -> Result<(), GitError> {
let workdir = repo.work_dir().ok_or_else(|| GitError::CheckoutFailed {
r#ref: r#ref.to_string(),
detail: "bare repository has no working tree".into(),
})?;
let tree_id = tree_of_commit(repo, r#ref, target)?;
let mut index = build_index_from_tree(repo, r#ref, tree_id)?;
let objects = repo.objects.clone().into_arc().map_err(|e: std::io::Error| {
GitError::CheckoutFailed { r#ref: r#ref.to_string(), detail: e.to_string() }
})?;
let interrupt = AtomicBool::new(false);
let opts = gix_worktree_state::checkout::Options {
overwrite_existing: true,
destination_is_initially_empty: false,
..Default::default()
};
gix_worktree_state::checkout(
&mut index,
workdir.to_path_buf(),
objects,
&Discard,
&Discard,
&interrupt,
opts,
)
.map_err(|e| GitError::CheckoutFailed { r#ref: r#ref.to_string(), detail: e.to_string() })?;
index.write(Default::default()).map_err(|e| GitError::CheckoutFailed {
r#ref: r#ref.to_string(),
detail: e.to_string(),
})?;
Ok(())
}
fn tree_of_commit(
repo: &gix::Repository,
r#ref: &str,
commit_id: gix::ObjectId,
) -> Result<gix::ObjectId, GitError> {
let object = repo.find_object(commit_id).map_err(|e| GitError::CheckoutFailed {
r#ref: r#ref.to_string(),
detail: e.to_string(),
})?;
let tree = object.peel_to_kind(gix::object::Kind::Tree).map_err(|e| {
GitError::CheckoutFailed { r#ref: r#ref.to_string(), detail: e.to_string() }
})?;
Ok(tree.id)
}
fn build_index_from_tree(
repo: &gix::Repository,
r#ref: &str,
tree_id: gix::ObjectId,
) -> Result<gix::index::File, GitError> {
let validate = gix::validate::path::component::Options::default();
let state = gix::index::State::from_tree(&tree_id, &repo.objects, validate).map_err(|e| {
GitError::CheckoutFailed { r#ref: r#ref.to_string(), detail: e.to_string() }
})?;
Ok(gix::index::File::from_state(state, repo.index_path()))
}
fn read_head_sha(repo: &gix::Repository) -> Result<String, GitError> {
let id = repo.head_id().map_err(|e| GitError::Internal(format!("head_id: {e}")))?;
Ok(id.detach().to_hex().to_string())
}
#[doc(hidden)]
#[must_use]
pub fn file_url_from_path(path: &Path) -> String {
let s = path.to_string_lossy().replace('\\', "/");
if s.starts_with('/') {
format!("file://{s}")
} else {
format!("file:///{s}")
}
}