use std::path::Path;
use dk_core::{Error, Result};
pub struct GitRepository {
inner: gix::Repository,
}
impl GitRepository {
pub fn init(path: &Path) -> Result<Self> {
std::fs::create_dir_all(path).map_err(|e| {
Error::Git(format!("failed to create directory {}: {}", path.display(), e))
})?;
let repo = gix::init(path).map_err(|e| {
Error::Git(format!("failed to init repository at {}: {}", path.display(), e))
})?;
Ok(Self { inner: repo })
}
pub fn open(path: &Path) -> Result<Self> {
let repo = gix::open(path).map_err(|e| {
Error::Git(format!("failed to open repository at {}: {}", path.display(), e))
})?;
Ok(Self { inner: repo })
}
pub fn path(&self) -> &Path {
self.inner
.workdir()
.unwrap_or_else(|| self.inner.git_dir())
}
pub fn inner(&self) -> &gix::Repository {
&self.inner
}
pub fn commit(&self, message: &str, author_name: &str, author_email: &str) -> Result<String> {
let workdir = self.path();
let output = std::process::Command::new("git")
.args(["add", "-A"])
.current_dir(workdir)
.output()
.map_err(|e| Error::Git(format!("git add failed: {e}")))?;
if !output.status.success() {
return Err(Error::Git(format!(
"git add failed: {}",
String::from_utf8_lossy(&output.stderr)
)));
}
let output = std::process::Command::new("git")
.args([
"commit",
"--allow-empty",
"-m", message,
"--author", &format!("{} <{}>", author_name, author_email),
])
.current_dir(workdir)
.output()
.map_err(|e| Error::Git(format!("git commit failed: {e}")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("nothing to commit") {
return self.head_hash()?
.ok_or_else(|| Error::Git("no HEAD after commit".into()));
}
return Err(Error::Git(format!("git commit failed: {stderr}")));
}
self.head_hash()?
.ok_or_else(|| Error::Git("no HEAD after commit".into()))
}
pub fn read_tree_entry(&self, commit_hex: &str, path: &str) -> Result<Vec<u8>> {
let oid = gix::ObjectId::from_hex(commit_hex.as_bytes())
.map_err(|e| Error::Git(format!("invalid commit hex '{commit_hex}': {e}")))?;
let commit = self
.inner
.find_commit(oid)
.map_err(|e| Error::Git(format!("failed to find commit {commit_hex}: {e}")))?;
let tree = self
.inner
.find_tree(commit.tree_id().expect("commit always has tree"))
.map_err(|e| Error::Git(format!("failed to find tree for commit {commit_hex}: {e}")))?;
let entry = tree
.lookup_entry_by_path(path)
.map_err(|e| Error::Git(format!("failed to lookup '{path}' in {commit_hex}: {e}")))?
.ok_or_else(|| Error::Git(format!("path '{path}' not found in commit {commit_hex}")))?;
let object = entry
.object()
.map_err(|e| Error::Git(format!("failed to read object for '{path}': {e}")))?;
if object.kind != gix::object::Kind::Blob {
return Err(Error::Git(format!(
"path '{path}' in commit {commit_hex} is not a blob (is {:?})",
object.kind
)));
}
Ok(object.data.clone())
}
pub fn list_tree_files(&self, commit_hex: &str) -> Result<Vec<String>> {
let oid = gix::ObjectId::from_hex(commit_hex.as_bytes())
.map_err(|e| Error::Git(format!("invalid commit hex '{commit_hex}': {e}")))?;
let commit = self
.inner
.find_commit(oid)
.map_err(|e| Error::Git(format!("failed to find commit {commit_hex}: {e}")))?;
let tree = self
.inner
.find_tree(commit.tree_id().expect("commit always has tree"))
.map_err(|e| Error::Git(format!("failed to find tree for commit {commit_hex}: {e}")))?;
let entries = tree
.traverse()
.breadthfirst
.files()
.map_err(|e| Error::Git(format!("tree traversal failed for {commit_hex}: {e}")))?;
let paths = entries
.into_iter()
.filter(|e| !e.mode.is_tree())
.map(|e| e.filepath.to_string())
.collect();
Ok(paths)
}
pub fn commit_tree_overlay(
&self,
base_commit_hex: &str,
overlay: &[(String, Option<Vec<u8>>)],
parent_commit_hex: &str,
message: &str,
author_name: &str,
author_email: &str,
) -> Result<String> {
use gix::object::tree::EntryKind;
let base_oid = gix::ObjectId::from_hex(base_commit_hex.as_bytes())
.map_err(|e| Error::Git(format!("invalid base commit hex '{base_commit_hex}': {e}")))?;
let base_commit = self
.inner
.find_commit(base_oid)
.map_err(|e| Error::Git(format!("failed to find base commit {base_commit_hex}: {e}")))?;
let base_tree = self
.inner
.find_tree(base_commit.tree_id().expect("commit always has tree"))
.map_err(|e| Error::Git(format!("failed to find base tree: {e}")))?;
let parent_oid = gix::ObjectId::from_hex(parent_commit_hex.as_bytes())
.map_err(|e| Error::Git(format!("invalid parent commit hex '{parent_commit_hex}': {e}")))?;
let mut editor = self
.inner
.edit_tree(base_tree.id)
.map_err(|e| Error::Git(format!("failed to create tree editor: {e}")))?;
for (path, maybe_content) in overlay {
match maybe_content {
Some(content) => {
let blob_id = self
.inner
.write_blob(content)
.map_err(|e| Error::Git(format!("failed to write blob for '{path}': {e}")))?;
editor
.upsert(path.as_str(), EntryKind::Blob, blob_id.detach())
.map_err(|e| Error::Git(format!("failed to upsert '{path}': {e}")))?;
}
None => {
editor
.remove(path.as_str())
.map_err(|e| Error::Git(format!("failed to remove '{path}': {e}")))?;
}
}
}
let new_tree_id = editor
.write()
.map_err(|e| Error::Git(format!("failed to write edited tree: {e}")))?;
let now_secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
let time = gix::date::Time {
seconds: now_secs,
offset: 0,
};
let sig = gix::actor::Signature {
name: author_name.into(),
email: author_email.into(),
time,
};
let mut time_buf = gix::date::parse::TimeBuf::default();
let sig_ref = sig.to_ref(&mut time_buf);
let commit_id = self
.inner
.commit_as(sig_ref, sig_ref, "HEAD", message, new_tree_id.detach(), [parent_oid])
.map_err(|e| Error::Git(format!("failed to create commit: {e}")))?;
let commit_hex = commit_id.to_hex().to_string();
let work_dir = self.path().to_path_buf();
let output = std::thread::spawn(move || {
std::process::Command::new("git")
.args(["checkout", "HEAD", "--", "."])
.current_dir(&work_dir)
.output()
})
.join()
.map_err(|_| Error::Git("git checkout thread panicked".into()))?
.map_err(|e| Error::Git(format!("git checkout failed: {e}")))?;
if !output.status.success() {
tracing::warn!(
"git checkout HEAD -- . failed after commit: {}",
String::from_utf8_lossy(&output.stderr)
);
}
Ok(commit_hex)
}
pub fn commit_initial_overlay(
&self,
overlay: &[(String, Option<Vec<u8>>)],
message: &str,
author_name: &str,
author_email: &str,
) -> Result<String> {
use gix::object::tree::EntryKind;
let empty_tree = self
.inner
.empty_tree();
let mut editor = self
.inner
.edit_tree(empty_tree.id)
.map_err(|e| Error::Git(format!("failed to create tree editor: {e}")))?;
for (path, maybe_content) in overlay {
if let Some(content) = maybe_content {
let blob_id = self
.inner
.write_blob(content)
.map_err(|e| Error::Git(format!("failed to write blob for '{path}': {e}")))?;
editor
.upsert(path.as_str(), EntryKind::Blob, blob_id.detach())
.map_err(|e| Error::Git(format!("failed to upsert '{path}': {e}")))?;
}
}
let new_tree_id = editor
.write()
.map_err(|e| Error::Git(format!("failed to write initial tree: {e}")))?;
let now_secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
let time = gix::date::Time {
seconds: now_secs,
offset: 0,
};
let sig = gix::actor::Signature {
name: author_name.into(),
email: author_email.into(),
time,
};
let mut time_buf = gix::date::parse::TimeBuf::default();
let sig_ref = sig.to_ref(&mut time_buf);
let commit_id = self
.inner
.commit_as(
sig_ref,
sig_ref,
"HEAD",
message,
new_tree_id.detach(),
std::iter::empty::<gix::ObjectId>(),
)
.map_err(|e| Error::Git(format!("failed to create initial commit: {e}")))?;
let commit_hex = commit_id.to_hex().to_string();
let work_dir = self.path().to_path_buf();
let output = std::thread::spawn(move || {
std::process::Command::new("git")
.args(["checkout", "HEAD", "--", "."])
.current_dir(&work_dir)
.output()
})
.join()
.map_err(|_| Error::Git("git checkout thread panicked".into()))?
.map_err(|e| Error::Git(format!("git checkout failed: {e}")))?;
if !output.status.success() {
tracing::warn!(
"git checkout HEAD -- . failed after initial commit: {}",
String::from_utf8_lossy(&output.stderr)
);
}
Ok(commit_hex)
}
pub fn head_hash(&self) -> Result<Option<String>> {
let head = self
.inner
.head()
.map_err(|e| Error::Git(format!("failed to get HEAD: {}", e)))?;
if head.is_unborn() {
return Ok(None);
}
match head.into_peeled_id() {
Ok(id) => Ok(Some(id.to_hex().to_string())),
Err(e) => Err(Error::Git(format!("failed to peel HEAD: {}", e))),
}
}
}