use std::{
collections::{HashMap, HashSet},
fs,
io::Write,
path::{Path, PathBuf},
sync::atomic::AtomicBool,
time::{SystemTime, UNIX_EPOCH},
};
use gix::{
bstr::ByteSlice,
hash::{Kind as ObjectHashKind, ObjectId},
refs::{
Target,
transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog},
},
};
use gix_transport::{
Protocol, Service,
client::{MessageKind, WriteMode, blocking_io::Transport},
};
use objects::{
error::HeddleError,
object::{ChangeId, ChangeIdParseError, Tree},
store::ObjectStore,
};
use refs::Head;
use repo::Repository as HeddleRepository;
use super::{git_export::export_all, git_import::import_all};
#[derive(Debug, thiserror::Error)]
pub enum GitBridgeError {
#[error("git error: {0}")]
Git(String),
#[error("store error: {0}")]
Store(#[from] HeddleError),
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("invalid trailer format: {0}")]
InvalidTrailer(String),
#[error("missing required trailer: {0}")]
MissingTrailer(String),
#[error("invalid mapping: {0}")]
InvalidMapping(String),
#[error("commit not found: {0}")]
CommitNotFound(String),
#[error("state not found: {0}")]
StateNotFound(ChangeId),
#[error("git repository not initialized")]
GitRepoNotInitialized,
#[error("conflict during sync: {0}")]
Conflict(String),
#[error("change id parse error: {0}")]
ChangeIdParse(#[from] ChangeIdParseError),
}
pub type GitResult<T> = std::result::Result<T, GitBridgeError>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum RefNamespace {
Branch,
Tag,
Note,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct RefUpdate {
pub name: String,
pub target: ObjectId,
pub namespace: RefNamespace,
}
#[derive(Debug, Clone)]
enum ResolvedRemote {
Local(PathBuf),
Url(gix::Url),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WriteThroughSkipReason {
MissingDotGit,
DetachedHead,
NoAttachedThread,
NoMappedCommit,
MirrorIsWorktree,
IndexAlreadyDirty,
}
impl std::fmt::Display for WriteThroughSkipReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
WriteThroughSkipReason::MissingDotGit => {
write!(f, "this checkout does not have a Git working tree")
}
WriteThroughSkipReason::DetachedHead => {
write!(f, "the current Heddle head is detached")
}
WriteThroughSkipReason::NoAttachedThread => {
write!(f, "the attached Heddle thread does not resolve to a state")
}
WriteThroughSkipReason::NoMappedCommit => {
write!(f, "the current Heddle state has not been exported to Git")
}
WriteThroughSkipReason::MirrorIsWorktree => {
write!(f, "the Git mirror is already the active checkout")
}
WriteThroughSkipReason::IndexAlreadyDirty => {
write!(f, "the Git index is already locked by another operation")
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WriteThroughOutcome {
Wrote(ObjectId),
Skipped(WriteThroughSkipReason),
}
impl WriteThroughOutcome {
pub fn object_id(self) -> Option<ObjectId> {
match self {
WriteThroughOutcome::Wrote(oid) => Some(oid),
WriteThroughOutcome::Skipped(_) => None,
}
}
pub fn skip_reason(self) -> Option<WriteThroughSkipReason> {
match self {
WriteThroughOutcome::Skipped(reason) => Some(reason),
WriteThroughOutcome::Wrote(_) => None,
}
}
}
#[derive(Debug, Default)]
pub struct SyncMapping {
heddle_to_git: HashMap<ChangeId, ObjectId>,
git_to_heddle: HashMap<ObjectId, ChangeId>,
}
impl SyncMapping {
pub fn new() -> Self {
Self::default()
}
pub fn insert(&mut self, change_id: ChangeId, git_oid: ObjectId) {
self.heddle_to_git.insert(change_id, git_oid);
self.git_to_heddle.insert(git_oid, change_id);
}
pub(crate) fn insert_checked(
&mut self,
change_id: ChangeId,
git_oid: ObjectId,
) -> GitResult<()> {
if let Some(existing) = self.heddle_to_git.get(&change_id)
&& *existing != git_oid
{
return Err(GitBridgeError::Conflict(format!(
"change id {} mapped to {} (new {})",
change_id, existing, git_oid
)));
}
if let Some(existing) = self.git_to_heddle.get(&git_oid)
&& *existing != change_id
{
return Err(GitBridgeError::Conflict(format!(
"git oid {} mapped to {} (new {})",
git_oid, existing, change_id
)));
}
self.insert(change_id, git_oid);
Ok(())
}
pub fn get_git(&self, change_id: &ChangeId) -> Option<ObjectId> {
self.heddle_to_git.get(change_id).copied()
}
pub fn get_heddle(&self, git_oid: ObjectId) -> Option<ChangeId> {
self.git_to_heddle.get(&git_oid).copied()
}
pub fn has_heddle(&self, change_id: &ChangeId) -> bool {
self.heddle_to_git.contains_key(change_id)
}
pub fn has_git(&self, git_oid: ObjectId) -> bool {
self.git_to_heddle.contains_key(&git_oid)
}
pub(crate) fn iter(&self) -> impl Iterator<Item = (&ChangeId, &ObjectId)> {
self.heddle_to_git.iter()
}
pub(crate) fn retain_git_objects(&mut self, repo: &gix::Repository) {
let retained: Vec<(ChangeId, ObjectId)> = self
.heddle_to_git
.iter()
.filter_map(|(change_id, git_oid)| {
repo.find_object(*git_oid)
.ok()
.map(|_| (*change_id, *git_oid))
})
.collect();
self.heddle_to_git.clear();
self.git_to_heddle.clear();
for (change_id, git_oid) in retained {
self.insert(change_id, git_oid);
}
}
#[cfg_attr(not(feature = "git-overlay"), allow(dead_code))]
pub(crate) fn retain_git_object_set(&mut self, reachable: &HashSet<ObjectId>) -> usize {
let before = self.heddle_to_git.len();
let retained: Vec<(ChangeId, ObjectId)> = self
.heddle_to_git
.iter()
.filter_map(|(change_id, git_oid)| {
reachable
.contains(git_oid)
.then_some((*change_id, *git_oid))
})
.collect();
self.heddle_to_git.clear();
self.git_to_heddle.clear();
for (change_id, git_oid) in retained {
self.insert(change_id, git_oid);
}
before.saturating_sub(self.heddle_to_git.len())
}
}
pub struct GitBridge<'a> {
pub(crate) heddle_repo: &'a HeddleRepository,
pub(crate) git_repo_path: Option<PathBuf>,
pub(crate) mapping: SyncMapping,
}
impl<'a> GitBridge<'a> {
pub(crate) const TRAILER_CHANGE_ID: &'static str = "Heddle-Change-Id";
pub(crate) const TRAILER_AGENT: &'static str = "Heddle-Agent";
pub(crate) const TRAILER_CONFIDENCE: &'static str = "Heddle-Confidence";
pub(crate) const TRAILER_STATUS: &'static str = "Heddle-Status";
pub fn new(heddle_repo: &'a HeddleRepository) -> Self {
Self {
heddle_repo,
git_repo_path: None,
mapping: SyncMapping::new(),
}
}
pub fn init_mirror(&mut self) -> GitResult<()> {
let _guard = self.init_mirror_with_guard()?;
_guard.commit();
Ok(())
}
pub(crate) fn init_mirror_with_guard(&mut self) -> GitResult<MirrorInitGuard> {
let git_dir = self.heddle_repo.heddle_dir().join("git");
let did_create = if git_dir.exists() {
let _ = open_repo(&git_dir)?;
false
} else {
fs::create_dir_all(&git_dir)?;
let _ = gix::init_bare(&git_dir).map_err(git_err)?;
true
};
self.git_repo_path = Some(git_dir.clone());
Ok(MirrorInitGuard::new_from_init(git_dir, did_create))
}
pub fn mirror_path(&self) -> PathBuf {
self.heddle_repo.heddle_dir().join("git")
}
pub fn is_initialized(&self) -> bool {
self.mirror_path().exists()
}
pub(crate) fn open_git_repo(&self) -> GitResult<gix::Repository> {
if let Some(ref path) = self.git_repo_path {
open_repo(path)
} else {
let mirror_path = self.mirror_path();
if mirror_path.exists() {
open_repo(&mirror_path)
} else {
open_repo(self.heddle_repo.root())
}
}
}
pub(crate) fn sort_states_topologically(
&self,
states: &[ChangeId],
) -> GitResult<Vec<ChangeId>> {
let mut sorted = Vec::new();
let mut visited: std::collections::HashSet<ChangeId> = std::collections::HashSet::new();
fn visit<S: ObjectStore + ?Sized>(
state_id: &ChangeId,
store: &S,
visited: &mut std::collections::HashSet<ChangeId>,
sorted: &mut Vec<ChangeId>,
) -> GitResult<()> {
if visited.contains(state_id) {
return Ok(());
}
if let Some(state) = store.get_state(state_id)? {
for parent in &state.parents {
visit(parent, store, visited, sorted)?;
}
}
visited.insert(*state_id);
sorted.push(*state_id);
Ok(())
}
for state_id in states {
visit(
state_id,
self.heddle_repo.store(),
&mut visited,
&mut sorted,
)?;
}
Ok(sorted)
}
pub fn export(&mut self) -> GitResult<super::git_util::ExportStats> {
export_all(self)
}
pub fn import(&mut self, git_path: Option<&Path>) -> GitResult<super::git_util::ImportStats> {
import_all(self, git_path)
}
pub fn push(&mut self, remote_name: &str) -> GitResult<()> {
self.init_mirror()?;
self.export()?;
self.write_through_current_checkout()?;
let log_message = format!("heddle: push from {}", self.heddle_repo.root().display());
match self.resolve_remote(remote_name, gix::remote::Direction::Push)? {
ResolvedRemote::Local(target_path) => self.copy_mirror_to_path(
&target_path,
&log_message,
false,
),
ResolvedRemote::Url(url) => {
let mirror_repo = self.open_git_repo()?;
push_network_remote(&mirror_repo, &url)
}
}
}
pub fn export_to_path(
&mut self,
target_path: &Path,
) -> GitResult<super::git_util::ExportStats> {
self.init_mirror()?;
let stats = self.export()?;
self.copy_mirror_to_path(
target_path,
&format!("heddle: export from {}", self.heddle_repo.root().display()),
true,
)?;
Ok(stats)
}
fn copy_mirror_to_path(
&mut self,
target_path: &Path,
log_message: &str,
init_if_missing: bool,
) -> GitResult<()> {
let mirror_repo = self.open_git_repo()?;
let target_repo = if target_path.exists() {
open_repo(target_path)?
} else if init_if_missing {
fs::create_dir_all(target_path)?;
gix::init_bare(target_path).map_err(git_err)?;
open_repo(target_path)?
} else {
return Err(GitBridgeError::Git(format!(
"destination '{}' does not exist",
target_path.display()
)));
};
let updates = collect_ref_updates(&mirror_repo)?;
copy_reachable_objects(
&mirror_repo,
&target_repo,
updates.iter().map(|update| update.target),
)?;
apply_ref_updates(&target_repo, &updates, log_message)?;
Ok(())
}
pub fn fetch(&mut self, remote_name: &str) -> GitResult<()> {
self.init_mirror()?;
let mirror_repo = self.open_git_repo()?;
match self.resolve_remote(remote_name, gix::remote::Direction::Fetch)? {
ResolvedRemote::Local(path) => {
let remote_repo = open_repo(&path)?;
let updates = collect_ref_updates(&remote_repo)?;
copy_reachable_objects(
&remote_repo,
&mirror_repo,
updates.iter().map(|update| update.target),
)?;
apply_ref_updates(
&mirror_repo,
&updates,
&format!("heddle: fetch from {remote_name}"),
)?;
}
ResolvedRemote::Url(url) => {
fetch_network_remote(&mirror_repo, remote_name, &url)?;
}
}
self.git_repo_path = Some(self.mirror_path());
Ok(())
}
pub fn pull(&mut self, remote_name: &str) -> GitResult<()> {
let head_before = self.heddle_repo.refs().read_head()?;
let attached_before = match &head_before {
Head::Attached { thread } => self
.heddle_repo
.refs()
.get_thread(thread)?
.map(|state| (thread.clone(), state)),
Head::Detached { .. } => None,
};
self.fetch(remote_name)?;
self.import(None)?;
if let Some((thread, old_state)) = attached_before
&& let Some(new_state) = self.heddle_repo.refs().get_thread(&thread)?
&& new_state != old_state
{
self.heddle_repo.refs().set_thread(&thread, &old_state)?;
self.heddle_repo.refs().write_head(&Head::Attached {
thread: thread.clone(),
})?;
self.heddle_repo
.goto_verified_clean_without_record(&new_state)?;
self.heddle_repo.refs().set_thread(&thread, &new_state)?;
self.heddle_repo
.refs()
.write_head(&Head::Attached { thread })?;
}
Ok(())
}
pub fn write_through_current_checkout(&mut self) -> GitResult<WriteThroughOutcome> {
if !self.heddle_repo.root().join(".git").exists() {
return Ok(WriteThroughOutcome::Skipped(
WriteThroughSkipReason::MissingDotGit,
));
}
let mirror_guard = self.init_mirror_with_guard()?;
self.export()?;
mirror_guard.commit();
let (thread, state_id) = match self.heddle_repo.head_ref()? {
Head::Attached { thread } => {
let Some(state_id) = self.heddle_repo.refs().get_thread(&thread)? else {
return Ok(WriteThroughOutcome::Skipped(
WriteThroughSkipReason::NoAttachedThread,
));
};
(thread, state_id)
}
Head::Detached { .. } => {
return Ok(WriteThroughOutcome::Skipped(
WriteThroughSkipReason::DetachedHead,
));
}
};
let Some(git_oid) = self.mapping.get_git(&state_id) else {
return Ok(WriteThroughOutcome::Skipped(
WriteThroughSkipReason::NoMappedCommit,
));
};
let mirror_repo = self.open_git_repo()?;
let checkout_repo = gix::discover(self.heddle_repo.root()).map_err(git_err)?;
if checkout_repo.git_dir() == mirror_repo.git_dir() {
return Ok(WriteThroughOutcome::Skipped(
WriteThroughSkipReason::MirrorIsWorktree,
));
}
let git_dir = checkout_repo.git_dir().to_path_buf();
if git_dir.join("index.lock").exists() {
return Ok(WriteThroughOutcome::Skipped(
WriteThroughSkipReason::IndexAlreadyDirty,
));
}
let object_repo = common_repo_for_worktree(&checkout_repo)?;
let branch_ref = format!("refs/heads/{thread}");
let head_path = git_dir.join("HEAD");
let index_path = git_dir.join("index");
let previous_head = fs::read(&head_path).ok();
let previous_index = fs::read(&index_path).ok();
let previous_branch = object_repo
.find_reference(&branch_ref)
.ok()
.and_then(|mut reference| reference.peel_to_id().ok())
.map(|id| id.detach());
let write_result = (|| -> GitResult<()> {
copy_reachable_objects(&mirror_repo, &object_repo, [git_oid])?;
fs::write(&head_path, format!("ref: {branch_ref}\n"))?;
let commit = checkout_repo.find_commit(git_oid).map_err(git_err)?;
let tree_id = commit.tree_id().map_err(git_err)?;
let mut index = checkout_repo.index_from_tree(&tree_id).map_err(git_err)?;
index
.write(gix_index::write::Options::default())
.map_err(git_err)?;
set_reference(
&object_repo,
&branch_ref,
git_oid,
PreviousValue::Any,
"heddle: write-through current thread",
)?;
mirror_notes_ref(&mirror_repo, &object_repo)?;
fsync_path(&head_path)?;
fsync_path(&index_path)?;
fsync_path(&git_dir)?;
Ok(())
})();
if let Err(err) = write_result {
restore_file(head_path.clone(), previous_head.as_deref())?;
restore_file(index_path.clone(), previous_index.as_deref())?;
if let Some(previous_branch) = previous_branch {
set_reference(
&object_repo,
&branch_ref,
previous_branch,
PreviousValue::Any,
"heddle: rollback failed write-through",
)?;
} else {
let _ = delete_reference_if_present(&object_repo, &branch_ref);
}
let _ = fsync_path(&head_path);
let _ = fsync_path(&index_path);
let _ = fsync_path(&git_dir);
return Err(err);
}
Ok(WriteThroughOutcome::Wrote(git_oid))
}
fn resolve_remote(
&self,
remote_name: &str,
direction: gix::remote::Direction,
) -> GitResult<ResolvedRemote> {
let repo = self.open_git_repo()?;
let url = match remote_url_from_repo(&repo, remote_name, direction)? {
Some(url) => Some(url),
None => self.checkout_remote_url(remote_name, direction)?,
};
let url = match url {
Some(url) => url,
None => gix::url::parse(remote_name.as_bytes().as_bstr()).map_err(git_err)?,
};
match url.scheme {
gix::url::Scheme::File => Ok(ResolvedRemote::Local(local_path_from_url(&url)?)),
_ => Ok(ResolvedRemote::Url(url)),
}
}
fn checkout_remote_url(
&self,
remote_name: &str,
direction: gix::remote::Direction,
) -> GitResult<Option<gix::Url>> {
let Ok(repo) = gix::discover(self.heddle_repo.root()) else {
return Ok(None);
};
remote_url_from_repo(&repo, remote_name, direction)
}
}
fn remote_url_from_repo(
repo: &gix::Repository,
remote_name: &str,
direction: gix::remote::Direction,
) -> GitResult<Option<gix::Url>> {
if direction == gix::remote::Direction::Fetch {
repo.find_fetch_remote(Some(remote_name.as_bytes().as_bstr()))
.map(|remote| remote.url(direction).cloned())
.map_err(git_err)
} else if let Ok(remote) = repo.find_remote(remote_name.as_bytes().as_bstr()) {
Ok(remote.url(direction).cloned())
} else {
Ok(None)
}
}
fn common_repo_for_worktree(repo: &gix::Repository) -> GitResult<gix::Repository> {
let common_dir_file = repo.git_dir().join("commondir");
let Ok(contents) = fs::read_to_string(&common_dir_file) else {
return Ok(repo.clone());
};
let target = contents.trim();
if target.is_empty() {
return Ok(repo.clone());
}
let common_dir = {
let path = Path::new(target);
if path.is_absolute() {
path.to_path_buf()
} else {
repo.git_dir().join(path)
}
};
open_repo(&common_dir)
}
pub(crate) fn git_err(err: impl std::fmt::Display) -> GitBridgeError {
GitBridgeError::Git(err.to_string())
}
fn restore_file(path: PathBuf, previous: Option<&[u8]>) -> GitResult<()> {
if let Some(previous) = previous {
fs::write(path, previous)?;
} else if path.exists() {
fs::remove_file(path)?;
}
Ok(())
}
fn fsync_path(path: &Path) -> GitResult<()> {
match std::fs::File::open(path) {
Ok(file) => {
file.sync_all()?;
Ok(())
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(err) => Err(GitBridgeError::Io(err)),
}
}
fn mirror_notes_ref(mirror_repo: &gix::Repository, object_repo: &gix::Repository) -> GitResult<()> {
const NOTES_REF: &str = "refs/notes/heddle";
let Ok(mut notes_ref) = mirror_repo.find_reference(NOTES_REF) else {
return Ok(());
};
let notes_oid = notes_ref.peel_to_id().map_err(git_err)?.detach();
copy_reachable_objects(mirror_repo, object_repo, [notes_oid])?;
set_reference(
object_repo,
NOTES_REF,
notes_oid,
PreviousValue::Any,
"heddle: mirror notes/heddle from bridge",
)?;
Ok(())
}
pub(crate) struct MirrorInitGuard {
path: PathBuf,
rollback: Option<bool>,
}
impl MirrorInitGuard {
pub(crate) fn new_from_init(path: PathBuf, did_create: bool) -> Self {
Self {
path,
rollback: Some(did_create),
}
}
pub(crate) fn commit(mut self) {
self.rollback = None;
}
}
impl Drop for MirrorInitGuard {
fn drop(&mut self) {
if matches!(self.rollback, Some(true))
&& self.path.exists()
&& let Err(err) = std::fs::remove_dir_all(&self.path)
{
tracing::warn!(
path = %self.path.display(),
error = %err,
"failed to roll back partial bridge mirror; manual cleanup may be required"
);
}
}
}
pub(crate) fn thread_is_unclaimed_bootstrap(
heddle_repo: &HeddleRepository,
change_id: &ChangeId,
) -> GitResult<bool> {
let Some(state) = heddle_repo.store().get_state(change_id)? else {
return Ok(false);
};
if !state.parents.is_empty() {
return Ok(false);
}
let Some(tree) = heddle_repo.store().get_tree(&state.tree)? else {
return Ok(false);
};
Ok(tree == Tree::new())
}
pub(crate) fn open_repo(path: &Path) -> GitResult<gix::Repository> {
match gix::discover(path) {
Ok(repo) => Ok(repo),
Err(_) => gix::open(path).map_err(git_err),
}
}
pub(crate) fn delete_reference_if_present(repo: &gix::Repository, name: &str) -> GitResult<()> {
let signature = bridge_signature();
let mut time_buf = gix::date::parse::TimeBuf::default();
let edit = RefEdit {
change: Change::Delete {
log: RefLog::AndReference,
expected: PreviousValue::MustExist,
},
name: name
.try_into()
.map_err(|err| GitBridgeError::Git(format!("invalid ref {name}: {err}")))?,
deref: false,
};
match repo.edit_references_as([edit], Some(signature.to_ref(&mut time_buf))) {
Ok(_) => Ok(()),
Err(err) if err.to_string().contains("did not exist") => Ok(()),
Err(err) => Err(git_err(err)),
}
}
pub(crate) fn set_reference(
repo: &gix::Repository,
name: &str,
target: ObjectId,
constraint: PreviousValue,
log_message: &str,
) -> GitResult<()> {
let signature = bridge_signature();
let mut time_buf = gix::date::parse::TimeBuf::default();
let edit = RefEdit {
change: Change::Update {
log: LogChange {
mode: RefLog::AndReference,
force_create_reflog: false,
message: log_message.into(),
},
expected: constraint,
new: Target::Object(target),
},
name: name
.try_into()
.map_err(|err| GitBridgeError::Git(format!("invalid ref {name}: {err}")))?,
deref: false,
};
repo.edit_references_as([edit], Some(signature.to_ref(&mut time_buf)))
.map_err(git_err)?;
Ok(())
}
fn bridge_signature() -> gix::actor::Signature {
let seconds = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs() as i64)
.unwrap_or(0);
gix::actor::Signature {
name: "Heddle".into(),
email: "heddle@local".into(),
time: gix::date::Time { seconds, offset: 0 },
}
}
fn local_path_from_url(url: &gix::Url) -> GitResult<PathBuf> {
if url.scheme != gix::url::Scheme::File {
return Err(GitBridgeError::Git(format!(
"remote '{}' uses unsupported scheme {:?}; only local path and file:// remotes are supported",
url, url.scheme
)));
}
let path = PathBuf::from(String::from_utf8_lossy(url.path.as_ref()).into_owned());
if path.as_os_str().is_empty() {
return Err(GitBridgeError::Git(format!(
"remote '{}' has no filesystem path",
url
)));
}
Ok(path)
}
fn collect_ref_updates(repo: &gix::Repository) -> GitResult<Vec<RefUpdate>> {
let mut updates = Vec::new();
for branch in repo
.references()
.map_err(git_err)?
.local_branches()
.map_err(git_err)?
{
let branch = branch.map_err(git_err)?;
let Some(target) = branch.try_id() else {
continue;
};
updates.push(RefUpdate {
name: branch.name().shorten().to_string(),
target: target.detach(),
namespace: RefNamespace::Branch,
});
}
for tag in repo
.references()
.map_err(git_err)?
.tags()
.map_err(git_err)?
{
let tag = tag.map_err(git_err)?;
let Some(target) = tag.try_id() else {
continue;
};
updates.push(RefUpdate {
name: tag.name().shorten().to_string(),
target: target.detach(),
namespace: RefNamespace::Tag,
});
}
for note_ref in repo
.references()
.map_err(git_err)?
.prefixed("refs/notes/")
.map_err(git_err)?
{
let note_ref = note_ref.map_err(git_err)?;
let Some(target) = note_ref.try_id() else {
continue;
};
let full = note_ref.name().as_bstr().to_string();
let short = full
.strip_prefix("refs/notes/")
.unwrap_or(&full)
.to_string();
updates.push(RefUpdate {
name: short,
target: target.detach(),
namespace: RefNamespace::Note,
});
}
Ok(updates)
}
fn full_ref_name(update: &RefUpdate) -> String {
match update.namespace {
RefNamespace::Branch => format!("refs/heads/{}", update.name),
RefNamespace::Tag => format!("refs/tags/{}", update.name),
RefNamespace::Note => format!("refs/notes/{}", update.name),
}
}
pub(crate) fn apply_ref_updates(
repo: &gix::Repository,
updates: &[RefUpdate],
log_message: &str,
) -> GitResult<()> {
for update in updates {
let full_name = full_ref_name(update);
set_reference(
repo,
&full_name,
update.target,
PreviousValue::Any,
log_message,
)?;
}
Ok(())
}
pub fn copy_local_repo_to_bare(source_path: &Path, dest: &Path) -> GitResult<()> {
fs::create_dir_all(dest)?;
let source = open_repo(source_path)?;
let target = match open_repo(dest) {
Ok(repo) => repo,
Err(_) => gix::init_bare(dest).map_err(git_err)?,
};
let updates = collect_ref_updates(&source)?;
copy_reachable_objects(&source, &target, updates.iter().map(|update| update.target))?;
apply_ref_updates(
&target,
&updates,
&format!("heddle: clone from {}", source_path.display()),
)?;
let copied_branches: HashSet<&str> = updates
.iter()
.filter(|update| update.namespace == RefNamespace::Branch)
.map(|update| update.name.as_str())
.collect();
let source_head_branch = source
.head_name()
.ok()
.flatten()
.and_then(|full_name| {
full_name
.as_bstr()
.to_str()
.ok()
.and_then(|s| s.strip_prefix("refs/heads/").map(str::to_owned))
})
.filter(|branch| copied_branches.contains(branch.as_str()));
if let Some(branch) = source_head_branch {
fs::write(dest.join("HEAD"), format!("ref: refs/heads/{branch}\n"))?;
} else if copied_branches.contains("main") {
fs::write(dest.join("HEAD"), b"ref: refs/heads/main\n")?;
} else if let Some(first_branch) = updates
.iter()
.find(|update| update.namespace == RefNamespace::Branch)
{
fs::write(
dest.join("HEAD"),
format!("ref: refs/heads/{}\n", first_branch.name),
)?;
}
Ok(())
}
pub fn clone_url_to_bare(url: &gix::Url, dest: &Path) -> GitResult<()> {
fs::create_dir_all(dest)?;
let repo = gix::init_bare(dest).map_err(git_err)?;
let mut remote = repo.remote_at(url.clone()).map_err(git_err)?;
remote
.replace_refspecs(
["+refs/heads/*:refs/heads/*"],
gix::remote::Direction::Fetch,
)
.map_err(git_err)?;
remote = remote.with_fetch_tags(gix::remote::fetch::Tags::All);
let connection = remote
.connect(gix::remote::Direction::Fetch)
.map_err(git_err)?;
let prepare = connection
.prepare_fetch(
gix::progress::Discard,
gix::remote::ref_map::Options::default(),
)
.map_err(git_err)?;
prepare
.with_reflog_message(gix::remote::fetch::RefLogMessage::Override {
message: format!("heddle: clone from {url}").into(),
})
.receive(gix::progress::Discard, &AtomicBool::new(false))
.map_err(|err| GitBridgeError::Git(format!("clone failed for {url}: {err}")))?;
Ok(())
}
pub(crate) fn copy_reachable_objects(
source: &gix::Repository,
target: &gix::Repository,
roots: impl IntoIterator<Item = ObjectId>,
) -> GitResult<()> {
if source.object_hash() != target.object_hash() {
return Err(GitBridgeError::Git(format!(
"object hash mismatch: {:?} vs {:?}",
source.object_hash(),
target.object_hash()
)));
}
for oid in collect_reachable_object_ids(source, roots)? {
let object = source.find_object(oid).map_err(git_err)?;
let object_ref =
gix::objs::ObjectRef::from_bytes(object.kind, &object.data).map_err(git_err)?;
target.write_object(object_ref).map_err(git_err)?;
}
Ok(())
}
fn collect_reachable_object_ids(
source: &gix::Repository,
roots: impl IntoIterator<Item = ObjectId>,
) -> GitResult<Vec<ObjectId>> {
let mut stack: Vec<ObjectId> = roots.into_iter().collect();
let mut seen = HashSet::new();
let mut ordered = Vec::new();
while let Some(oid) = stack.pop() {
if !seen.insert(oid) {
continue;
}
ordered.push(oid);
let object = source.find_object(oid).map_err(git_err)?;
match object.kind {
gix::objs::Kind::Commit => {
let commit = source.find_commit(oid).map_err(git_err)?;
stack.push(commit.tree_id().map_err(git_err)?.detach());
for parent in commit.parent_ids() {
stack.push(parent.detach());
}
}
gix::objs::Kind::Tree => {
let tree = source.find_tree(oid).map_err(git_err)?;
for entry in tree.iter() {
let entry = entry.map_err(git_err)?;
if entry.mode().kind() == gix::object::tree::EntryKind::Commit {
continue;
}
stack.push(entry.object_id());
}
}
gix::objs::Kind::Tag => {
let tag = source.find_tag(oid).map_err(git_err)?;
stack.push(tag.target_id().map_err(git_err)?.detach());
}
gix::objs::Kind::Blob => {}
}
}
Ok(ordered)
}
fn fetch_network_remote(
mirror_repo: &gix::Repository,
remote_name: &str,
url: &gix::Url,
) -> GitResult<()> {
let mut remote = mirror_repo.remote_at(url.clone()).map_err(git_err)?;
remote
.replace_refspecs(
["+refs/heads/*:refs/heads/*"],
gix::remote::Direction::Fetch,
)
.map_err(git_err)?;
remote = remote.with_fetch_tags(gix::remote::fetch::Tags::All);
let connection = remote
.connect(gix::remote::Direction::Fetch)
.map_err(git_err)?;
let progress = gix::progress::Discard;
let prepare = connection
.prepare_fetch(progress, gix::remote::ref_map::Options::default())
.map_err(git_err)?;
let progress = gix::progress::Discard;
prepare
.with_reflog_message(gix::remote::fetch::RefLogMessage::Override {
message: format!("heddle: fetch from {remote_name}").into(),
})
.receive(progress, &AtomicBool::new(false))
.map_err(|err| GitBridgeError::Git(format!("failed to fetch from {url}: {err}")))?;
Ok(())
}
fn push_network_remote(mirror_repo: &gix::Repository, url: &gix::Url) -> GitResult<()> {
let updates = collect_ref_updates(mirror_repo)?;
if updates.is_empty() {
return Ok(());
}
let mut transport = gix_transport::client::blocking_io::connect::connect(
url.clone(),
gix_transport::client::blocking_io::connect::Options {
version: Protocol::V1,
..Default::default()
},
)
.map_err(|err| GitBridgeError::Git(format!("failed to connect to {url}: {err}")))?;
let remote_refs = {
let mut handshake = transport
.handshake(Service::ReceivePack, &[])
.map_err(|err| {
GitBridgeError::Git(format!("receive-pack handshake failed for {url}: {err}"))
})?;
if !handshake.capabilities.contains("report-status") {
return Err(GitBridgeError::Git(format!(
"remote {url} does not support report-status; refusing to push without server acknowledgement"
)));
}
remote_refs_from_receive_pack_handshake(&mut handshake)?
};
let mut commands = Vec::new();
for update in &updates {
let full_name = full_ref_name(update);
let old = remote_refs
.get(&full_name)
.copied()
.unwrap_or_else(|| ObjectHashKind::Sha1.null());
if old == update.target {
continue;
}
commands.push((full_name, old, update.target));
}
if commands.is_empty() {
return Ok(());
}
let pack =
pack_reachable_objects(mirror_repo, commands.iter().map(|(_, _, new_oid)| *new_oid))?;
let mut request = transport
.request(
WriteMode::OneLfTerminatedLinePerWriteCall,
MessageKind::Flush,
false,
)
.map_err(git_err)?;
for (idx, (name, old, new_oid)) in commands.iter().enumerate() {
let mut line = format!("{old} {new_oid} {name}");
if idx == 0 {
line.push('\0');
line.push_str("report-status");
}
request.write_all(line.as_bytes()).map_err(git_err)?;
}
request.write_message(MessageKind::Flush).map_err(git_err)?;
let (mut raw_writer, mut reader) = request.into_parts();
raw_writer.write_all(&pack).map_err(git_err)?;
raw_writer.flush().map_err(git_err)?;
drop(raw_writer);
read_receive_pack_status(&mut reader, &commands, url)
}
fn remote_refs_from_receive_pack_handshake(
handshake: &mut gix_transport::client::blocking_io::SetServiceResponse<'_>,
) -> GitResult<HashMap<String, ObjectId>> {
let mut remote_refs = HashMap::new();
let Some(refs) = handshake.refs.as_mut() else {
return Ok(remote_refs);
};
let (parsed, _) =
gix_protocol::handshake::refs::from_v1_refs_received_as_part_of_handshake_and_capabilities(
refs,
handshake.capabilities.iter(),
)
.map_err(git_err)?;
for remote_ref in parsed {
let (name, target, _) = remote_ref.unpack();
let Some(target) = target else {
continue;
};
remote_refs.insert(name.to_string(), target.to_owned());
}
Ok(remote_refs)
}
fn pack_reachable_objects(
repo: &gix::Repository,
roots: impl IntoIterator<Item = ObjectId>,
) -> GitResult<Vec<u8>> {
let oids = collect_reachable_object_ids(repo, roots)?;
let mut entries = Vec::with_capacity(oids.len());
for oid in &oids {
let object = repo.find_object(*oid).map_err(git_err)?;
let data = gix::objs::Data {
kind: object.kind,
data: &object.data,
};
let count = gix_pack::data::output::Count::from_data(*oid, None);
let entry = gix_pack::data::output::Entry::from_data(&count, &data).map_err(git_err)?;
entries.push(entry);
}
let mut pack = Vec::new();
let input = std::iter::once(Ok::<_, GitBridgeError>(entries));
let mut writer = gix_pack::data::output::bytes::FromEntriesIter::new(
input,
&mut pack,
oids.len().try_into().map_err(|_| {
GitBridgeError::Git(format!(
"push pack has too many objects to encode: {}",
oids.len()
))
})?,
gix_pack::data::Version::V2,
ObjectHashKind::Sha1,
);
for result in writer.by_ref() {
result.map_err(git_err)?;
}
drop(writer);
Ok(pack)
}
fn read_receive_pack_status(
reader: &mut (dyn gix_transport::client::blocking_io::ExtendedBufRead<'_> + Unpin),
commands: &[(String, ObjectId, ObjectId)],
url: &gix::Url,
) -> GitResult<()> {
let mut line = String::new();
let mut saw_unpack_ok = false;
let mut acknowledged = HashSet::new();
loop {
line.clear();
let read = reader.readline_str(&mut line).map_err(git_err)?;
if read == 0 {
break;
}
let status = line.trim_end_matches(['\r', '\n']);
if status == "unpack ok" {
saw_unpack_ok = true;
continue;
}
if let Some(name) = status.strip_prefix("ok ") {
acknowledged.insert(name.to_string());
continue;
}
if let Some(rest) = status.strip_prefix("ng ") {
return Err(GitBridgeError::Git(format!(
"push rejected by {url}: {rest}"
)));
}
if let Some(rest) = status.strip_prefix("unpack ") {
return Err(GitBridgeError::Git(format!(
"push pack rejected by {url}: {rest}"
)));
}
}
if !saw_unpack_ok {
return Err(GitBridgeError::Git(format!(
"push to {url} did not return an unpack acknowledgement"
)));
}
for (name, _, _) in commands {
if !acknowledged.contains(name) {
return Err(GitBridgeError::Git(format!(
"push to {url} did not acknowledge ref {name}"
)));
}
}
Ok(())
}