use std::{
collections::{BTreeSet, HashMap, HashSet},
fs,
path::{Path, PathBuf},
time::{SystemTime, UNIX_EPOCH},
};
use objects::{
error::HeddleError,
object::{ChangeId, ChangeIdParseError, ContentHash, FileMode, Principal, ThreadName, Tree},
store::ObjectStore,
};
use refs::Head;
use repo::Repository as HeddleRepository;
use sley::{
BString as GitBString, DeleteRef, FullName, GitObjectType, GitTime, Index, IndexEntry,
IndexWriteOptions, ObjectFormat, ObjectId, RefPrecondition, ReferenceTarget,
Repository as SleyRepository, Signature,
plumbing::sley_core::ByteString as GitByteString,
remote::{
FetchOptions, LsRemoteFilter, NoCredentials, PushActionPlan, PushCommand, PushOptions,
SilentProgress,
},
};
use super::{
git_export::{export_all, export_current_thread},
git_ingest::import_git_history,
git_util::ImportStats,
};
#[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(
"shallow Git repository at {repository} cannot be imported until full ancestry is available"
)]
ShallowClone {
repository: PathBuf,
retry_command: String,
},
#[error("conflict during sync: {0}")]
Conflict(String),
#[error("Git branch '{branch}' cannot be imported as a Heddle thread: {message}")]
InvalidThreadName { branch: String, message: String },
#[error(
"Git branch {branch} and Heddle thread {thread} diverged: thread {thread_change}, branch {branch_change}"
)]
GitHeddleThreadDiverged {
thread: String,
branch: String,
thread_change: ChangeId,
branch_change: ChangeId,
},
#[error(
"ref update would rewrite {name}: {old} -> {new}; refusing to replace a user-visible Git commit with a Heddle export commit"
)]
NonFastForwardRef {
name: String,
old: ObjectId,
new: ObjectId,
},
#[error(
"remote branch {upstream} does not fast-forward the local Git checkpoint for {branch}: local {local}, remote {remote}"
)]
RemoteDiverged {
branch: String,
upstream: String,
local: ObjectId,
remote: ObjectId,
},
#[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,
}
pub const REMOTE_NAME_FOR_LOCAL_GIT_REPO: &str = "git";
pub(crate) fn is_reserved_git_remote_name(remote: &str) -> bool {
remote == REMOTE_NAME_FOR_LOCAL_GIT_REPO
}
fn reject_reserved_git_remote_name(remote: &str) -> GitResult<()> {
if is_reserved_git_remote_name(remote) {
return Err(GitBridgeError::Git(format!(
"a Git remote named '{remote}' collides with heddle's reserved namespace \
(local refs are recorded under the '{REMOTE_NAME_FOR_LOCAL_GIT_REPO}' sentinel); \
rename the remote (e.g. `git remote rename {remote} origin`) and retry"
)));
}
Ok(())
}
fn remote_name_from_remote_ref(ref_name: &str) -> Option<&str> {
let remote_and_name = ref_name.strip_prefix("refs/remotes/")?;
let remote = remote_and_name
.split_once('/')
.map_or(remote_and_name, |(remote, _)| remote);
(!remote.is_empty()).then_some(remote)
}
fn validate_refspec_ref(ref_name: &str) -> GitResult<()> {
if let Some(remote) = remote_name_from_remote_ref(ref_name) {
reject_reserved_git_remote_name(remote)?;
}
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GitRefKind {
Branch,
Tag,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ParsedGitRef<'a> {
pub kind: GitRefKind,
pub name: &'a str,
pub remote: &'a str,
}
pub fn parse_git_ref(ref_name: &str) -> Option<ParsedGitRef<'_>> {
RefSpec::new(None, ref_name, false).ok()?;
if let Some(name) = ref_name.strip_prefix("refs/heads/") {
(name != "HEAD").then_some(ParsedGitRef {
kind: GitRefKind::Branch,
name,
remote: REMOTE_NAME_FOR_LOCAL_GIT_REPO,
})
} else if let Some(remote_and_name) = ref_name.strip_prefix("refs/remotes/") {
let (remote, name) = remote_and_name.split_once('/')?;
(name != "HEAD").then_some(ParsedGitRef {
kind: GitRefKind::Branch,
name,
remote,
})
} else {
ref_name
.strip_prefix("refs/tags/")
.map(|name| ParsedGitRef {
kind: GitRefKind::Tag,
name,
remote: REMOTE_NAME_FOR_LOCAL_GIT_REPO,
})
}
}
mod refspec {
use super::{GitResult, validate_refspec_ref};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RefSpec {
forced: bool,
source: Option<String>,
destination: String,
}
impl RefSpec {
pub fn new(
source: Option<String>,
destination: impl Into<String>,
forced: bool,
) -> GitResult<Self> {
let destination = destination.into();
if source.is_none() && destination.is_empty() {
return Err(super::GitBridgeError::InvalidMapping(
"refspec source and destination cannot both be empty".to_string(),
));
}
if let Some(source) = source.as_deref() {
validate_refspec_ref(source)?;
}
validate_refspec_ref(&destination)?;
Ok(Self {
forced,
source,
destination,
})
}
pub fn forced(
source: impl Into<String>,
destination: impl Into<String>,
) -> GitResult<Self> {
Self::new(Some(source.into()), destination, true)
}
pub fn delete(destination: impl Into<String>) -> GitResult<Self> {
Self::new(None, destination, false)
}
pub fn to_git_format(&self) -> String {
format!(
"{}{}",
if self.forced { "+" } else { "" },
self.to_git_format_not_forced()
)
}
pub fn to_git_format_not_forced(&self) -> String {
format!(
"{}:{}",
self.source.as_deref().unwrap_or(""),
self.destination
)
}
}
}
pub use refspec::RefSpec;
mod negative_refspec {
use super::{GitBridgeError, GitResult, validate_refspec_ref};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NegativeRefSpec {
source: String,
}
impl NegativeRefSpec {
pub fn new(source: impl Into<String>) -> GitResult<Self> {
let source = source.into();
validate_refspec_ref(&source)?;
if source.contains('*') {
return Err(GitBridgeError::InvalidMapping(format!(
"invalid negative refspec source '{source}': Negative glob patterns are not supported"
)));
}
Ok(Self { source })
}
pub fn to_git_format(&self) -> String {
format!("^{}", self.source)
}
}
}
pub use negative_refspec::NegativeRefSpec;
fn heddle_mirror_fetch_refspecs() -> GitResult<[String; 2]> {
Ok([
RefSpec::forced("refs/heads/*", "refs/heads/*")?.to_git_format(),
RefSpec::forced("refs/notes/*", "refs/notes/*")?.to_git_format(),
])
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GitPushScope {
CurrentThread,
AllThreads,
}
#[derive(Debug, Clone, Default)]
pub struct GitPullOutcome {
pub changed: bool,
pub states_created: usize,
pub commits_seen: usize,
pub materialized_checkout: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PullPreflight {
UpToDate,
ImportRequired,
}
fn pull_outcome(stats: &ImportStats, materialized_checkout: bool) -> GitPullOutcome {
GitPullOutcome {
changed: materialized_checkout || stats.states_created > 0,
states_created: stats.states_created,
commits_seen: stats.commits_imported,
materialized_checkout,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum GitFetchScope {
BranchesAndNotes,
AllRefs,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RefreshCheckoutAfterFetch {
Yes,
No,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RemoteDirection {
Fetch,
Push,
}
#[derive(Debug, Clone)]
enum ResolvedRemote {
Local(PathBuf),
Url(String),
}
#[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, "Git 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),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct LocalGitIdentity {
pub(crate) name: String,
pub(crate) email: String,
}
impl LocalGitIdentity {
pub(crate) fn from_principal(principal: &Principal) -> Self {
Self {
name: principal.name.clone(),
email: principal.email.clone(),
}
}
pub(crate) fn to_ident_line(&self, seconds: i64) -> Vec<u8> {
format!("{} <{}> {} +0000", self.name, self.email, seconds).into_bytes()
}
pub(crate) fn to_signature(&self, seconds: i64) -> Signature {
let ident = self.to_ident_line(seconds);
Signature {
name: GitByteString::new(self.name.as_bytes().to_vec()),
email: GitByteString::new(self.email.as_bytes().to_vec()),
time: GitTime::new(seconds, 0),
raw: ident,
}
}
}
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, Clone, Default, PartialEq, Eq)]
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) {
if let Some(previous_git) = self.heddle_to_git.remove(&change_id) {
self.git_to_heddle.remove(&previous_git);
}
if let Some(previous_change) = self.git_to_heddle.remove(&git_oid) {
self.heddle_to_git.remove(&previous_change);
}
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(crate) fn remove(&mut self, change_id: &ChangeId) -> Option<ObjectId> {
let git_oid = self.heddle_to_git.remove(change_id)?;
self.git_to_heddle.remove(&git_oid);
Some(git_oid)
}
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: &SleyRepository) {
let retained: Vec<(ChangeId, ObjectId)> = self
.heddle_to_git
.iter()
.filter_map(|(change_id, git_oid)| {
repo.read_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(|(_, git_oid)| reachable.contains(*git_oid))
.map(|(change_id, git_oid)| (*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,
pub(crate) commit_message_overrides: HashMap<ChangeId, String>,
}
struct MappingFileSnapshot {
path: PathBuf,
contents: Option<Vec<u8>>,
}
impl MappingFileSnapshot {
fn read(path: PathBuf) -> GitResult<Self> {
let contents = match fs::read(&path) {
Ok(contents) => Some(contents),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
Err(error) => return Err(error.into()),
};
Ok(Self { path, contents })
}
fn restore(self) -> GitResult<()> {
match self.contents {
Some(contents) => {
if let Some(parent) = self.path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&self.path, contents)?;
}
None => match fs::remove_file(&self.path) {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(error.into()),
},
}
Ok(())
}
}
impl<'a> GitBridge<'a> {
pub fn new(heddle_repo: &'a HeddleRepository) -> Self {
Self {
heddle_repo,
git_repo_path: None,
mapping: SyncMapping::new(),
commit_message_overrides: HashMap::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 _ = SleyRepository::init_bare(&git_dir).map_err(git_err)?;
let mirror_repo = open_repo(&git_dir)?;
seed_checkout_note_refs_into_mirror(self.heddle_repo.root(), &mirror_repo)?;
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<SleyRepository> {
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(crate) fn set_commit_message_override(&mut self, state_id: ChangeId, message: String) {
self.commit_message_overrides.insert(state_id, message);
}
pub(crate) fn with_mapping_rollback<T>(
&mut self,
operation: impl FnOnce(&mut Self) -> GitResult<T>,
) -> GitResult<T> {
let mapping = self.mapping.clone();
let commit_message_overrides = self.commit_message_overrides.clone();
let mapping_file = MappingFileSnapshot::read(self.mapping_path())?;
let mapping_tmp_file = MappingFileSnapshot::read(self.mapping_tmp_path())?;
match operation(self) {
Ok(value) => Ok(value),
Err(error) => {
self.mapping = mapping;
self.commit_message_overrides = commit_message_overrides;
if let Err(rollback_error) = mapping_file
.restore()
.and_then(|()| mapping_tmp_file.restore())
{
return Err(GitBridgeError::Git(format!(
"operation failed ({error}); additionally failed to roll back git bridge mapping state ({rollback_error})"
)));
}
Err(error)
}
}
}
pub fn push(&mut self, remote_name: &str) -> GitResult<Vec<String>> {
self.push_with_scope(remote_name, GitPushScope::AllThreads)
}
pub fn push_with_scope(
&mut self,
remote_name: &str,
scope: GitPushScope,
) -> GitResult<Vec<String>> {
self.push_with_scope_force(remote_name, scope, false)
}
pub fn push_with_scope_force(
&mut self,
remote_name: &str,
scope: GitPushScope,
force: bool,
) -> GitResult<Vec<String>> {
self.init_mirror()?;
let current_branch = match scope {
GitPushScope::CurrentThread => Some(self.current_attached_thread_for_push()?),
GitPushScope::AllThreads => None,
};
match scope {
GitPushScope::CurrentThread => {
export_current_thread(self, current_branch.as_deref().expect("current branch"))?;
}
GitPushScope::AllThreads => {
self.export()?;
self.mirror_checkout_tags_for_push()?;
}
}
self.write_current_checkout_from_existing_mirror()?;
let log_message = format!("heddle: push from {}", self.heddle_repo.root().display());
match self.resolve_remote(remote_name, RemoteDirection::Push)? {
ResolvedRemote::Local(target_path) => self.copy_mirror_to_path(
&target_path,
&log_message,
false,
scope,
current_branch.as_deref(),
force,
),
ResolvedRemote::Url(url) => {
let mirror_repo = self.open_git_repo()?;
push_network_remote(
&mirror_repo,
self.heddle_repo.heddle_dir(),
&url,
scope,
current_branch.as_deref(),
force,
)
}
}
}
fn current_attached_thread_for_push(&self) -> GitResult<String> {
let Head::Attached { thread } = self.heddle_repo.head_ref()? else {
return Err(GitBridgeError::Git(
"cannot push the current Git-overlay branch from a detached Heddle HEAD; use --all-threads to push all exported refs".to_string(),
));
};
if self.heddle_repo.refs().get_thread(&thread)?.is_none() {
return Err(GitBridgeError::Git(format!(
"attached thread '{thread}' has no state to push"
)));
}
Ok(thread.to_string())
}
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,
GitPushScope::AllThreads,
None,
false,
)?;
Ok(stats)
}
fn copy_mirror_to_path(
&mut self,
target_path: &Path,
log_message: &str,
init_if_missing: bool,
scope: GitPushScope,
current_branch: Option<&str>,
force: bool,
) -> GitResult<Vec<String>> {
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)?;
SleyRepository::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 managed_record = read_mirror_managed_refs(&mirror_repo)?;
let served_frontier = collect_managed_ref_updates(&mirror_repo, &managed_record)?;
copy_reachable_objects(
&mirror_repo,
&target_repo,
served_frontier.iter().map(|update| update.target),
)?;
let creatable = creatable_ref_names(&served_frontier, scope, current_branch);
let old_at_destination = read_destination_ref_map(&target_repo)?;
let previously_exported = read_exported_refs(&target_repo)?;
let plan = plan_destination_reconcile(
&mirror_repo,
&served_frontier,
creatable.as_ref(),
&old_at_destination,
&previously_exported,
force,
)?;
for write in &plan.writes {
let constraint = match write.old {
Some(old) => RefPrecondition::MustExistAndMatch(ReferenceTarget::Direct(old)),
None => RefPrecondition::MustNotExist,
};
set_reference(
&target_repo,
&write.full_name,
write.new,
constraint,
log_message,
)?;
}
for delete in &plan.deletes {
delete_reference_matching(&target_repo, &delete.full_name, delete.old)?;
}
write_exported_refs(&target_repo, &plan.new_manifest)?;
Ok(planned_write_names(&plan))
}
pub fn fetch(&mut self, remote_name: &str) -> GitResult<()> {
self.fetch_with_scope(
remote_name,
GitFetchScope::BranchesAndNotes,
RefreshCheckoutAfterFetch::Yes,
)
}
fn fetch_with_scope(
&mut self,
remote_name: &str,
scope: GitFetchScope,
refresh_checkout: RefreshCheckoutAfterFetch,
) -> GitResult<()> {
reject_reserved_git_remote_name(remote_name)?;
self.init_mirror()?;
let current_branch = self.heddle_repo.git_overlay_current_branch()?;
let tracking_remote = checkout_tracking_remote_name(self.heddle_repo.root(), remote_name)?
.or_else(|| {
(!looks_like_remote_location(remote_name)).then(|| remote_name.to_string())
});
if let Some(tracking_remote) = tracking_remote.as_deref() {
reject_reserved_git_remote_name(tracking_remote)?;
}
let mirror_repo = self.open_git_repo()?;
match self.resolve_remote(remote_name, RemoteDirection::Fetch)? {
ResolvedRemote::Local(path) => {
let remote_repo = open_repo(&path)?;
let updates = collect_ref_updates_for_fetch(&remote_repo, scope)?;
tracing::debug!(
remote = remote_name,
path = %path.display(),
refs = updates.len(),
notes = updates
.iter()
.filter(|update| update.namespace == RefNamespace::Note)
.count(),
"fetching Git refs from local remote"
);
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}"),
)?;
if let Some(tracking_remote) = tracking_remote.as_deref() {
apply_remote_tracking_ref_updates(
&mirror_repo,
tracking_remote,
&updates,
&format!("heddle: fetch from {remote_name}"),
)?;
}
}
ResolvedRemote::Url(url) => {
fetch_network_remote(&mirror_repo, remote_name, &url, scope)?;
let updates = collect_ref_updates_for_fetch(&mirror_repo, scope)?;
if let Some(tracking_remote) = tracking_remote.as_deref() {
apply_remote_tracking_ref_updates(
&mirror_repo,
tracking_remote,
&updates,
&format!("heddle: fetch from {remote_name}"),
)?;
}
}
}
self.git_repo_path = Some(self.mirror_path());
if matches!(refresh_checkout, RefreshCheckoutAfterFetch::Yes) {
if let Some(tracking_remote) = tracking_remote.as_deref() {
self.refresh_checkout_remote_tracking_refs(tracking_remote)?;
}
if let Some(branch) = current_branch {
self.refresh_checkout_remote_tracking_ref(remote_name, &branch)?;
}
self.refresh_checkout_note_refs_from_mirror()?;
}
Ok(())
}
pub(crate) fn hydrate_checkout_heddle_notes_without_mirror(root: &Path) -> bool {
if checkout_note_ref_exists(root).unwrap_or(false) {
return true;
}
let mut remotes = match checkout_remote_url_items(root) {
Ok(remotes) => remotes
.into_iter()
.map(|(name, _)| name)
.collect::<Vec<_>>(),
Err(error) => {
tracing::debug!(
error = %error,
"skipping configured remote note hydration before ingest-backed adopt"
);
return false;
}
};
remotes.sort_by(|left, right| {
match (left.as_str() == "origin", right.as_str() == "origin") {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
_ => left.cmp(right),
}
});
remotes.dedup();
for remote in remotes {
match hydrate_checkout_notes_from_remote_without_mirror(root, &remote) {
Ok(()) if checkout_note_ref_exists(root).unwrap_or(false) => return true,
Ok(()) => {}
Err(error) => {
tracing::debug!(
remote = remote.as_str(),
error = %error,
"configured remote did not provide Heddle notes during ingest-backed adopt"
);
}
}
}
false
}
pub fn pull(&mut self, remote_name: &str) -> GitResult<GitPullOutcome> {
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.to_string(), state)),
Head::Detached { .. } => None,
};
let attached_thread = attached_before.as_ref().map(|(thread, _)| thread.clone());
self.fetch_with_scope(
remote_name,
GitFetchScope::AllRefs,
RefreshCheckoutAfterFetch::No,
)?;
if self.preflight_attached_pull_fast_forward(remote_name, attached_before.as_ref())?
== PullPreflight::UpToDate
{
if let Some(thread) = attached_thread {
self.refresh_checkout_remote_tracking_ref(remote_name, &thread)?;
}
self.refresh_checkout_note_refs_from_mirror()?;
return Ok(GitPullOutcome::default());
}
let mirror_path = self.mirror_path();
let stats = import_git_history(self, Some(&mirror_path), &[], Default::default(), None)?;
let mut materialized_attached_thread = false;
if let Some((thread, old_state)) = attached_before
&& let Some(new_state) = self
.heddle_repo
.refs()
.get_thread(&ThreadName::new(&thread))?
&& new_state != old_state
{
self.heddle_repo
.refs()
.set_thread(&ThreadName::new(&thread), &old_state)?;
self.heddle_repo.refs().write_head(&Head::Attached {
thread: ThreadName::new(&thread),
})?;
self.heddle_repo
.goto_verified_clean_without_record(&new_state)?;
self.heddle_repo
.refs()
.set_thread(&ThreadName::new(&thread), &new_state)?;
self.heddle_repo.refs().write_head(&Head::Attached {
thread: ThreadName::new(&thread),
})?;
materialized_attached_thread = true;
}
if materialized_attached_thread {
self.write_current_checkout_from_existing_mirror()?;
}
if let Some(thread) = attached_thread {
self.refresh_checkout_remote_tracking_ref(remote_name, &thread)?;
}
self.refresh_checkout_note_refs_from_mirror()?;
Ok(pull_outcome(&stats, materialized_attached_thread))
}
fn preflight_attached_pull_fast_forward(
&mut self,
remote_name: &str,
attached_before: Option<&(String, ChangeId)>,
) -> GitResult<PullPreflight> {
let Some((thread, state_id)) = attached_before else {
return Ok(PullPreflight::ImportRequired);
};
self.build_existing_mapping(None)?;
let Some(local_git_oid) = self.mapping.get_git(state_id) else {
return Ok(PullPreflight::ImportRequired);
};
let mirror_repo = self.open_git_repo()?;
let branch_ref = format!("refs/heads/{thread}");
let Some(reference) = mirror_repo.find_reference(&branch_ref).map_err(git_err)? else {
return Ok(PullPreflight::ImportRequired);
};
let Some(remote_git_oid) = reference.peeled_oid(&mirror_repo).map_err(git_err)? else {
return Ok(PullPreflight::ImportRequired);
};
if remote_git_oid == local_git_oid {
return Ok(PullPreflight::UpToDate);
}
if commit_is_descendant_of(&mirror_repo, remote_git_oid, local_git_oid)? {
return Ok(PullPreflight::ImportRequired);
}
Err(GitBridgeError::RemoteDiverged {
branch: thread.clone(),
upstream: format!("{remote_name}/{thread}"),
local: local_git_oid,
remote: remote_git_oid,
})
}
fn mirror_checkout_tags_for_push(&self) -> GitResult<()> {
if !self.heddle_repo.root().join(".git").exists() {
return Ok(());
}
let mirror_repo = self.open_git_repo()?;
let checkout_repo = SleyRepository::discover(self.heddle_repo.root()).map_err(git_err)?;
if checkout_repo.git_dir() == mirror_repo.git_dir() {
return Ok(());
}
let object_repo = common_repo_for_worktree(&checkout_repo)?;
let tag_updates = collect_ref_updates(&object_repo)?
.into_iter()
.filter(|update| update.namespace == RefNamespace::Tag)
.collect::<Vec<_>>();
if tag_updates.is_empty() {
return Ok(());
}
copy_reachable_objects(
&object_repo,
&mirror_repo,
tag_updates.iter().map(|u| u.target),
)?;
apply_ref_updates(
&mirror_repo,
&tag_updates,
"heddle: mirror checkout tags before push",
)?;
let mut record = read_mirror_managed_refs(&mirror_repo)?;
for update in &tag_updates {
record.insert(full_ref_name(update), update.target);
}
write_mirror_managed_refs(&mirror_repo, &record)?;
Ok(())
}
pub(crate) fn seed_git_checkpoint_mappings_from_checkout(
&mut self,
mirror_repo: &SleyRepository,
) -> GitResult<()> {
if !self.heddle_repo.root().join(".git").exists() {
return Ok(());
}
let checkout_repo = match SleyRepository::discover(self.heddle_repo.root()) {
Ok(repo) => repo,
Err(_) => return Ok(()),
};
if checkout_repo.git_dir() == mirror_repo.git_dir() {
return Ok(());
}
let object_repo = common_repo_for_worktree(&checkout_repo)?;
for record in self.heddle_repo.list_git_checkpoints()? {
let change_id = ChangeId::parse(&record.change_id)?;
let git_oid = record
.git_commit
.parse::<ObjectId>()
.map_err(|err| GitBridgeError::InvalidMapping(err.to_string()))?;
if mirror_repo.read_object(&git_oid).is_err() {
copy_reachable_objects(&object_repo, mirror_repo, [git_oid])?;
}
mirror_repo
.read_object(&git_oid)
.map_err(|_| GitBridgeError::CommitNotFound(record.git_commit.clone()))?;
self.mapping.insert(change_id, git_oid);
let tier = self
.heddle_repo
.effective_visibility_tier(&change_id)
.map_err(|e| {
GitBridgeError::Git(format!("resolve visibility for {change_id}: {e:#}"))
})?;
if repo::visible(&tier, &repo::AudienceTier::Public)
&& super::git_notes::read_note(mirror_repo, git_oid)?.is_none()
&& let Some(state) = self.heddle_repo.store().get_state(&change_id)?
{
let note = super::git_notes::HeddleNote::from_state(&state);
super::git_notes::write_note(mirror_repo, git_oid, ¬e)?;
}
}
Ok(())
}
pub(crate) fn stage_ingest_source_in_mirror(
&mut self,
source: &Path,
refs: &[String],
) -> GitResult<()> {
let source_repo = open_repo(source)?;
let updates = collect_import_source_ref_updates(&source_repo, refs)?;
if updates.is_empty() {
return Ok(());
}
self.init_mirror()?;
let mirror_repo = self.open_git_repo()?;
copy_reachable_objects(
&source_repo,
&mirror_repo,
updates.iter().map(|update| update.target),
)?;
apply_ref_updates(
&mirror_repo,
&updates,
&format!("heddle: stage ingest source from {}", source.display()),
)?;
let mut record = read_or_seed_mirror_managed_refs(&mirror_repo)?;
for update in &updates {
record.insert(full_ref_name(update), update.target);
}
write_mirror_managed_refs(&mirror_repo, &record)?;
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,
));
}
if checkout_git_head_is_detached(self.heddle_repo.root())? {
return Ok(WriteThroughOutcome::Skipped(
WriteThroughSkipReason::DetachedHead,
));
}
let Head::Attached { thread } = self.heddle_repo.head_ref()? else {
return Ok(WriteThroughOutcome::Skipped(
WriteThroughSkipReason::DetachedHead,
));
};
let mirror_guard = self.init_mirror_with_guard()?;
export_current_thread(self, &thread)?;
mirror_guard.commit();
self.write_thread_checkout_from_existing_mirror(&thread)
}
pub fn write_through_current_checkout_with_message(
&mut self,
state_id: ChangeId,
message: String,
) -> GitResult<WriteThroughOutcome> {
self.set_commit_message_override(state_id, message);
self.write_through_current_checkout()
}
pub fn update_intent_to_add(&self, state_id: &ChangeId) -> GitResult<()> {
let root = self.heddle_repo.root();
if !root.join(".git").exists() {
return Ok(());
}
let checkout_repo = SleyRepository::discover(root).map_err(git_err)?;
if checkout_repo
.head()
.map(|head| head.is_detached())
.unwrap_or(false)
{
return Ok(());
}
let Some(state) = self.heddle_repo.store().get_state(state_id)? else {
return Ok(());
};
let Some(tree) = self.heddle_repo.store().get_tree(&state.tree)? else {
return Ok(());
};
let mut captured: Vec<(String, FileMode)> = Vec::new();
collect_capture_paths(self.heddle_repo.store(), &tree, "", &mut captured)?;
let mut index = checkout_repo
.open_index()
.map_err(git_err)?
.unwrap_or_else(|| Index {
version: 2,
entries: Vec::new(),
extensions: Vec::new(),
checksum: None,
});
let mut real_tracked: HashSet<String> = HashSet::new();
let mut existing_ita: HashSet<String> = HashSet::new();
for entry in &index.entries {
let path = String::from_utf8_lossy(entry.path.as_bytes()).into_owned();
if entry.is_intent_to_add() {
existing_ita.insert(path);
} else {
real_tracked.insert(path);
}
}
let captured_paths: HashSet<&str> = captured.iter().map(|(p, _)| p.as_str()).collect();
let before_prune = index.entries.len();
index.entries.retain(|entry| {
!entry.is_intent_to_add()
|| captured_paths.contains(String::from_utf8_lossy(entry.path.as_bytes()).as_ref())
});
let mut changed = index.entries.len() != before_prune;
for (path, mode) in &captured {
if real_tracked.contains(path) || existing_ita.contains(path) {
continue;
}
if real_tracked
.iter()
.any(|tracked| path_prefix_conflict(path, tracked))
{
continue;
}
let mut entry = IndexEntry::intent_to_add(
checkout_repo.object_format(),
GitBString::from(path.as_str()),
);
entry.mode = match mode {
FileMode::Executable => 0o100755,
FileMode::Symlink => 0o120000,
FileMode::Normal => 0o100644,
};
changed = true;
index.entries.push(entry);
}
if changed {
index
.entries
.sort_by(|left, right| left.path.as_bytes().cmp(right.path.as_bytes()));
index.upgrade_version_for_flags();
checkout_repo
.write_index(
&index,
IndexWriteOptions {
fsync: true,
validate_checksum: true,
},
)
.map_err(git_err)?;
}
Ok(())
}
pub fn write_through_thread_checkout(
&mut self,
thread: &str,
) -> GitResult<WriteThroughOutcome> {
if !self.heddle_repo.root().join(".git").exists() {
return Ok(WriteThroughOutcome::Skipped(
WriteThroughSkipReason::MissingDotGit,
));
}
let mirror_guard = self.init_mirror_with_guard()?;
export_current_thread(self, thread)?;
mirror_guard.commit();
self.write_thread_checkout_from_existing_mirror(thread)
}
pub(crate) fn write_current_checkout_from_existing_mirror(
&mut self,
) -> GitResult<WriteThroughOutcome> {
if !self.heddle_repo.root().join(".git").exists() {
return Ok(WriteThroughOutcome::Skipped(
WriteThroughSkipReason::MissingDotGit,
));
}
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,
));
}
};
self.write_thread_state_checkout_from_existing_mirror(&thread, &state_id)
}
fn write_thread_checkout_from_existing_mirror(
&mut self,
thread: &str,
) -> GitResult<WriteThroughOutcome> {
let Some(state_id) = self
.heddle_repo
.refs()
.get_thread(&ThreadName::new(thread))?
else {
return Ok(WriteThroughOutcome::Skipped(
WriteThroughSkipReason::NoAttachedThread,
));
};
self.write_thread_state_checkout_from_existing_mirror(thread, &state_id)
}
fn write_thread_state_checkout_from_existing_mirror(
&mut self,
thread: &str,
state_id: &ChangeId,
) -> GitResult<WriteThroughOutcome> {
let mirror_repo = self.open_git_repo()?;
let git_oid = if let Some(git_oid) = self.mapping.get_git(state_id) {
git_oid
} else if let Some(git_commit) = self
.heddle_repo
.git_overlay_mapped_git_commit_for_change(state_id)
.map_err(|error| GitBridgeError::Git(error.to_string()))?
{
ObjectId::from_hex(mirror_repo.object_format(), &git_commit)
.map_err(|error| GitBridgeError::InvalidMapping(error.to_string()))?
} else {
return Ok(WriteThroughOutcome::Skipped(
WriteThroughSkipReason::NoMappedCommit,
));
};
let checkout_repo = SleyRepository::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()
.flatten()
.and_then(|reference| reference.peeled_oid(&object_repo).ok().flatten());
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.read_commit(&git_oid).map_err(git_err)?;
let mut index = checkout_repo
.index_from_tree(&commit.tree)
.map_err(git_err)?;
index.upgrade_version_for_flags();
checkout_repo
.write_index(
&index,
IndexWriteOptions {
fsync: true,
validate_checksum: true,
},
)
.map_err(git_err)?;
update_checkout_head_ref(
&checkout_repo,
git_oid,
previous_branch,
"heddle: write-through current thread",
)?;
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,
RefPrecondition::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 refresh_checkout_remote_tracking_ref(
&self,
remote_name: &str,
branch: &str,
) -> GitResult<()> {
if !self.heddle_repo.root().join(".git").exists() {
return Ok(());
}
let Some(tracking_remote) =
checkout_tracking_remote_name(self.heddle_repo.root(), remote_name)?
else {
return Ok(());
};
reject_reserved_git_remote_name(&tracking_remote)?;
let mirror_repo = self.open_git_repo()?;
let branch_ref = format!("refs/heads/{branch}");
let Some(reference) = mirror_repo.find_reference(&branch_ref).map_err(git_err)? else {
return Ok(());
};
let Some(target) = reference.peeled_oid(&mirror_repo).map_err(git_err)? else {
return Ok(());
};
let checkout_repo = SleyRepository::discover(self.heddle_repo.root()).map_err(git_err)?;
if checkout_repo.git_dir() == mirror_repo.git_dir() {
return Ok(());
}
let object_repo = common_repo_for_worktree(&checkout_repo)?;
copy_reachable_objects(&mirror_repo, &object_repo, [target])?;
set_reference(
&object_repo,
&format!("refs/remotes/{tracking_remote}/{branch}"),
target,
RefPrecondition::Any,
"heddle: refresh remote-tracking branch after pull",
)?;
Ok(())
}
fn refresh_checkout_remote_tracking_refs(&self, remote_name: &str) -> GitResult<()> {
if !self.heddle_repo.root().join(".git").exists() {
return Ok(());
}
let Some(tracking_remote) =
checkout_tracking_remote_name(self.heddle_repo.root(), remote_name)?
else {
return Ok(());
};
reject_reserved_git_remote_name(&tracking_remote)?;
let mirror_repo = self.open_git_repo()?;
let checkout_repo = SleyRepository::discover(self.heddle_repo.root()).map_err(git_err)?;
if checkout_repo.git_dir() == mirror_repo.git_dir() {
return Ok(());
}
let object_repo = common_repo_for_worktree(&checkout_repo)?;
let prefix = format!("refs/remotes/{remote_name}/");
for reference in mirror_repo.references().list_refs().map_err(git_err)? {
if !reference.name.starts_with(&prefix) {
continue;
}
let ReferenceTarget::Direct(target) = reference.target else {
continue;
};
let full = reference.name;
let Some(branch) = full.strip_prefix(&prefix) else {
continue;
};
if branch.ends_with("/HEAD") {
continue;
}
copy_reachable_objects(&mirror_repo, &object_repo, [target])?;
set_reference(
&object_repo,
&format!("refs/remotes/{tracking_remote}/{branch}"),
target,
RefPrecondition::Any,
"heddle: refresh remote-tracking branch after fetch",
)?;
}
Ok(())
}
fn refresh_checkout_note_refs_from_mirror(&self) -> GitResult<()> {
if !self.heddle_repo.root().join(".git").exists() {
return Ok(());
}
let mirror_repo = self.open_git_repo()?;
let checkout_repo = SleyRepository::discover(self.heddle_repo.root()).map_err(git_err)?;
if checkout_repo.git_dir() == mirror_repo.git_dir() {
return Ok(());
}
let object_repo = common_repo_for_worktree(&checkout_repo)?;
let note_updates = collect_ref_updates(&mirror_repo)?
.into_iter()
.filter(|update| update.namespace == RefNamespace::Note)
.collect::<Vec<_>>();
if note_updates.is_empty() {
return Ok(());
}
copy_reachable_objects(
&mirror_repo,
&object_repo,
note_updates.iter().map(|u| u.target),
)?;
apply_ref_updates(
&object_repo,
¬e_updates,
"heddle: refresh Heddle note refs",
)?;
Ok(())
}
fn resolve_remote(
&self,
remote_name: &str,
direction: RemoteDirection,
) -> 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 base = repo_relative_base(&repo);
let url = match url {
Some(url) => url,
None => parse_configured_remote_url(remote_name, &base)?,
};
if let Some(path) = local_path_from_url(&url)? {
Ok(ResolvedRemote::Local(path))
} else {
Ok(ResolvedRemote::Url(url))
}
}
fn checkout_remote_url(
&self,
remote_name: &str,
direction: RemoteDirection,
) -> GitResult<Option<String>> {
if direction == RemoteDirection::Fetch
&& let Some(url) =
remote_fetch_url_from_checkout_config(self.heddle_repo.root(), remote_name)?
{
return Ok(Some(url));
}
let Ok(repo) = SleyRepository::discover(self.heddle_repo.root()) else {
return Ok(None);
};
remote_url_from_repo(&repo, remote_name, direction)
}
}
fn remote_url_from_repo(
repo: &SleyRepository,
remote_name: &str,
direction: RemoteDirection,
) -> GitResult<Option<String>> {
let config = repo.config_snapshot().map_err(git_err)?;
let push = direction == RemoteDirection::Push;
let value = if push {
config
.get("remote", Some(remote_name), "pushurl")
.or_else(|| config.get("remote", Some(remote_name), "url"))
} else {
config.get("remote", Some(remote_name), "url")
};
let Some(value) = value else {
return Ok(None);
};
let rewritten =
sley::plumbing::sley_config::remotes::rewrite_url_with_config(&config, value, push);
parse_configured_remote_url(&rewritten, &repo_relative_base(repo)).map(Some)
}
fn checkout_tracking_remote_name(root: &Path, requested: &str) -> GitResult<Option<String>> {
let remotes = checkout_remote_url_items(root)?;
if remotes.is_empty() {
return Ok(None);
}
if let Some((name, _)) = remotes.iter().find(|(name, _)| name == requested) {
return Ok(Some(name.clone()));
}
if let Some((name, _)) = remotes
.iter()
.find(|(_, url)| configured_remote_values_match(url, requested))
{
return Ok(Some(name.clone()));
}
if looks_like_remote_location(requested) && remotes.len() == 1 {
return Ok(Some(remotes[0].0.clone()));
}
if !looks_like_remote_location(requested) {
return Ok(Some(requested.to_string()));
}
Ok(None)
}
fn checkout_remote_url_items(root: &Path) -> GitResult<Vec<(String, String)>> {
let mut remotes = Vec::new();
for config_path in checkout_git_config_paths(root) {
parse_remote_url_items_from_config(&config_path, &mut remotes)?;
}
Ok(remotes)
}
fn checkout_note_ref_exists(root: &Path) -> GitResult<bool> {
if !root.join(".git").exists() {
return Ok(false);
}
let checkout_repo = SleyRepository::discover(root).map_err(git_err)?;
let object_repo = common_repo_for_worktree(&checkout_repo)?;
Ok(object_repo
.find_reference(super::git_notes::NOTES_REF)
.map_err(git_err)?
.is_some())
}
fn seed_checkout_note_refs_into_mirror(root: &Path, mirror_repo: &SleyRepository) -> GitResult<()> {
if !root.join(".git").exists() {
return Ok(());
}
let checkout_repo = match SleyRepository::discover(root) {
Ok(repo) => repo,
Err(_) => return Ok(()),
};
if checkout_repo.git_dir() == mirror_repo.git_dir() {
return Ok(());
}
let object_repo = common_repo_for_worktree(&checkout_repo)?;
let note_updates = collect_ref_updates(&object_repo)?
.into_iter()
.filter(|update| update.namespace == RefNamespace::Note)
.collect::<Vec<_>>();
if note_updates.is_empty() {
return Ok(());
}
copy_reachable_objects(
&object_repo,
mirror_repo,
note_updates.iter().map(|update| update.target),
)?;
apply_ref_updates(
mirror_repo,
¬e_updates,
"heddle: seed mirror note refs from checkout",
)
}
fn hydrate_checkout_notes_from_remote_without_mirror(
root: &Path,
remote_name: &str,
) -> GitResult<()> {
reject_reserved_git_remote_name(remote_name)?;
let checkout_repo = SleyRepository::discover(root).map_err(git_err)?;
let object_repo = common_repo_for_worktree(&checkout_repo)?;
let url = remote_fetch_url_from_checkout_config(root, remote_name)?
.ok_or_else(|| GitBridgeError::Git(format!("remote '{remote_name}' has no fetch URL")))?;
if let Some(path) = local_path_from_url(&url)? {
let remote_repo = open_repo(&path)?;
let note_updates = collect_ref_updates(&remote_repo)?
.into_iter()
.filter(|update| update.namespace == RefNamespace::Note)
.collect::<Vec<_>>();
if note_updates.is_empty() {
return Ok(());
}
copy_reachable_objects(
&remote_repo,
&object_repo,
note_updates.iter().map(|update| update.target),
)?;
apply_ref_updates(
&object_repo,
¬e_updates,
&format!("heddle: hydrate notes from {remote_name}"),
)?;
return Ok(());
}
fetch_heddle_notes_into_repo(&object_repo, remote_name, &url)
}
fn fetch_heddle_notes_into_repo(
repo: &SleyRepository,
remote_name: &str,
url: &str,
) -> GitResult<()> {
let mut credentials = NoCredentials;
let mut progress = SilentProgress;
let refspec = RefSpec::forced("refs/notes/*", "refs/notes/*")?.to_git_format();
repo.fetch(
url,
&[refspec],
FetchOptions {
quiet: true,
auto_follow_tags: false,
fetch_all_tags: false,
prune: false,
dry_run: false,
append: false,
write_fetch_head: true,
tag_option_explicit: true,
prune_option_explicit: true,
depth: None,
merge_srcs: Vec::new(),
filter: None,
cloning: false,
update_shallow: false,
deepen_relative: false,
deepen_since: None,
deepen_not: Vec::new(),
},
&mut credentials,
&mut progress,
)
.map(|_| ())
.map_err(|err| GitBridgeError::Git(format!("failed to fetch notes from {remote_name}: {err}")))
}
fn parse_remote_url_items_from_config(
path: &Path,
remotes: &mut Vec<(String, String)>,
) -> GitResult<()> {
let Ok(contents) = fs::read_to_string(path) else {
return Ok(());
};
let mut current_remote: Option<String> = None;
for raw in contents.lines() {
let line = raw.trim();
if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
continue;
}
if line.starts_with('[') && line.ends_with(']') {
current_remote = line
.strip_prefix("[remote \"")
.and_then(|rest| rest.strip_suffix("\"]"))
.map(str::to_string);
continue;
}
let Some(name) = current_remote.as_ref() else {
continue;
};
let Some((key, value)) = line.split_once('=') else {
continue;
};
if key.trim().eq_ignore_ascii_case("url") {
remotes.push((name.clone(), git_config_value(value.trim())?));
}
}
Ok(())
}
fn configured_remote_values_match(left: &str, right: &str) -> bool {
if left == right {
return true;
}
let left_path = Path::new(left);
let right_path = Path::new(right);
if let (Ok(left), Ok(right)) = (left_path.canonicalize(), right_path.canonicalize()) {
return left == right;
}
false
}
fn looks_like_remote_location(value: &str) -> bool {
value.starts_with('/')
|| value.starts_with("./")
|| value.starts_with("../")
|| value.starts_with("~/")
|| value.contains("://")
|| value.contains('\\')
}
fn remote_fetch_url_from_checkout_config(
root: &Path,
remote_name: &str,
) -> GitResult<Option<String>> {
for config_path in checkout_git_config_paths(root) {
let Some(url) = parse_remote_fetch_url_from_config(&config_path, remote_name)? else {
continue;
};
return parse_configured_remote_url(&url, root).map(Some);
}
Ok(None)
}
fn parse_configured_remote_url(value: &str, relative_base: &Path) -> GitResult<String> {
if configured_remote_is_local_path(value) {
let path = configured_remote_local_path(value, relative_base);
return Ok(format!("file://{}", path.display()));
}
Ok(value.to_string())
}
fn configured_remote_local_path(value: &str, relative_base: &Path) -> PathBuf {
if value == "~"
&& let Some(home) = std::env::var_os("HOME")
{
return PathBuf::from(home);
}
if let Some(rest) = value.strip_prefix("~/")
&& let Some(home) = std::env::var_os("HOME")
{
return PathBuf::from(home).join(rest);
}
let path = Path::new(value);
if path.is_absolute() {
path.to_path_buf()
} else {
relative_base.join(path)
}
}
fn configured_remote_is_local_path(value: &str) -> bool {
value.starts_with('/')
|| value.starts_with("./")
|| value.starts_with("../")
|| value.starts_with('~')
|| value.starts_with(std::path::MAIN_SEPARATOR)
}
fn checkout_git_config_paths(root: &Path) -> Vec<PathBuf> {
let dot_git = root.join(".git");
let mut paths = Vec::new();
if dot_git.is_dir() {
paths.push(dot_git.join("config"));
if let Some(common_dir) = common_git_dir_from_git_dir(&dot_git) {
paths.push(common_dir.join("config"));
}
return paths;
}
let Ok(contents) = fs::read_to_string(&dot_git) else {
return paths;
};
let Some(target) = contents.trim().strip_prefix("gitdir:").map(str::trim) else {
return paths;
};
let git_dir = {
let path = Path::new(target);
if path.is_absolute() {
path.to_path_buf()
} else {
dot_git
.parent()
.map(|parent| parent.join(path))
.unwrap_or_else(|| path.to_path_buf())
}
};
paths.push(git_dir.join("config"));
if let Some(common_dir) = common_git_dir_from_git_dir(&git_dir) {
paths.push(common_dir.join("config"));
}
paths
}
fn common_git_dir_from_git_dir(git_dir: &Path) -> Option<PathBuf> {
let contents = fs::read_to_string(git_dir.join("commondir")).ok()?;
let target = contents.trim();
let path = Path::new(target);
Some(if path.is_absolute() {
path.to_path_buf()
} else {
git_dir.join(path)
})
}
fn parse_remote_fetch_url_from_config(path: &Path, remote_name: &str) -> GitResult<Option<String>> {
let Ok(contents) = fs::read_to_string(path) else {
return Ok(None);
};
let mut in_remote = false;
for raw in contents.lines() {
let line = raw.trim();
if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
continue;
}
if line.starts_with('[') && line.ends_with(']') {
in_remote = line
.strip_prefix("[remote \"")
.and_then(|rest| rest.strip_suffix("\"]"))
== Some(remote_name);
continue;
}
if !in_remote {
continue;
}
let Some((key, value)) = line.split_once('=') else {
continue;
};
if key.trim().eq_ignore_ascii_case("url") {
return git_config_value(value.trim()).map(Some);
}
}
Ok(None)
}
fn common_repo_for_worktree(repo: &SleyRepository) -> GitResult<SleyRepository> {
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)),
}
}
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<SleyRepository> {
match SleyRepository::discover(path) {
Ok(repo) => Ok(repo),
Err(_) => SleyRepository::open(path).map_err(git_err),
}
}
pub(crate) fn delete_reference_if_present(repo: &SleyRepository, name: &str) -> GitResult<()> {
delete_reference(repo, name, None, true)
}
fn delete_reference_matching(
repo: &SleyRepository,
name: &str,
expected_old: ObjectId,
) -> GitResult<()> {
delete_reference(repo, name, Some(expected_old), false)
}
fn delete_reference(
repo: &SleyRepository,
name: &str,
expected_old: Option<ObjectId>,
missing_ok: bool,
) -> GitResult<()> {
let refs = repo.references();
match refs.read_ref(name).map_err(git_err)? {
None if missing_ok => Ok(()),
None => Err(GitBridgeError::Git(format!(
"failed to delete Git reference '{name}': ref is missing"
))),
Some(ReferenceTarget::Direct(oid)) => repo
.delete_ref(DeleteRef {
name: FullName::new(name).map_err(git_err)?,
expected_old: Some(expected_old.unwrap_or(oid)),
expected: None,
reflog: None,
reflog_committer: None,
})
.map_err(git_err),
Some(ReferenceTarget::Symbolic(_)) => {
if let Some(expected_old) = expected_old {
let current = repo
.find_reference(name)
.map_err(git_err)?
.and_then(|reference| reference.peeled_oid(repo).ok().flatten());
if current != Some(expected_old) {
return Err(GitBridgeError::Git(format!(
"failed to delete Git reference '{name}': expected {expected_old}, found {}",
current
.map(|oid| oid.to_string())
.unwrap_or_else(|| "missing".to_string())
)));
}
}
refs.delete_symbolic_ref(name).map(|_| ()).map_err(git_err)
}
}
}
pub(crate) fn set_reference(
repo: &SleyRepository,
name: &str,
target: ObjectId,
constraint: RefPrecondition,
log_message: &str,
) -> GitResult<()> {
let refs = repo.references();
let old_oid = match refs.read_ref(name).map_err(git_err)? {
Some(ReferenceTarget::Direct(oid)) => oid,
_ => ObjectId::null(repo.object_format()),
};
let reflog = sley::plumbing::sley_refs::ReflogEntry {
old_oid,
new_oid: target,
committer: bridge_signature(),
message: log_message.as_bytes().to_vec(),
};
let mut tx = refs.transaction();
tx.update_to(
name.to_string(),
ReferenceTarget::Direct(target),
constraint,
Some(reflog),
);
tx.commit().map_err(git_err)?;
Ok(())
}
fn path_prefix_conflict(a: &str, b: &str) -> bool {
let child_of = |parent: &str, child: &str| {
child
.strip_prefix(parent)
.is_some_and(|rest| rest.starts_with('/'))
};
child_of(a, b) || child_of(b, a)
}
fn collect_capture_paths<S: ObjectStore + ?Sized>(
store: &S,
tree: &Tree,
prefix: &str,
out: &mut Vec<(String, FileMode)>,
) -> GitResult<()> {
for entry in tree.iter() {
let path = if prefix.is_empty() {
entry.name.clone()
} else {
format!("{prefix}/{}", entry.name)
};
if entry.is_tree() {
if let Some(subtree) = store.get_tree(&entry.hash)? {
collect_capture_paths(store, &subtree, &path, out)?;
}
} else {
out.push((path, entry.mode));
}
}
Ok(())
}
fn update_checkout_head_ref(
repo: &SleyRepository,
target: ObjectId,
previous_branch: Option<ObjectId>,
log_message: &str,
) -> GitResult<()> {
let expected = previous_branch.map_or(RefPrecondition::MustNotExist, |oid| {
RefPrecondition::MustExistAndMatch(ReferenceTarget::Direct(oid))
});
let ref_name = repo
.head()
.ok()
.and_then(|head| head.symbolic_target.map(|name| name.to_string()))
.unwrap_or_else(|| "HEAD".to_string());
let old_oid = previous_branch.unwrap_or_else(|| ObjectId::null(repo.object_format()));
let head_reflog = sley::plumbing::sley_refs::ReflogEntry {
old_oid,
new_oid: target,
committer: bridge_signature(),
message: log_message.as_bytes().to_vec(),
};
set_reference(repo, &ref_name, target, expected, log_message)?;
if ref_name != "HEAD" {
repo.references()
.append_reflog("HEAD", &head_reflog)
.map_err(git_err)?;
}
Ok(())
}
fn checkout_git_head_is_detached(root: &Path) -> GitResult<bool> {
let repo = SleyRepository::discover(root).map_err(git_err)?;
Ok(repo.head().map(|head| head.is_detached()).unwrap_or(false))
}
pub(crate) fn resolve_git_commit_identity(
repo_root: &Path,
fallback: &Principal,
) -> GitResult<LocalGitIdentity> {
if !principal_is_default_unknown(fallback) {
return Ok(LocalGitIdentity::from_principal(fallback));
}
if let Some(identity) = git_config_identity_with_global_fallback(repo_root)? {
return Ok(identity);
}
Err(GitBridgeError::Git(
"refusing to write a Git commit with Unknown <unknown@example.com>; configure user.name/user.email, HEDDLE_PRINCIPAL_NAME/HEDDLE_PRINCIPAL_EMAIL, or .heddle principal".to_string(),
))
}
pub(crate) fn git_config_identity_with_global_fallback(
repo_root: &Path,
) -> GitResult<Option<LocalGitIdentity>> {
let name = git_config_value_with_global_fallback(repo_root, "user.name")?;
let email = git_config_value_with_global_fallback(repo_root, "user.email")?;
if let (Some(name), Some(email)) = (name, email)
&& !name.trim().is_empty()
&& !email.trim().is_empty()
{
return Ok(Some(LocalGitIdentity { name, email }));
}
Ok(None)
}
pub(crate) fn principal_is_default_unknown(principal: &Principal) -> bool {
principal.name.trim().is_empty()
|| principal.email.trim().is_empty()
|| (principal.name.trim() == "Unknown" && principal.email.trim() == "unknown@example.com")
}
fn git_config_value_with_global_fallback(repo_root: &Path, key: &str) -> GitResult<Option<String>> {
let Ok(repo) = SleyRepository::discover(repo_root) else {
return Ok(None);
};
let Some((section, variable)) = key.split_once('.') else {
return Ok(None);
};
Ok(repo
.config_snapshot()
.map_err(git_err)?
.get(section, None, variable)
.map(str::to_string))
}
fn git_config_value(value: &str) -> GitResult<String> {
let Some(quoted) = value
.strip_prefix('"')
.and_then(|rest| rest.strip_suffix('"'))
else {
return Ok(value.to_string());
};
let mut out = String::new();
let mut chars = quoted.chars();
while let Some(ch) = chars.next() {
if ch != '\\' {
out.push(ch);
continue;
}
let Some(escaped) = chars.next() else {
return Err(GitBridgeError::Git(
"unterminated escape in repo-local Git config".to_string(),
));
};
match escaped {
'"' | '\\' => out.push(escaped),
'n' => out.push('\n'),
't' => out.push('\t'),
'b' => out.push('\u{0008}'),
other => out.push(other),
}
}
Ok(out)
}
fn bridge_signature() -> Vec<u8> {
let seconds = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs() as i64)
.unwrap_or(0);
format!("Heddle <heddle@local> {seconds} +0000").into_bytes()
}
fn repo_relative_base(repo: &SleyRepository) -> PathBuf {
repo.workdir().unwrap_or_else(|| {
if repo
.git_dir()
.file_name()
.is_some_and(|name| name == ".git")
{
repo.git_dir()
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| repo.git_dir().to_path_buf())
} else {
repo.git_dir().to_path_buf()
}
})
}
fn local_path_from_url(url: &str) -> GitResult<Option<PathBuf>> {
let Some(raw_path) = url.strip_prefix("file://") else {
return Ok(None);
};
let path = PathBuf::from(raw_path);
if path.as_os_str().is_empty() {
return Err(GitBridgeError::Git(format!(
"remote '{}' has no filesystem path",
url
)));
}
Ok(Some(path))
}
fn collect_ref_updates(repo: &SleyRepository) -> GitResult<Vec<RefUpdate>> {
let mut updates = Vec::new();
for reference in repo.references().list_refs().map_err(git_err)? {
let ReferenceTarget::Direct(target) = reference.target else {
continue;
};
if let Some(name) = reference.name.strip_prefix("refs/heads/") {
updates.push(RefUpdate {
name: name.to_string(),
target,
namespace: RefNamespace::Branch,
});
} else if let Some(name) = reference.name.strip_prefix("refs/tags/") {
updates.push(RefUpdate {
name: name.to_string(),
target,
namespace: RefNamespace::Tag,
});
} else if let Some(name) = reference.name.strip_prefix("refs/notes/") {
updates.push(RefUpdate {
name: name.to_string(),
target,
namespace: RefNamespace::Note,
});
}
}
Ok(updates)
}
#[derive(Debug, Default, Clone, Copy)]
pub(crate) struct ExportedCommitCounts {
pub total: usize,
pub newly: usize,
}
pub(crate) fn count_exported_commits(
repo: &SleyRepository,
newly_minted: &HashSet<ObjectId>,
) -> GitResult<ExportedCommitCounts> {
let tips: Vec<ObjectId> = collect_ref_updates(repo)?
.into_iter()
.filter(|update| matches!(update.namespace, RefNamespace::Branch | RefNamespace::Tag))
.map(|update| update.target)
.collect();
let mut stack = tips;
let mut seen = HashSet::new();
let mut counts = ExportedCommitCounts::default();
while let Some(oid) = stack.pop() {
if !seen.insert(oid) {
continue;
}
let object = repo.read_object(&oid).map_err(git_err)?;
match object.object_type {
GitObjectType::Commit => {
counts.total += 1;
if newly_minted.contains(&oid) {
counts.newly += 1;
}
let commit = repo.read_commit(&oid).map_err(git_err)?;
for parent in commit.parents {
stack.push(parent);
}
}
GitObjectType::Tag => {
let tag = repo.read_tag(&oid).map_err(git_err)?;
stack.push(tag.object);
}
GitObjectType::Tree | GitObjectType::Blob => {}
}
}
Ok(counts)
}
fn collect_ref_updates_for_fetch(
repo: &SleyRepository,
scope: GitFetchScope,
) -> GitResult<Vec<RefUpdate>> {
let updates = collect_ref_updates(repo)?;
match scope {
GitFetchScope::AllRefs => Ok(updates),
GitFetchScope::BranchesAndNotes => Ok(updates
.into_iter()
.filter(|update| matches!(update.namespace, RefNamespace::Branch | RefNamespace::Note))
.collect()),
}
}
pub(crate) fn collect_import_source_ref_updates(
repo: &SleyRepository,
refs: &[String],
) -> GitResult<Vec<RefUpdate>> {
let updates = collect_ref_updates(repo)?;
if refs.is_empty() {
return Ok(updates);
}
let wanted: HashSet<&str> = refs.iter().map(String::as_str).collect();
Ok(updates
.into_iter()
.filter(|update| matches_import_ref(update, &wanted))
.collect())
}
fn matches_import_ref(update: &RefUpdate, wanted: &HashSet<&str>) -> bool {
let full = full_ref_name(update);
wanted.contains(update.name.as_str()) || wanted.contains(full.as_str())
}
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),
}
}
#[cfg(test)]
pub(crate) fn ensure_commit_update_fast_forward(
repo: &SleyRepository,
name: &str,
old: ObjectId,
new: ObjectId,
) -> GitResult<()> {
if old == new || old == ObjectId::null(repo.object_format()) {
return Ok(());
}
match commit_is_descendant_of(repo, new, old) {
Ok(true) => Ok(()),
Ok(false) => Err(GitBridgeError::NonFastForwardRef {
name: name.to_string(),
old,
new,
}),
Err(err) => Err(GitBridgeError::Git(format!(
"ref update would move {name}: {old} -> {new}, but Heddle could not verify it as a fast-forward ({err}); fetch/import first or inspect the refs explicitly"
))),
}
}
fn commit_is_descendant_of(
repo: &SleyRepository,
descendant: ObjectId,
ancestor: ObjectId,
) -> GitResult<bool> {
let mut stack = vec![descendant];
let mut seen = HashSet::new();
while let Some(oid) = stack.pop() {
if oid == ancestor {
return Ok(true);
}
if !seen.insert(oid) {
continue;
}
let commit = repo.read_commit(&oid).map_err(git_err)?;
for parent in commit.parents {
stack.push(parent);
}
}
Ok(false)
}
const HEDDLE_EXPORTED_REFS_FILE: &str = "heddle-exported-refs";
const HEDDLE_NETWORK_EXPORTED_REFS_DIR: &str = "git-network-exported-refs";
fn exported_refs_manifest_path(target_repo: &SleyRepository) -> PathBuf {
target_repo.git_dir().join(HEDDLE_EXPORTED_REFS_FILE)
}
fn network_exported_refs_path(heddle_dir: &Path, url: &str) -> PathBuf {
let key = ContentHash::compute_typed("git-network-exported-refs", url.as_bytes()).to_hex();
heddle_dir
.join(HEDDLE_NETWORK_EXPORTED_REFS_DIR)
.join(format!("{key}.refs"))
}
fn read_exported_refs_at(path: &Path) -> GitResult<HashMap<String, ObjectId>> {
match fs::read_to_string(path) {
Ok(text) => {
let mut map = HashMap::new();
for line in text.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let mut parts = line.split_whitespace();
let Some(name) = parts.next() else {
continue;
};
let tip = parts
.next()
.and_then(|token| token.parse::<ObjectId>().ok())
.unwrap_or_else(|| ObjectId::null(ObjectFormat::Sha1));
map.insert(name.to_string(), tip);
}
Ok(map)
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(HashMap::new()),
Err(e) => Err(GitBridgeError::Io(e)),
}
}
fn write_exported_refs_at(path: &Path, refs: &HashMap<String, ObjectId>) -> GitResult<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let mut sorted: Vec<(&str, &ObjectId)> = refs
.iter()
.map(|(name, tip)| (name.as_str(), tip))
.collect();
sorted.sort_unstable_by(|a, b| a.0.cmp(b.0));
let body = sorted
.iter()
.map(|(name, tip)| format!("{name} {tip}"))
.collect::<Vec<_>>()
.join("\n");
let tmp = path.with_extension("tmp");
fs::write(&tmp, body)?;
fs::rename(&tmp, path)?;
Ok(())
}
pub(crate) fn read_exported_refs(
target_repo: &SleyRepository,
) -> GitResult<HashMap<String, ObjectId>> {
read_exported_refs_at(&exported_refs_manifest_path(target_repo))
}
pub(crate) fn write_exported_refs(
target_repo: &SleyRepository,
refs: &HashMap<String, ObjectId>,
) -> GitResult<()> {
write_exported_refs_at(&exported_refs_manifest_path(target_repo), refs)
}
const HEDDLE_MIRROR_MANAGED_REFS_FILE: &str = "heddle-mirror-managed-refs";
fn mirror_managed_refs_path(mirror_repo: &SleyRepository) -> PathBuf {
mirror_repo.git_dir().join(HEDDLE_MIRROR_MANAGED_REFS_FILE)
}
pub(crate) fn mirror_managed_refs_recorded(mirror_repo: &SleyRepository) -> bool {
mirror_managed_refs_path(mirror_repo).exists()
}
pub(crate) fn read_mirror_managed_refs(
mirror_repo: &SleyRepository,
) -> GitResult<HashMap<String, ObjectId>> {
read_exported_refs_at(&mirror_managed_refs_path(mirror_repo))
}
pub(crate) fn write_mirror_managed_refs(
mirror_repo: &SleyRepository,
refs: &HashMap<String, ObjectId>,
) -> GitResult<()> {
write_exported_refs_at(&mirror_managed_refs_path(mirror_repo), refs)
}
pub(crate) fn read_or_seed_mirror_managed_refs(
mirror_repo: &SleyRepository,
) -> GitResult<HashMap<String, ObjectId>> {
if mirror_managed_refs_recorded(mirror_repo) {
read_mirror_managed_refs(mirror_repo)
} else {
Ok(collect_ref_updates(mirror_repo)?
.into_iter()
.map(|update| (full_ref_name(&update), update.target))
.collect())
}
}
pub(crate) fn collect_managed_ref_updates(
repo: &SleyRepository,
record: &HashMap<String, ObjectId>,
) -> GitResult<Vec<RefUpdate>> {
Ok(collect_ref_updates(repo)?
.into_iter()
.filter(|update| {
matches!(update.namespace, RefNamespace::Note)
|| record.contains_key(&full_ref_name(update))
})
.collect())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RefMove {
Unchanged,
Create,
FastForward,
Rewind,
Diverged,
}
fn classify_ref_move(
repo: &SleyRepository,
old: Option<ObjectId>,
new: ObjectId,
recorded_tip: Option<ObjectId>,
) -> GitResult<RefMove> {
let Some(old) = old else {
return Ok(RefMove::Create);
};
if old == ObjectId::null(repo.object_format()) {
return Ok(RefMove::Create);
}
if old == new {
return Ok(RefMove::Unchanged);
}
if commit_is_descendant_of(repo, new, old)? {
return Ok(RefMove::FastForward);
}
if recorded_tip == Some(old)
&& repo.read_commit(&old).is_ok()
&& commit_is_descendant_of(repo, old, new)?
{
return Ok(RefMove::Rewind);
}
Ok(RefMove::Diverged)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum WriteVerdict {
Skip,
Write,
RequireForce,
}
fn verdict_from_move(m: RefMove) -> WriteVerdict {
match m {
RefMove::Unchanged => WriteVerdict::Skip,
RefMove::Create | RefMove::FastForward | RefMove::Rewind => WriteVerdict::Write,
RefMove::Diverged => WriteVerdict::RequireForce,
}
}
fn classify_tag_move(
old: Option<ObjectId>,
target: ObjectId,
recorded: Option<ObjectId>,
) -> WriteVerdict {
match old {
None => WriteVerdict::Write,
Some(o) if o == target => WriteVerdict::Skip,
Some(o) if recorded == Some(o) => WriteVerdict::Write,
Some(_) => WriteVerdict::RequireForce,
}
}
#[derive(Debug)]
pub(crate) struct PlannedRefWrite {
pub(crate) full_name: String,
pub(crate) old: Option<ObjectId>,
pub(crate) new: ObjectId,
pub(crate) force: bool,
}
#[derive(Debug)]
pub(crate) struct PlannedRefDelete {
pub(crate) full_name: String,
pub(crate) old: ObjectId,
}
#[derive(Debug)]
pub(crate) struct DestinationReconcilePlan {
pub(crate) writes: Vec<PlannedRefWrite>,
pub(crate) deletes: Vec<PlannedRefDelete>,
pub(crate) new_manifest: HashMap<String, ObjectId>,
}
pub(crate) fn planned_write_names(plan: &DestinationReconcilePlan) -> Vec<String> {
let mut names: Vec<String> = plan
.writes
.iter()
.map(|write| write.full_name.clone())
.collect();
names.sort_unstable();
names
}
fn creatable_ref_names(
served_frontier: &[RefUpdate],
scope: GitPushScope,
current_branch: Option<&str>,
) -> Option<HashSet<String>> {
match scope {
GitPushScope::AllThreads => None,
GitPushScope::CurrentThread => {
let branch = current_branch.unwrap_or_default();
Some(
served_frontier
.iter()
.filter(|update| {
(matches!(update.namespace, RefNamespace::Branch) && update.name == branch)
|| matches!(update.namespace, RefNamespace::Note)
})
.map(full_ref_name)
.collect(),
)
}
}
}
pub(crate) fn plan_destination_reconcile(
mirror_repo: &SleyRepository,
served_frontier: &[RefUpdate],
creatable_names: Option<&HashSet<String>>,
old_at_destination: &HashMap<String, ObjectId>,
previously_exported: &HashMap<String, ObjectId>,
force: bool,
) -> GitResult<DestinationReconcilePlan> {
let desired: HashMap<String, &RefUpdate> = served_frontier
.iter()
.map(|u| (full_ref_name(u), u))
.collect();
let mut names: BTreeSet<String> = desired.keys().cloned().collect();
names.extend(previously_exported.keys().cloned());
let mut writes = Vec::new();
let mut deletes = Vec::new();
let mut new_manifest: HashMap<String, ObjectId> = HashMap::new();
for full in names {
let old = old_at_destination.get(&full).copied();
let recorded = previously_exported.get(&full).copied();
if let Some(update) = desired.get(&full).copied() {
if old.is_none() && creatable_names.is_some_and(|names| !names.contains(&full)) {
if let Some(recorded) = recorded {
new_manifest.insert(full, recorded);
}
continue;
}
let (verdict, force_write) = match update.namespace {
RefNamespace::Branch | RefNamespace::Note => {
let movement = classify_ref_move(mirror_repo, old, update.target, recorded)?;
(
verdict_from_move(movement),
matches!(movement, RefMove::Rewind),
)
}
RefNamespace::Tag => {
let verdict = classify_tag_move(old, update.target, recorded);
(
verdict,
old.is_some_and(|old| old != update.target)
&& matches!(verdict, WriteVerdict::Write),
)
}
};
let proceed = match verdict {
WriteVerdict::Skip => false,
WriteVerdict::Write => true,
WriteVerdict::RequireForce => {
if force {
true
} else {
return Err(GitBridgeError::NonFastForwardRef {
name: full.clone(),
old: old.unwrap_or_else(|| ObjectId::null(mirror_repo.object_format())),
new: update.target,
});
}
}
};
if proceed {
writes.push(PlannedRefWrite {
full_name: full.clone(),
old,
new: update.target,
force: force_write || matches!(verdict, WriteVerdict::RequireForce),
});
}
if proceed || recorded.is_some() {
new_manifest.insert(full, update.target);
}
continue;
}
match old {
Some(old) if recorded == Some(old) || force => {
deletes.push(PlannedRefDelete {
full_name: full,
old,
});
}
Some(_) => {
if let Some(recorded) = recorded {
new_manifest.insert(full, recorded);
}
}
None => {
}
}
}
Ok(DestinationReconcilePlan {
writes,
deletes,
new_manifest,
})
}
fn read_destination_ref_map(repo: &SleyRepository) -> GitResult<HashMap<String, ObjectId>> {
Ok(collect_ref_updates(repo)?
.iter()
.map(|update| (full_ref_name(update), update.target))
.collect())
}
pub(crate) fn apply_ref_updates(
repo: &SleyRepository,
updates: &[RefUpdate],
log_message: &str,
) -> GitResult<()> {
for update in updates {
let full_name = full_ref_name(update);
set_reference(
repo,
&full_name,
update.target,
RefPrecondition::Any,
log_message,
)?;
}
Ok(())
}
fn apply_remote_tracking_ref_updates(
repo: &SleyRepository,
remote_name: &str,
updates: &[RefUpdate],
log_message: &str,
) -> GitResult<()> {
reject_reserved_git_remote_name(remote_name)?;
for update in updates
.iter()
.filter(|update| update.namespace == RefNamespace::Branch)
{
set_reference(
repo,
&format!("refs/remotes/{remote_name}/{}", update.name),
update.target,
RefPrecondition::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 SleyRepository::open(dest) {
Ok(repo) => repo,
Err(_) => SleyRepository::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()
.ok()
.and_then(|head| head.branch_name().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: &str,
dest: &Path,
depth: Option<u32>,
filter: Option<&str>,
) -> GitResult<()> {
if let Some(spec) = filter {
return Err(GitBridgeError::Git(format!(
"partial Git clone filter `{spec}` is not supported in Heddle's native no-git runtime yet; retry without --filter/--lazy so Heddle can import a complete object graph"
)));
}
if let Some(source_path) = local_path_from_url(url)? {
if depth.is_some() {
return Err(GitBridgeError::Git(
"shallow file:// Git clones are not supported in Heddle's native no-git runtime yet; retry without --depth so Heddle can copy the local Git object graph without spawning Git transport helpers"
.to_string(),
));
}
return copy_local_repo_to_bare(&source_path, dest);
}
let default_branch =
clone_url_to_bare_via_sley(url, dest, depth)?.or_else(|| default_branch_from_file_url(url));
if let Some(branch) = default_branch
&& bare_branch_exists(dest, &branch)?
{
fs::write(dest.join("HEAD"), format!("ref: refs/heads/{branch}\n"))?;
}
Ok(())
}
fn default_branch_from_file_url(url: &str) -> Option<String> {
let source_path = local_path_from_url(url).ok().flatten()?;
let head_path = if source_path.join("HEAD").is_file() {
source_path.join("HEAD")
} else {
source_path.join(".git").join("HEAD")
};
let head = fs::read_to_string(head_path).ok()?;
let branch = head.trim().strip_prefix("ref: refs/heads/")?;
(!branch.is_empty()).then(|| branch.to_string())
}
fn bare_branch_exists(repo_path: &Path, branch: &str) -> GitResult<bool> {
let repo = open_repo(repo_path)?;
Ok(repo
.find_reference(&format!("refs/heads/{branch}"))
.map_err(git_err)?
.is_some())
}
fn clone_url_to_bare_via_sley(
url: &str,
dest: &Path,
depth: Option<u32>,
) -> GitResult<Option<String>> {
fs::create_dir_all(dest)?;
let repo = SleyRepository::init_bare(dest).map_err(git_err)?;
let mut credentials = NoCredentials;
let mut progress = SilentProgress;
let outcome = repo
.fetch(
url,
&heddle_mirror_fetch_refspecs()?,
FetchOptions {
quiet: true,
auto_follow_tags: true,
fetch_all_tags: true,
prune: false,
dry_run: false,
append: false,
write_fetch_head: true,
tag_option_explicit: true,
prune_option_explicit: true,
depth,
merge_srcs: Vec::new(),
filter: None,
cloning: true,
update_shallow: false,
deepen_relative: false,
deepen_since: None,
deepen_not: Vec::new(),
},
&mut credentials,
&mut progress,
)
.map_err(|err| GitBridgeError::Git(format!("clone failed for {url}: {err}")))?;
Ok(outcome
.head_symref
.and_then(|target| target.strip_prefix("refs/heads/").map(str::to_string)))
}
pub(crate) fn copy_reachable_objects(
source: &SleyRepository,
target: &SleyRepository,
roots: impl IntoIterator<Item = ObjectId>,
) -> GitResult<()> {
let roots = roots.into_iter().collect::<Vec<_>>();
target.copy_reachable_from(source, &roots).map_err(git_err)
}
fn fetch_network_remote(
mirror_repo: &SleyRepository,
remote_name: &str,
url: &str,
scope: GitFetchScope,
) -> GitResult<()> {
let mut credentials = NoCredentials;
let mut progress = SilentProgress;
mirror_repo
.fetch(
url,
&heddle_mirror_fetch_refspecs()?,
FetchOptions {
quiet: true,
auto_follow_tags: matches!(scope, GitFetchScope::AllRefs),
fetch_all_tags: matches!(scope, GitFetchScope::AllRefs),
prune: false,
dry_run: false,
append: false,
write_fetch_head: true,
tag_option_explicit: true,
prune_option_explicit: true,
depth: None,
merge_srcs: Vec::new(),
filter: None,
cloning: false,
update_shallow: false,
deepen_relative: false,
deepen_since: None,
deepen_not: Vec::new(),
},
&mut credentials,
&mut progress,
)
.map_err(|err| GitBridgeError::Git(format!("failed to fetch from {url}: {err}")))?;
let _ = remote_name;
Ok(())
}
fn push_network_remote(
mirror_repo: &SleyRepository,
heddle_dir: &Path,
url: &str,
scope: GitPushScope,
current_branch: Option<&str>,
force: bool,
) -> GitResult<Vec<String>> {
let manifest_path = network_exported_refs_path(heddle_dir, url);
let previously_exported = read_exported_refs_at(&manifest_path)?;
let managed_record = read_mirror_managed_refs(mirror_repo)?;
let served_frontier = collect_managed_ref_updates(mirror_repo, &managed_record)?;
if served_frontier.is_empty() && previously_exported.is_empty() {
return Ok(Vec::new());
}
let mut credentials = NoCredentials;
let records = mirror_repo
.ls_remote(
url,
LsRemoteFilter {
heads: false,
tags: false,
refs_only: true,
},
&|_| true,
&mut credentials,
)
.map_err(|err| GitBridgeError::Git(format!("failed to list refs from {url}: {err}")))?;
let remote_refs = records
.into_iter()
.filter(|record| {
record.name.starts_with("refs/heads/")
|| record.name.starts_with("refs/tags/")
|| record.name.starts_with("refs/notes/")
})
.map(|record| (record.name, record.oid))
.collect::<HashMap<_, _>>();
let creatable = creatable_ref_names(&served_frontier, scope, current_branch);
let plan = plan_destination_reconcile(
mirror_repo,
&served_frontier,
creatable.as_ref(),
&remote_refs,
&previously_exported,
force,
)?;
if plan.writes.is_empty() && plan.deletes.is_empty() {
write_exported_refs_at(&manifest_path, &plan.new_manifest)?;
return Ok(Vec::new());
}
let mut commands = Vec::with_capacity(plan.writes.len() + plan.deletes.len());
let mut pack_objects = Vec::with_capacity(plan.writes.len());
let force_transport_checks = plan.writes.iter().any(|write| write.force);
for write in &plan.writes {
commands.push(PushCommand {
src: Some(write.new),
dst: write.full_name.clone(),
expected_old: write.old,
force: write.force,
});
pack_objects.push(write.new);
}
for delete in &plan.deletes {
commands.push(PushCommand {
src: None,
dst: delete.full_name.clone(),
expected_old: Some(delete.old),
force: false,
});
}
let mut credentials = NoCredentials;
let mut progress = SilentProgress;
mirror_repo
.push_actions(
url,
PushActionPlan {
commands,
pack_objects,
options: PushOptions {
quiet: true,
force: force || force_transport_checks,
},
},
&mut credentials,
&mut progress,
)
.map_err(|err| GitBridgeError::Git(format!("push failed for {url}: {err}")))?;
write_exported_refs_at(&manifest_path, &plan.new_manifest)?;
Ok(planned_write_names(&plan))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_git_ref_local_branch() {
let parsed = parse_git_ref("refs/heads/main").expect("local branch parses");
assert_eq!(parsed.kind, GitRefKind::Branch);
assert_eq!(parsed.name, "main");
assert_eq!(parsed.remote, REMOTE_NAME_FOR_LOCAL_GIT_REPO);
}
#[test]
fn parse_git_ref_remote_branch_keeps_nested_name() {
let parsed = parse_git_ref("refs/remotes/origin/feature/x").expect("remote branch parses");
assert_eq!(parsed.kind, GitRefKind::Branch);
assert_eq!(parsed.name, "feature/x");
assert_eq!(parsed.remote, "origin");
}
#[test]
fn parse_git_ref_tag() {
let parsed = parse_git_ref("refs/tags/v1.0").expect("tag parses");
assert_eq!(parsed.kind, GitRefKind::Tag);
assert_eq!(parsed.name, "v1.0");
assert_eq!(parsed.remote, REMOTE_NAME_FOR_LOCAL_GIT_REPO);
}
#[test]
fn parse_git_ref_skips_head_symrefs() {
assert_eq!(parse_git_ref("refs/heads/HEAD"), None);
assert_eq!(parse_git_ref("refs/remotes/origin/HEAD"), None);
}
#[test]
fn parse_git_ref_rejects_unknown_or_malformed() {
assert_eq!(parse_git_ref("refs/notes/heddle"), None);
assert_eq!(parse_git_ref("HEAD"), None);
assert_eq!(parse_git_ref("refs/remotes/origin"), None);
}
#[test]
fn parse_git_ref_rejects_reserved_git_remote_namespace() {
assert_eq!(parse_git_ref("refs/remotes/git/main"), None);
assert_eq!(parse_git_ref("refs/remotes/git/feature/x"), None);
assert!(is_reserved_git_remote_name(REMOTE_NAME_FOR_LOCAL_GIT_REPO));
assert!(!is_reserved_git_remote_name("origin"));
}
#[test]
fn refspec_forced_round_trips_git_format() {
let spec =
RefSpec::forced("refs/heads/main", "refs/heads/main").expect("valid forced refspec");
assert_eq!(spec.to_git_format(), "+refs/heads/main:refs/heads/main");
assert_eq!(
spec.to_git_format_not_forced(),
"refs/heads/main:refs/heads/main"
);
}
#[test]
fn refspec_constructor_rejects_reserved_remote_name() {
let err = RefSpec::new(
Some("refs/remotes/git/main".to_string()),
"refs/heads/main",
false,
)
.expect_err("reserved remote source is rejected");
assert!(err.to_string().contains("reserved namespace"));
let err = RefSpec::new(
Some("refs/heads/main".to_string()),
"refs/remotes/git/main",
false,
)
.expect_err("reserved remote destination is rejected");
assert!(err.to_string().contains("reserved namespace"));
}
#[test]
fn refspec_forced_rejects_reserved_remote_name() {
assert!(RefSpec::forced("refs/remotes/git/main", "refs/heads/main").is_err());
assert!(RefSpec::forced("refs/heads/main", "refs/remotes/git/main").is_err());
}
#[test]
fn refspec_delete_has_empty_source() {
let spec = RefSpec::delete("refs/heads/stale").expect("valid delete refspec");
assert_eq!(spec.to_git_format(), ":refs/heads/stale");
assert_eq!(spec.to_git_format_not_forced(), ":refs/heads/stale");
}
#[test]
fn refspec_delete_rejects_reserved_remote_name() {
assert!(RefSpec::delete("refs/remotes/git/stale").is_err());
}
#[test]
fn refspec_constructor_rejects_empty_source_and_destination() {
let err = RefSpec::new(None, "", false)
.expect_err("empty source plus empty destination is rejected");
assert!(err.to_string().contains("cannot both be empty"));
}
#[test]
fn negative_refspec_prefixes_caret() {
let spec = NegativeRefSpec::new("refs/heads/wip").expect("valid negative refspec");
assert_eq!(spec.to_git_format(), "^refs/heads/wip");
}
#[test]
fn negative_refspec_constructor_rejects_unparseable_negation() {
let err = NegativeRefSpec::new("refs/heads/wip/*").expect_err("negative glob is rejected");
assert!(err.to_string().contains("Negative glob patterns"));
}
#[test]
fn negative_refspec_constructor_rejects_reserved_remote_name() {
let err = NegativeRefSpec::new("refs/remotes/git/main")
.expect_err("reserved remote negative source is rejected");
assert!(err.to_string().contains("reserved namespace"));
}
#[test]
fn mirror_fetch_refspecs_cover_branches_and_notes() {
assert_eq!(
heddle_mirror_fetch_refspecs().expect("mirror refspecs are valid"),
[
"+refs/heads/*:refs/heads/*".to_string(),
"+refs/notes/*:refs/notes/*".to_string(),
]
);
}
#[test]
fn scoped_import_ref_updates_do_not_include_notes_implicitly() {
let tmp = tempfile::TempDir::new().unwrap();
let repo = SleyRepository::init_bare(tmp.path()).expect("init bare repo");
let main = seed_commit(&repo, "main");
let other = seed_commit(&repo, "other");
let notes = seed_commit(&repo, "notes");
set_reference(
&repo,
"refs/heads/main",
main,
RefPrecondition::MustNotExist,
"test: main",
)
.expect("write main");
set_reference(
&repo,
"refs/heads/other",
other,
RefPrecondition::MustNotExist,
"test: other",
)
.expect("write other");
set_reference(
&repo,
"refs/notes/heddle",
notes,
RefPrecondition::MustNotExist,
"test: notes",
)
.expect("write notes");
let updates = collect_import_source_ref_updates(&repo, &["main".to_string()])
.expect("collect scoped updates");
let full_names = updates.iter().map(full_ref_name).collect::<Vec<_>>();
assert_eq!(full_names, vec!["refs/heads/main".to_string()]);
}
#[test]
fn fast_forward_guard_reports_exact_rewrite_before_after() {
let tmp = tempfile::TempDir::new().unwrap();
let repo = SleyRepository::init_bare(tmp.path()).expect("init bare repo");
let root = test_commit(&repo, "root", &[]);
let old = test_commit(&repo, "old", &[root]);
let new = test_commit(&repo, "new", &[root]);
let err = ensure_commit_update_fast_forward(&repo, "refs/heads/main", old, new)
.expect_err("sibling commit update should be refused");
let message = err.to_string();
assert!(message.contains("refs/heads/main"));
assert!(message.contains(&old.to_string()));
assert!(message.contains(&new.to_string()));
assert!(message.contains("refusing to replace"));
}
#[test]
fn fast_forward_guard_allows_descendant_update() {
let tmp = tempfile::TempDir::new().unwrap();
let repo = SleyRepository::init_bare(tmp.path()).expect("init bare repo");
let old = test_commit(&repo, "old", &[]);
let new = test_commit(&repo, "new", &[old]);
ensure_commit_update_fast_forward(&repo, "refs/heads/main", old, new)
.expect("descendant update should be allowed");
}
fn test_commit(repo: &SleyRepository, message: &str, parents: &[ObjectId]) -> ObjectId {
let empty_tree_oid = ObjectId::empty_tree(repo.object_format());
let sig = Signature {
name: GitByteString::new(b"Heddle Test".to_vec()),
email: GitByteString::new(b"heddle@test".to_vec()),
time: GitTime::new(0, 0),
raw: b"Heddle Test <heddle@test> 0 +0000".to_vec(),
};
let commit = sley::CommitObject {
tree: empty_tree_oid,
parents: parents.to_vec(),
author: sig.to_ident_bytes(),
committer: sig.to_ident_bytes(),
encoding: None,
message: message.as_bytes().to_vec(),
};
repo.write_object(sley::plumbing::sley_object::EncodedObject::new(
GitObjectType::Commit,
commit.write(),
))
.expect("write test commit")
}
fn seed_commit(repo: &SleyRepository, message: &str) -> ObjectId {
test_commit(repo, message, &[])
}
#[test]
fn clone_url_to_bare_via_sley_honours_remote_head_symref() {
let tmp = tempfile::TempDir::new().unwrap();
let source = tmp.path().join("source.git");
let dest = tmp.path().join("dest.git");
let src = SleyRepository::init_bare(&source).expect("init bare source");
let seed = seed_commit(&src, "seed");
for name in ["refs/heads/trunk", "refs/heads/abc-feature"] {
set_reference(&src, name, seed, RefPrecondition::Any, "test: seed branch")
.expect("set ref");
}
std::fs::write(source.join("HEAD"), b"ref: refs/heads/trunk\n").unwrap();
let url = format!("file://{}", source.display());
clone_url_to_bare(&url, &dest, None, None).expect("clone url to bare");
let dest_head = std::fs::read_to_string(dest.join("HEAD")).expect("read dest HEAD");
assert_eq!(
dest_head.trim(),
"ref: refs/heads/trunk",
"dest HEAD must mirror the remote's symref (trunk), not sley's \
init-time default and not the alphabetically-first branch \
(abc-feature) — see heddle#141"
);
}
}