#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::{
io::{Read, Write},
net::{TcpListener, TcpStream},
process::{Child, Command, Stdio},
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
thread,
time::Duration,
};
use base64::Engine;
use gix::refs::transaction::PreviousValue;
use objects::object::{Blob, ChangeId, EntryType, FileMode, Tree, TreeEntry};
use repo::Repository;
use tempfile::TempDir;
use crate::bridge::{
GitBridge,
git_core::{copy_local_repo_to_bare, delete_reference_if_present, set_reference},
git_export::export_tree,
git_import::{import_all, import_git_tree},
git_sync::{sync_branches, sync_tags, sync_track_to_branch},
};
fn init_git_repo() -> (TempDir, gix::Repository) {
let temp = TempDir::new().expect("temp dir");
let repo = gix::init(temp.path()).expect("init git repo");
(temp, repo)
}
fn init_bare_git_repo() -> (TempDir, gix::Repository) {
let temp = TempDir::new().expect("temp dir");
let repo = gix::init_bare(temp.path()).expect("init bare git repo");
(temp, repo)
}
fn init_named_bare_git_repo(root: &TempDir, name: &str) -> gix::Repository {
gix::init_bare(root.path().join(name)).expect("init named bare git repo")
}
fn test_signature() -> gix::actor::Signature {
gix::actor::Signature {
name: "Heddle Test".into(),
email: "heddle@test".into(),
time: gix::date::Time {
seconds: 0,
offset: 0,
},
}
}
fn empty_tree_oid(repo: &gix::Repository) -> gix::hash::ObjectId {
repo.empty_tree().id
}
fn commit_with_tree(
repo: &gix::Repository,
reference: Option<&str>,
tree_oid: gix::hash::ObjectId,
message: &str,
parents: &[gix::hash::ObjectId],
) -> gix::hash::ObjectId {
let sig = test_signature();
let mut committer_buf = gix::date::parse::TimeBuf::default();
let mut author_buf = gix::date::parse::TimeBuf::default();
let commit = repo
.new_commit_as(
sig.to_ref(&mut committer_buf),
sig.to_ref(&mut author_buf),
message,
tree_oid,
parents.to_vec(),
)
.expect("commit");
if let Some(reference) = reference {
set_reference(
repo,
reference,
commit.id,
PreviousValue::Any,
"test: update ref",
)
.expect("update ref");
}
commit.id
}
fn create_annotated_tag(
repo: &gix::Repository,
name: &str,
target: gix::hash::ObjectId,
message: &str,
) -> gix::hash::ObjectId {
let tag = gix::objs::Tag {
target,
target_kind: gix::objs::Kind::Commit,
name: name.into(),
tagger: Some(test_signature()),
message: message.into(),
pgp_signature: None,
};
let tag_id = repo.write_object(&tag).expect("write tag").detach();
set_reference(
repo,
&format!("refs/tags/{name}"),
tag_id,
PreviousValue::MustNotExist,
"test: create tag",
)
.expect("create tag ref");
tag_id
}
struct GitDaemon {
child: Child,
port: u16,
}
struct GitHttpBackend {
join: Option<std::thread::JoinHandle<()>>,
port: u16,
stop: Arc<AtomicBool>,
basic_auth: Option<(String, String)>,
}
impl GitHttpBackend {
fn spawn(root: &std::path::Path) -> Self {
Self::spawn_with_auth(root, None)
}
fn spawn_authenticated(root: &std::path::Path, username: &str, password: &str) -> Self {
Self::spawn_with_auth(root, Some((username.to_string(), password.to_string())))
}
fn spawn_with_auth(root: &std::path::Path, basic_auth: Option<(String, String)>) -> Self {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind ephemeral http port");
let port = listener.local_addr().expect("listener addr").port();
listener
.set_nonblocking(true)
.expect("set nonblocking listener");
let root = root.to_path_buf();
let stop = Arc::new(AtomicBool::new(false));
let stop_signal = Arc::clone(&stop);
let auth = basic_auth.clone();
let join = thread::spawn(move || {
loop {
if stop_signal.load(Ordering::Relaxed) {
break;
}
match listener.accept() {
Ok((stream, _)) => handle_http_backend_connection(stream, &root, auth.as_ref()),
Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
thread::sleep(Duration::from_millis(10));
}
Err(_) => break,
}
}
});
let mut delay = Duration::from_millis(10);
for _ in 0..20 {
if TcpStream::connect(("127.0.0.1", port)).is_ok() {
return Self {
join: Some(join),
port,
stop,
basic_auth,
};
}
thread::sleep(delay);
delay = (delay * 2).min(Duration::from_millis(500));
}
panic!("git http backend did not become ready");
}
fn url(&self, repo_name: &str) -> String {
match &self.basic_auth {
Some((username, password)) => format!(
"http://{}:{}@127.0.0.1:{}/{}",
username, password, self.port, repo_name
),
None => format!("http://127.0.0.1:{}/{}", self.port, repo_name),
}
}
}
impl Drop for GitHttpBackend {
fn drop(&mut self) {
self.stop.store(true, Ordering::Relaxed);
let _ = TcpStream::connect(("127.0.0.1", self.port));
if let Some(join) = self.join.take() {
let _ = join.join();
}
}
}
fn handle_http_backend_connection(
mut stream: TcpStream,
root: &std::path::Path,
basic_auth: Option<&(String, String)>,
) {
stream.set_nonblocking(false).expect("set stream blocking");
let mut buffer = Vec::new();
let mut chunk = [0u8; 8192];
let header_end;
loop {
let read = stream.read(&mut chunk).expect("read request");
if read == 0 {
return;
}
buffer.extend_from_slice(&chunk[..read]);
if let Some(pos) = buffer.windows(4).position(|w| w == b"\r\n\r\n") {
header_end = pos + 4;
break;
}
}
let header_text = String::from_utf8_lossy(&buffer[..header_end]);
let mut lines = header_text.split("\r\n");
let request_line = lines.next().expect("request line");
let mut parts = request_line.split_whitespace();
let method = parts.next().expect("method");
let target = parts.next().expect("target");
let (path, query) = target.split_once('?').map_or((target, ""), |(p, q)| (p, q));
let mut content_type = String::new();
let mut content_length = 0usize;
let mut authorization = None;
for line in lines {
if line.is_empty() {
continue;
}
if let Some((name, value)) = line.split_once(':') {
let value = value.trim();
if name.eq_ignore_ascii_case("content-type") {
content_type = value.to_string();
} else if name.eq_ignore_ascii_case("content-length") {
content_length = value.parse().expect("content length");
} else if name.eq_ignore_ascii_case("authorization") {
authorization = Some(value.to_string());
}
}
}
if let Some((username, password)) = basic_auth {
let expected = format!(
"Basic {}",
base64::engine::general_purpose::STANDARD.encode(format!("{username}:{password}"))
);
if authorization.as_deref() != Some(expected.as_str()) {
write!(
stream,
"HTTP/1.1 401 Unauthorized\r\nWWW-Authenticate: Basic realm=\"heddle-test\"\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
)
.expect("write unauthorized response");
return;
}
}
let mut body = buffer[header_end..].to_vec();
while body.len() < content_length {
let read = stream.read(&mut chunk).expect("read body");
if read == 0 {
break;
}
body.extend_from_slice(&chunk[..read]);
}
body.truncate(content_length);
let mut child = Command::new("git")
.arg("http-backend")
.env("GIT_PROJECT_ROOT", root)
.env("GIT_HTTP_EXPORT_ALL", "1")
.env("REQUEST_METHOD", method)
.env("PATH_INFO", path)
.env("QUERY_STRING", query)
.env("CONTENT_TYPE", &content_type)
.env("CONTENT_LENGTH", content_length.to_string())
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
.expect("spawn git http-backend");
if !body.is_empty() {
child
.stdin
.as_mut()
.expect("stdin")
.write_all(&body)
.expect("write backend body");
}
let output = child.wait_with_output().expect("wait for backend");
assert!(output.status.success(), "git http-backend failed");
let response = output.stdout;
let split = response
.windows(4)
.position(|w| w == b"\r\n\r\n")
.map(|pos| pos + 4)
.or_else(|| {
response
.windows(2)
.position(|w| w == b"\n\n")
.map(|pos| pos + 2)
})
.expect("cgi headers");
let headers = String::from_utf8_lossy(&response[..split]);
let body = &response[split..];
let mut status = "200 OK".to_string();
let mut response_headers = Vec::new();
for line in headers.lines() {
let line = line.trim_end_matches('\r');
if line.is_empty() {
continue;
}
if let Some(value) = line.strip_prefix("Status:") {
status = value.trim().to_string();
} else {
response_headers.push(line.to_string());
}
}
response_headers.push(format!("Content-Length: {}", body.len()));
response_headers.push("Connection: close".to_string());
write!(stream, "HTTP/1.1 {}\r\n", status).expect("write status");
for header in response_headers {
write!(stream, "{}\r\n", header).expect("write header");
}
write!(stream, "\r\n").expect("write response separator");
stream.write_all(body).expect("write response body");
}
impl GitDaemon {
fn spawn(root: &std::path::Path) -> Self {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind ephemeral port");
let port = listener.local_addr().expect("listener addr").port();
drop(listener);
let child = Command::new("git")
.args([
"daemon",
"--reuseaddr",
"--export-all",
&format!("--base-path={}", root.display()),
"--listen=127.0.0.1",
&format!("--port={port}"),
root.to_str().expect("root path"),
])
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.expect("spawn git daemon");
let mut delay = Duration::from_millis(10);
for _ in 0..20 {
if TcpStream::connect(("127.0.0.1", port)).is_ok() {
return Self { child, port };
}
thread::sleep(delay);
delay = (delay * 2).min(Duration::from_millis(500));
}
let output = child.wait_with_output().expect("wait for git daemon");
panic!(
"git daemon did not become ready: {}",
String::from_utf8_lossy(&output.stderr)
);
}
fn url(&self, repo_name: &str) -> String {
format!("git://127.0.0.1:{}/{}", self.port, repo_name)
}
}
impl Drop for GitDaemon {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
#[test]
fn sync_tags_peels_annotated_tags() {
let heddle_temp = TempDir::new().expect("heddle temp");
let repo = Repository::init(heddle_temp.path()).expect("init heddle");
let (_git_temp, git_repo) = init_git_repo();
let tree_oid = empty_tree_oid(&git_repo);
let commit_oid = commit_with_tree(&git_repo, Some("refs/heads/main"), tree_oid, "base", &[]);
create_annotated_tag(&git_repo, "v1.0", commit_oid, "release");
let mut bridge = GitBridge::new(&repo);
bridge.git_repo_path = Some(git_repo.workdir().expect("workdir").to_path_buf());
let change_id = ChangeId::generate();
bridge.mapping.insert(change_id, commit_oid);
let synced = sync_tags(&mut bridge).expect("sync tags");
assert_eq!(synced, 1);
assert_eq!(repo.refs().get_marker("v1.0").unwrap(), Some(change_id));
}
#[test]
fn sync_track_to_branch_advances_branch_to_thread_tip() {
let (_git_temp, git_repo) = init_git_repo();
let tree_oid = empty_tree_oid(&git_repo);
let first = commit_with_tree(&git_repo, Some("refs/heads/main"), tree_oid, "base", &[]);
let second = commit_with_tree(&git_repo, None, tree_oid, "next", &[first]);
sync_track_to_branch(&git_repo, "main", second).expect("branch sync must succeed");
let mut updated = git_repo
.find_reference("refs/heads/main")
.expect("branch ref present after sync");
let updated_oid = updated.peel_to_id().expect("peel ref").detach();
assert_eq!(
updated_oid, second,
"branch main should now point at the thread tip"
);
}
#[test]
fn export_tree_writes_submodule_entries() {
let heddle_temp = TempDir::new().expect("heddle temp");
let repo = Repository::init(heddle_temp.path()).expect("init heddle");
let (_git_temp, git_repo) = init_git_repo();
let submodule_oid: gix::hash::ObjectId = "0303030303030303030303030303030303030303"
.parse()
.expect("oid");
let blob = Blob::new(format!("heddle-submodule: {}", submodule_oid).into_bytes());
let blob_hash = repo.store().put_blob(&blob).expect("blob");
let tree = Tree::from_entries(vec![TreeEntry {
name: "vendor".to_string(),
mode: FileMode::Normal,
entry_type: EntryType::Blob,
hash: blob_hash,
}]);
let tree_hash = repo.store().put_tree(&tree).expect("tree");
let tree_oid = export_tree(&repo, &git_repo, &tree_hash).expect("export");
let git_tree = git_repo.find_tree(tree_oid).expect("git tree");
let entry = git_tree.find_entry("vendor").expect("entry");
assert_eq!(entry.mode().kind(), gix::object::tree::EntryKind::Commit);
assert_eq!(entry.object_id(), submodule_oid);
}
#[test]
fn export_tree_substitutes_stub_for_redacted_blob() {
use chrono::Utc;
use objects::object::{ContentHash, Principal, Redaction};
let heddle_temp = TempDir::new().expect("heddle temp");
let repo = Repository::init(heddle_temp.path()).expect("init heddle");
let (_git_temp, git_repo) = init_git_repo();
let secret_bytes = b"AWS_SECRET_ACCESS_KEY=hunter2-leaked\n";
let blob = Blob::new(secret_bytes.to_vec());
let blob_hash = repo.store().put_blob(&blob).expect("blob");
let tree = Tree::from_entries(vec![TreeEntry {
name: "secrets.env".to_string(),
mode: FileMode::Normal,
entry_type: EntryType::Blob,
hash: blob_hash,
}]);
let tree_hash = repo.store().put_tree(&tree).expect("tree");
let dummy_state = ChangeId::from_bytes([42u8; 16]);
repo.put_redaction(Redaction {
redacted_blob: blob_hash,
state: dummy_state,
path: "secrets.env".into(),
reason: "leaked AWS key".into(),
redactor: Principal {
name: "Auditor".into(),
email: "auditor@heddle.sh".into(),
},
redacted_at: Utc::now(),
signature: None,
purged_at: None,
supersedes: None,
})
.expect("declare redaction");
let tree_oid = export_tree(&repo, &git_repo, &tree_hash).expect("export");
let git_tree = git_repo.find_tree(tree_oid).expect("git tree");
let entry = git_tree.find_entry("secrets.env").expect("entry");
let git_blob = git_repo
.find_blob(entry.object_id())
.expect("find exported blob");
let exported_bytes = git_blob.data.as_slice();
let exported_text = std::str::from_utf8(exported_bytes).expect("stub is utf-8");
assert!(
!exported_text.contains("hunter2-leaked"),
"EXPORT LEAK: redacted blob bytes reached Git. Got: {exported_text:?}"
);
assert!(
!exported_bytes
.windows(secret_bytes.len())
.any(|w| w == secret_bytes),
"EXPORT LEAK (byte-level): raw secret bytes reached Git tree"
);
assert!(
exported_text.contains("redacted by Heddle"),
"stub must announce itself; got: {exported_text:?}"
);
assert!(
exported_text.contains("leaked AWS key"),
"stub must carry the redaction reason; got: {exported_text:?}"
);
let still_in_store = repo
.store()
.get_blob(&blob_hash)
.expect("store lookup")
.expect("blob still present pre-purge");
assert_eq!(still_in_store.content(), secret_bytes);
let _ = ContentHash::from_bytes([0u8; 32]);
}
#[test]
fn import_tree_reads_submodule_entries() {
let (_git_temp, git_repo) = init_git_repo();
let submodule_oid: gix::hash::ObjectId = "0404040404040404040404040404040404040404"
.parse()
.expect("oid");
let mut editor = git_repo
.edit_tree(gix::hash::ObjectId::empty_tree(git_repo.object_hash()))
.expect("tree editor");
editor
.upsert(
"vendor",
gix::object::tree::EntryKind::Commit,
submodule_oid,
)
.expect("insert submodule");
let tree_oid = editor.write().expect("write tree").detach();
let heddle_temp = TempDir::new().expect("heddle temp");
let repo = Repository::init(heddle_temp.path()).expect("init heddle");
let tree_hash = import_git_tree(&repo, &git_repo, tree_oid).expect("import");
let heddle_tree = repo.store().get_tree(&tree_hash).expect("tree").unwrap();
let entry = heddle_tree
.entries()
.iter()
.find(|entry| entry.name == "vendor")
.expect("entry");
let blob = repo.store().get_blob(&entry.hash).expect("blob").unwrap();
let text = std::str::from_utf8(blob.content()).expect("utf8");
assert!(text.starts_with("heddle-submodule:"));
assert!(text.contains(&submodule_oid.to_string()));
}
#[test]
fn copy_local_repo_to_bare_handles_gitlink_entries() {
let (_src_temp, source) = init_bare_git_repo();
let dest_temp = TempDir::new().expect("dest temp");
let dest_path = dest_temp.path().join("dest.git");
let foreign_submodule_oid: gix::hash::ObjectId = "855827c583bc30645ba427885caa40c5b81764d2"
.parse()
.expect("oid");
let mut editor = source
.edit_tree(empty_tree_oid(&source))
.expect("tree editor");
editor
.upsert(
"sha1collisiondetection",
gix::object::tree::EntryKind::Commit,
foreign_submodule_oid,
)
.expect("insert gitlink");
let tree_with_gitlink = editor.write().expect("write tree").detach();
let commit = commit_with_tree(
&source,
Some("refs/heads/main"),
tree_with_gitlink,
"add submodule",
&[],
);
assert!(
source.find_object(foreign_submodule_oid).is_err(),
"test setup invariant: gitlink target should not be present locally"
);
assert!(
source.find_commit(commit).is_ok(),
"test setup invariant: parent commit should be present"
);
copy_local_repo_to_bare(source.path(), &dest_path).expect("copy with gitlink");
let dest = gix::open(&dest_path).expect("open dest");
assert!(
dest.find_commit(commit).is_ok(),
"destination must contain the parent commit"
);
assert!(
dest.find_tree(tree_with_gitlink).is_ok(),
"destination must contain the gitlink-bearing tree"
);
assert!(
dest.find_object(foreign_submodule_oid).is_err(),
"gitlink target stays out-of-band — that's the whole point"
);
let copied_tree = dest.find_tree(tree_with_gitlink).expect("dest tree");
let entry = copied_tree
.find_entry("sha1collisiondetection")
.expect("entry");
assert_eq!(entry.mode().kind(), gix::object::tree::EntryKind::Commit);
assert_eq!(entry.object_id(), foreign_submodule_oid);
}
#[test]
fn copy_local_repo_to_bare_preserves_source_head_branch() {
let (_src_temp, source) = init_bare_git_repo();
let dest_temp = TempDir::new().expect("dest temp");
let dest_path = dest_temp.path().join("dest.git");
let tree = empty_tree_oid(&source);
let master_tip = commit_with_tree(&source, Some("refs/heads/master"), tree, "M", &[]);
let main_tip = commit_with_tree(&source, Some("refs/heads/main"), tree, "Mn", &[]);
assert_ne!(master_tip, main_tip);
std::fs::write(source.path().join("HEAD"), b"ref: refs/heads/master\n").expect("set HEAD");
copy_local_repo_to_bare(source.path(), &dest_path).expect("copy");
let head = std::fs::read_to_string(dest_path.join("HEAD")).expect("read HEAD");
assert_eq!(
head.trim(),
"ref: refs/heads/master",
"destination HEAD must mirror source HEAD even when a `main` branch exists alongside"
);
}
#[test]
fn delete_reference_if_present_drops_new_branch_for_rollback() {
let (_temp, repo) = init_bare_git_repo();
let tree = empty_tree_oid(&repo);
let oid = commit_with_tree(&repo, None, tree, "rollback target", &[]);
set_reference(
&repo,
"refs/heads/feature-x",
oid,
PreviousValue::MustNotExist,
"create branch",
)
.expect("create branch");
assert!(
repo.find_reference("refs/heads/feature-x").is_ok(),
"set_reference must create the branch"
);
delete_reference_if_present(&repo, "refs/heads/feature-x").expect("delete");
assert!(
repo.find_reference("refs/heads/feature-x").is_err(),
"rollback must remove the branch we just created"
);
delete_reference_if_present(&repo, "refs/heads/feature-x")
.expect("delete on missing ref must be a no-op");
}
#[test]
fn mapping_persists_between_runs() {
let heddle_temp = TempDir::new().expect("heddle temp");
let repo = Repository::init(heddle_temp.path()).expect("init heddle");
let (_git_temp, git_repo) = init_git_repo();
let change_id = ChangeId::generate();
let git_oid: gix::hash::ObjectId = "0909090909090909090909090909090909090909"
.parse()
.expect("oid");
let mut bridge = GitBridge::new(&repo);
bridge.mapping.insert(change_id, git_oid);
bridge.save_mapping_to_disk().expect("save mapping");
let mut reloaded = GitBridge::new(&repo);
reloaded
.build_existing_mapping(Some(git_repo.workdir().expect("workdir")))
.expect("build mapping");
assert_eq!(reloaded.mapping.get_git(&change_id), Some(git_oid));
}
#[test]
fn legacy_mapping_is_migrated_out_of_git_dir() {
let heddle_temp = TempDir::new().expect("heddle temp");
let repo = Repository::init(heddle_temp.path()).expect("init heddle");
let (_git_temp, git_repo) = init_git_repo();
let change_id = ChangeId::generate();
let git_oid: gix::hash::ObjectId = "0808080808080808080808080808080808080808"
.parse()
.expect("oid");
let legacy_dir = repo.heddle_dir().join("git");
std::fs::create_dir_all(&legacy_dir).expect("create legacy dir");
std::fs::write(
legacy_dir.join("bridge-mapping.json"),
format!(
"{{\n \"entries\": [\n {{\"change_id\": \"{}\", \"git_oid\": \"{}\"}}\n ]\n}}\n",
change_id.to_string_full(),
git_oid
),
)
.expect("write legacy mapping");
let mut bridge = GitBridge::new(&repo);
bridge
.build_existing_mapping(Some(git_repo.workdir().expect("workdir")))
.expect("build mapping");
assert_eq!(bridge.mapping.get_git(&change_id), Some(git_oid));
assert!(bridge.mapping_path().exists());
assert!(!repo.heddle_dir().join("git/bridge-mapping.json").exists());
}
#[test]
fn test_sync_mapping() {
use super::git_core::SyncMapping;
let mut mapping = SyncMapping::new();
let change_id = ChangeId::generate();
let oid: gix::hash::ObjectId = "0101010101010101010101010101010101010101".parse().unwrap();
mapping.insert(change_id, oid);
assert_eq!(mapping.get_git(&change_id), Some(oid));
assert_eq!(mapping.get_heddle(oid), Some(change_id));
}
#[test]
#[cfg(unix)]
fn sync_branches_propagates_track_write_failures() {
let heddle_temp = TempDir::new().expect("heddle temp");
let repo = Repository::init(heddle_temp.path()).expect("init heddle");
let (_git_temp, git_repo) = init_git_repo();
let tree_oid = empty_tree_oid(&git_repo);
let commit_oid = commit_with_tree(&git_repo, Some("refs/heads/main"), tree_oid, "base", &[]);
let threads_dir = repo.heddle_dir().join("refs/threads");
let original_mode = std::fs::metadata(&threads_dir)
.unwrap()
.permissions()
.mode();
std::fs::set_permissions(&threads_dir, std::fs::Permissions::from_mode(0o555)).unwrap();
let mut bridge = GitBridge::new(&repo);
bridge.git_repo_path = Some(git_repo.workdir().expect("workdir").to_path_buf());
let change_id = ChangeId::generate();
bridge.mapping.insert(change_id, commit_oid);
let result = sync_branches(&mut bridge);
std::fs::set_permissions(&threads_dir, std::fs::Permissions::from_mode(original_mode)).unwrap();
assert!(result.is_err(), "thread write failures should be returned");
}
#[test]
#[cfg(unix)]
fn sync_tags_propagates_marker_write_failures() {
let heddle_temp = TempDir::new().expect("heddle temp");
let repo = Repository::init(heddle_temp.path()).expect("init heddle");
let (_git_temp, git_repo) = init_git_repo();
let tree_oid = empty_tree_oid(&git_repo);
let commit_oid = commit_with_tree(&git_repo, Some("refs/heads/main"), tree_oid, "base", &[]);
create_annotated_tag(&git_repo, "v1.0", commit_oid, "release");
let markers_dir = repo.heddle_dir().join("refs/markers");
let original_mode = std::fs::metadata(&markers_dir)
.unwrap()
.permissions()
.mode();
std::fs::set_permissions(&markers_dir, std::fs::Permissions::from_mode(0o555)).unwrap();
let mut bridge = GitBridge::new(&repo);
bridge.git_repo_path = Some(git_repo.workdir().expect("workdir").to_path_buf());
let change_id = ChangeId::generate();
bridge.mapping.insert(change_id, commit_oid);
let result = sync_tags(&mut bridge);
std::fs::set_permissions(&markers_dir, std::fs::Permissions::from_mode(original_mode)).unwrap();
assert!(result.is_err(), "marker write failures should be returned");
}
#[test]
fn pull_imports_remote_branches_and_tags_from_path_remote() {
let heddle_temp = TempDir::new().expect("heddle temp");
let repo = Repository::init(heddle_temp.path()).expect("init heddle");
let (source_temp, source_repo) = init_git_repo();
let tree_oid = empty_tree_oid(&source_repo);
let commit_oid = commit_with_tree(&source_repo, Some("refs/heads/main"), tree_oid, "base", &[]);
create_annotated_tag(&source_repo, "v1.0", commit_oid, "release");
let mut bridge = GitBridge::new(&repo);
bridge
.pull(source_temp.path().to_str().expect("remote path"))
.expect("pull remote");
assert!(repo.refs().get_thread("main").unwrap().is_some());
assert!(repo.refs().get_marker("v1.0").unwrap().is_some());
}
#[test]
fn pull_imports_remote_branches_and_tags_from_file_url_remote() {
let heddle_temp = TempDir::new().expect("heddle temp");
let repo = Repository::init(heddle_temp.path()).expect("init heddle");
let (source_temp, source_repo) = init_git_repo();
let tree_oid = empty_tree_oid(&source_repo);
let commit_oid = commit_with_tree(&source_repo, Some("refs/heads/main"), tree_oid, "base", &[]);
create_annotated_tag(&source_repo, "v1.0", commit_oid, "release");
let mut bridge = GitBridge::new(&repo);
bridge
.pull(&format!("file://{}", source_temp.path().display()))
.expect("pull remote");
assert!(repo.refs().get_thread("main").unwrap().is_some());
assert!(repo.refs().get_marker("v1.0").unwrap().is_some());
}
#[test]
fn pull_imports_remote_branches_and_tags_from_git_daemon() {
let heddle_temp = TempDir::new().expect("heddle temp");
let repo = Repository::init(heddle_temp.path()).expect("init heddle");
let remote_root = TempDir::new().expect("remote root");
let remote_repo = init_named_bare_git_repo(&remote_root, "remote.git");
let tree_oid = empty_tree_oid(&remote_repo);
let commit_oid = commit_with_tree(&remote_repo, Some("refs/heads/main"), tree_oid, "base", &[]);
create_annotated_tag(&remote_repo, "v1.0", commit_oid, "release");
let daemon = GitDaemon::spawn(remote_root.path());
let mut bridge = GitBridge::new(&repo);
bridge.pull(&daemon.url("remote.git")).expect("pull remote");
assert!(repo.refs().get_thread("main").unwrap().is_some());
assert!(repo.refs().get_marker("v1.0").unwrap().is_some());
}
#[test]
fn pull_imports_remote_branches_and_tags_from_git_http_backend() {
let heddle_temp = TempDir::new().expect("heddle temp");
let repo = Repository::init(heddle_temp.path()).expect("init heddle");
let remote_root = TempDir::new().expect("remote root");
let remote_repo = init_named_bare_git_repo(&remote_root, "remote.git");
let tree_oid = empty_tree_oid(&remote_repo);
let commit_oid = commit_with_tree(&remote_repo, Some("refs/heads/main"), tree_oid, "base", &[]);
create_annotated_tag(&remote_repo, "v1.0", commit_oid, "release");
let backend = GitHttpBackend::spawn(remote_root.path());
let mut bridge = GitBridge::new(&repo);
bridge
.pull(&backend.url("remote.git"))
.expect("pull remote over http");
assert!(repo.refs().get_thread("main").unwrap().is_some());
assert!(repo.refs().get_marker("v1.0").unwrap().is_some());
}
#[test]
fn pull_imports_remote_branches_and_tags_from_authenticated_git_http_backend() {
let heddle_temp = TempDir::new().expect("heddle temp");
let repo = Repository::init(heddle_temp.path()).expect("init heddle");
let remote_root = TempDir::new().expect("remote root");
let remote_repo = init_named_bare_git_repo(&remote_root, "remote.git");
let tree_oid = empty_tree_oid(&remote_repo);
let commit_oid = commit_with_tree(&remote_repo, Some("refs/heads/main"), tree_oid, "base", &[]);
create_annotated_tag(&remote_repo, "v1.0", commit_oid, "release");
let backend = GitHttpBackend::spawn_authenticated(remote_root.path(), "heddle", "secret");
let mut bridge = GitBridge::new(&repo);
bridge
.pull(&backend.url("remote.git"))
.expect("pull remote over authenticated http");
assert!(repo.refs().get_thread("main").unwrap().is_some());
assert!(repo.refs().get_marker("v1.0").unwrap().is_some());
}
#[test]
fn import_handles_merge_history_without_missing_parent_mappings() {
let heddle_temp = TempDir::new().expect("heddle temp");
let repo = Repository::init(heddle_temp.path()).expect("init heddle");
let (_git_temp, git_repo) = init_git_repo();
let tree_oid = empty_tree_oid(&git_repo);
let base = commit_with_tree(&git_repo, Some("refs/heads/main"), tree_oid, "base", &[]);
let left = commit_with_tree(
&git_repo,
Some("refs/heads/left"),
tree_oid,
"left",
&[base],
);
let right = commit_with_tree(
&git_repo,
Some("refs/heads/right"),
tree_oid,
"right",
&[base],
);
let merge = commit_with_tree(
&git_repo,
Some("refs/heads/main"),
tree_oid,
"merge",
&[left, right],
);
let mut bridge = GitBridge::new(&repo);
let stats = import_all(&mut bridge, Some(git_repo.workdir().expect("workdir")))
.expect("import merge history");
assert_eq!(stats.commits_imported, 4);
assert_eq!(
repo.refs().get_thread("main").unwrap(),
bridge.mapping.get_heddle(merge)
);
assert!(bridge.mapping.get_heddle(base).is_some());
assert!(bridge.mapping.get_heddle(left).is_some());
assert!(bridge.mapping.get_heddle(right).is_some());
assert!(bridge.mapping.get_heddle(merge).is_some());
}
#[test]
fn push_exports_local_branches_and_tags_to_path_remote() {
let heddle_temp = TempDir::new().expect("heddle temp");
let repo = Repository::init(heddle_temp.path()).expect("init heddle");
let (_source_temp, source_repo) = init_git_repo();
let (remote_temp, remote_repo) = init_bare_git_repo();
let tree_oid = empty_tree_oid(&source_repo);
let commit_oid = commit_with_tree(&source_repo, Some("refs/heads/main"), tree_oid, "base", &[]);
create_annotated_tag(&source_repo, "v1.0", commit_oid, "release");
let mut bridge = GitBridge::new(&repo);
bridge
.import(Some(source_repo.workdir().expect("workdir")))
.expect("import from git");
bridge
.push(remote_temp.path().to_str().expect("remote path"))
.expect("push remote");
let main_oid = remote_repo
.find_reference("refs/heads/main")
.expect("main ref")
.peel_to_id()
.expect("main target")
.detach();
let tag_oid = remote_repo
.find_reference("refs/tags/v1.0")
.expect("tag ref")
.peel_to_id()
.expect("tag target")
.detach();
let commit = remote_repo.find_commit(main_oid).expect("main commit");
let message = commit.message_raw_sloppy().to_string();
assert_eq!(tag_oid, main_oid);
assert_eq!(
main_oid, commit_oid,
"Phase B: SHA must be preserved across import → push"
);
assert!(
!message.contains("Heddle-Change-Id:"),
"Phase B: Heddle trailers must not be written into commit messages; \
change_id lives in refs/notes/heddle instead"
);
let note_ref = remote_repo
.find_reference(crate::bridge::git_notes::NOTES_REF)
.expect("notes ref should be pushed to remote");
let _ = note_ref;
}
#[test]
fn import_handles_deep_linear_history_without_stack_overflow() {
const DEPTH: usize = 5_000;
let heddle_temp = TempDir::new().expect("heddle temp");
let repo = Repository::init(heddle_temp.path()).expect("init heddle");
let (_src_temp, source_repo) = init_git_repo();
let tree_oid = empty_tree_oid(&source_repo);
let mut parent: Option<gix::hash::ObjectId> = None;
let mut last: Option<gix::hash::ObjectId> = None;
for i in 0..DEPTH {
let parents: Vec<gix::hash::ObjectId> = parent.into_iter().collect();
let oid = commit_with_tree(
&source_repo,
None, tree_oid,
&format!("c{i}"),
&parents,
);
parent = Some(oid);
last = Some(oid);
}
set_reference(
&source_repo,
"refs/heads/main",
last.expect("last commit oid"),
gix::refs::transaction::PreviousValue::Any,
"test: set main",
)
.expect("set main");
let mut bridge = GitBridge::new(&repo);
let stats = import_all(&mut bridge, Some(source_repo.workdir().expect("workdir")))
.expect("deep import must complete without stack overflow");
assert_eq!(stats.commits_imported, DEPTH);
assert_eq!(stats.states_created, DEPTH);
}
#[test]
fn import_skips_tags_pointing_at_blob_or_tree() {
let heddle_temp = TempDir::new().expect("heddle temp");
let repo = Repository::init(heddle_temp.path()).expect("init heddle");
let (_src_temp, source_repo) = init_git_repo();
let tree_oid = empty_tree_oid(&source_repo);
let commit_oid = commit_with_tree(&source_repo, Some("refs/heads/main"), tree_oid, "base", &[]);
create_annotated_tag(&source_repo, "v1.0", commit_oid, "release");
let blob_oid = source_repo
.write_blob(b"-----BEGIN PGP PUBLIC KEY BLOCK-----\n")
.expect("write gpg blob")
.detach();
let blob_tag = gix::objs::Tag {
target: blob_oid,
target_kind: gix::objs::Kind::Blob,
name: "junio-gpg-pub".into(),
tagger: Some(test_signature()),
message: "GPG public key".into(),
pgp_signature: None,
};
let blob_tag_oid = source_repo
.write_object(&blob_tag)
.expect("write tag")
.detach();
set_reference(
&source_repo,
"refs/tags/junio-gpg-pub",
blob_tag_oid,
gix::refs::transaction::PreviousValue::MustNotExist,
"test: tag pointing at blob",
)
.expect("set blob tag ref");
let key_blob = source_repo
.write_blob(b"key data")
.expect("write key blob")
.detach();
let mut editor = source_repo
.edit_tree(empty_tree_oid(&source_repo))
.expect("editor");
editor
.upsert("alice.asc", gix::object::tree::EntryKind::Blob, key_blob)
.expect("add key");
let tree_for_tag_oid = editor.write().expect("write tree").detach();
let tree_tag = gix::objs::Tag {
target: tree_for_tag_oid,
target_kind: gix::objs::Kind::Tree,
name: "core-gpg-keys".into(),
tagger: Some(test_signature()),
message: "core GPG keys directory".into(),
pgp_signature: None,
};
let tree_tag_oid = source_repo
.write_object(&tree_tag)
.expect("write tree tag")
.detach();
set_reference(
&source_repo,
"refs/tags/core-gpg-keys",
tree_tag_oid,
gix::refs::transaction::PreviousValue::MustNotExist,
"test: tag pointing at tree",
)
.expect("set tree tag ref");
let mut bridge = GitBridge::new(&repo);
let stats = import_all(&mut bridge, Some(source_repo.workdir().expect("workdir")))
.expect("import must complete despite non-commit-pointing tags");
assert_eq!(stats.commits_imported, 1);
assert!(
bridge.mapping.get_heddle(commit_oid).is_some(),
"the regular commit should have been mapped"
);
let skipped_names: std::collections::HashSet<String> = stats
.skipped_non_commit_refs
.iter()
.map(|s| s.name.clone())
.collect();
assert!(
skipped_names.contains("refs/tags/junio-gpg-pub"),
"junio-gpg-pub (tag → blob) should appear in skipped_non_commit_refs, \
got: {skipped_names:?}"
);
assert!(
skipped_names.contains("refs/tags/core-gpg-keys"),
"core-gpg-keys (tag → tree) should appear in skipped_non_commit_refs, \
got: {skipped_names:?}"
);
let blob_skip = stats
.skipped_non_commit_refs
.iter()
.find(|s| s.name == "refs/tags/junio-gpg-pub")
.expect("blob skip recorded");
assert!(
blob_skip.peeled_kind.contains("Blob"),
"expected Blob, got {}",
blob_skip.peeled_kind
);
}
#[test]
fn git_source_parse_distinguishes_urls_and_paths() {
use crate::cli::cli_args::GitSource;
assert!(matches!(
GitSource::parse("https://github.com/foo/bar.git").unwrap(),
GitSource::Url(_)
));
assert!(matches!(
GitSource::parse("ssh://git@example.com/foo.git").unwrap(),
GitSource::Url(_)
));
assert!(matches!(
GitSource::parse("git://example.com/foo.git").unwrap(),
GitSource::Url(_)
));
assert!(matches!(
GitSource::parse("file:///tmp/some-repo").unwrap(),
GitSource::Url(_)
));
assert!(matches!(
GitSource::parse("git@github.com:foo/bar.git").unwrap(),
GitSource::Url(_)
));
assert!(matches!(
GitSource::parse("/tmp/foo").unwrap(),
GitSource::Path(_)
));
assert!(matches!(
GitSource::parse("./relative").unwrap(),
GitSource::Path(_)
));
assert!(matches!(
GitSource::parse("just-a-name").unwrap(),
GitSource::Path(_)
));
}
#[test]
fn clone_url_to_bare_populates_destination_from_file_url() {
use gix::bstr::ByteSlice;
use crate::bridge::git_core::clone_url_to_bare;
let (_src_temp, source_repo) = init_git_repo();
let tree_oid = empty_tree_oid(&source_repo);
let commit_oid = commit_with_tree(&source_repo, Some("refs/heads/main"), tree_oid, "base", &[]);
create_annotated_tag(&source_repo, "v1.0", commit_oid, "release v1");
let src_path = source_repo
.workdir()
.expect("workdir")
.canonicalize()
.expect("canonicalize");
let url_str = format!("file://{}", src_path.display());
let url = gix::url::parse(url_str.as_bytes().as_bstr()).expect("parse file url");
let dest_root = TempDir::new().expect("dest temp");
let dest = dest_root.path().join("clone-dest");
clone_url_to_bare(&url, &dest).expect("clone file url");
let dest_repo = gix::open(&dest).expect("open dest");
let dest_main = dest_repo
.find_reference("refs/heads/main")
.expect("main ref present after clone")
.peel_to_id()
.expect("peel main")
.detach();
assert_eq!(
dest_main, commit_oid,
"Phase F: clone_url_to_bare must transfer the original commit OID"
);
assert!(
dest_repo.find_reference("refs/tags/v1.0").is_ok(),
"Phase F: tags must be fetched too (with_fetch_tags::All)"
);
}
#[test]
fn export_to_path_writes_branches_and_tags_to_fresh_destination() {
let heddle_temp = TempDir::new().expect("heddle temp");
let repo = Repository::init(heddle_temp.path()).expect("init heddle");
let (_source_temp, source_repo) = init_git_repo();
let tree_oid = empty_tree_oid(&source_repo);
let commit_oid = commit_with_tree(&source_repo, Some("refs/heads/main"), tree_oid, "base", &[]);
create_annotated_tag(&source_repo, "v1.0", commit_oid, "release");
let mut bridge = GitBridge::new(&repo);
bridge
.import(Some(source_repo.workdir().expect("workdir")))
.expect("import from git");
let dest_root = TempDir::new().expect("dest temp");
let dest_path = dest_root.path().join("export-target");
assert!(!dest_path.exists());
let stats = bridge
.export_to_path(&dest_path)
.expect("export to fresh path");
assert!(stats.threads_synced >= 1, "should sync the main thread");
assert!(stats.markers_synced >= 1, "should sync the v1.0 tag");
let dest_repo = gix::open(&dest_path).expect("open exported repo");
assert!(
dest_repo.find_reference("refs/heads/main").is_ok(),
"exported repo should have refs/heads/main"
);
assert!(
dest_repo.find_reference("refs/tags/v1.0").is_ok(),
"exported repo should have refs/tags/v1.0"
);
}
#[test]
fn export_to_path_is_idempotent_against_existing_destination() {
let heddle_temp = TempDir::new().expect("heddle temp");
let repo = Repository::init(heddle_temp.path()).expect("init heddle");
let (_source_temp, source_repo) = init_git_repo();
let tree_oid = empty_tree_oid(&source_repo);
commit_with_tree(&source_repo, Some("refs/heads/main"), tree_oid, "base", &[]);
let mut bridge = GitBridge::new(&repo);
bridge
.import(Some(source_repo.workdir().expect("workdir")))
.expect("import from git");
let dest_root = TempDir::new().expect("dest temp");
let dest_path = dest_root.path().join("export-target");
bridge
.export_to_path(&dest_path)
.expect("first export should create dest");
bridge
.export_to_path(&dest_path)
.expect("second export against existing dest should not error");
}
#[test]
fn import_stats_report_states_created() {
let heddle_temp = TempDir::new().expect("heddle temp");
let repo = Repository::init(heddle_temp.path()).expect("init heddle");
let (_source_temp, source_repo) = init_git_repo();
let tree_oid = empty_tree_oid(&source_repo);
commit_with_tree(
&source_repo,
Some("refs/heads/main"),
tree_oid,
"first",
&[],
);
let mut bridge = GitBridge::new(&repo);
let stats =
import_all(&mut bridge, Some(source_repo.workdir().expect("workdir"))).expect("import");
assert_eq!(stats.commits_imported, 1);
assert_eq!(
stats.states_created, stats.commits_imported,
"states_created should match commits_imported on a fresh import"
);
}
#[test]
fn export_preserves_original_commit_shas() {
let heddle_temp = TempDir::new().expect("heddle temp");
let repo = Repository::init(heddle_temp.path()).expect("init heddle");
let (_source_temp, source_repo) = init_git_repo();
let tree_oid = empty_tree_oid(&source_repo);
let first = commit_with_tree(&source_repo, None, tree_oid, "first", &[]);
let second = commit_with_tree(
&source_repo,
Some("refs/heads/main"),
tree_oid,
"second",
&[first],
);
create_annotated_tag(&source_repo, "v1.0", second, "release v1");
let mut bridge = GitBridge::new(&repo);
bridge
.import(Some(source_repo.workdir().expect("workdir")))
.expect("import from git");
let dest_root = TempDir::new().expect("dest temp");
let dest_path = dest_root.path().join("export");
bridge.export_to_path(&dest_path).expect("export");
let dest_repo = gix::open(&dest_path).expect("open dest");
let dest_main = dest_repo
.find_reference("refs/heads/main")
.expect("main ref")
.peel_to_id()
.expect("main target")
.detach();
assert_eq!(
dest_main, second,
"Phase B: exported main HEAD must equal the original git SHA"
);
let dest_main_commit = dest_repo.find_commit(dest_main).expect("dest main commit");
let dest_first = dest_main_commit
.parent_ids()
.next()
.expect("dest main has parent")
.detach();
assert_eq!(
dest_first, first,
"Phase B: parent commit SHAs must also be preserved"
);
}
#[test]
fn round_trip_preserves_change_ids_via_notes() {
let heddle_a_temp = TempDir::new().expect("heddle A temp");
let repo_a = Repository::init(heddle_a_temp.path()).expect("init heddle A");
let (_src_temp, source_repo) = init_git_repo();
let tree_oid = empty_tree_oid(&source_repo);
let commit_oid = commit_with_tree(&source_repo, Some("refs/heads/main"), tree_oid, "base", &[]);
let mut bridge_a = GitBridge::new(&repo_a);
bridge_a
.import(Some(source_repo.workdir().expect("workdir")))
.expect("import into A");
let change_id_in_a = bridge_a
.mapping
.get_heddle(commit_oid)
.expect("change_id should be mapped in A");
let dest_root = TempDir::new().expect("dest temp");
let dest_path = dest_root.path().join("export");
bridge_a.export_to_path(&dest_path).expect("export from A");
let heddle_b_temp = TempDir::new().expect("heddle B temp");
let repo_b = Repository::init(heddle_b_temp.path()).expect("init heddle B");
let mut bridge_b = GitBridge::new(&repo_b);
bridge_b.import(Some(&dest_path)).expect("import into B");
let change_id_in_b = bridge_b
.mapping
.get_heddle(commit_oid)
.expect("B should have mapped the original commit OID");
assert_eq!(
change_id_in_a, change_id_in_b,
"Phase B: change_id must survive the git→heddle→git→heddle roundtrip via the note"
);
}
#[test]
#[cfg(unix)]
fn round_trip_preserves_symlinks() {
let heddle_temp = TempDir::new().expect("heddle temp");
let repo = Repository::init(heddle_temp.path()).expect("init heddle");
let (_src_temp, source_repo) = init_git_repo();
let target_oid = source_repo
.write_blob(b"hello\n")
.expect("write target blob")
.detach();
let link_oid = source_repo
.write_blob(b"target.txt")
.expect("write link blob")
.detach();
let empty = empty_tree_oid(&source_repo);
let mut editor = source_repo.edit_tree(empty).expect("editor");
editor
.upsert("target.txt", gix::object::tree::EntryKind::Blob, target_oid)
.expect("add target");
editor
.upsert("link", gix::object::tree::EntryKind::Link, link_oid)
.expect("add symlink");
let tree_oid = editor.write().expect("write tree").detach();
let _commit = commit_with_tree(
&source_repo,
Some("refs/heads/main"),
tree_oid,
"with symlink",
&[],
);
let mut bridge = GitBridge::new(&repo);
bridge
.import(Some(source_repo.workdir().expect("workdir")))
.expect("import");
let head_change_id = repo
.refs()
.get_thread("main")
.unwrap()
.expect("main thread");
let state = repo
.store()
.get_state(&head_change_id)
.expect("state lookup")
.expect("state present");
let imported_tree = repo
.store()
.get_tree(&state.tree)
.expect("tree lookup")
.expect("tree present");
let link_entry = imported_tree
.entries()
.iter()
.find(|e| e.name == "link")
.expect("link entry exists");
assert_eq!(
link_entry.entry_type,
EntryType::Symlink,
"Phase E: imported symlinks must have EntryType::Symlink (was Blob \
pre-Phase-E, which broke goto-time materialization)"
);
assert_eq!(link_entry.mode, FileMode::Symlink);
let dest_root = TempDir::new().expect("dest temp");
let dest_path = dest_root.path().join("export");
bridge.export_to_path(&dest_path).expect("export");
let dest_repo = gix::open(&dest_path).expect("open dest");
let dest_main = dest_repo
.find_reference("refs/heads/main")
.expect("main")
.peel_to_id()
.expect("peel")
.detach();
let dest_commit = dest_repo.find_commit(dest_main).expect("dest commit");
let dest_tree_oid = dest_commit.tree_id().expect("tree id").detach();
let dest_tree = dest_repo.find_tree(dest_tree_oid).expect("dest tree");
let entries: Vec<(String, gix::object::tree::EntryKind)> = dest_tree
.iter()
.map(|e| {
let e = e.expect("entry");
(e.filename().to_string(), e.mode().kind())
})
.collect();
let link_kind = entries
.iter()
.find(|(name, _)| name == "link")
.map(|(_, k)| *k)
.expect("link entry in exported tree");
assert_eq!(
link_kind,
gix::object::tree::EntryKind::Link,
"Phase E: exported tree must mark 'link' as a symlink (Link), not a Blob"
);
}
#[test]
fn round_trip_preserves_annotated_tag_object_sha() {
let heddle_temp = TempDir::new().expect("heddle temp");
let repo = Repository::init(heddle_temp.path()).expect("init heddle");
let (_src_temp, source_repo) = init_git_repo();
let tree_oid = empty_tree_oid(&source_repo);
let commit_oid = commit_with_tree(&source_repo, Some("refs/heads/main"), tree_oid, "base", &[]);
set_reference(
&source_repo,
"refs/tags/light",
commit_oid,
gix::refs::transaction::PreviousValue::MustNotExist,
"test: lightweight tag",
)
.expect("set lightweight tag");
let annotated_tag_oid = create_annotated_tag(&source_repo, "v1.0", commit_oid, "release");
let mut bridge = GitBridge::new(&repo);
bridge
.import(Some(source_repo.workdir().expect("workdir")))
.expect("import");
let dest_root = TempDir::new().expect("dest temp");
let dest_path = dest_root.path().join("export");
bridge.export_to_path(&dest_path).expect("export");
let dest_repo = gix::open(&dest_path).expect("open dest");
let dest_light = dest_repo
.find_reference("refs/tags/light")
.expect("light ref")
.target()
.try_id()
.expect("light has direct id")
.to_owned();
assert_eq!(
dest_light, commit_oid,
"lightweight tag should still point at the commit"
);
let dest_v10_immediate = dest_repo
.find_reference("refs/tags/v1.0")
.expect("v1.0 ref")
.target()
.try_id()
.expect("v1.0 has direct id")
.to_owned();
assert_eq!(
dest_v10_immediate, annotated_tag_oid,
"Follow-up A: annotated tag SHA must match (got {dest_v10_immediate}, want {annotated_tag_oid})"
);
let dest_tag_obj = dest_repo
.find_object(annotated_tag_oid)
.expect("annotated tag object should be in destination");
assert_eq!(dest_tag_obj.kind, gix::objs::Kind::Tag);
}
#[test]
fn import_isolates_per_ref_mirror_failures() {
let heddle_temp = TempDir::new().expect("heddle temp");
let repo = Repository::init(heddle_temp.path()).expect("init heddle");
let (_src_temp, source_repo) = init_git_repo();
let tree_oid = empty_tree_oid(&source_repo);
let good_oid = commit_with_tree(&source_repo, Some("refs/heads/main"), tree_oid, "good", &[]);
let phantom_oid: gix::hash::ObjectId = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"
.parse()
.expect("parse phantom oid");
set_reference(
&source_repo,
"refs/tags/phantom",
phantom_oid,
gix::refs::transaction::PreviousValue::MustNotExist,
"test: phantom tag",
)
.expect("set phantom tag");
let mut bridge = GitBridge::new(&repo);
let result = bridge.import(Some(source_repo.workdir().expect("workdir")));
if let Ok(stats) = result {
assert_eq!(
stats.commits_imported, 1,
"the good commit should be mapped"
);
assert!(
bridge.mapping.get_heddle(good_oid).is_some(),
"good commit's change_id should be in mapping"
);
} else {
eprintln!(
"phantom-tag import returned hard error (acceptable): {:?}",
result.err()
);
}
}
#[test]
fn import_honors_legacy_heddle_change_id_trailer() {
let heddle_temp = TempDir::new().expect("heddle temp");
let repo = Repository::init(heddle_temp.path()).expect("init heddle");
let (_src_temp, source_repo) = init_git_repo();
let legacy_change_id = "hd-fwsb54t27h1z2ktsjnd4wkaeg0";
let message = format!(
"Add feature X\n\n\
Heddle-Change-Id: {}\n\
Heddle-Status: published",
legacy_change_id
);
let tree_oid = empty_tree_oid(&source_repo);
let commit_oid = commit_with_tree(
&source_repo,
Some("refs/heads/main"),
tree_oid,
&message,
&[],
);
let mut bridge = GitBridge::new(&repo);
bridge
.import(Some(source_repo.workdir().expect("workdir")))
.expect("import legacy");
let recovered = bridge
.mapping
.get_heddle(commit_oid)
.expect("legacy commit must be mapped");
assert_eq!(
recovered.to_string_full(),
legacy_change_id,
"Phase B: legacy trailer change_ids must round-trip"
);
}