#[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 cli::bridge::{
GitBridge,
git_core::{
GitBridgeError, GitPushScope, SyncMapping, clone_url_to_bare, copy_local_repo_to_bare,
},
git_export::{export_all, export_current_thread, export_tree},
git_notes,
git_sync::{sync_branches, sync_tags, sync_track_to_branch},
git_util::ImportStats,
test_support,
test_support::{
RefNamespace, RefUpdate, collect_managed_ref_updates, delete_reference_if_present,
plan_destination_reconcile, read_exported_refs, read_mirror_managed_refs, set_reference,
write_exported_refs, write_mirror_managed_refs,
},
};
use objects::{
object::{
Blob, ChangeId, ContentHash, EntryType, FileMode, MarkerName, ThreadName, Tree, TreeEntry,
},
store::ObjectStore,
};
use repo::Repository;
use sley::{
CommitObject, EntryKind, GitObjectType, GitTime, ObjectId, RefPrecondition, ReferenceTarget,
Repository as SleyRepository, Signature, TagObject,
plumbing::{sley_core::ByteString as GitByteString, sley_object::EncodedObject},
};
use tempfile::TempDir;
type TestImportResult<T> = Result<T, String>;
trait TestGitBridgeImport {
fn import(&mut self, git_path: Option<&std::path::Path>) -> TestImportResult<ImportStats>;
}
impl TestGitBridgeImport for GitBridge<'_> {
fn import(&mut self, git_path: Option<&std::path::Path>) -> TestImportResult<ImportStats> {
import_all(self, git_path)
}
}
fn import_all(
bridge: &mut GitBridge<'_>,
git_path: Option<&std::path::Path>,
) -> TestImportResult<ImportStats> {
import_all_with_options(bridge, git_path, ingest::ImportOptions::default())
}
fn import_all_with_options(
bridge: &mut GitBridge<'_>,
git_path: Option<&std::path::Path>,
options: ingest::ImportOptions,
) -> TestImportResult<ImportStats> {
let target = bridge_root_from_mirror(bridge);
let source_owned;
let source = if let Some(path) = git_path {
path
} else {
source_owned = target.clone();
source_owned.as_path()
};
let (stats, _map) = ingest::import_git_into_with_options(source, &target, options)
.map_err(|error| error.to_string())?;
test_support::stage_ingest_source_in_mirror(bridge, source, &[])
.map_err(|error| error.to_string())?;
test_support::build_existing_mapping(bridge, Some(source))
.map_err(|error| error.to_string())?;
let mirror_repo = test_support::open_git_repo(bridge).map_err(|error| error.to_string())?;
test_support::seed_ingest_identity_mappings_from_mirror(bridge, &mirror_repo)
.map_err(|error| error.to_string())?;
Ok(import_stats_from_ingest(stats))
}
fn bridge_root_from_mirror(bridge: &GitBridge<'_>) -> std::path::PathBuf {
bridge
.mirror_path()
.parent()
.and_then(std::path::Path::parent)
.expect("mirror path should be <root>/.heddle/git")
.to_path_buf()
}
fn import_stats_from_ingest(stats: ingest::ImportStats) -> ImportStats {
ImportStats {
commits_imported: stats.commits_imported,
states_created: stats.states_created,
branches_synced: stats.refs.threads_written,
tags_synced: stats.refs.markers_written,
skipped_non_commit_refs: stats.refs_seen.non_commit_skipped,
lossy_entries: stats.lossy_entries,
}
}
trait SleyTestRepoExt {
fn find_tree(&self, oid: ObjectId) -> Result<sley::TreeObject, String>;
fn find_object(&self, oid: ObjectId) -> Result<TestObject, String>;
fn find_blob(&self, oid: ObjectId) -> Result<TestBlob, String>;
fn find_commit(&self, oid: ObjectId) -> Result<TestCommit, String>;
fn path(&self) -> &std::path::Path;
}
impl SleyTestRepoExt for SleyRepository {
fn find_tree(&self, oid: ObjectId) -> Result<sley::TreeObject, String> {
self.read_tree(&oid).map_err(|err| err.to_string())
}
fn find_object(&self, oid: ObjectId) -> Result<TestObject, String> {
let object = self.read_object(&oid).map_err(|err| err.to_string())?;
Ok(TestObject {
kind: object.object_type,
data: object.body.clone(),
})
}
fn find_blob(&self, oid: ObjectId) -> Result<TestBlob, String> {
let object = self.read_object(&oid).map_err(|err| err.to_string())?;
if object.object_type != GitObjectType::Blob {
return Err(format!("object {oid} is not a blob"));
}
Ok(TestBlob {
data: object.body.clone(),
})
}
fn find_commit(&self, oid: ObjectId) -> Result<TestCommit, String> {
self.read_commit(&oid)
.map(|commit| TestCommit { commit })
.map_err(|err| err.to_string())
}
fn path(&self) -> &std::path::Path {
self.git_dir()
}
}
struct TestReference<'a> {
repo: &'a SleyRepository,
target: ReferenceTarget,
}
fn find_reference<'a>(repo: &'a SleyRepository, name: &str) -> Result<TestReference<'a>, String> {
let target = repo
.references()
.read_ref(name)
.map_err(|err| err.to_string())?
.ok_or_else(|| format!("reference {name} not found"))?;
Ok(TestReference { repo, target })
}
impl TestReference<'_> {
fn id(&self) -> ObjectId {
self.try_id()
.expect("test reference should point directly at an object")
}
fn try_id(&self) -> Option<ObjectId> {
self.target().try_id().copied()
}
fn peel_to_id(&mut self) -> Result<ObjectId, String> {
let oid = match &self.target {
ReferenceTarget::Direct(oid) => *oid,
ReferenceTarget::Symbolic(name) => {
let reference = find_reference(self.repo, name)?;
match reference.target {
ReferenceTarget::Direct(oid) => oid,
ReferenceTarget::Symbolic(_) => {
return Err(format!("nested symbolic reference {name} is unsupported"));
}
}
}
};
peel_oid(self.repo, oid)
}
fn target(&self) -> TestReferenceTarget<'_> {
TestReferenceTarget {
target: &self.target,
}
}
}
struct TestReferenceTarget<'a> {
target: &'a ReferenceTarget,
}
impl<'a> TestReferenceTarget<'a> {
fn try_id(&self) -> Option<&'a ObjectId> {
match self.target {
ReferenceTarget::Direct(oid) => Some(oid),
ReferenceTarget::Symbolic(_) => None,
}
}
}
struct TestObject {
kind: GitObjectType,
data: Vec<u8>,
}
struct TestBlob {
data: Vec<u8>,
}
struct TestCommit {
commit: CommitObject,
}
impl TestCommit {
fn message_raw_sloppy(&self) -> TestRawMessage<'_> {
TestRawMessage(&self.commit.message)
}
fn parent_ids(&self) -> impl Iterator<Item = ObjectId> + '_ {
self.commit.parents.iter().copied()
}
fn tree_id(&self) -> Option<ObjectId> {
Some(self.commit.tree)
}
}
struct TestRawMessage<'a>(&'a [u8]);
impl TestRawMessage<'_> {
fn to_vec(&self) -> Vec<u8> {
self.0.to_vec()
}
}
impl std::fmt::Display for TestRawMessage<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", String::from_utf8_lossy(self.0))
}
}
trait SleyTestTreeExt {
fn find_entry(&self, name: &str) -> Option<TestTreeEntry<'_>>;
fn iter(&self) -> std::vec::IntoIter<Result<TestTreeEntry<'_>, String>>;
}
impl SleyTestTreeExt for sley::TreeObject {
fn find_entry(&self, name: &str) -> Option<TestTreeEntry<'_>> {
self.entries
.iter()
.find(|entry| entry.name.as_bytes() == name.as_bytes())
.map(|entry| TestTreeEntry { entry })
}
fn iter(&self) -> std::vec::IntoIter<Result<TestTreeEntry<'_>, String>> {
self.entries
.iter()
.map(|entry| Ok(TestTreeEntry { entry }))
.collect::<Vec<_>>()
.into_iter()
}
}
struct TestTreeEntry<'a> {
entry: &'a sley::plumbing::sley_object::TreeEntry,
}
impl TestTreeEntry<'_> {
fn filename(&self) -> String {
String::from_utf8_lossy(self.entry.name.as_bytes()).into_owned()
}
fn mode(&self) -> TestEntryMode {
TestEntryMode(self.entry.mode)
}
fn object_id(&self) -> ObjectId {
self.entry.oid
}
}
struct TestEntryMode(u32);
impl TestEntryMode {
fn kind(&self) -> EntryKind {
EntryKind::from_mode(self.0).expect("canonical tree entry mode")
}
}
fn peel_oid(repo: &SleyRepository, oid: ObjectId) -> Result<ObjectId, String> {
let object = repo.read_object(&oid).map_err(|err| err.to_string())?;
if object.object_type == GitObjectType::Tag {
let tag = repo.read_tag(&oid).map_err(|err| err.to_string())?;
peel_oid(repo, tag.object)
} else {
Ok(oid)
}
}
fn open_git(path: impl AsRef<std::path::Path>) -> Result<SleyRepository, String> {
SleyRepository::open(path.as_ref())
.or_else(|_| SleyRepository::discover(path.as_ref()))
.map_err(|err| err.to_string())
}
fn init_git_repo() -> (TempDir, SleyRepository) {
let temp = TempDir::new().expect("temp dir");
let repo = SleyRepository::init(temp.path()).expect("init git repo");
(temp, repo)
}
fn init_bare_git_repo() -> (TempDir, SleyRepository) {
let temp = TempDir::new().expect("temp dir");
let repo = SleyRepository::init_bare(temp.path()).expect("init bare git repo");
(temp, repo)
}
fn init_named_bare_git_repo(root: &TempDir, name: &str) -> SleyRepository {
SleyRepository::init_bare(root.path().join(name)).expect("init named bare git repo")
}
fn test_signature() -> Signature {
test_signature_at("Heddle Test", "heddle@test", 0, 0)
}
fn test_signature_at(
name: &str,
email: &str,
seconds: i64,
timezone_offset_minutes: i16,
) -> Signature {
let time = GitTime::new(seconds, timezone_offset_minutes);
let raw = format!("{name} <{email}> {seconds} {}", time.offset_token()).into_bytes();
Signature {
name: GitByteString::new(name.as_bytes().to_vec()),
email: GitByteString::new(email.as_bytes().to_vec()),
time,
raw,
}
}
fn empty_tree_oid(repo: &SleyRepository) -> ObjectId {
repo.write_tree(sley::TreeEditor::new())
.expect("write empty tree")
}
fn commit_with_tree(
repo: &SleyRepository,
reference: Option<&str>,
tree_oid: ObjectId,
message: &str,
parents: &[ObjectId],
) -> ObjectId {
let sig = test_signature();
let commit = CommitObject {
tree: tree_oid,
parents: parents.to_vec(),
author: sig.to_ident_bytes(),
committer: sig.to_ident_bytes(),
encoding: None,
message: message.as_bytes().to_vec(),
};
let commit_id = repo
.write_object(EncodedObject::new(GitObjectType::Commit, commit.write()))
.expect("commit");
if let Some(reference) = reference {
set_reference(
repo,
reference,
commit_id,
RefPrecondition::Any,
"test: update ref",
)
.expect("update ref");
}
commit_id
}
fn create_annotated_tag(
repo: &SleyRepository,
name: &str,
target: ObjectId,
message: &str,
) -> ObjectId {
let tag = TagObject {
object: target,
object_type: GitObjectType::Commit,
name: name.as_bytes().to_vec(),
tagger: Some(test_signature().to_ident_bytes()),
message: message.as_bytes().to_vec(),
raw_body: None,
};
let tag_id = repo
.write_object(EncodedObject::new(GitObjectType::Tag, tag.write()))
.expect("write tag");
set_reference(
repo,
&format!("refs/tags/{name}"),
tag_id,
RefPrecondition::MustNotExist,
"test: create tag",
)
.expect("create tag ref");
tag_id
}
fn gitlink_tree(repo: &SleyRepository, name: &str) -> ObjectId {
let submodule_oid: ObjectId = "0505050505050505050505050505050505050505"
.parse()
.expect("oid");
let mut editor = repo
.edit_tree(&ObjectId::empty_tree(repo.object_format()))
.expect("tree editor");
editor.upsert(name, EntryKind::Commit, submodule_oid);
repo.write_tree(editor).expect("write tree")
}
fn init_gitlink_repo() -> (TempDir, SleyRepository) {
let (git_temp, git_repo) = init_git_repo();
let tree_oid = gitlink_tree(&git_repo, "vendor");
commit_with_tree(&git_repo, Some("refs/heads/main"), tree_oid, "gitlink", &[]);
(git_temp, git_repo)
}
struct GitDaemon {
child: Child,
port: u16,
}
struct GitHttpBackend {
join: Option<std::thread::JoinHandle<()>>,
port: u16,
stop: Arc<AtomicBool>,
basic_auth: Option<(String, String)>,
}
fn bind_loopback_ephemeral(context: &str) -> Option<TcpListener> {
match TcpListener::bind("127.0.0.1:0") {
Ok(listener) => Some(listener),
Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => {
eprintln!("skipping git bridge network test: {context}: {err}");
None
}
Err(err) => panic!("{context}: {err}"),
}
}
impl GitHttpBackend {
fn spawn(root: &std::path::Path) -> Option<Self> {
Self::spawn_with_auth(root, None)
}
fn spawn_authenticated(root: &std::path::Path, username: &str, password: &str) -> Option<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)>,
) -> Option<Self> {
let listener = bind_loopback_ephemeral("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 Some(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) -> Option<Self> {
Self::spawn_with_push(root, false)
}
fn spawn_push(root: &std::path::Path) -> Option<Self> {
Self::spawn_with_push(root, true)
}
fn spawn_with_push(root: &std::path::Path, allow_push: bool) -> Option<Self> {
let listener = bind_loopback_ephemeral("bind ephemeral port")?;
let port = listener.local_addr().expect("listener addr").port();
drop(listener);
let mut args: Vec<String> = vec![
"daemon".to_string(),
"--reuseaddr".to_string(),
"--export-all".to_string(),
format!("--base-path={}", root.display()),
"--listen=127.0.0.1".to_string(),
format!("--port={port}"),
];
if allow_push {
args.push("--enable=receive-pack".to_string());
}
args.push(root.to_str().expect("root path").to_string());
let child = Command::new("git")
.args(&args)
.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 Some(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);
test_support::set_git_repo_path(
&mut bridge,
git_repo.workdir().expect("workdir").to_path_buf(),
);
let change_id = ChangeId::generate();
test_support::mapping_mut(&mut bridge).insert(change_id, commit_oid);
let synced = sync_tags(&mut bridge).expect("sync tags");
assert_eq!(synced, 1);
assert_eq!(
repo.refs().get_marker(&MarkerName::new("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 =
find_reference(&git_repo, "refs/heads/main").expect("branch ref present after sync");
let updated_oid = updated.peel_to_id().expect("peel ref");
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: 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(), 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_all_rejects_gitlink_by_default_and_lossy_reports_drop() {
let (_git_temp, git_repo) = init_gitlink_repo();
let default_heddle = TempDir::new().expect("heddle temp");
let default_repo = Repository::init(default_heddle.path()).expect("init heddle");
let mut default_bridge = GitBridge::new(&default_repo);
let err = import_all(
&mut default_bridge,
Some(git_repo.workdir().expect("workdir").as_path()),
)
.expect_err("default import must fail on gitlink");
let message = err.to_string();
assert!(message.contains("vendor"), "error names entry: {message}");
assert!(message.contains("--lossy"), "error names opt-in: {message}");
let lossy_heddle = TempDir::new().expect("heddle temp");
let lossy_repo = Repository::init(lossy_heddle.path()).expect("init heddle");
let mut lossy_bridge = GitBridge::new(&lossy_repo);
let stats = import_all_with_options(
&mut lossy_bridge,
Some(git_repo.workdir().expect("workdir").as_path()),
ingest::ImportOptions { lossy: true },
)
.expect("lossy import accepts gitlink drop");
assert_eq!(stats.states_created, 1);
assert_eq!(stats.lossy_entries.len(), 1);
assert_eq!(stats.lossy_entries[0].path, "vendor");
assert!(stats.lossy_entries[0].summary_line().contains("dropped"));
}
#[test]
fn import_all_lossy_reports_cached_shared_subtree_entries_without_mapping_sidecar() {
let (_git_temp, git_repo) = init_git_repo();
let shared_tree = gitlink_tree(&git_repo, "vendor");
let mut first_editor = git_repo
.edit_tree(&empty_tree_oid(&git_repo))
.expect("first tree editor");
first_editor.upsert("shared", EntryKind::Tree, shared_tree);
let first_tree = git_repo.write_tree(first_editor).expect("write first tree");
let first_commit = commit_with_tree(&git_repo, None, first_tree, "add shared subtree", &[]);
let note_blob = git_repo.write_blob(b"second\n").expect("note blob");
let mut second_editor = git_repo
.edit_tree(&empty_tree_oid(&git_repo))
.expect("second tree editor");
second_editor.upsert("shared", EntryKind::Tree, shared_tree);
second_editor.upsert("note.txt", EntryKind::Blob, note_blob);
let second_tree = git_repo
.write_tree(second_editor)
.expect("write second tree");
let _second_commit = commit_with_tree(
&git_repo,
Some("refs/heads/main"),
second_tree,
"reuse shared subtree",
&[first_commit],
);
let heddle_temp = TempDir::new().expect("heddle temp");
let repo = Repository::init(heddle_temp.path()).expect("init heddle");
let mut bridge = GitBridge::new(&repo);
let stats = import_all_with_options(
&mut bridge,
Some(&git_repo.workdir().expect("workdir")),
ingest::ImportOptions { lossy: true },
)
.expect("lossy import accepts shared gitlink subtree");
assert_eq!(stats.states_created, 2);
assert_eq!(
stats.lossy_entries.len(),
2,
"runtime stats should include both the original and cached ingest lossy entries"
);
assert_eq!(stats.lossy_entries[0].path, "shared/vendor");
assert_eq!(stats.lossy_entries[1].path, "shared/vendor");
assert!(
!test_support::mapping_path(&bridge).exists(),
"bridge import must not publish lossy entries through the served mapping cache"
);
}
#[test]
fn import_all_default_fails_on_cached_lossy_commit_from_prior_run() {
let (_git_temp, git_repo) = init_gitlink_repo();
let git_path = git_repo.workdir().expect("workdir");
let heddle_temp = TempDir::new().expect("heddle temp");
let repo = Repository::init(heddle_temp.path()).expect("init heddle");
let mut first_bridge = GitBridge::new(&repo);
let first = import_all_with_options(
&mut first_bridge,
Some(git_path.as_path()),
ingest::ImportOptions { lossy: true },
)
.expect("initial lossy bridge import succeeds");
assert_eq!(first.lossy_entries.len(), 1);
assert!(
!test_support::mapping_path(&first_bridge).exists(),
"bridge import must not publish lossy entries through the served mapping cache"
);
let mut rerun_bridge = GitBridge::new(&repo);
let err = import_all(&mut rerun_bridge, Some(git_path.as_path()))
.expect_err("default bridge import must not reuse cached lossy state silently");
assert_lossy_default_rerun_error("bridge", &err.to_string());
}
#[test]
fn import_all_lossy_reports_cached_lossy_commit_from_prior_run() {
let (_git_temp, git_repo) = init_gitlink_repo();
let git_path = git_repo.workdir().expect("workdir");
let heddle_temp = TempDir::new().expect("heddle temp");
let repo = Repository::init(heddle_temp.path()).expect("init heddle");
let mut first_bridge = GitBridge::new(&repo);
import_all_with_options(
&mut first_bridge,
Some(git_path.as_path()),
ingest::ImportOptions { lossy: true },
)
.expect("initial lossy bridge import succeeds");
let mut rerun_bridge = GitBridge::new(&repo);
let second = import_all_with_options(
&mut rerun_bridge,
Some(git_path.as_path()),
ingest::ImportOptions { lossy: true },
)
.expect("lossy bridge rerun reports ingest-cached lossy entries");
assert_eq!(second.states_created, 0);
assert_eq!(second.lossy_entries.len(), 1);
assert_eq!(second.lossy_entries[0].path, "vendor");
assert!(second.lossy_entries[0].summary_line().contains("dropped"));
}
#[cfg(feature = "ingest")]
#[derive(Clone, Copy, Debug)]
enum ImportEngine {
Ingest,
Bridge,
}
#[cfg(feature = "ingest")]
impl ImportEngine {
fn label(self) -> &'static str {
match self {
ImportEngine::Ingest => "ingest",
ImportEngine::Bridge => "bridge",
}
}
}
fn assert_lossy_default_rerun_error(engine: &str, message: &str) {
assert!(
message.contains("vendor"),
"{engine} error names entry: {message}"
);
assert!(
message.contains("losslessly"),
"{engine} error explains policy: {message}"
);
assert!(
message.contains("--lossy"),
"{engine} error names opt-in: {message}"
);
}
#[cfg(feature = "ingest")]
fn run_lossy_then_default_rerun(engine: ImportEngine) {
let (_git_temp, git_repo) = init_gitlink_repo();
let git_path = git_repo.workdir().expect("workdir");
let message = match engine {
ImportEngine::Ingest => {
use ingest::{ImportOptions, import_git_into, import_git_into_with_options};
let heddle_temp = TempDir::new().expect("heddle temp");
let (first, map) = import_git_into_with_options(
&git_path,
heddle_temp.path(),
ImportOptions { lossy: true },
)
.expect("initial lossy ingest import succeeds");
drop(map);
assert_eq!(first.lossy_entries.len(), 1);
import_git_into(git_path, heddle_temp.path())
.expect_err("default ingest import must fail on cached lossy tree")
.to_string()
}
ImportEngine::Bridge => {
let heddle_temp = TempDir::new().expect("heddle temp");
let repo = Repository::init(heddle_temp.path()).expect("init heddle");
let mut first_bridge = GitBridge::new(&repo);
let first = import_all_with_options(
&mut first_bridge,
Some(git_path.as_path()),
ingest::ImportOptions { lossy: true },
)
.expect("initial lossy bridge import succeeds");
assert_eq!(first.lossy_entries.len(), 1);
let mut rerun_bridge = GitBridge::new(&repo);
import_all(&mut rerun_bridge, Some(git_path.as_path()))
.expect_err("default bridge import must fail on cached lossy commit")
.to_string()
}
};
assert_lossy_default_rerun_error(engine.label(), &message);
}
#[cfg(feature = "ingest")]
#[test]
fn both_engines_fail_hard_on_default_rerun_after_lossy() {
for engine in [ImportEngine::Ingest, ImportEngine::Bridge] {
run_lossy_then_default_rerun(engine);
}
}
#[test]
fn import_all_lossy_clean_repo_reports_no_lossy_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 tree_oid = empty_tree_oid(&git_repo);
commit_with_tree(&git_repo, Some("refs/heads/main"), tree_oid, "clean", &[]);
let mut bridge = GitBridge::new(&repo);
let stats = import_all_with_options(
&mut bridge,
Some(git_repo.workdir().expect("workdir").as_path()),
ingest::ImportOptions { lossy: true },
)
.expect("clean repo imports with lossy flag too");
assert_eq!(stats.states_created, 1);
assert!(stats.lossy_entries.is_empty());
}
fn assert_only_state_is_lossy_with_fidelity(repo: &Repository, surface: &str) {
let states: Vec<_> = repo
.store()
.list_states()
.expect("list states")
.iter()
.filter_map(|id| repo.store().get_state(id).expect("get state"))
.collect();
assert_eq!(states.len(), 1, "{surface}: single gitlink commit imported");
let state = &states[0];
assert!(
state.raw_message.is_some(),
"{surface}: git-fidelity (raw_message) present — without the lossy \
marker this is exactly what the buggy guard reconstructs"
);
assert!(
state.git_lossy,
"{surface}: a --lossy import must set the canonical git_lossy marker so \
export does not reconstruct a wrong-SHA object"
);
}
#[test]
fn bridge_import_lossy_state_carries_canonical_git_lossy_marker() {
let heddle_temp = TempDir::new().expect("heddle temp");
let repo = Repository::init(heddle_temp.path()).expect("init heddle");
let (_git_temp, git_repo) = init_gitlink_repo();
let mut bridge = GitBridge::new(&repo);
let stats = import_all_with_options(
&mut bridge,
Some(git_repo.workdir().expect("workdir").as_path()),
ingest::ImportOptions { lossy: true },
)
.expect("lossy bridge import succeeds");
assert_eq!(
stats.lossy_entries.len(),
1,
"gitlink is the one lossy entry"
);
assert_only_state_is_lossy_with_fidelity(&repo, "bridge import --lossy");
}
#[cfg(feature = "ingest")]
#[test]
fn ingest_lossy_state_carries_canonical_git_lossy_marker() {
use ingest::{ImportOptions, import_git_into_with_options};
let (_git_temp, git_repo) = init_gitlink_repo();
let git_path = git_repo.workdir().expect("workdir");
let heddle_temp = TempDir::new().expect("heddle temp");
let (stats, map) =
import_git_into_with_options(git_path, heddle_temp.path(), ImportOptions { lossy: true })
.expect("lossy ingest succeeds");
drop(map);
assert_eq!(
stats.lossy_entries.len(),
1,
"gitlink is the one lossy entry"
);
let repo = Repository::open(heddle_temp.path()).expect("open heddle repo");
assert_only_state_is_lossy_with_fidelity(&repo, "ingest-backed lossy import");
}
#[cfg(feature = "ingest")]
#[test]
fn export_mints_lossy_ingest_state_from_raw_metadata() {
use ingest::{ImportOptions, import_git_into_with_options};
let (_git_temp, git_repo) = init_gitlink_repo();
let git_path = git_repo.workdir().expect("workdir");
let heddle_temp = TempDir::new().expect("heddle temp");
let (stats, map) =
import_git_into_with_options(git_path, heddle_temp.path(), ImportOptions { lossy: true })
.expect("lossy ingest succeeds");
drop(map);
assert_eq!(
stats.lossy_entries.len(),
1,
"gitlink is the one lossy entry"
);
let repo = Repository::open(heddle_temp.path()).expect("open heddle repo");
let state = {
let ids = repo.store().list_states().expect("list states");
assert_eq!(ids.len(), 1, "single gitlink commit ingested");
repo.store()
.get_state(&ids[0])
.expect("get state")
.expect("state present")
};
let raw_message = state
.raw_message
.clone()
.expect("ingest records raw_message");
let mut bridge = GitBridge::new(&repo);
let dest_root = TempDir::new().expect("dest temp");
let dest_path = dest_root.path().join("export");
bridge
.export_to_path(&dest_path)
.expect("export must MINT the unmapped lossy state, not reject it");
let dest_repo = open_git(&dest_path).expect("open dest");
let dest_main = find_reference(&dest_repo, "refs/heads/main")
.expect("exported main ref")
.peel_to_id()
.expect("exported main target");
let dest_commit = dest_repo.find_commit(dest_main).expect("dest main commit");
assert_eq!(
dest_commit.message_raw_sloppy().to_vec(),
raw_message,
"exported commit must preserve the ingest state's raw message verbatim, \
not the native \"No intent specified\" + Heddle-State footer"
);
}
#[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: ObjectId = "855827c583bc30645ba427885caa40c5b81764d2"
.parse()
.expect("oid");
let mut editor = source
.edit_tree(&empty_tree_oid(&source))
.expect("tree editor");
editor.upsert(
"sha1collisiondetection",
EntryKind::Commit,
foreign_submodule_oid,
);
let tree_with_gitlink = source.write_tree(editor).expect("write tree");
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 = open_git(&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(), 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,
RefPrecondition::MustNotExist,
"create branch",
)
.expect("create branch");
assert!(
find_reference(&repo, "refs/heads/feature-x").is_ok(),
"set_reference must create the branch"
);
delete_reference_if_present(&repo, "refs/heads/feature-x").expect("delete");
assert!(
find_reference(&repo, "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: ObjectId = "0909090909090909090909090909090909090909"
.parse()
.expect("oid");
let mut bridge = GitBridge::new(&repo);
test_support::mapping_mut(&mut bridge).insert(change_id, git_oid);
test_support::save_mapping_to_disk(&bridge).expect("save mapping");
let mut reloaded = GitBridge::new(&repo);
test_support::build_existing_mapping(
&mut reloaded,
Some(git_repo.workdir().expect("workdir").as_path()),
)
.expect("build mapping");
assert_eq!(
test_support::mapping(&reloaded).get_git(&change_id),
Some(git_oid)
);
}
#[test]
fn mapping_rebuilds_from_heddle_notes() {
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 change_id = ChangeId::generate();
let note = git_notes::HeddleNote {
change_id: change_id.to_string_full(),
agent: None,
confidence: None,
status: "published".to_string(),
omitted_annotations_breakdown: None,
signal_counts: None,
attribution: None,
};
git_notes::write_note(&git_repo, commit_oid, ¬e).expect("write heddle note");
let mut bridge = GitBridge::new(&repo);
test_support::build_existing_mapping(
&mut bridge,
Some(git_repo.workdir().expect("workdir").as_path()),
)
.expect("build mapping");
assert_eq!(
test_support::mapping(&bridge).get_heddle(commit_oid),
Some(change_id)
);
}
#[test]
fn mapping_rebuild_prefers_heddle_notes_over_stale_cache() {
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 change_id = ChangeId::generate();
let stale_oid: ObjectId = "abababababababababababababababababababab"
.parse()
.expect("oid");
let note = git_notes::HeddleNote {
change_id: change_id.to_string_full(),
agent: None,
confidence: None,
status: "published".to_string(),
omitted_annotations_breakdown: None,
signal_counts: None,
attribution: None,
};
git_notes::write_note(&git_repo, commit_oid, ¬e).expect("write heddle note");
let mut stale_bridge = GitBridge::new(&repo);
test_support::mapping_mut(&mut stale_bridge).insert(change_id, stale_oid);
test_support::save_mapping_to_disk(&stale_bridge).expect("save stale cache");
let mut bridge = GitBridge::new(&repo);
test_support::build_existing_mapping(
&mut bridge,
Some(git_repo.workdir().expect("workdir").as_path()),
)
.expect("notes should rebuild over stale cache");
assert_eq!(
test_support::mapping(&bridge).get_git(&change_id),
Some(commit_oid),
"refs/notes/heddle is authoritative for exported Git identity"
);
assert_eq!(
test_support::mapping(&bridge).get_heddle(stale_oid),
None,
"stale cache-only identity must be discarded when a note supersedes it"
);
let mapping = std::fs::read_to_string(test_support::mapping_path(&bridge))
.expect("existing mapping cache");
assert!(
!mapping.contains(&commit_oid.to_string()),
"mapping rebuild is read-only; export owns the visibility-filtered cache write"
);
assert!(
mapping.contains(&stale_oid.to_string()),
"read-only mapping rebuild must not rewrite the existing cache"
);
}
#[test]
fn test_sync_mapping() {
let mut mapping = SyncMapping::new();
let change_id = ChangeId::generate();
let oid: 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);
test_support::set_git_repo_path(
&mut bridge,
git_repo.workdir().expect("workdir").to_path_buf(),
);
let change_id = ChangeId::generate();
test_support::mapping_mut(&mut bridge).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);
test_support::set_git_repo_path(
&mut bridge,
git_repo.workdir().expect("workdir").to_path_buf(),
);
let change_id = ChangeId::generate();
test_support::mapping_mut(&mut bridge).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(&ThreadName::new("main"))
.unwrap()
.is_some()
);
assert!(
repo.refs()
.get_marker(&MarkerName::new("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(&ThreadName::new("main"))
.unwrap()
.is_some()
);
assert!(
repo.refs()
.get_marker(&MarkerName::new("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 Some(daemon) = GitDaemon::spawn(remote_root.path()) else {
return;
};
let mut bridge = GitBridge::new(&repo);
bridge.pull(&daemon.url("remote.git")).expect("pull remote");
assert!(
repo.refs()
.get_thread(&ThreadName::new("main"))
.unwrap()
.is_some()
);
assert!(
repo.refs()
.get_marker(&MarkerName::new("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 Some(backend) = GitHttpBackend::spawn(remote_root.path()) else {
return;
};
let mut bridge = GitBridge::new(&repo);
bridge
.pull(&backend.url("remote.git"))
.expect("pull remote over http");
assert!(
repo.refs()
.get_thread(&ThreadName::new("main"))
.unwrap()
.is_some()
);
assert!(
repo.refs()
.get_marker(&MarkerName::new("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 Some(backend) = GitHttpBackend::spawn_authenticated(remote_root.path(), "heddle", "secret")
else {
return;
};
let mut bridge = GitBridge::new(&repo);
bridge
.pull(&backend.url("remote.git"))
.expect("pull remote over authenticated http");
assert!(
repo.refs()
.get_thread(&ThreadName::new("main"))
.unwrap()
.is_some()
);
assert!(
repo.refs()
.get_marker(&MarkerName::new("v1.0"))
.unwrap()
.is_some()
);
}
#[test]
fn fetch_rejects_reserved_git_remote_name_at_boundary() {
let heddle_temp = TempDir::new().expect("heddle temp");
let repo = Repository::init(heddle_temp.path()).expect("init heddle");
let mut bridge = GitBridge::new(&repo);
let err = bridge
.fetch("git")
.expect_err("fetch of reserved remote name must be rejected");
let message = err.to_string();
assert!(
message.contains("reserved namespace") && message.contains("rename"),
"fetch error must explain the reserved-namespace collision and how to fix it, got: {message}"
);
}
#[test]
fn pull_rejects_reserved_git_remote_name_at_boundary() {
let heddle_temp = TempDir::new().expect("heddle temp");
let repo = Repository::init(heddle_temp.path()).expect("init heddle");
let mut bridge = GitBridge::new(&repo);
let err = bridge
.pull("git")
.expect_err("pull of reserved remote name must be rejected");
let message = err.to_string();
assert!(
message.contains("reserved namespace") && message.contains("rename"),
"pull error must explain the reserved-namespace collision and how to fix it, got: {message}"
);
}
#[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").as_path()),
)
.expect("import merge history");
assert_eq!(stats.commits_imported, 4);
assert_eq!(
repo.refs().get_thread(&ThreadName::new("main")).unwrap(),
test_support::mapping(&bridge).get_heddle(merge)
);
assert!(test_support::mapping(&bridge).get_heddle(base).is_some());
assert!(test_support::mapping(&bridge).get_heddle(left).is_some());
assert!(test_support::mapping(&bridge).get_heddle(right).is_some());
assert!(test_support::mapping(&bridge).get_heddle(merge).is_some());
}
#[test]
fn import_rejects_branch_name_that_is_not_a_valid_thread_id() {
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", &[]);
commit_with_tree(
&git_repo,
Some("refs/heads/evil;rm"),
tree_oid,
"evil",
&[base],
);
let mut bridge = GitBridge::new(&repo);
let err = import_all(
&mut bridge,
Some(git_repo.workdir().expect("workdir").as_path()),
)
.expect_err("import must reject a branch whose name is not a valid thread id");
assert!(
err.contains("evil;rm"),
"the rejection must name the branch, got: {err}"
);
}
#[test]
fn failed_import_restores_mapping_and_overrides() {
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", &[]);
commit_with_tree(
&git_repo,
Some("refs/heads/evil;rm"),
tree_oid,
"evil",
&[base],
);
let mut bridge = GitBridge::new(&repo);
test_support::set_commit_message_override(
&mut bridge,
ChangeId::from_bytes([3; 16]),
"pre-call override".to_string(),
);
let pre_mapping = test_support::mapping(&bridge).clone();
let pre_overrides = test_support::commit_message_overrides(&bridge).clone();
import_all(&mut bridge, Some(&git_repo.workdir().expect("workdir")))
.expect_err("invalid branch name must fail after commits are mapped");
assert_eq!(
test_support::mapping(&bridge),
&pre_mapping,
"failed import must restore the pre-call mapping"
);
assert_eq!(
test_support::commit_message_overrides(&bridge),
&pre_overrides,
"failed import must restore pre-call commit message overrides"
);
test_support::build_existing_mapping(&mut bridge, Some(&git_repo.workdir().expect("workdir")))
.expect("mapping rebuild after failed import");
assert_eq!(
test_support::mapping(&bridge),
&pre_mapping,
"rebuilding after a failed import must not recover partial mappings from the sidecar"
);
}
#[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").as_path()))
.expect("import from git");
bridge
.push_with_scope(
remote_temp.path().to_str().expect("remote path"),
GitPushScope::AllThreads,
)
.expect("push remote");
let main_oid = find_reference(&remote_repo, "refs/heads/main")
.expect("main ref")
.peel_to_id()
.expect("main target");
let tag_oid = find_reference(&remote_repo, "refs/tags/v1.0")
.expect("tag ref")
.peel_to_id()
.expect("tag target");
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 = find_reference(&remote_repo, git_notes::NOTES_REF)
.expect("notes ref should be pushed to remote");
let _ = note_ref;
}
#[test]
fn push_current_thread_scope_exports_only_attached_branch_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 main_oid = commit_with_tree(&source_repo, Some("refs/heads/main"), tree_oid, "main", &[]);
let side_oid = commit_with_tree(&source_repo, Some("refs/heads/side"), tree_oid, "side", &[]);
create_annotated_tag(&source_repo, "v1.0", side_oid, "release");
let mut bridge = GitBridge::new(&repo);
bridge
.import(Some(source_repo.workdir().expect("workdir").as_path()))
.expect("import from git");
bridge
.push_with_scope(
remote_temp.path().to_str().expect("remote path"),
GitPushScope::CurrentThread,
)
.expect("push current thread");
let pushed_main = find_reference(&remote_repo, "refs/heads/main")
.expect("main ref")
.peel_to_id()
.expect("main target");
assert_eq!(pushed_main, main_oid);
assert!(
find_reference(&remote_repo, "refs/heads/side").is_err(),
"current-thread push must not push sibling branches"
);
assert!(
find_reference(&remote_repo, "refs/tags/v1.0").is_err(),
"current-thread push must not push tags"
);
assert!(
find_reference(&remote_repo, git_notes::NOTES_REF).is_ok(),
"current-thread push must carry Heddle notes so cloned Git commits keep stable state IDs"
);
}
#[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<ObjectId> = None;
let mut last: Option<ObjectId> = None;
for i in 0..DEPTH {
let parents: Vec<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"),
RefPrecondition::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").as_path()),
)
.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");
let blob_tag = TagObject {
object: blob_oid,
object_type: GitObjectType::Blob,
name: b"junio-gpg-pub".to_vec(),
tagger: Some(test_signature().to_ident_bytes()),
message: b"GPG public key".to_vec(),
raw_body: None,
};
let blob_tag_oid = source_repo
.write_object(EncodedObject::new(GitObjectType::Tag, blob_tag.write()))
.expect("write tag");
set_reference(
&source_repo,
"refs/tags/junio-gpg-pub",
blob_tag_oid,
RefPrecondition::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");
let mut editor = source_repo
.edit_tree(&empty_tree_oid(&source_repo))
.expect("editor");
editor.upsert("alice.asc", EntryKind::Blob, key_blob);
let tree_for_tag_oid = source_repo.write_tree(editor).expect("write tree");
let tree_tag = TagObject {
object: tree_for_tag_oid,
object_type: GitObjectType::Tree,
name: b"core-gpg-keys".to_vec(),
tagger: Some(test_signature().to_ident_bytes()),
message: b"core GPG keys directory".to_vec(),
raw_body: None,
};
let tree_tag_oid = source_repo
.write_object(EncodedObject::new(GitObjectType::Tag, tree_tag.write()))
.expect("write tree tag");
set_reference(
&source_repo,
"refs/tags/core-gpg-keys",
tree_tag_oid,
RefPrecondition::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").as_path()),
)
.expect("import must complete despite non-commit-pointing tags");
assert_eq!(stats.commits_imported, 1);
assert!(
repo.refs()
.get_thread(&ThreadName::new("main"))
.expect("read main thread")
.is_some(),
"the regular commit should have produced a main thread"
);
assert_eq!(
stats.skipped_non_commit_refs, 2,
"both non-commit-pointing tags should be counted"
);
}
#[test]
fn git_source_parse_distinguishes_urls_and_paths() {
use cli::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() {
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 = format!("file://{}", src_path.display());
let dest_root = TempDir::new().expect("dest temp");
let dest = dest_root.path().join("clone-dest");
clone_url_to_bare(url.as_str(), &dest, None, None).expect("clone file url");
let dest_repo = open_git(&dest).expect("open dest");
let dest_main = find_reference(&dest_repo, "refs/heads/main")
.expect("main ref present after clone")
.peel_to_id()
.expect("peel main");
assert_eq!(
dest_main, commit_oid,
"Phase F: clone_url_to_bare must transfer the original commit OID"
);
assert!(
find_reference(&dest_repo, "refs/tags/v1.0").is_ok(),
"Phase F: tags must be fetched too (with_fetch_tags::All)"
);
}
fn build_source_repo_three_commits_with_blobs()
-> (TempDir, SleyRepository, [ObjectId; 3], [ObjectId; 3]) {
let (temp, repo) = init_git_repo();
let blob1 = repo.write_blob(b"alpha\n").expect("blob1");
let blob2 = repo.write_blob(b"beta\n").expect("blob2");
let blob3 = repo.write_blob(b"gamma\n").expect("blob3");
let mut e1 = repo.edit_tree(&empty_tree_oid(&repo)).expect("e1");
e1.upsert("a.txt", EntryKind::Blob, blob1);
let t1 = repo.write_tree(e1).expect("write t1");
let c1 = commit_with_tree(&repo, None, t1, "c1: add a.txt", &[]);
let mut e2 = repo.edit_tree(&empty_tree_oid(&repo)).expect("e2");
e2.upsert("a.txt", EntryKind::Blob, blob1);
e2.upsert("b.txt", EntryKind::Blob, blob2);
let t2 = repo.write_tree(e2).expect("write t2");
let c2 = commit_with_tree(&repo, None, t2, "c2: add b.txt", &[c1]);
let mut e3 = repo.edit_tree(&empty_tree_oid(&repo)).expect("e3");
e3.upsert("a.txt", EntryKind::Blob, blob1);
e3.upsert("b.txt", EntryKind::Blob, blob2);
e3.upsert("c.txt", EntryKind::Blob, blob3);
let t3 = repo.write_tree(e3).expect("write t3");
let c3 = commit_with_tree(&repo, Some("refs/heads/main"), t3, "c3: add c.txt", &[c2]);
(temp, repo, [c1, c2, c3], [blob1, blob2, blob3])
}
#[test]
fn clone_url_to_bare_rejects_shallow_file_url_without_shelling_to_git() {
let (_src_temp, source_repo, _commits, _blobs) = build_source_repo_three_commits_with_blobs();
let src_path = source_repo
.workdir()
.expect("workdir")
.canonicalize()
.expect("canonicalize");
let url = format!("file://{}", src_path.display());
let dest_root = TempDir::new().expect("dest temp");
let dest = dest_root.path().join("clone-dest");
let err = clone_url_to_bare(url.as_str(), &dest, Some(1), None)
.expect_err("shallow file:// clone should fail closed in no-git runtime");
let msg = err.to_string();
assert!(
msg.contains("shallow file:// Git clones are not supported")
&& msg.contains("native no-git runtime")
&& msg.contains("without spawning Git transport helpers"),
"shallow file:// refusal should explain the no-git native boundary: {msg}"
);
assert!(
!dest.exists(),
"rejection should happen before writing destination state"
);
}
#[test]
fn clone_url_to_bare_rejects_blob_none_filter_without_shelling_to_git() {
let (_src_temp, source_repo, _commits, _blobs) = build_source_repo_three_commits_with_blobs();
let src_path = source_repo
.workdir()
.expect("workdir")
.canonicalize()
.expect("canonicalize");
let url = format!("file://{}", src_path.display());
let dest_root = TempDir::new().expect("dest temp");
let dest = dest_root.path().join("clone-dest");
let err = clone_url_to_bare(url.as_str(), &dest, Some(1), Some("blob:none"))
.expect_err("filtered clone must be rejected in no-git runtime");
let msg = err.to_string();
assert!(
msg.contains("partial Git clone filter `blob:none` is not supported")
&& msg.contains("native no-git runtime"),
"filter refusal should explain the no-git native boundary: {msg}"
);
}
#[test]
fn clone_url_to_bare_filter_rejection_preserves_pre_created_empty_dest() {
let (_src_temp, source_repo, _commits, _blobs) = build_source_repo_three_commits_with_blobs();
let src_path = source_repo
.workdir()
.expect("workdir")
.canonicalize()
.expect("canonicalize");
let url = format!("file://{}", src_path.display());
let dest_root = TempDir::new().expect("dest temp");
let dest = dest_root.path().join("clone-dest");
std::fs::create_dir(&dest).expect("pre-create empty dest");
assert!(dest.exists() && dest.read_dir().expect("read empty").next().is_none());
let err = clone_url_to_bare(url.as_str(), &dest, None, Some("blob:none"))
.expect_err("filtered clone must be rejected in no-git runtime");
assert!(
err.to_string().contains("retry without --filter/--lazy"),
"filter refusal should give the native retry path: {err}"
);
assert!(
dest.exists(),
"rejection should not remove caller scratch dir"
);
assert!(
dest.read_dir()
.expect("read preserved empty")
.next()
.is_none(),
"rejection should not write partial Git state"
);
}
#[test]
fn clone_url_to_bare_filter_rejection_precedes_remote_probe() {
let scratch = TempDir::new().expect("scratch");
let nowhere = scratch.path().join("does-not-exist");
let url = format!("file://{}", nowhere.display());
let dest_root = TempDir::new().expect("dest temp");
let dest = dest_root.path().join("clone-dest");
let err =
clone_url_to_bare(url.as_str(), &dest, Some(1), Some("blob:none")).expect_err("must fail");
let msg = err.to_string();
assert!(
msg.contains("partial Git clone filter `blob:none` is not supported"),
"unsupported native capability should be reported before remote probing: {msg}"
);
assert!(
!dest.exists(),
"rejection should not create destination state"
);
}
#[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").as_path()))
.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 = open_git(&dest_path).expect("open exported repo");
assert!(
find_reference(&dest_repo, "refs/heads/main").is_ok(),
"exported repo should have refs/heads/main"
);
assert!(
find_reference(&dest_repo, "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").as_path()))
.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 export_stats_report_total_commits_when_all_states_pre_mapped() {
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");
let mut bridge = GitBridge::new(&repo);
bridge
.import(Some(source_repo.workdir().expect("workdir").as_path()))
.expect("import from git");
let dest_root = TempDir::new().expect("dest temp");
let dest_path = dest_root.path().join("export-target");
let stats = bridge.export_to_path(&dest_path).expect("export");
assert_eq!(
stats.states_exported, 0,
"SHA-stable overlay export mints no new commits"
);
assert!(
stats.commits_total >= 2,
"commits_total must count every state that landed in the destination, got {}",
stats.commits_total
);
assert!(
stats.commits_total > stats.states_exported,
"total must exceed newly-minted in the overlay case"
);
assert!(
stats.branches.iter().any(|b| b.name == "main"),
"branch detail should list main with its tip: {:?}",
stats.branches
);
assert!(
stats.tags.iter().any(|t| t.name == "v1.0"),
"tag detail should list v1.0 with its tip: {:?}",
stats.tags
);
}
#[test]
fn sync_export_and_import_report_consistent_total_and_new() {
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", &[]);
commit_with_tree(
&source_repo,
Some("refs/heads/main"),
tree_oid,
"second",
&[first],
);
let mut bridge = GitBridge::new(&repo);
bridge
.import(Some(source_repo.workdir().expect("workdir").as_path()))
.expect("import from git");
let export_stats = export_all(&mut bridge).expect("re-export");
let import_stats = import_all(
&mut bridge,
Some(source_repo.workdir().expect("workdir").as_path()),
)
.expect("re-import");
assert_eq!(
export_stats.states_exported, 0,
"nothing new to export on a synced overlay"
);
assert!(
export_stats.commits_total >= 2,
"export total still reflects the populated destination: {}",
export_stats.commits_total
);
assert_eq!(
import_stats.states_created, 0,
"nothing new to import on a synced overlay"
);
assert!(
import_stats.commits_imported >= 2,
"import total still reflects the walked commits: {}",
import_stats.commits_imported
);
}
#[test]
fn export_total_counts_stale_mirror_ref_left_by_dropped_thread() {
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", &[]);
commit_with_tree(
&source_repo,
Some("refs/heads/main"),
tree_oid,
"second",
&[first],
);
let feature_tip = commit_with_tree(
&source_repo,
Some("refs/heads/feature"),
tree_oid,
"feature-only",
&[],
);
let mut bridge = GitBridge::new(&repo);
bridge
.import(Some(source_repo.workdir().expect("workdir").as_path()))
.expect("import from git");
assert_eq!(
test_support::heddle_repo(&bridge)
.store()
.list_states()
.expect("states")
.len(),
3,
"import should have created three states (two on main, one on feature)"
);
test_support::heddle_repo(&bridge)
.refs()
.delete_thread(&ThreadName::new("feature"))
.expect("delete feature thread");
let dest_temp = TempDir::new().expect("dest temp");
let dest_path = dest_temp.path().join("dest.git");
let stats = bridge.export_to_path(&dest_path).expect("export to path");
assert_eq!(
stats.commits_total, 3,
"export total must count what lands in the destination — main's 2 \
plus the stale feature ref's 1, got {}",
stats.commits_total
);
let dest = open_git(&dest_path).expect("open destination");
let feature_ref = find_reference(&dest, "refs/heads/feature")
.expect("destination must contain the stale feature ref");
assert_eq!(
feature_ref.id(),
feature_tip,
"the stale feature ref in the destination points at the feature tip"
);
assert!(
find_reference(&dest, "refs/heads/main").is_ok(),
"destination must contain the main branch"
);
assert!(
!stats.branches.iter().any(|b| b.name == "feature"),
"dropped feature thread is not a current synced branch: {:?}",
stats.branches
);
}
#[test]
fn export_counts_exclude_orphan_minted_state_from_total_and_newly() {
use objects::object::{Attribution, Principal, State};
let heddle_temp = TempDir::new().expect("heddle temp");
let repo = Repository::init(heddle_temp.path()).expect("init heddle");
let bridge = GitBridge::new(&repo);
let attribution = || Attribution::human(Principal::new("Alice", "alice@example.com"));
let put_state = |parents: Vec<ChangeId>| -> State {
let store = test_support::heddle_repo(&bridge).store();
let blob_hash = store
.put_blob(&Blob::from_slice(b"contents"))
.expect("put blob");
let tree_hash = store
.put_tree(&Tree::from_entries(vec![
TreeEntry::file("file.txt".to_string(), blob_hash, false).expect("tree entry"),
]))
.expect("put tree");
let state = State::new(tree_hash, parents, attribution());
store.put_state(&state).expect("put state");
state
};
let main_first = put_state(Vec::new());
let main_tip = put_state(vec![main_first.change_id]);
test_support::heddle_repo(&bridge)
.refs()
.set_thread(&ThreadName::new("main"), &main_tip.change_id)
.expect("set main thread");
let orphan = put_state(Vec::new());
test_support::heddle_repo(&bridge)
.refs()
.set_thread(&ThreadName::new("scratch"), &orphan.change_id)
.expect("set scratch thread");
test_support::heddle_repo(&bridge)
.refs()
.delete_thread(&ThreadName::new("scratch"))
.expect("delete scratch thread");
let mut bridge = bridge;
assert_eq!(
test_support::heddle_repo(&bridge)
.store()
.list_states()
.expect("states")
.len(),
3,
"store holds main's two states plus the dropped scratch state"
);
let dest_temp = TempDir::new().expect("dest temp");
let dest_path = dest_temp.path().join("dest.git");
let stats = bridge.export_to_path(&dest_path).expect("export to path");
assert_eq!(
stats.commits_total, 2,
"total counts only the copied ref set (main's 2), not the orphan, got {}",
stats.commits_total
);
assert_eq!(
stats.states_exported, 2,
"newly counts only minted commits that landed (main's 2), not the orphan, got {}",
stats.states_exported
);
let already = stats.commits_total.saturating_sub(stats.states_exported);
assert!(
stats.states_exported <= stats.commits_total,
"newly ({}) must never exceed total ({})",
stats.states_exported,
stats.commits_total
);
assert_eq!(
stats.states_exported + already,
stats.commits_total,
"newly + already must equal total by construction"
);
let dest = open_git(&dest_path).expect("open destination");
assert!(
find_reference(&dest, "refs/heads/main").is_ok(),
"destination must contain the main branch"
);
assert!(
find_reference(&dest, "refs/heads/scratch").is_err(),
"dropped scratch thread must not appear in the destination"
);
assert!(
!stats.branches.iter().any(|b| b.name == "scratch"),
"dropped scratch thread is not a synced branch: {:?}",
stats.branches
);
}
#[test]
fn failed_export_restores_mapping_and_overrides_after_purge() {
use chrono::Utc;
use objects::object::{Attribution, Principal, State, StateVisibility, VisibilityTier};
let heddle_temp = TempDir::new().expect("heddle temp");
let repo = Repository::init_default(heddle_temp.path()).expect("init heddle");
let mut cfg = repo.config().clone();
cfg.set_principal("Grace Hopper", "grace@example.com");
cfg.save(&repo.heddle_dir().join("config.toml"))
.expect("save principal");
let repo = Repository::open(heddle_temp.path()).expect("reopen heddle");
let attribution = || Attribution::human(Principal::new("Grace Hopper", "grace@example.com"));
let store = repo.store();
let blob_hash = store
.put_blob(&Blob::from_slice(b"public\n"))
.expect("put blob");
let tree_hash = store
.put_tree(&Tree::from_entries(vec![
TreeEntry::file("public.txt".to_string(), blob_hash, false).expect("tree entry"),
]))
.expect("put tree");
let exported_state = State::new(tree_hash, Vec::new(), attribution());
store
.put_state(&exported_state)
.expect("put exported state");
repo.refs()
.set_thread(&ThreadName::new("main"), &exported_state.change_id)
.expect("set main thread");
let mut bridge = GitBridge::new(&repo);
export_all(&mut bridge).expect("initial export seeds mapping");
repo.put_state_visibility(StateVisibility {
state: exported_state.change_id,
tier: VisibilityTier::Private {
scope_label: "embargo".to_string(),
},
embargo_until: None,
declarer: Principal::new("Grace Hopper", "grace@example.com"),
declared_at: Utc::now(),
signature: None,
supersedes: None,
})
.expect("mark exported state private");
let bad_tree_hash = store
.put_tree(&Tree::from_entries(vec![
TreeEntry::file(
"missing.txt".to_string(),
ContentHash::from_bytes([9; 32]),
false,
)
.expect("tree entry"),
]))
.expect("put bad tree");
let bad_state = State::new(bad_tree_hash, Vec::new(), attribution());
store.put_state(&bad_state).expect("put bad state");
repo.refs()
.set_thread(&ThreadName::new("main"), &bad_state.change_id)
.expect("set main to bad state");
test_support::set_commit_message_override(
&mut bridge,
exported_state.change_id,
"pre-call override".to_string(),
);
let pre_mapping = test_support::mapping(&bridge).clone();
let pre_overrides = test_support::commit_message_overrides(&bridge).clone();
export_all(&mut bridge).expect_err("missing blob must fail after visibility purge");
assert_eq!(
test_support::mapping(&bridge),
&pre_mapping,
"failed export must restore the pre-call mapping"
);
assert_eq!(
test_support::commit_message_overrides(&bridge),
&pre_overrides,
"failed export must restore pre-call commit message overrides"
);
test_support::build_existing_mapping(&mut bridge, None)
.expect("mapping rebuild after failed export");
assert_eq!(
test_support::mapping(&bridge),
&pre_mapping,
"rebuilding after a failed export must behave as if the purge never ran"
);
}
#[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").as_path()),
)
.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").as_path()))
.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 = open_git(&dest_path).expect("open dest");
let dest_main = find_reference(&dest_repo, "refs/heads/main")
.expect("main ref")
.peel_to_id()
.expect("main target");
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");
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").as_path()))
.expect("import into A");
let change_id_in_a = test_support::mapping(&bridge_a)
.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 = test_support::mapping(&bridge_b)
.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");
let link_oid = source_repo
.write_blob(b"target.txt")
.expect("write link blob");
let empty = empty_tree_oid(&source_repo);
let mut editor = source_repo.edit_tree(&empty).expect("editor");
editor.upsert("target.txt", EntryKind::Blob, target_oid);
editor.upsert("link", EntryKind::Symlink, link_oid);
let tree_oid = source_repo.write_tree(editor).expect("write tree");
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").as_path()))
.expect("import");
let head_change_id = repo
.refs()
.get_thread(&ThreadName::new("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 = open_git(&dest_path).expect("open dest");
let dest_main = find_reference(&dest_repo, "refs/heads/main")
.expect("main")
.peel_to_id()
.expect("peel");
let dest_commit = dest_repo.find_commit(dest_main).expect("dest commit");
let dest_tree_oid = dest_commit.tree_id().expect("tree id");
let dest_tree = dest_repo.find_tree(dest_tree_oid).expect("dest tree");
let entries: Vec<(String, 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,
EntryKind::Symlink,
"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,
RefPrecondition::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").as_path()))
.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 = open_git(&dest_path).expect("open dest");
let dest_light = find_reference(&dest_repo, "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 = find_reference(&dest_repo, "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, GitObjectType::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: ObjectId = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"
.parse()
.expect("parse phantom oid");
set_reference(
&source_repo,
"refs/tags/phantom",
phantom_oid,
RefPrecondition::MustNotExist,
"test: phantom tag",
)
.expect("set phantom tag");
let mut bridge = GitBridge::new(&repo);
let result = bridge.import(Some(source_repo.workdir().expect("workdir").as_path()));
if let Ok(stats) = result {
assert_eq!(
stats.commits_imported, 1,
"the good commit should be mapped"
);
assert!(
test_support::mapping(&bridge)
.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_populates_mirror_with_identical_annotated_tag_object() {
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 blob = source_repo.write_blob(b"hello\n").expect("blob");
let mut editor = source_repo
.edit_tree(&ObjectId::empty_tree(source_repo.object_format()))
.expect("tree editor");
editor.upsert("file.txt", EntryKind::Blob, blob);
let tree = source_repo.write_tree(editor).expect("tree");
let first = commit_with_tree(&source_repo, Some("refs/heads/main"), tree, "first", &[]);
let second = commit_with_tree(
&source_repo,
Some("refs/heads/main"),
tree,
"second",
&[first],
);
let tag_oid = create_annotated_tag(&source_repo, "v1.0", second, "release");
let mut bridge = GitBridge::new(&repo);
bridge
.import(Some(&source_repo.workdir().expect("workdir")))
.expect("import");
let mirror = test_support::open_git_repo(&bridge).expect("open mirror");
let source_tag = source_repo
.find_object(tag_oid)
.expect("source tag present");
let mirror_tag = mirror.find_object(tag_oid).expect("mirror tag present");
assert_eq!(source_tag.kind, GitObjectType::Tag);
assert_eq!(mirror_tag.kind, source_tag.kind);
assert_eq!(mirror_tag.data, source_tag.data);
}
#[test]
fn export_lags_public_branch_to_frontier_emitting_absence_for_embargoed_tip() {
use chrono::Utc;
use objects::object::{Principal, StateVisibility, VisibilityTier};
let heddle_temp = TempDir::new().expect("heddle temp");
let repo = Repository::init_default(heddle_temp.path()).expect("init heddle");
let mut cfg = repo.config().clone();
cfg.set_principal("Grace Hopper", "grace@example.com");
cfg.save(&repo.heddle_dir().join("config.toml"))
.expect("save principal");
let repo = Repository::open(heddle_temp.path()).expect("reopen heddle");
std::fs::write(heddle_temp.path().join("a.txt"), b"base\n").unwrap();
repo.snapshot(Some("base".into()), None).unwrap();
let state_a = repo
.refs()
.get_thread(&ThreadName::new("main"))
.unwrap()
.unwrap();
std::fs::write(heddle_temp.path().join("b.txt"), b"embargoed fix\n").unwrap();
repo.snapshot(Some("fix".into()), None).unwrap();
let state_b = repo
.refs()
.get_thread(&ThreadName::new("main"))
.unwrap()
.unwrap();
assert_ne!(state_a, state_b);
repo.put_state_visibility(StateVisibility {
state: state_b,
tier: VisibilityTier::Private {
scope_label: "sec-embargo".into(),
},
embargo_until: None,
declarer: Principal {
name: "Grace Hopper".into(),
email: "grace@example.com".into(),
},
declared_at: Utc::now(),
signature: None,
supersedes: None,
})
.unwrap();
let mut bridge = GitBridge::new(&repo);
let stats = export_all(&mut bridge).expect("export");
assert!(
test_support::mapping(&bridge).get_git(&state_b).is_none(),
"embargoed tip must not be minted into the public mirror"
);
let oid_a = test_support::mapping(&bridge)
.get_git(&state_a)
.expect("public base A must be minted");
let main = stats
.branches
.iter()
.find(|b| b.name == "main")
.expect("main branch must be exported");
assert_eq!(
main.tip, oid_a,
"public branch must lag to the visibility frontier (A), not the embargoed tip"
);
}
#[test]
fn export_retracts_branch_when_public_commit_is_later_embargoed() {
use chrono::Utc;
use objects::object::{Principal, StateVisibility, VisibilityTier};
let heddle_temp = TempDir::new().expect("heddle temp");
let repo = Repository::init_default(heddle_temp.path()).expect("init heddle");
let mut cfg = repo.config().clone();
cfg.set_principal("Grace Hopper", "grace@example.com");
cfg.save(&repo.heddle_dir().join("config.toml"))
.expect("save principal");
let repo = Repository::open(heddle_temp.path()).expect("reopen heddle");
std::fs::write(heddle_temp.path().join("a.txt"), b"base\n").unwrap();
repo.snapshot(Some("base".into()), None).unwrap();
let state_a = repo
.refs()
.get_thread(&ThreadName::new("main"))
.unwrap()
.unwrap();
std::fs::write(heddle_temp.path().join("b.txt"), b"fix\n").unwrap();
repo.snapshot(Some("fix".into()), None).unwrap();
let state_b = repo
.refs()
.get_thread(&ThreadName::new("main"))
.unwrap()
.unwrap();
let mut bridge = GitBridge::new(&repo);
let run1 = export_all(&mut bridge).expect("first export");
let oid_a = test_support::mapping(&bridge)
.get_git(&state_a)
.expect("A minted");
let oid_b = test_support::mapping(&bridge)
.get_git(&state_b)
.expect("B minted");
let run1_main = run1
.branches
.iter()
.find(|b| b.name == "main")
.expect("main exported");
assert_eq!(
run1_main.tip, oid_b,
"run 1 branch advertises the public tip B"
);
repo.put_state_visibility(StateVisibility {
state: state_b,
tier: VisibilityTier::Private {
scope_label: "sec-embargo".into(),
},
embargo_until: None,
declarer: Principal {
name: "Grace Hopper".into(),
email: "grace@example.com".into(),
},
declared_at: Utc::now(),
signature: None,
supersedes: None,
})
.unwrap();
let run2 = export_all(&mut bridge).expect("second export");
let run2_main = run2
.branches
.iter()
.find(|b| b.name == "main")
.expect("main re-exported");
assert_eq!(
run2_main.tip, oid_a,
"run 2 must lag the public branch to A, retracting the now-embargoed B"
);
assert!(
test_support::mapping(&bridge).get_git(&state_b).is_none(),
"the now-Private B must be purged from the served mapping"
);
let mapping_cache = std::fs::read_to_string(test_support::mapping_path(&bridge))
.expect("mapping cache should be readable after export");
assert!(
!mapping_cache.contains(&state_b.to_string_full()),
"the served bridge mapping cache must not persist embargoed ChangeIds: {mapping_cache}"
);
assert!(
!mapping_cache.contains(&oid_b.to_string()),
"the served bridge mapping cache must not persist embargoed Git OIDs: {mapping_cache}"
);
let mirror = test_support::open_git_repo(&bridge).expect("open mirror");
let mut main_ref = find_reference(&mirror, "refs/heads/main").expect("main ref present");
let tip = main_ref.peel_to_id().unwrap();
assert_eq!(
tip, oid_a,
"refs/heads/main must point at A after retraction"
);
assert_ne!(
tip, oid_b,
"refs/heads/main must not keep serving embargoed B"
);
}
#[test]
fn export_purge_drops_seeded_embargoed_sha_before_serving_mapping() {
use chrono::Utc;
use objects::object::{Principal, StateVisibility, VisibilityTier};
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 git_a = commit_with_tree(&source_repo, None, tree_oid, "base", &[]);
let git_b = commit_with_tree(
&source_repo,
Some("refs/heads/main"),
tree_oid,
"embargoed fix",
&[git_a],
);
let source_workdir = source_repo.workdir().expect("workdir").to_path_buf();
let mut import_bridge = GitBridge::new(&repo);
import_bridge
.import(Some(source_workdir.as_path()))
.expect("import source git history");
let change_a = test_support::mapping(&import_bridge)
.get_heddle(git_a)
.expect("base A mapped after import");
let change_b = test_support::mapping(&import_bridge)
.get_heddle(git_b)
.expect("tip B mapped after import");
assert_ne!(change_a, change_b);
drop(import_bridge);
let ingest_map = repo
.git_overlay_ingest_commit_mapping()
.expect("read ingest commit mapping");
assert!(
ingest_map.values().any(|c| c == &change_b.to_string_full()),
"precondition: ingest SHA map must hold the (to-be-embargoed) tip B"
);
repo.put_state_visibility(StateVisibility {
state: change_b,
tier: VisibilityTier::Private {
scope_label: "sec-embargo".into(),
},
embargo_until: None,
declarer: Principal {
name: "Heddle Test".into(),
email: "heddle@test".into(),
},
declared_at: Utc::now(),
signature: None,
supersedes: None,
})
.expect("embargo tip B");
{
let mut seed_probe = GitBridge::new(&repo);
test_support::set_git_repo_path(&mut seed_probe, source_workdir.clone());
let mirror = test_support::open_git_repo(&seed_probe).expect("open mirror for seed probe");
test_support::seed_ingest_identity_mappings_from_mirror(&mut seed_probe, &mirror)
.expect("seed from ingest map");
assert_eq!(
test_support::mapping(&seed_probe).get_git(&change_b),
Some(git_b),
"precondition: the seed re-introduces the embargoed tip B from the ingest map"
);
}
let mut export_bridge = GitBridge::new(&repo);
export_all(&mut export_bridge).expect("export with seeded embargoed tip");
assert!(
test_support::mapping(&export_bridge)
.get_git(&change_b)
.is_none(),
"the purge must drop the seeded-but-embargoed tip B from the served mapping"
);
assert_eq!(
test_support::mapping(&export_bridge).get_git(&change_a),
Some(git_a),
"the still-public base A must remain served"
);
let served = std::fs::read_to_string(test_support::mapping_path(&export_bridge))
.expect("served bridge-mapping.json must exist after export");
assert!(
!served.contains(&change_b.to_string_full()),
"served mapping must NOT contain the embargoed ChangeId B (seed→purge→save leak): {served}"
);
assert!(
!served.contains(&git_b.to_string()),
"served mapping must NOT contain the embargoed Git OID B (seed→purge→save leak): {served}"
);
assert!(
served.contains(&change_a.to_string_full()) && served.contains(&git_a.to_string()),
"served mapping must still publish the public base A: {served}"
);
}
#[test]
fn export_path_saves_mapping_strictly_after_the_embargo_purge() {
let src_path = std::path::PathBuf::from(concat!(
env!("CARGO_MANIFEST_DIR"),
"/src/bridge/git_export.rs"
));
let src = std::fs::read_to_string(&src_path)
.unwrap_or_else(|e| panic!("read git_export.rs at {}: {e}", src_path.display()));
assert!(
!src.contains("save_mapping_to_disk_preserving"),
"git_export.rs reintroduced a pre-purge save helper \
(save_mapping_to_disk_preserving) — this resurrects the #737 embargo leak"
);
let purge_at = src
.find("purge_unserved_mappings(")
.expect("git_export.rs must call purge_unserved_mappings in the export path");
let save_at = src
.find("save_mapping_to_disk()")
.expect("git_export.rs must call save_mapping_to_disk in the export path");
assert!(
src.matches("save_mapping_to_disk()").count() == 1,
"expected exactly one save_mapping_to_disk() call site in the export path; \
a second one may bypass the purge"
);
assert!(
save_at > purge_at,
"save_mapping_to_disk() (offset {save_at}) must run AFTER \
purge_unserved_mappings() (offset {purge_at}) — saving the mapping before \
the embargo purge resurrects the #737 leak"
);
}
#[test]
fn export_retracts_note_for_retracted_commit() {
use chrono::Utc;
use objects::object::{Principal, StateVisibility, VisibilityTier};
let heddle_temp = TempDir::new().expect("heddle temp");
let repo = Repository::init_default(heddle_temp.path()).expect("init heddle");
let mut cfg = repo.config().clone();
cfg.set_principal("Grace Hopper", "grace@example.com");
cfg.save(&repo.heddle_dir().join("config.toml"))
.expect("save principal");
let repo = Repository::open(heddle_temp.path()).expect("reopen heddle");
std::fs::write(heddle_temp.path().join("a.txt"), b"base\n").unwrap();
repo.snapshot(Some("base".into()), None).unwrap();
let state_a = repo
.refs()
.get_thread(&ThreadName::new("main"))
.unwrap()
.unwrap();
std::fs::write(heddle_temp.path().join("b.txt"), b"fix\n").unwrap();
repo.snapshot(Some("fix".into()), None).unwrap();
let state_b = repo
.refs()
.get_thread(&ThreadName::new("main"))
.unwrap()
.unwrap();
let mut bridge = GitBridge::new(&repo);
export_all(&mut bridge).expect("first export");
let oid_a = test_support::mapping(&bridge)
.get_git(&state_a)
.expect("A minted");
let oid_b = test_support::mapping(&bridge)
.get_git(&state_b)
.expect("B minted");
{
let mirror = test_support::open_git_repo(&bridge).expect("open mirror");
assert!(
cli::bridge::git_notes::read_note(&mirror, oid_a)
.unwrap()
.is_some(),
"A must carry a note after run 1"
);
assert!(
cli::bridge::git_notes::read_note(&mirror, oid_b)
.unwrap()
.is_some(),
"B must carry a note after run 1"
);
}
repo.put_state_visibility(StateVisibility {
state: state_b,
tier: VisibilityTier::Private {
scope_label: "sec-embargo".into(),
},
embargo_until: None,
declarer: Principal {
name: "Grace Hopper".into(),
email: "grace@example.com".into(),
},
declared_at: Utc::now(),
signature: None,
supersedes: None,
})
.unwrap();
export_all(&mut bridge).expect("second export");
let mirror = test_support::open_git_repo(&bridge).expect("open mirror");
assert!(
cli::bridge::git_notes::read_note(&mirror, oid_b)
.unwrap()
.is_none(),
"run 2 must retract the note for the now-embargoed B (no metadata leak)"
);
assert!(
cli::bridge::git_notes::read_note(&mirror, oid_a)
.unwrap()
.is_some(),
"A is still served — its note must survive the retraction"
);
}
#[test]
fn scoped_export_retracts_note_for_commit_with_embargoed_ancestor() {
use chrono::Utc;
use objects::object::{Attribution, Principal, State, StateVisibility, VisibilityTier};
let heddle_temp = TempDir::new().expect("heddle temp");
let repo = Repository::init(heddle_temp.path()).expect("init heddle");
let put_state = |content: &[u8], parents: Vec<ChangeId>| -> State {
let store = repo.store();
let blob_hash = store
.put_blob(&Blob::from_slice(content))
.expect("put blob");
let tree_hash = store
.put_tree(&Tree::from_entries(vec![
TreeEntry::file("file.txt".to_string(), blob_hash, false).expect("tree entry"),
]))
.expect("put tree");
let state = State::new(
tree_hash,
parents,
Attribution::human(Principal::new("Alice", "alice@example.com")),
);
store.put_state(&state).expect("put state");
state
};
let state_r = put_state(b"root\n", Vec::new());
let state_x = put_state(b"tip\n", vec![state_r.change_id]);
repo.refs()
.set_thread(&ThreadName::new("main"), &state_x.change_id)
.expect("set main to X");
let state_o = put_state(b"other root\n", Vec::new());
repo.refs()
.set_thread(&ThreadName::new("other"), &state_o.change_id)
.expect("set other to O");
let mut bridge = GitBridge::new(&repo);
export_all(&mut bridge).expect("first export");
let oid_x = test_support::mapping(&bridge)
.get_git(&state_x.change_id)
.expect("X minted while public");
let oid_o = test_support::mapping(&bridge)
.get_git(&state_o.change_id)
.expect("O minted while public");
{
let mirror = test_support::open_git_repo(&bridge).expect("open mirror");
assert!(
cli::bridge::git_notes::read_note(&mirror, oid_x)
.unwrap()
.is_some(),
"X must carry a note after run 1"
);
}
repo.put_state_visibility(StateVisibility {
state: state_r.change_id,
tier: VisibilityTier::Private {
scope_label: "sec-embargo".into(),
},
embargo_until: None,
declarer: Principal {
name: "Alice".into(),
email: "alice@example.com".into(),
},
declared_at: Utc::now(),
signature: None,
supersedes: None,
})
.unwrap();
export_current_thread(&mut bridge, "other").expect("scoped export");
let mirror = test_support::open_git_repo(&bridge).expect("open mirror");
assert!(
cli::bridge::git_notes::read_note(&mirror, oid_x)
.unwrap()
.is_none(),
"scoped export must retract X's note (ancestor embargoed) — no notes leak"
);
assert!(
cli::bridge::git_notes::read_note(&mirror, oid_o)
.unwrap()
.is_some(),
"O is served — its note must survive the scoped retraction"
);
}
#[test]
fn scoped_export_reconciles_cross_thread_embargo() {
use chrono::Utc;
use objects::object::{Attribution, Principal, State, StateVisibility, VisibilityTier};
let heddle_temp = TempDir::new().expect("heddle temp");
let repo = Repository::init(heddle_temp.path()).expect("init heddle");
let put_state = |content: &[u8], parents: Vec<ChangeId>| -> State {
let store = repo.store();
let blob_hash = store
.put_blob(&Blob::from_slice(content))
.expect("put blob");
let tree_hash = store
.put_tree(&Tree::from_entries(vec![
TreeEntry::file("file.txt".to_string(), blob_hash, false).expect("tree entry"),
]))
.expect("put tree");
let state = State::new(
tree_hash,
parents,
Attribution::human(Principal::new("Alice", "alice@example.com")),
);
store.put_state(&state).expect("put state");
state
};
let state_a0 = put_state(b"a0\n", Vec::new());
let state_a1 = put_state(b"a1\n", vec![state_a0.change_id]);
repo.refs()
.set_thread(&ThreadName::new("alpha"), &state_a1.change_id)
.expect("set alpha to A1");
let state_b0 = put_state(b"b0\n", Vec::new());
let state_b1 = put_state(b"b1\n", vec![state_b0.change_id]);
let state_b2 = put_state(b"b2\n", vec![state_b1.change_id]);
repo.refs()
.set_thread(&ThreadName::new("beta"), &state_b2.change_id)
.expect("set beta to B2");
let mut bridge = GitBridge::new(&repo);
let run1 = export_all(&mut bridge).expect("first export");
let oid_a1 = test_support::mapping(&bridge)
.get_git(&state_a1.change_id)
.expect("A1 minted while public");
let oid_b0 = test_support::mapping(&bridge)
.get_git(&state_b0.change_id)
.expect("B0 minted while public");
let oid_b2 = test_support::mapping(&bridge)
.get_git(&state_b2.change_id)
.expect("B2 minted while public");
assert!(
run1.branches
.iter()
.any(|b| b.name == "beta" && b.tip == oid_b2),
"run 1 advertises beta at its public tip B2"
);
repo.put_state_visibility(StateVisibility {
state: state_b1.change_id,
tier: VisibilityTier::Private {
scope_label: "sec-embargo".into(),
},
embargo_until: None,
declarer: Principal {
name: "Alice".into(),
email: "alice@example.com".into(),
},
declared_at: Utc::now(),
signature: None,
supersedes: None,
})
.unwrap();
export_current_thread(&mut bridge, "alpha").expect("scoped export");
let mirror = test_support::open_git_repo(&bridge).expect("open mirror");
let beta_tip = find_reference(&mirror, "refs/heads/beta")
.expect("beta still present at its served frontier")
.peel_to_id()
.expect("peel beta");
assert_eq!(
beta_tip, oid_b0,
"scoped alpha export must rewind beta off the embargoed commit to B0"
);
assert_ne!(
beta_tip, oid_b2,
"beta must not keep serving the now-embargoed tip B2"
);
let alpha_tip = find_reference(&mirror, "refs/heads/alpha")
.expect("alpha present")
.peel_to_id()
.expect("peel alpha");
assert_eq!(
alpha_tip, oid_a1,
"scoped export must leave its own thread's ref at A1"
);
}
#[test]
fn scoped_push_propagates_cross_thread_embargo_to_destination() {
use chrono::Utc;
use objects::object::{Attribution, Principal, State, StateVisibility, VisibilityTier};
use refs::Head;
let heddle_temp = TempDir::new().expect("heddle temp");
let repo = Repository::init(heddle_temp.path()).expect("init heddle");
let put_state = |content: &[u8], parents: Vec<ChangeId>| -> State {
let store = repo.store();
let blob_hash = store
.put_blob(&Blob::from_slice(content))
.expect("put blob");
let tree_hash = store
.put_tree(&Tree::from_entries(vec![
TreeEntry::file("file.txt".to_string(), blob_hash, false).expect("tree entry"),
]))
.expect("put tree");
let state = State::new(
tree_hash,
parents,
Attribution::human(Principal::new("Alice", "alice@example.com")),
);
store.put_state(&state).expect("put state");
state
};
let state_a0 = put_state(b"a0\n", Vec::new());
let state_a1 = put_state(b"a1\n", vec![state_a0.change_id]);
repo.refs()
.set_thread(&ThreadName::new("alpha"), &state_a1.change_id)
.expect("set alpha to A1");
let state_b0 = put_state(b"b0\n", Vec::new());
let state_b1 = put_state(b"b1\n", vec![state_b0.change_id]);
let state_b2 = put_state(b"b2\n", vec![state_b1.change_id]);
repo.refs()
.set_thread(&ThreadName::new("beta"), &state_b2.change_id)
.expect("set beta to B2");
let state_g0 = put_state(b"g0\n", Vec::new());
repo.refs()
.set_thread(&ThreadName::new("gamma"), &state_g0.change_id)
.expect("set gamma to G0");
let mut bridge = GitBridge::new(&repo);
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 full export+push publishes alpha, beta, gamma");
let oid_a1 = test_support::mapping(&bridge)
.get_git(&state_a1.change_id)
.expect("A1 minted");
let oid_b0 = test_support::mapping(&bridge)
.get_git(&state_b0.change_id)
.expect("B0 minted");
let oid_b2 = test_support::mapping(&bridge)
.get_git(&state_b2.change_id)
.expect("B2 minted");
let oid_g0 = test_support::mapping(&bridge)
.get_git(&state_g0.change_id)
.expect("G0 minted");
{
let dest = open_git(&dest_path).expect("open dest");
assert_eq!(
find_reference(&dest, "refs/heads/beta")
.unwrap()
.peel_to_id()
.unwrap(),
oid_b2,
"run 1 publishes beta at its public tip B2 to the destination"
);
}
let oid_g_oob = {
let dest = open_git(&dest_path).expect("open dest");
commit_with_tree(
&dest,
Some("refs/heads/gamma"),
empty_tree_oid(&dest),
"out-of-band gamma",
&[oid_g0],
)
};
let embargo = |state: ChangeId| {
repo.put_state_visibility(StateVisibility {
state,
tier: VisibilityTier::Private {
scope_label: "sec-embargo".into(),
},
embargo_until: None,
declarer: Principal {
name: "Alice".into(),
email: "alice@example.com".into(),
},
declared_at: Utc::now(),
signature: None,
supersedes: None,
})
.unwrap();
};
embargo(state_b1.change_id);
embargo(state_g0.change_id);
repo.refs()
.write_head(&Head::Attached {
thread: ThreadName::new("alpha"),
})
.expect("attach HEAD to alpha");
bridge
.push_with_scope(
dest_path.to_str().expect("dest path"),
GitPushScope::CurrentThread,
)
.expect("scoped push of alpha");
let dest = open_git(&dest_path).expect("reopen dest");
let beta_tip = find_reference(&dest, "refs/heads/beta")
.expect("beta still present at its served frontier")
.peel_to_id()
.expect("peel beta");
assert_eq!(
beta_tip, oid_b0,
"scoped push of alpha must rewind the destination's beta off the embargoed commit to B0"
);
assert_ne!(
beta_tip, oid_b2,
"the destination must not keep serving the now-embargoed tip B2"
);
let alpha_tip = find_reference(&dest, "refs/heads/alpha")
.expect("alpha present")
.peel_to_id()
.expect("peel alpha");
assert_eq!(
alpha_tip, oid_a1,
"scoped push must leave alpha at A1 at the destination"
);
let gamma_tip = find_reference(&dest, "refs/heads/gamma")
.expect("gamma survives — heddle must not delete a tip it never published")
.peel_to_id()
.expect("peel gamma");
assert_eq!(
gamma_tip, oid_g_oob,
"the out-of-band gamma tip must survive the scoped push (r13 ownership gate holds)"
);
}
#[test]
fn export_deletes_branch_when_whole_line_is_later_embargoed() {
use chrono::Utc;
use objects::object::{Principal, StateVisibility, VisibilityTier};
let heddle_temp = TempDir::new().expect("heddle temp");
let repo = Repository::init_default(heddle_temp.path()).expect("init heddle");
let mut cfg = repo.config().clone();
cfg.set_principal("Grace Hopper", "grace@example.com");
cfg.save(&repo.heddle_dir().join("config.toml"))
.expect("save principal");
let repo = Repository::open(heddle_temp.path()).expect("reopen heddle");
std::fs::write(heddle_temp.path().join("a.txt"), b"only\n").unwrap();
repo.snapshot(Some("only".into()), None).unwrap();
let state_a = repo
.refs()
.get_thread(&ThreadName::new("main"))
.unwrap()
.unwrap();
let mut bridge = GitBridge::new(&repo);
export_all(&mut bridge).expect("first export");
assert!(
test_support::mapping(&bridge).get_git(&state_a).is_some(),
"A minted while public"
);
for state in repo.store().list_states().unwrap() {
repo.put_state_visibility(StateVisibility {
state,
tier: VisibilityTier::Private {
scope_label: "sec-embargo".into(),
},
embargo_until: None,
declarer: Principal {
name: "Grace Hopper".into(),
email: "grace@example.com".into(),
},
declared_at: Utc::now(),
signature: None,
supersedes: None,
})
.unwrap();
}
let run2 = export_all(&mut bridge).expect("second export");
assert!(
run2.branches.iter().all(|b| b.name != "main"),
"main must not be advertised once the whole line is embargoed"
);
let mirror = test_support::open_git_repo(&bridge).expect("open mirror");
assert!(
find_reference(&mirror, "refs/heads/main").is_err(),
"the stale public branch must be deleted, not left serving the embargoed commit"
);
}
#[test]
fn export_deletes_branch_when_thread_reset_to_private_root() {
use chrono::Utc;
use objects::object::{Attribution, Principal, State, StateVisibility, VisibilityTier};
let heddle_temp = TempDir::new().expect("heddle temp");
let repo = Repository::init(heddle_temp.path()).expect("init heddle");
let put_state = |content: &[u8], parents: Vec<ChangeId>| -> State {
let store = repo.store();
let blob_hash = store
.put_blob(&Blob::from_slice(content))
.expect("put blob");
let tree_hash = store
.put_tree(&Tree::from_entries(vec![
TreeEntry::file("file.txt".to_string(), blob_hash, false).expect("tree entry"),
]))
.expect("put tree");
let state = State::new(
tree_hash,
parents,
Attribution::human(Principal::new("Alice", "alice@example.com")),
);
store.put_state(&state).expect("put state");
state
};
let state_a = put_state(b"public base\n", Vec::new());
repo.refs()
.set_thread(&ThreadName::new("main"), &state_a.change_id)
.expect("set main to A");
let mut bridge = GitBridge::new(&repo);
let run1 = export_all(&mut bridge).expect("first export");
let oid_a = test_support::mapping(&bridge)
.get_git(&state_a.change_id)
.expect("A minted while public");
assert!(
run1.branches
.iter()
.any(|b| b.name == "main" && b.tip == oid_a),
"run 1 advertises main at the public root A"
);
let state_b = put_state(b"private root\n", Vec::new());
repo.put_state_visibility(StateVisibility {
state: state_b.change_id,
tier: VisibilityTier::Private {
scope_label: "sec-embargo".into(),
},
embargo_until: None,
declarer: Principal {
name: "Alice".into(),
email: "alice@example.com".into(),
},
declared_at: Utc::now(),
signature: None,
supersedes: None,
})
.unwrap();
repo.refs()
.set_thread(&ThreadName::new("main"), &state_b.change_id)
.expect("reset main to B");
let run2 = export_all(&mut bridge).expect("second export");
assert!(
run2.branches.iter().all(|b| b.name != "main"),
"main must not be advertised once reset onto a Private root"
);
let mirror = test_support::open_git_repo(&bridge).expect("open mirror");
assert!(
find_reference(&mirror, "refs/heads/main").is_err(),
"the stale public branch must be deleted after a reset to a Private root"
);
assert!(
test_support::mapping(&bridge)
.get_git(&state_a.change_id)
.is_some(),
"the old public tip A remains served; deletion is not driven by an embargo of A"
);
}
#[test]
fn export_deletes_tag_when_marker_retargeted_to_private() {
use chrono::Utc;
use objects::object::{Attribution, Principal, State, StateVisibility, VisibilityTier};
let heddle_temp = TempDir::new().expect("heddle temp");
let repo = Repository::init(heddle_temp.path()).expect("init heddle");
let put_state = |content: &[u8], parents: Vec<ChangeId>| -> State {
let store = repo.store();
let blob_hash = store
.put_blob(&Blob::from_slice(content))
.expect("put blob");
let tree_hash = store
.put_tree(&Tree::from_entries(vec![
TreeEntry::file("file.txt".to_string(), blob_hash, false).expect("tree entry"),
]))
.expect("put tree");
let state = State::new(
tree_hash,
parents,
Attribution::human(Principal::new("Alice", "alice@example.com")),
);
store.put_state(&state).expect("put state");
state
};
let state_a = put_state(b"public release\n", Vec::new());
repo.refs()
.set_thread(&ThreadName::new("main"), &state_a.change_id)
.expect("set main to A");
repo.refs()
.create_marker(&MarkerName::new("v1.0"), &state_a.change_id)
.expect("create marker at A");
let mut bridge = GitBridge::new(&repo);
let run1 = export_all(&mut bridge).expect("first export");
let oid_a = test_support::mapping(&bridge)
.get_git(&state_a.change_id)
.expect("A minted while public");
assert!(
run1.tags.iter().any(|t| t.name == "v1.0" && t.tip == oid_a),
"run 1 publishes tag v1.0 at the public state A"
);
let state_b = put_state(b"private release\n", Vec::new());
repo.put_state_visibility(StateVisibility {
state: state_b.change_id,
tier: VisibilityTier::Private {
scope_label: "sec-embargo".into(),
},
embargo_until: None,
declarer: Principal {
name: "Alice".into(),
email: "alice@example.com".into(),
},
declared_at: Utc::now(),
signature: None,
supersedes: None,
})
.unwrap();
repo.refs()
.delete_marker(&MarkerName::new("v1.0"))
.expect("clear old marker");
repo.refs()
.create_marker(&MarkerName::new("v1.0"), &state_b.change_id)
.expect("retarget marker to B");
let run2 = export_all(&mut bridge).expect("second export");
assert!(
run2.tags.iter().all(|t| t.name != "v1.0"),
"v1.0 must not be published once retargeted to a Private state"
);
let mirror = test_support::open_git_repo(&bridge).expect("open mirror");
assert!(
find_reference(&mirror, "refs/tags/v1.0").is_err(),
"the stale public tag must be deleted after retarget to a Private state"
);
assert!(
test_support::mapping(&bridge)
.get_git(&state_a.change_id)
.is_some(),
"the old tag tip A remains served; deletion is not driven by an embargo of A"
);
}
#[test]
fn scoped_export_preserves_unminted_out_of_scope_public_tag() {
use refs::Head;
let heddle_temp = TempDir::new().expect("heddle temp");
let repo = Repository::init(heddle_temp.path()).expect("init heddle");
let put_state = |content: &[u8], parents: Vec<ChangeId>| -> objects::object::State {
use objects::object::{Attribution, Principal, State};
let store = repo.store();
let blob_hash = store
.put_blob(&Blob::from_slice(content))
.expect("put blob");
let tree_hash = store
.put_tree(&Tree::from_entries(vec![
TreeEntry::file("file.txt".to_string(), blob_hash, false).expect("tree entry"),
]))
.expect("put tree");
let state = State::new(
tree_hash,
parents,
Attribution::human(Principal::new("Alice", "alice@example.com")),
);
store.put_state(&state).expect("put state");
state
};
let state_a = put_state(b"alpha\n", Vec::new());
repo.refs()
.set_thread(&ThreadName::new("alpha"), &state_a.change_id)
.expect("set alpha to A");
let state_b = put_state(b"beta release\n", Vec::new());
repo.refs()
.set_thread(&ThreadName::new("beta"), &state_b.change_id)
.expect("set beta to B");
repo.refs()
.create_marker(&MarkerName::new("v1.0"), &state_b.change_id)
.expect("create marker v1.0 at B");
let mut bridge = GitBridge::new(&repo);
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 full export publishes alpha, beta, and tag v1.0");
let oid_b = test_support::mapping(&bridge)
.get_git(&state_b.change_id)
.expect("B minted while public");
{
let dest = open_git(&dest_path).expect("open dest");
assert_eq!(
find_reference(&dest, "refs/tags/v1.0")
.expect("run 1 publishes tag v1.0 to the destination")
.peel_to_id()
.expect("peel v1.0"),
oid_b,
"run 1 publishes tag v1.0 at the public state B"
);
}
let state_c = put_state(b"beta rc\n", vec![state_b.change_id]);
repo.refs()
.set_thread(&ThreadName::new("beta"), &state_c.change_id)
.expect("advance beta to C");
repo.refs()
.delete_marker(&MarkerName::new("v1.0"))
.expect("clear old marker");
repo.refs()
.create_marker(&MarkerName::new("v1.0"), &state_c.change_id)
.expect("retarget marker v1.0 to C");
repo.refs()
.write_head(&Head::Attached {
thread: ThreadName::new("alpha"),
})
.expect("attach HEAD to alpha");
assert!(
test_support::mapping(&bridge)
.get_git(&state_c.change_id)
.is_none(),
"C must be unminted so the marker reconcile takes the `None` arm"
);
bridge
.push_with_scope(
dest_path.to_str().expect("dest path"),
GitPushScope::CurrentThread,
)
.expect("scoped push of alpha");
let mirror = test_support::open_git_repo(&bridge).expect("open mirror");
assert_eq!(
find_reference(&mirror, "refs/tags/v1.0")
.expect("mirror must keep the served-but-unminted public tag v1.0")
.peel_to_id()
.expect("peel mirror v1.0"),
oid_b,
"the scoped export must not retract a tag whose target is still public"
);
let dest = open_git(&dest_path).expect("reopen dest");
assert_eq!(
find_reference(&dest, "refs/tags/v1.0")
.expect("destination must keep the served-but-unminted public tag v1.0")
.peel_to_id()
.expect("peel dest v1.0"),
oid_b,
"the scoped push must not delete a tag whose target is still public"
);
}
fn matrix_put_state(
repo: &Repository,
content: &[u8],
parents: Vec<ChangeId>,
) -> objects::object::State {
use objects::object::{Attribution, Principal, State};
let store = repo.store();
let blob_hash = store
.put_blob(&Blob::from_slice(content))
.expect("put blob");
let tree_hash = store
.put_tree(&Tree::from_entries(vec![
TreeEntry::file("file.txt".to_string(), blob_hash, false).expect("tree entry"),
]))
.expect("put tree");
let state = State::new(
tree_hash,
parents,
Attribution::human(Principal::new("Alice", "alice@example.com")),
);
store.put_state(&state).expect("put state");
state
}
fn matrix_embargo(repo: &Repository, state: ChangeId) {
use chrono::Utc;
use objects::object::{Principal, StateVisibility, VisibilityTier};
repo.put_state_visibility(StateVisibility {
state,
tier: VisibilityTier::Private {
scope_label: "sec-embargo".into(),
},
embargo_until: None,
declarer: Principal {
name: "Alice".into(),
email: "alice@example.com".into(),
},
declared_at: Utc::now(),
signature: None,
supersedes: None,
})
.unwrap();
}
fn matrix_mirror_tag(bridge: &GitBridge, name: &str) -> Option<ObjectId> {
let mirror = test_support::open_git_repo(bridge).expect("open mirror");
find_reference(&mirror, &format!("refs/tags/{name}"))
.ok()
.and_then(|mut r| r.peel_to_id().ok())
}
fn matrix_mirror_head(bridge: &GitBridge, name: &str) -> Option<ObjectId> {
let mirror = test_support::open_git_repo(bridge).expect("open mirror");
find_reference(&mirror, &format!("refs/heads/{name}"))
.ok()
.and_then(|mut r| r.peel_to_id().ok())
}
fn matrix_plant_foreign_tag(bridge: &GitBridge, name: &str, target: ObjectId) {
let mirror = test_support::open_git_repo(bridge).expect("open mirror");
set_reference(
&mirror,
&format!("refs/tags/{name}"),
target,
RefPrecondition::Any,
"test: foreign tag",
)
.expect("plant foreign tag");
}
fn matrix_plant_foreign_branch(bridge: &GitBridge, name: &str, target: ObjectId) {
let mirror = test_support::open_git_repo(bridge).expect("open mirror");
set_reference(
&mirror,
&format!("refs/heads/{name}"),
target,
RefPrecondition::Any,
"test: foreign branch",
)
.expect("plant foreign branch");
}
fn matrix_managed_record(bridge: &GitBridge) -> std::collections::HashMap<String, ObjectId> {
let mirror = test_support::open_git_repo(bridge).expect("open mirror");
read_mirror_managed_refs(&mirror).expect("read mirror managed record")
}
fn matrix_in_managed_frontier(bridge: &GitBridge, name: &str, namespace: RefNamespace) -> bool {
let mirror = test_support::open_git_repo(bridge).expect("open mirror");
let record = read_mirror_managed_refs(&mirror).expect("read record");
collect_managed_ref_updates(&mirror, &record)
.expect("collect managed frontier")
.iter()
.any(|u| u.namespace == namespace && u.name == name)
}
#[test]
fn tag_reconcile_conformance_matrix() {
struct Cell {
label: &'static str,
run: Box<dyn Fn() -> (Option<ObjectId>, Option<ObjectId>)>,
}
let cells: Vec<Cell> = vec![
Cell {
label: "served_minted_existing_absent_full_creates",
run: Box::new(|| {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path()).unwrap();
let a = matrix_put_state(&repo, b"A\n", Vec::new());
repo.refs()
.set_thread(&ThreadName::new("main"), &a.change_id)
.unwrap();
repo.refs()
.create_marker(&MarkerName::new("v"), &a.change_id)
.unwrap();
let mut bridge = GitBridge::new(&repo);
export_all(&mut bridge).unwrap();
let oid_a = test_support::mapping(&bridge)
.get_git(&a.change_id)
.expect("A minted");
(matrix_mirror_tag(&bridge, "v"), Some(oid_a))
}),
},
Cell {
label: "served_minted_existing_served_full_retargets",
run: Box::new(|| {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path()).unwrap();
let a = matrix_put_state(&repo, b"A\n", Vec::new());
let b = matrix_put_state(&repo, b"B\n", Vec::new());
repo.refs()
.set_thread(&ThreadName::new("main"), &a.change_id)
.unwrap();
repo.refs()
.set_thread(&ThreadName::new("rel"), &b.change_id)
.unwrap();
repo.refs()
.create_marker(&MarkerName::new("v"), &a.change_id)
.unwrap();
let mut bridge = GitBridge::new(&repo);
export_all(&mut bridge).unwrap();
let oid_b = test_support::mapping(&bridge)
.get_git(&b.change_id)
.expect("B minted");
repo.refs().delete_marker(&MarkerName::new("v")).unwrap();
repo.refs()
.create_marker(&MarkerName::new("v"), &b.change_id)
.unwrap();
export_all(&mut bridge).unwrap();
(matrix_mirror_tag(&bridge, "v"), Some(oid_b))
}),
},
Cell {
label: "served_minted_existing_embargoed_full_retargets",
run: Box::new(|| {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path()).unwrap();
let a = matrix_put_state(&repo, b"A\n", Vec::new());
let b = matrix_put_state(&repo, b"B\n", Vec::new());
repo.refs()
.set_thread(&ThreadName::new("main"), &a.change_id)
.unwrap();
repo.refs()
.set_thread(&ThreadName::new("rel"), &b.change_id)
.unwrap();
repo.refs()
.create_marker(&MarkerName::new("v"), &a.change_id)
.unwrap();
let mut bridge = GitBridge::new(&repo);
export_all(&mut bridge).unwrap();
let oid_b = test_support::mapping(&bridge)
.get_git(&b.change_id)
.expect("B minted");
matrix_embargo(&repo, a.change_id);
repo.refs().delete_marker(&MarkerName::new("v")).unwrap();
repo.refs()
.create_marker(&MarkerName::new("v"), &b.change_id)
.unwrap();
export_all(&mut bridge).unwrap();
(matrix_mirror_tag(&bridge, "v"), Some(oid_b))
}),
},
Cell {
label: "served_minted_existing_same_idempotent",
run: Box::new(|| {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path()).unwrap();
let a = matrix_put_state(&repo, b"A\n", Vec::new());
repo.refs()
.set_thread(&ThreadName::new("main"), &a.change_id)
.unwrap();
repo.refs()
.create_marker(&MarkerName::new("v"), &a.change_id)
.unwrap();
let mut bridge = GitBridge::new(&repo);
export_all(&mut bridge).unwrap();
let oid_a = test_support::mapping(&bridge)
.get_git(&a.change_id)
.expect("A minted");
export_all(&mut bridge).unwrap();
(matrix_mirror_tag(&bridge, "v"), Some(oid_a))
}),
},
Cell {
label: "served_minted_existing_absent_scoped_skips",
run: Box::new(|| {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path()).unwrap();
let a = matrix_put_state(&repo, b"A\n", Vec::new());
let b = matrix_put_state(&repo, b"B\n", Vec::new());
repo.refs()
.set_thread(&ThreadName::new("alpha"), &a.change_id)
.unwrap();
repo.refs()
.set_thread(&ThreadName::new("beta"), &b.change_id)
.unwrap();
repo.refs()
.create_marker(&MarkerName::new("v"), &b.change_id)
.unwrap();
let mut bridge = GitBridge::new(&repo);
export_current_thread(&mut bridge, "beta").unwrap();
(matrix_mirror_tag(&bridge, "v"), None)
}),
},
Cell {
label: "served_unminted_existing_served_scoped_preserves",
run: Box::new(|| {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path()).unwrap();
let a = matrix_put_state(&repo, b"A\n", Vec::new());
let b = matrix_put_state(&repo, b"B\n", Vec::new());
repo.refs()
.set_thread(&ThreadName::new("alpha"), &a.change_id)
.unwrap();
repo.refs()
.set_thread(&ThreadName::new("beta"), &b.change_id)
.unwrap();
repo.refs()
.create_marker(&MarkerName::new("v"), &b.change_id)
.unwrap();
let mut bridge = GitBridge::new(&repo);
export_all(&mut bridge).unwrap();
let oid_b = test_support::mapping(&bridge)
.get_git(&b.change_id)
.expect("B minted");
let c = matrix_put_state(&repo, b"C\n", vec![b.change_id]);
repo.refs()
.set_thread(&ThreadName::new("beta"), &c.change_id)
.unwrap();
repo.refs().delete_marker(&MarkerName::new("v")).unwrap();
repo.refs()
.create_marker(&MarkerName::new("v"), &c.change_id)
.unwrap();
export_current_thread(&mut bridge, "alpha").unwrap();
(matrix_mirror_tag(&bridge, "v"), Some(oid_b))
}),
},
Cell {
label: "existing_embargoed_unminted_new_deletes_tag",
run: Box::new(|| {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path()).unwrap();
let a = matrix_put_state(&repo, b"A\n", Vec::new());
let p = matrix_put_state(&repo, b"P\n", Vec::new());
repo.refs()
.set_thread(&ThreadName::new("alpha"), &a.change_id)
.unwrap();
repo.refs()
.set_thread(&ThreadName::new("rel"), &p.change_id)
.unwrap();
repo.refs()
.create_marker(&MarkerName::new("v"), &p.change_id)
.unwrap();
let mut bridge = GitBridge::new(&repo);
export_all(&mut bridge).unwrap();
let c = matrix_put_state(&repo, b"C\n", Vec::new());
matrix_embargo(&repo, p.change_id);
repo.refs().delete_marker(&MarkerName::new("v")).unwrap();
repo.refs()
.create_marker(&MarkerName::new("v"), &c.change_id)
.unwrap();
export_current_thread(&mut bridge, "alpha").unwrap();
(matrix_mirror_tag(&bridge, "v"), None)
}),
},
Cell {
label: "served_unminted_existing_absent_scoped_noop",
run: Box::new(|| {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path()).unwrap();
let a = matrix_put_state(&repo, b"A\n", Vec::new());
let c = matrix_put_state(&repo, b"C\n", Vec::new());
repo.refs()
.set_thread(&ThreadName::new("alpha"), &a.change_id)
.unwrap();
repo.refs()
.create_marker(&MarkerName::new("v"), &c.change_id)
.unwrap();
let mut bridge = GitBridge::new(&repo);
export_current_thread(&mut bridge, "alpha").unwrap();
(matrix_mirror_tag(&bridge, "v"), None)
}),
},
Cell {
label: "unserved_existing_served_full_deletes",
run: Box::new(|| {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path()).unwrap();
let a = matrix_put_state(&repo, b"A\n", Vec::new());
repo.refs()
.set_thread(&ThreadName::new("main"), &a.change_id)
.unwrap();
repo.refs()
.create_marker(&MarkerName::new("v"), &a.change_id)
.unwrap();
let mut bridge = GitBridge::new(&repo);
export_all(&mut bridge).unwrap();
let b = matrix_put_state(&repo, b"B\n", Vec::new());
matrix_embargo(&repo, b.change_id);
repo.refs().delete_marker(&MarkerName::new("v")).unwrap();
repo.refs()
.create_marker(&MarkerName::new("v"), &b.change_id)
.unwrap();
export_all(&mut bridge).unwrap();
(matrix_mirror_tag(&bridge, "v"), None)
}),
},
Cell {
label: "unserved_existing_embargoed_full_deletes",
run: Box::new(|| {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path()).unwrap();
let a = matrix_put_state(&repo, b"A\n", Vec::new());
repo.refs()
.set_thread(&ThreadName::new("main"), &a.change_id)
.unwrap();
repo.refs()
.create_marker(&MarkerName::new("v"), &a.change_id)
.unwrap();
let mut bridge = GitBridge::new(&repo);
export_all(&mut bridge).unwrap();
matrix_embargo(&repo, a.change_id);
export_all(&mut bridge).unwrap();
(matrix_mirror_tag(&bridge, "v"), None)
}),
},
Cell {
label: "unserved_existing_absent_noop_delete",
run: Box::new(|| {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path()).unwrap();
let a = matrix_put_state(&repo, b"A\n", Vec::new());
let b = matrix_put_state(&repo, b"B\n", Vec::new());
matrix_embargo(&repo, b.change_id);
repo.refs()
.set_thread(&ThreadName::new("main"), &a.change_id)
.unwrap();
repo.refs()
.create_marker(&MarkerName::new("v"), &b.change_id)
.unwrap();
let mut bridge = GitBridge::new(&repo);
export_all(&mut bridge).unwrap();
(matrix_mirror_tag(&bridge, "v"), None)
}),
},
Cell {
label: "deleted_marker_stale_tag_deleted",
run: Box::new(|| {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path()).unwrap();
let a = matrix_put_state(&repo, b"A\n", Vec::new());
repo.refs()
.set_thread(&ThreadName::new("main"), &a.change_id)
.unwrap();
repo.refs()
.create_marker(&MarkerName::new("v"), &a.change_id)
.unwrap();
let mut bridge = GitBridge::new(&repo);
export_all(&mut bridge).unwrap();
repo.refs().delete_marker(&MarkerName::new("v")).unwrap();
export_all(&mut bridge).unwrap();
(matrix_mirror_tag(&bridge, "v"), None)
}),
},
Cell {
label: "notarget_existing_absent_unvisited",
run: Box::new(|| {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path()).unwrap();
let a = matrix_put_state(&repo, b"A\n", Vec::new());
repo.refs()
.set_thread(&ThreadName::new("main"), &a.change_id)
.unwrap();
repo.refs()
.create_marker(&MarkerName::new("v"), &a.change_id)
.unwrap();
let mut bridge = GitBridge::new(&repo);
export_all(&mut bridge).unwrap();
(matrix_mirror_tag(&bridge, "ghost"), None)
}),
},
Cell {
label: "prior_run_embargo_existing_tag_deletes",
run: Box::new(|| {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path()).unwrap();
let p = matrix_put_state(&repo, b"P\n", Vec::new());
repo.refs()
.create_marker(&MarkerName::new("v"), &p.change_id)
.unwrap();
let mut bridge = GitBridge::new(&repo);
export_all(&mut bridge).unwrap();
matrix_embargo(&repo, p.change_id);
let alpha = matrix_put_state(&repo, b"A\n", Vec::new());
repo.refs()
.set_thread(&ThreadName::new("alpha"), &alpha.change_id)
.unwrap();
let c = matrix_put_state(&repo, b"C\n", Vec::new());
repo.refs().delete_marker(&MarkerName::new("v")).unwrap();
repo.refs()
.create_marker(&MarkerName::new("v"), &c.change_id)
.unwrap();
export_current_thread(&mut bridge, "alpha").unwrap();
(matrix_mirror_tag(&bridge, "v"), None)
}),
},
Cell {
label: "foreign_unmanaged_git_tag_preserved",
run: Box::new(|| {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path()).unwrap();
let a = matrix_put_state(&repo, b"A\n", Vec::new());
repo.refs()
.set_thread(&ThreadName::new("main"), &a.change_id)
.unwrap();
let mut bridge = GitBridge::new(&repo);
export_all(&mut bridge).unwrap();
let mirror = test_support::open_git_repo(&bridge).unwrap();
let foreign =
commit_with_tree(&mirror, None, empty_tree_oid(&mirror), "foreign", &[]);
set_reference(
&mirror,
"refs/tags/user-v1",
foreign,
RefPrecondition::MustNotExist,
"test: foreign tag",
)
.unwrap();
export_all(&mut bridge).unwrap();
(matrix_mirror_tag(&bridge, "user-v1"), Some(foreign))
}),
},
Cell {
label: "foreign_tag_at_heddle_oid_spared_and_excluded",
run: Box::new(|| {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path()).unwrap();
let a = matrix_put_state(&repo, b"A\n", Vec::new());
repo.refs()
.set_thread(&ThreadName::new("main"), &a.change_id)
.unwrap();
let mut bridge = GitBridge::new(&repo);
export_all(&mut bridge).unwrap();
let oid_a = test_support::mapping(&bridge)
.get_git(&a.change_id)
.expect("A minted");
matrix_plant_foreign_tag(&bridge, "user-v1", oid_a);
export_all(&mut bridge).unwrap();
assert!(
!matrix_in_managed_frontier(&bridge, "user-v1", RefNamespace::Tag),
"a foreign tag at a heddle OID must be EXCLUDED from the managed push frontier"
);
(matrix_mirror_tag(&bridge, "user-v1"), Some(oid_a))
}),
},
];
for cell in &cells {
let (observed, expected) = (cell.run)();
assert_eq!(observed, expected, "tag cell `{}`", cell.label);
}
}
#[test]
fn head_reconcile_conformance_matrix() {
struct HeadOutcome {
observed: Option<ObjectId>,
expected: Option<ObjectId>,
forbidden: Option<ObjectId>,
}
struct Cell {
label: &'static str,
run: Box<dyn Fn() -> HeadOutcome>,
}
let cells: Vec<Cell> = vec![
Cell {
label: "public_advance_fast_forwards",
run: Box::new(|| {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path()).unwrap();
let a = matrix_put_state(&repo, b"A\n", Vec::new());
repo.refs()
.set_thread(&ThreadName::new("main"), &a.change_id)
.unwrap();
let mut bridge = GitBridge::new(&repo);
export_all(&mut bridge).unwrap();
let b = matrix_put_state(&repo, b"B\n", vec![a.change_id]);
repo.refs()
.set_thread(&ThreadName::new("main"), &b.change_id)
.unwrap();
export_all(&mut bridge).unwrap();
let oid_b = test_support::mapping(&bridge)
.get_git(&b.change_id)
.expect("B minted");
HeadOutcome {
observed: matrix_mirror_head(&bridge, "main"),
expected: Some(oid_b),
forbidden: None,
}
}),
},
Cell {
label: "embargoed_tip_rewinds_to_served_ancestor",
run: Box::new(|| {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path()).unwrap();
let a = matrix_put_state(&repo, b"A\n", Vec::new());
let b = matrix_put_state(&repo, b"B\n", vec![a.change_id]);
repo.refs()
.set_thread(&ThreadName::new("main"), &b.change_id)
.unwrap();
let mut bridge = GitBridge::new(&repo);
export_all(&mut bridge).unwrap();
let oid_a = test_support::mapping(&bridge)
.get_git(&a.change_id)
.expect("A minted");
let oid_b = test_support::mapping(&bridge)
.get_git(&b.change_id)
.expect("B minted");
matrix_embargo(&repo, b.change_id);
export_all(&mut bridge).unwrap();
HeadOutcome {
observed: matrix_mirror_head(&bridge, "main"),
expected: Some(oid_a),
forbidden: Some(oid_b),
}
}),
},
Cell {
label: "embargoed_whole_line_deletes_head",
run: Box::new(|| {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path()).unwrap();
let a = matrix_put_state(&repo, b"A\n", Vec::new());
repo.refs()
.set_thread(&ThreadName::new("main"), &a.change_id)
.unwrap();
let mut bridge = GitBridge::new(&repo);
export_all(&mut bridge).unwrap();
let oid_a = test_support::mapping(&bridge)
.get_git(&a.change_id)
.expect("A minted");
matrix_embargo(&repo, a.change_id);
export_all(&mut bridge).unwrap();
HeadOutcome {
observed: matrix_mirror_head(&bridge, "main"),
expected: None,
forbidden: Some(oid_a),
}
}),
},
Cell {
label: "prior_run_embargoed_existing_head_retracts",
run: Box::new(|| {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path()).unwrap();
let a = matrix_put_state(&repo, b"A\n", Vec::new());
let b = matrix_put_state(&repo, b"B\n", vec![a.change_id]);
repo.refs()
.set_thread(&ThreadName::new("main"), &b.change_id)
.unwrap();
let mut bridge = GitBridge::new(&repo);
export_all(&mut bridge).unwrap();
let oid_a = test_support::mapping(&bridge)
.get_git(&a.change_id)
.expect("A minted");
let oid_b = test_support::mapping(&bridge)
.get_git(&b.change_id)
.expect("B minted");
matrix_embargo(&repo, b.change_id);
repo.refs()
.set_thread(&ThreadName::new("main"), &a.change_id)
.unwrap();
export_current_thread(&mut bridge, "main").unwrap();
HeadOutcome {
observed: matrix_mirror_head(&bridge, "main"),
expected: Some(oid_a),
forbidden: Some(oid_b),
}
}),
},
Cell {
label: "foreign_branch_at_heddle_oid_spared",
run: Box::new(|| {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path()).unwrap();
let a = matrix_put_state(&repo, b"A\n", Vec::new());
repo.refs()
.set_thread(&ThreadName::new("main"), &a.change_id)
.unwrap();
let mut bridge = GitBridge::new(&repo);
export_all(&mut bridge).unwrap();
let oid_a = test_support::mapping(&bridge)
.get_git(&a.change_id)
.expect("A minted");
matrix_plant_foreign_branch(&bridge, "user-feature", oid_a);
export_all(&mut bridge).unwrap();
assert!(
!matrix_in_managed_frontier(&bridge, "user-feature", RefNamespace::Branch),
"a foreign branch at a heddle OID must be EXCLUDED from the managed push frontier"
);
HeadOutcome {
observed: matrix_mirror_head(&bridge, "user-feature"),
expected: Some(oid_a),
forbidden: None,
}
}),
},
];
for cell in &cells {
let out = (cell.run)();
assert_eq!(out.observed, out.expected, "head cell `{}`", cell.label);
if let Some(forbidden) = out.forbidden {
assert_ne!(
out.observed,
Some(forbidden),
"head cell `{}`: the head must NOT be left at the embargoed tip",
cell.label
);
}
}
}
#[test]
fn mirror_managed_record_claims_written_drops_deleted_excludes_foreign() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path()).unwrap();
let a = matrix_put_state(&repo, b"A\n", Vec::new());
repo.refs()
.set_thread(&ThreadName::new("main"), &a.change_id)
.unwrap();
repo.refs()
.create_marker(&MarkerName::new("v"), &a.change_id)
.unwrap();
let mut bridge = GitBridge::new(&repo);
export_all(&mut bridge).unwrap();
let record = matrix_managed_record(&bridge);
assert!(
record.contains_key("refs/heads/main"),
"written head claimed: {record:?}"
);
assert!(
record.contains_key("refs/tags/v"),
"written tag claimed: {record:?}"
);
let oid_a = test_support::mapping(&bridge)
.get_git(&a.change_id)
.expect("A minted");
matrix_plant_foreign_tag(&bridge, "user-v1", oid_a);
export_all(&mut bridge).unwrap();
let record = matrix_managed_record(&bridge);
assert!(
!record.contains_key("refs/tags/user-v1"),
"a foreign tag (even at a heddle OID) must never enter the record: {record:?}"
);
assert!(record.contains_key("refs/heads/main"), "{record:?}");
repo.refs().delete_marker(&MarkerName::new("v")).unwrap();
export_all(&mut bridge).unwrap();
let record = matrix_managed_record(&bridge);
assert!(
!record.contains_key("refs/tags/v"),
"a deleted marker's tag must drop from the record: {record:?}"
);
}
#[test]
fn mirror_managed_record_survives_round_trip() {
let temp = TempDir::new().unwrap();
let mirror = SleyRepository::init_bare(temp.path()).unwrap();
let main_tip = commit_with_tree(&mirror, None, empty_tree_oid(&mirror), "main", &[]);
let tag_tip = commit_with_tree(&mirror, None, empty_tree_oid(&mirror), "tag", &[]);
let mut record = std::collections::HashMap::new();
record.insert("refs/heads/main".to_string(), main_tip);
record.insert("refs/tags/v1.0".to_string(), tag_tip);
write_mirror_managed_refs(&mirror, &record).expect("write record");
let read_back = read_mirror_managed_refs(&mirror).expect("read record");
assert_eq!(
read_back, record,
"managed record must round-trip byte-for-byte"
);
}
#[test]
fn first_export_after_upgrade_seeds_record_and_still_retracts() {
let temp = TempDir::new().unwrap();
let repo = Repository::init(temp.path()).unwrap();
let a = matrix_put_state(&repo, b"A\n", Vec::new());
let p = matrix_put_state(&repo, b"P\n", Vec::new());
repo.refs()
.set_thread(&ThreadName::new("main"), &a.change_id)
.unwrap();
repo.refs()
.create_marker(&MarkerName::new("v"), &p.change_id)
.unwrap();
let mut bridge = GitBridge::new(&repo);
export_all(&mut bridge).unwrap();
assert!(
matrix_mirror_tag(&bridge, "v").is_some(),
"run 1 must publish the tag"
);
let record_path = {
let mirror = test_support::open_git_repo(&bridge).unwrap();
mirror.git_dir().join("heddle-mirror-managed-refs")
};
assert!(record_path.exists(), "run 1 must have written the record");
std::fs::remove_file(&record_path).unwrap();
repo.refs().delete_marker(&MarkerName::new("v")).unwrap();
export_all(&mut bridge).unwrap();
assert_eq!(
matrix_mirror_tag(&bridge, "v"),
None,
"the first post-upgrade export must retract the stale tag via record seeding"
);
assert!(
record_path.exists(),
"the export must (re)write the managed record"
);
}
#[test]
fn export_propagates_branch_deletion_to_destination() {
use chrono::Utc;
use objects::object::{Principal, StateVisibility, VisibilityTier};
let heddle_temp = TempDir::new().expect("heddle temp");
let repo = Repository::init_default(heddle_temp.path()).expect("init heddle");
let mut cfg = repo.config().clone();
cfg.set_principal("Grace Hopper", "grace@example.com");
cfg.save(&repo.heddle_dir().join("config.toml"))
.expect("save principal");
let repo = Repository::open(heddle_temp.path()).expect("reopen heddle");
std::fs::write(heddle_temp.path().join("a.txt"), b"only\n").unwrap();
repo.snapshot(Some("only".into()), None).unwrap();
let dest_root = TempDir::new().expect("dest temp");
let dest_path = dest_root.path().join("export-target");
let mut bridge = GitBridge::new(&repo);
bridge.export_to_path(&dest_path).expect("first export");
let dest = open_git(&dest_path).expect("open dest");
assert!(
find_reference(&dest, "refs/heads/main").is_ok(),
"destination must advertise main while the line is public"
);
for state in repo.store().list_states().unwrap() {
repo.put_state_visibility(StateVisibility {
state,
tier: VisibilityTier::Private {
scope_label: "sec-embargo".into(),
},
embargo_until: None,
declarer: Principal {
name: "Grace Hopper".into(),
email: "grace@example.com".into(),
},
declared_at: Utc::now(),
signature: None,
supersedes: None,
})
.unwrap();
}
bridge.export_to_path(&dest_path).expect("second export");
let dest = open_git(&dest_path).expect("reopen dest");
assert!(
find_reference(&dest, "refs/heads/main").is_err(),
"retracting the line must DELETE refs/heads/main at the destination, not leave it serving now-private commits"
);
}
#[test]
fn export_propagates_tag_and_note_deletion() {
use chrono::Utc;
use objects::object::{Attribution, Principal, State, StateVisibility, VisibilityTier};
let heddle_temp = TempDir::new().expect("heddle temp");
let repo = Repository::init_default(heddle_temp.path()).expect("init heddle");
let mut cfg = repo.config().clone();
cfg.set_principal("Grace Hopper", "grace@example.com");
cfg.save(&repo.heddle_dir().join("config.toml"))
.expect("save principal");
let repo = Repository::open(heddle_temp.path()).expect("reopen heddle");
std::fs::write(heddle_temp.path().join("main.txt"), b"trunk\n").unwrap();
repo.snapshot(Some("trunk".into()), None).unwrap();
let r = {
let store = repo.store();
let blob = store
.put_blob(&Blob::from_slice(b"release\n"))
.expect("blob");
let tree = store
.put_tree(&Tree::from_entries(vec![
TreeEntry::file("release.txt".to_string(), blob, false).expect("entry"),
]))
.expect("tree");
let state = State::new(
tree,
Vec::new(),
Attribution::human(Principal::new("Grace Hopper", "grace@example.com")),
);
store.put_state(&state).expect("put R");
state
};
repo.refs()
.create_marker(&MarkerName::new("v1.0"), &r.change_id)
.expect("create marker at R");
let dest_root = TempDir::new().expect("dest temp");
let dest_path = dest_root.path().join("export-target");
let mut bridge = GitBridge::new(&repo);
bridge.export_to_path(&dest_path).expect("first export");
let oid_r = test_support::mapping(&bridge)
.get_git(&r.change_id)
.expect("R minted");
{
let dest = open_git(&dest_path).expect("open dest");
assert!(
find_reference(&dest, "refs/tags/v1.0").is_ok(),
"destination must have the tag while public"
);
assert!(
cli::bridge::git_notes::read_note(&dest, oid_r)
.unwrap()
.is_some(),
"destination must carry R's note while public"
);
set_reference(
&dest,
"refs/notes/legacy",
oid_r,
RefPrecondition::Any,
"test: stale heddle-exported notes ref",
)
.expect("plant stale notes ref");
let mut exported = read_exported_refs(&dest).expect("read exported-refs record");
exported.insert("refs/notes/legacy".to_string(), oid_r);
write_exported_refs(&dest, &exported).expect("record legacy as heddle-exported");
}
repo.put_state_visibility(StateVisibility {
state: r.change_id,
tier: VisibilityTier::Private {
scope_label: "sec-embargo".into(),
},
embargo_until: None,
declarer: Principal {
name: "Grace Hopper".into(),
email: "grace@example.com".into(),
},
declared_at: Utc::now(),
signature: None,
supersedes: None,
})
.unwrap();
bridge.export_to_path(&dest_path).expect("second export");
let dest = open_git(&dest_path).expect("reopen dest");
assert!(
find_reference(&dest, "refs/tags/v1.0").is_err(),
"retracting the marker must DELETE refs/tags/v1.0 at the destination"
);
assert!(
find_reference(&dest, "refs/notes/legacy").is_err(),
"a stale heddle-managed notes ref absent from the served mirror must be DELETED at the destination"
);
assert!(
cli::bridge::git_notes::read_note(&dest, oid_r)
.unwrap()
.is_none(),
"the embargoed commit's note must no longer be readable at the destination"
);
assert!(
find_reference(&dest, "refs/heads/main").is_ok(),
"the still-public main branch must survive the reconciliation"
);
}
#[test]
fn export_does_not_delete_foreign_refs() {
let heddle_temp = TempDir::new().expect("heddle temp");
let repo = Repository::init_default(heddle_temp.path()).expect("init heddle");
let mut cfg = repo.config().clone();
cfg.set_principal("Grace Hopper", "grace@example.com");
cfg.save(&repo.heddle_dir().join("config.toml"))
.expect("save principal");
let repo = Repository::open(heddle_temp.path()).expect("reopen heddle");
std::fs::write(heddle_temp.path().join("a.txt"), b"only\n").unwrap();
repo.snapshot(Some("only".into()), None).unwrap();
let state_a = repo
.refs()
.get_thread(&ThreadName::new("main"))
.unwrap()
.unwrap();
let dest_root = TempDir::new().expect("dest temp");
let dest_path = dest_root.path().join("export-target");
let mut bridge = GitBridge::new(&repo);
bridge.export_to_path(&dest_path).expect("first export");
let oid_a = test_support::mapping(&bridge)
.get_git(&state_a)
.expect("A minted");
{
let dest = open_git(&dest_path).expect("open dest");
set_reference(
&dest,
"refs/keep/backup",
oid_a,
RefPrecondition::Any,
"test: foreign ref heddle does not own",
)
.expect("plant foreign ref");
}
bridge.export_to_path(&dest_path).expect("second export");
let dest = open_git(&dest_path).expect("reopen dest");
assert!(
find_reference(&dest, "refs/keep/backup").is_ok(),
"a foreign ref outside heddle-managed namespaces must be left untouched"
);
assert!(
find_reference(&dest, "refs/heads/main").is_ok(),
"a still-served heddle branch must survive the reconciliation"
);
}
#[test]
fn export_does_not_delete_foreign_managed_ref() {
let heddle_temp = TempDir::new().expect("heddle temp");
let repo = Repository::init_default(heddle_temp.path()).expect("init heddle");
let mut cfg = repo.config().clone();
cfg.set_principal("Grace Hopper", "grace@example.com");
cfg.save(&repo.heddle_dir().join("config.toml"))
.expect("save principal");
let repo = Repository::open(heddle_temp.path()).expect("reopen heddle");
std::fs::write(heddle_temp.path().join("a.txt"), b"only\n").unwrap();
repo.snapshot(Some("only".into()), None).unwrap();
let state_a = repo
.refs()
.get_thread(&ThreadName::new("main"))
.unwrap()
.unwrap();
let dest_root = TempDir::new().expect("dest temp");
let dest_path = dest_root.path().join("export-target");
let mut bridge = GitBridge::new(&repo);
bridge.export_to_path(&dest_path).expect("first export");
let oid_a = test_support::mapping(&bridge)
.get_git(&state_a)
.expect("A minted");
{
let dest = open_git(&dest_path).expect("open dest");
set_reference(
&dest,
"refs/heads/other-user-branch",
oid_a,
RefPrecondition::Any,
"test: foreign branch heddle never exported",
)
.expect("plant foreign managed ref");
}
bridge.export_to_path(&dest_path).expect("second export");
let dest = open_git(&dest_path).expect("reopen dest");
assert!(
find_reference(&dest, "refs/heads/other-user-branch").is_ok(),
"a foreign branch heddle never exported must NOT be deleted by a normal push"
);
assert!(
find_reference(&dest, "refs/heads/main").is_ok(),
"a still-served heddle branch must survive the reconciliation"
);
}
#[test]
fn export_still_deletes_previously_exported_then_retracted_ref() {
use chrono::Utc;
use objects::object::{Principal, StateVisibility, VisibilityTier};
let heddle_temp = TempDir::new().expect("heddle temp");
let repo = Repository::init_default(heddle_temp.path()).expect("init heddle");
let mut cfg = repo.config().clone();
cfg.set_principal("Grace Hopper", "grace@example.com");
cfg.save(&repo.heddle_dir().join("config.toml"))
.expect("save principal");
let repo = Repository::open(heddle_temp.path()).expect("reopen heddle");
std::fs::write(heddle_temp.path().join("a.txt"), b"only\n").unwrap();
repo.snapshot(Some("only".into()), None).unwrap();
let dest_root = TempDir::new().expect("dest temp");
let dest_path = dest_root.path().join("export-target");
let mut bridge = GitBridge::new(&repo);
bridge.export_to_path(&dest_path).expect("first export");
let dest = open_git(&dest_path).expect("open dest");
assert!(
find_reference(&dest, "refs/heads/main").is_ok(),
"destination must advertise main while the line is public"
);
for state in repo.store().list_states().unwrap() {
repo.put_state_visibility(StateVisibility {
state,
tier: VisibilityTier::Private {
scope_label: "sec-embargo".into(),
},
embargo_until: None,
declarer: Principal {
name: "Grace Hopper".into(),
email: "grace@example.com".into(),
},
declared_at: Utc::now(),
signature: None,
supersedes: None,
})
.unwrap();
}
bridge.export_to_path(&dest_path).expect("second export");
let dest = open_git(&dest_path).expect("reopen dest");
assert!(
find_reference(&dest, "refs/heads/main").is_err(),
"a previously-exported, now-retracted branch must be DELETED at the destination"
);
}
#[test]
fn retraction_delete_propagates_to_url_remote() {
use chrono::Utc;
use objects::object::{Principal, StateVisibility, VisibilityTier};
let heddle_temp = TempDir::new().expect("heddle temp");
let repo = Repository::init_default(heddle_temp.path()).expect("init heddle");
let mut cfg = repo.config().clone();
cfg.set_principal("Grace Hopper", "grace@example.com");
cfg.save(&repo.heddle_dir().join("config.toml"))
.expect("save principal");
let repo = Repository::open(heddle_temp.path()).expect("reopen heddle");
std::fs::write(heddle_temp.path().join("a.txt"), b"only\n").unwrap();
repo.snapshot(Some("only".into()), None).unwrap();
let remote_root = TempDir::new().expect("remote root");
let _remote_repo = init_named_bare_git_repo(&remote_root, "remote.git");
std::fs::write(
remote_root.path().join("remote.git").join("HEAD"),
b"ref: refs/heads/__heddle_placeholder\n",
)
.unwrap();
let Some(daemon) = GitDaemon::spawn_push(remote_root.path()) else {
return;
};
let url = daemon.url("remote.git");
let mut bridge = GitBridge::new(&repo);
bridge.push(&url).expect("first network push");
{
let remote = open_git(remote_root.path().join("remote.git")).expect("open remote");
assert!(
find_reference(&remote, "refs/heads/main").is_ok(),
"the remote must advertise main while the line is public"
);
}
for state in repo.store().list_states().unwrap() {
repo.put_state_visibility(StateVisibility {
state,
tier: VisibilityTier::Private {
scope_label: "sec-embargo".into(),
},
embargo_until: None,
declarer: Principal {
name: "Grace Hopper".into(),
email: "grace@example.com".into(),
},
declared_at: Utc::now(),
signature: None,
supersedes: None,
})
.unwrap();
}
bridge.push(&url).expect("second network push");
let remote = open_git(remote_root.path().join("remote.git")).expect("reopen remote");
assert!(
find_reference(&remote, "refs/heads/main").is_err(),
"a previously-exported, now-retracted branch must be DELETED on the URL/network remote"
);
}
#[test]
fn embargo_rewind_forced_through_destination_push() {
use chrono::Utc;
use objects::object::{Principal, StateVisibility, VisibilityTier};
let heddle_temp = TempDir::new().expect("heddle temp");
let repo = Repository::init_default(heddle_temp.path()).expect("init heddle");
let mut cfg = repo.config().clone();
cfg.set_principal("Grace Hopper", "grace@example.com");
cfg.save(&repo.heddle_dir().join("config.toml"))
.expect("save principal");
let repo = Repository::open(heddle_temp.path()).expect("reopen heddle");
std::fs::write(heddle_temp.path().join("a.txt"), b"base\n").unwrap();
repo.snapshot(Some("base".into()), None).unwrap();
let state_a = repo
.refs()
.get_thread(&ThreadName::new("main"))
.unwrap()
.unwrap();
std::fs::write(heddle_temp.path().join("b.txt"), b"fix\n").unwrap();
repo.snapshot(Some("fix".into()), None).unwrap();
let state_b = repo
.refs()
.get_thread(&ThreadName::new("main"))
.unwrap()
.unwrap();
let dest_root = TempDir::new().expect("dest temp");
let dest_path = dest_root.path().join("export-target");
let mut bridge = GitBridge::new(&repo);
bridge.export_to_path(&dest_path).expect("first export");
let oid_a = test_support::mapping(&bridge)
.get_git(&state_a)
.expect("A minted");
let oid_b = test_support::mapping(&bridge)
.get_git(&state_b)
.expect("B minted");
{
let dest = open_git(&dest_path).expect("open dest");
let tip = find_reference(&dest, "refs/heads/main")
.unwrap()
.peel_to_id()
.unwrap();
assert_eq!(tip, oid_b, "destination advertises the public tip B");
}
repo.put_state_visibility(StateVisibility {
state: state_b,
tier: VisibilityTier::Private {
scope_label: "sec-embargo".into(),
},
embargo_until: None,
declarer: Principal {
name: "Grace Hopper".into(),
email: "grace@example.com".into(),
},
declared_at: Utc::now(),
signature: None,
supersedes: None,
})
.unwrap();
bridge
.export_to_path(&dest_path)
.expect("second export must force the embargo rewind through the destination");
let dest = open_git(&dest_path).expect("reopen dest");
let tip = find_reference(&dest, "refs/heads/main")
.unwrap()
.peel_to_id()
.unwrap();
assert_eq!(
tip, oid_a,
"the destination branch must be rewound to the served ancestor A"
);
assert_ne!(
tip, oid_b,
"the destination must not keep advertising the embargoed tip B"
);
}
#[test]
fn foreign_ref_on_url_remote_survives() {
let heddle_temp = TempDir::new().expect("heddle temp");
let repo = Repository::init_default(heddle_temp.path()).expect("init heddle");
let mut cfg = repo.config().clone();
cfg.set_principal("Grace Hopper", "grace@example.com");
cfg.save(&repo.heddle_dir().join("config.toml"))
.expect("save principal");
let repo = Repository::open(heddle_temp.path()).expect("reopen heddle");
std::fs::write(heddle_temp.path().join("a.txt"), b"only\n").unwrap();
repo.snapshot(Some("only".into()), None).unwrap();
let remote_root = TempDir::new().expect("remote root");
let _remote_repo = init_named_bare_git_repo(&remote_root, "remote.git");
let Some(daemon) = GitDaemon::spawn_push(remote_root.path()) else {
return;
};
let url = daemon.url("remote.git");
let mut bridge = GitBridge::new(&repo);
bridge.push(&url).expect("first network push");
{
let remote = open_git(remote_root.path().join("remote.git")).expect("open remote");
let main_oid = find_reference(&remote, "refs/heads/main")
.unwrap()
.peel_to_id()
.unwrap();
set_reference(
&remote,
"refs/heads/other-user-branch",
main_oid,
RefPrecondition::Any,
"test: foreign branch heddle never exported",
)
.expect("plant foreign managed ref");
}
bridge.push(&url).expect("second network push");
let remote = open_git(remote_root.path().join("remote.git")).expect("reopen remote");
assert!(
find_reference(&remote, "refs/heads/other-user-branch").is_ok(),
"a foreign branch heddle never exported must NOT be deleted on the URL/network remote"
);
assert!(
find_reference(&remote, "refs/heads/main").is_ok(),
"a still-served heddle branch must survive the reconciliation"
);
}
#[test]
fn out_of_band_destination_descendant_not_force_overwritten() {
use cli::bridge::git_core::GitBridgeError;
let heddle_temp = TempDir::new().expect("heddle temp");
let repo = Repository::init_default(heddle_temp.path()).expect("init heddle");
let mut cfg = repo.config().clone();
cfg.set_principal("Grace Hopper", "grace@example.com");
cfg.save(&repo.heddle_dir().join("config.toml"))
.expect("save principal");
let repo = Repository::open(heddle_temp.path()).expect("reopen heddle");
std::fs::write(heddle_temp.path().join("a.txt"), b"base\n").unwrap();
repo.snapshot(Some("base".into()), None).unwrap();
std::fs::write(heddle_temp.path().join("b.txt"), b"fix\n").unwrap();
repo.snapshot(Some("fix".into()), None).unwrap();
let state_b = repo
.refs()
.get_thread(&ThreadName::new("main"))
.unwrap()
.unwrap();
let mut bridge = GitBridge::new(&repo);
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 publishes B (local)");
let oid_b = test_support::mapping(&bridge)
.get_git(&state_b)
.expect("B minted");
let oid_c = {
let mirror = test_support::open_git_repo(&bridge).expect("open mirror");
let in_mirror = commit_with_tree(
&mirror,
None,
empty_tree_oid(&mirror),
"out-of-band",
&[oid_b],
);
let dest = open_git(&dest_path).expect("open dest");
let in_dest = commit_with_tree(
&dest,
Some("refs/heads/main"),
empty_tree_oid(&dest),
"out-of-band",
&[oid_b],
);
assert_eq!(
in_mirror, in_dest,
"C must be the same commit in mirror and destination"
);
in_mirror
};
let err = bridge.export_to_path(&dest_path).expect_err(
"out-of-band descendant must be FF-rejected at the local destination, not force-overwritten",
);
assert!(
matches!(err, GitBridgeError::NonFastForwardRef { .. }),
"expected a non-fast-forward rejection, got: {err:?}"
);
{
let dest = open_git(&dest_path).expect("reopen dest");
let tip = find_reference(&dest, "refs/heads/main")
.unwrap()
.peel_to_id()
.unwrap();
assert_eq!(
tip, oid_c,
"the out-of-band commit must survive — heddle must not force-overwrite it"
);
}
bridge
.push_with_scope_force(
dest_path.to_str().expect("dest path"),
GitPushScope::AllThreads,
true,
)
.expect("--force overrides the FF guard at the local destination");
{
let dest = open_git(&dest_path).expect("reopen dest");
let tip = find_reference(&dest, "refs/heads/main")
.unwrap()
.peel_to_id()
.unwrap();
assert_eq!(
tip, oid_b,
"--force rewinds the local destination to the served frontier B"
);
}
let remote_root = TempDir::new().expect("remote root");
let _remote_repo = init_named_bare_git_repo(&remote_root, "remote.git");
let Some(daemon) = GitDaemon::spawn_push(remote_root.path()) else {
return;
};
let url = daemon.url("remote.git");
bridge.push(&url).expect("first network push publishes B");
{
let remote = open_git(remote_root.path().join("remote.git")).expect("open remote");
let tip = find_reference(&remote, "refs/heads/main")
.unwrap()
.peel_to_id()
.unwrap();
assert_eq!(tip, oid_b, "remote advertises the published tip B");
}
{
let remote = open_git(remote_root.path().join("remote.git")).expect("open remote");
let in_remote = commit_with_tree(
&remote,
Some("refs/heads/main"),
empty_tree_oid(&remote),
"out-of-band",
&[oid_b],
);
assert_eq!(
in_remote, oid_c,
"C must be the same commit on the wire remote"
);
}
let err = bridge
.push(&url)
.expect_err("out-of-band descendant must be FF-rejected on the URL/network remote too");
assert!(
matches!(err, GitBridgeError::NonFastForwardRef { .. }),
"expected a non-fast-forward rejection on the wire, got: {err:?}"
);
{
let remote = open_git(remote_root.path().join("remote.git")).expect("reopen remote");
let tip = find_reference(&remote, "refs/heads/main")
.unwrap()
.peel_to_id()
.unwrap();
assert_eq!(
tip, oid_c,
"the out-of-band commit must survive on the URL/network remote"
);
}
}
#[test]
fn heddle_published_tip_embargo_rewind_still_forced() {
use chrono::Utc;
use objects::object::{Principal, StateVisibility, VisibilityTier};
let heddle_temp = TempDir::new().expect("heddle temp");
let repo = Repository::init_default(heddle_temp.path()).expect("init heddle");
let mut cfg = repo.config().clone();
cfg.set_principal("Grace Hopper", "grace@example.com");
cfg.save(&repo.heddle_dir().join("config.toml"))
.expect("save principal");
let repo = Repository::open(heddle_temp.path()).expect("reopen heddle");
std::fs::write(heddle_temp.path().join("a.txt"), b"base\n").unwrap();
repo.snapshot(Some("base".into()), None).unwrap();
let state_a = repo
.refs()
.get_thread(&ThreadName::new("main"))
.unwrap()
.unwrap();
std::fs::write(heddle_temp.path().join("b.txt"), b"fix\n").unwrap();
repo.snapshot(Some("fix".into()), None).unwrap();
let state_b = repo
.refs()
.get_thread(&ThreadName::new("main"))
.unwrap()
.unwrap();
let mut bridge = GitBridge::new(&repo);
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 publishes B (local)");
let remote_root = TempDir::new().expect("remote root");
let _remote_repo = init_named_bare_git_repo(&remote_root, "remote.git");
let Some(daemon) = GitDaemon::spawn_push(remote_root.path()) else {
return;
};
let url = daemon.url("remote.git");
bridge.push(&url).expect("first network push publishes B");
let oid_a = test_support::mapping(&bridge)
.get_git(&state_a)
.expect("A minted");
let oid_b = test_support::mapping(&bridge)
.get_git(&state_b)
.expect("B minted");
{
let dest = open_git(&dest_path).expect("open dest");
assert_eq!(
find_reference(&dest, "refs/heads/main")
.unwrap()
.peel_to_id()
.unwrap(),
oid_b,
"local destination advertises the published tip B"
);
let remote = open_git(remote_root.path().join("remote.git")).expect("open remote");
assert_eq!(
find_reference(&remote, "refs/heads/main")
.unwrap()
.peel_to_id()
.unwrap(),
oid_b,
"remote advertises the published tip B"
);
}
repo.put_state_visibility(StateVisibility {
state: state_b,
tier: VisibilityTier::Private {
scope_label: "sec-embargo".into(),
},
embargo_until: None,
declarer: Principal {
name: "Grace Hopper".into(),
email: "grace@example.com".into(),
},
declared_at: Utc::now(),
signature: None,
supersedes: None,
})
.unwrap();
bridge
.export_to_path(&dest_path)
.expect("second export must FORCE the heddle-owned embargo rewind (local)");
bridge
.push(&url)
.expect("second network push must FORCE the heddle-owned embargo rewind (network)");
let dest = open_git(&dest_path).expect("reopen dest");
let tip = find_reference(&dest, "refs/heads/main")
.unwrap()
.peel_to_id()
.unwrap();
assert_eq!(
tip, oid_a,
"local destination must be force-rewound to the served ancestor A"
);
assert_ne!(
tip, oid_b,
"local destination must not keep advertising the embargoed tip B"
);
let remote = open_git(remote_root.path().join("remote.git")).expect("reopen remote");
let rtip = find_reference(&remote, "refs/heads/main")
.unwrap()
.peel_to_id()
.unwrap();
assert_eq!(
rtip, oid_a,
"URL/network remote must be force-rewound to the served ancestor A"
);
assert_ne!(
rtip, oid_b,
"URL/network remote must not keep advertising the embargoed tip B"
);
}
#[test]
fn out_of_band_advance_after_embargo_not_deleted() {
use chrono::Utc;
use objects::object::{Principal, StateVisibility, VisibilityTier};
let heddle_temp = TempDir::new().expect("heddle temp");
let repo = Repository::init_default(heddle_temp.path()).expect("init heddle");
let mut cfg = repo.config().clone();
cfg.set_principal("Grace Hopper", "grace@example.com");
cfg.save(&repo.heddle_dir().join("config.toml"))
.expect("save principal");
let repo = Repository::open(heddle_temp.path()).expect("reopen heddle");
std::fs::write(heddle_temp.path().join("a.txt"), b"base\n").unwrap();
repo.snapshot(Some("base".into()), None).unwrap();
let state_b = repo
.refs()
.get_thread(&ThreadName::new("main"))
.unwrap()
.unwrap();
let mut bridge = GitBridge::new(&repo);
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 publishes B (local)");
let oid_b = test_support::mapping(&bridge)
.get_git(&state_b)
.expect("B minted");
let remote_root = TempDir::new().expect("remote root");
let _remote_repo = init_named_bare_git_repo(&remote_root, "remote.git");
std::fs::write(
remote_root.path().join("remote.git").join("HEAD"),
b"ref: refs/heads/__heddle_placeholder\n",
)
.unwrap();
let Some(daemon) = GitDaemon::spawn_push(remote_root.path()) else {
return;
};
let url = daemon.url("remote.git");
bridge.push(&url).expect("first network push publishes B");
let oid_c = {
let mirror = test_support::open_git_repo(&bridge).expect("open mirror");
let in_mirror = commit_with_tree(
&mirror,
None,
empty_tree_oid(&mirror),
"out-of-band",
&[oid_b],
);
let dest = open_git(&dest_path).expect("open dest");
let in_dest = commit_with_tree(
&dest,
Some("refs/heads/main"),
empty_tree_oid(&dest),
"out-of-band",
&[oid_b],
);
let remote = open_git(remote_root.path().join("remote.git")).expect("open remote");
let in_remote = commit_with_tree(
&remote,
Some("refs/heads/main"),
empty_tree_oid(&remote),
"out-of-band",
&[oid_b],
);
assert_eq!(
in_mirror, in_dest,
"C identical in mirror and local destination"
);
assert_eq!(
in_mirror, in_remote,
"C identical in mirror and wire remote"
);
in_mirror
};
for state in repo.store().list_states().unwrap() {
repo.put_state_visibility(StateVisibility {
state,
tier: VisibilityTier::Private {
scope_label: "sec-embargo".into(),
},
embargo_until: None,
declarer: Principal {
name: "Grace Hopper".into(),
email: "grace@example.com".into(),
},
declared_at: Utc::now(),
signature: None,
supersedes: None,
})
.unwrap();
}
bridge
.export_to_path(&dest_path)
.expect("plain export must not error on the out-of-band retraction (local)");
{
let dest = open_git(&dest_path).expect("reopen dest");
let tip = find_reference(&dest, "refs/heads/main")
.unwrap()
.peel_to_id()
.unwrap();
assert_eq!(
tip, oid_c,
"the out-of-band commit must survive — heddle must not delete a tip it never published (local)"
);
}
bridge
.push(&url)
.expect("plain network push must not error on the out-of-band retraction");
{
let remote = open_git(remote_root.path().join("remote.git")).expect("reopen remote");
let tip = find_reference(&remote, "refs/heads/main")
.unwrap()
.peel_to_id()
.unwrap();
assert_eq!(
tip, oid_c,
"the out-of-band commit must survive on the URL/network remote too"
);
}
bridge
.push_with_scope_force(
dest_path.to_str().expect("dest path"),
GitPushScope::AllThreads,
true,
)
.expect("--force retracts the out-of-band branch at the local destination");
{
let dest = open_git(&dest_path).expect("reopen dest");
assert!(
find_reference(&dest, "refs/heads/main").is_err(),
"--force deletes the retracted branch even when the destination tip was advanced out of band"
);
}
}
#[test]
fn reconcile_ownership_conformance_matrix() {
use std::collections::{HashMap, HashSet};
let (_mirror_temp, mirror) = init_git_repo();
let a = commit_with_tree(&mirror, None, empty_tree_oid(&mirror), "A", &[]);
let b = commit_with_tree(&mirror, None, empty_tree_oid(&mirror), "B", &[a]);
let tag_obj = create_annotated_tag(&mirror, "annot", b, "annotated tag object");
enum Outcome {
Write(ObjectId),
Delete,
Absent,
NonFastForward,
}
struct Cell {
label: &'static str,
ns: RefNamespace,
old: Option<ObjectId>,
target: ObjectId,
recorded: Option<ObjectId>,
desired: bool,
force: bool,
expect: Outcome,
}
fn is_ancestor_commit(repo: &SleyRepository, ancestor: ObjectId, descendant: ObjectId) -> bool {
let mut pending = vec![descendant];
let mut seen = HashSet::new();
while let Some(oid) = pending.pop() {
if oid == ancestor {
return true;
}
if !seen.insert(oid) {
continue;
}
if let Ok(commit) = repo.read_commit(&oid) {
pending.extend(commit.parents);
}
}
false
}
fn expected_write_force(repo: &SleyRepository, cell: &Cell) -> bool {
if cell.force {
return true;
}
let Some(old) = cell.old else {
return false;
};
if old == cell.target {
return false;
}
match cell.ns {
RefNamespace::Tag => true,
RefNamespace::Branch | RefNamespace::Note => {
!is_ancestor_commit(repo, old, cell.target)
}
}
}
let cells = vec![
Cell {
label: "branch/create",
ns: RefNamespace::Branch,
old: None,
target: b,
recorded: None,
desired: true,
force: false,
expect: Outcome::Write(b),
},
Cell {
label: "branch/no-op",
ns: RefNamespace::Branch,
old: Some(b),
target: b,
recorded: Some(b),
desired: true,
force: false,
expect: Outcome::Absent,
},
Cell {
label: "branch/fast-forward/owned",
ns: RefNamespace::Branch,
old: Some(a),
target: b,
recorded: Some(a),
desired: true,
force: false,
expect: Outcome::Write(b),
},
Cell {
label: "branch/fast-forward/out-of-band",
ns: RefNamespace::Branch,
old: Some(a),
target: b,
recorded: None,
desired: true,
force: false,
expect: Outcome::Write(b),
},
Cell {
label: "branch/rewind/owned",
ns: RefNamespace::Branch,
old: Some(b),
target: a,
recorded: Some(b),
desired: true,
force: false,
expect: Outcome::Write(a),
},
Cell {
label: "branch/rewind/out-of-band/force-off",
ns: RefNamespace::Branch,
old: Some(b),
target: a,
recorded: None,
desired: true,
force: false,
expect: Outcome::NonFastForward,
},
Cell {
label: "branch/rewind/out-of-band/force-on",
ns: RefNamespace::Branch,
old: Some(b),
target: a,
recorded: None,
desired: true,
force: true,
expect: Outcome::Write(a),
},
Cell {
label: "branch/retract/owned",
ns: RefNamespace::Branch,
old: Some(b),
target: b,
recorded: Some(b),
desired: false,
force: false,
expect: Outcome::Delete,
},
Cell {
label: "branch/retract/out-of-band/force-off",
ns: RefNamespace::Branch,
old: Some(b),
target: b,
recorded: Some(a),
desired: false,
force: false,
expect: Outcome::Absent,
},
Cell {
label: "branch/retract/out-of-band/force-on",
ns: RefNamespace::Branch,
old: Some(b),
target: b,
recorded: Some(a),
desired: false,
force: true,
expect: Outcome::Delete,
},
Cell {
label: "tag/create",
ns: RefNamespace::Tag,
old: None,
target: b,
recorded: None,
desired: true,
force: false,
expect: Outcome::Write(b),
},
Cell {
label: "tag/no-op",
ns: RefNamespace::Tag,
old: Some(b),
target: b,
recorded: Some(b),
desired: true,
force: false,
expect: Outcome::Absent,
},
Cell {
label: "tag/owned-overwrite",
ns: RefNamespace::Tag,
old: Some(a),
target: b,
recorded: Some(a),
desired: true,
force: false,
expect: Outcome::Write(b),
},
Cell {
label: "tag/out-of-band-overwrite/unrecorded/force-off",
ns: RefNamespace::Tag,
old: Some(a),
target: b,
recorded: None,
desired: true,
force: false,
expect: Outcome::NonFastForward,
},
Cell {
label: "tag/out-of-band-overwrite/mismatched-record/force-off",
ns: RefNamespace::Tag,
old: Some(a),
target: b,
recorded: Some(b),
desired: true,
force: false,
expect: Outcome::NonFastForward,
},
Cell {
label: "tag/out-of-band-overwrite/force-on",
ns: RefNamespace::Tag,
old: Some(a),
target: b,
recorded: None,
desired: true,
force: true,
expect: Outcome::Write(b),
},
Cell {
label: "tag/owned-overwrite/annotated-object-target",
ns: RefNamespace::Tag,
old: Some(a),
target: tag_obj,
recorded: Some(a),
desired: true,
force: false,
expect: Outcome::Write(tag_obj),
},
Cell {
label: "tag/out-of-band-overwrite/annotated-object-old/force-off",
ns: RefNamespace::Tag,
old: Some(tag_obj),
target: b,
recorded: None,
desired: true,
force: false,
expect: Outcome::NonFastForward,
},
Cell {
label: "tag/retract/owned",
ns: RefNamespace::Tag,
old: Some(b),
target: b,
recorded: Some(b),
desired: false,
force: false,
expect: Outcome::Delete,
},
Cell {
label: "tag/retract/out-of-band/force-off",
ns: RefNamespace::Tag,
old: Some(b),
target: b,
recorded: Some(a),
desired: false,
force: false,
expect: Outcome::Absent,
},
Cell {
label: "tag/retract/out-of-band/force-on",
ns: RefNamespace::Tag,
old: Some(b),
target: b,
recorded: Some(a),
desired: false,
force: true,
expect: Outcome::Delete,
},
Cell {
label: "note/create",
ns: RefNamespace::Note,
old: None,
target: b,
recorded: None,
desired: true,
force: false,
expect: Outcome::Write(b),
},
Cell {
label: "note/no-op",
ns: RefNamespace::Note,
old: Some(b),
target: b,
recorded: Some(b),
desired: true,
force: false,
expect: Outcome::Absent,
},
Cell {
label: "note/fast-forward/owned",
ns: RefNamespace::Note,
old: Some(a),
target: b,
recorded: Some(a),
desired: true,
force: false,
expect: Outcome::Write(b),
},
Cell {
label: "note/rewind/owned",
ns: RefNamespace::Note,
old: Some(b),
target: a,
recorded: Some(b),
desired: true,
force: false,
expect: Outcome::Write(a),
},
Cell {
label: "note/rewind/out-of-band/force-off",
ns: RefNamespace::Note,
old: Some(b),
target: a,
recorded: None,
desired: true,
force: false,
expect: Outcome::NonFastForward,
},
Cell {
label: "note/rewind/out-of-band/force-on",
ns: RefNamespace::Note,
old: Some(b),
target: a,
recorded: None,
desired: true,
force: true,
expect: Outcome::Write(a),
},
Cell {
label: "note/retract/owned",
ns: RefNamespace::Note,
old: Some(b),
target: b,
recorded: Some(b),
desired: false,
force: false,
expect: Outcome::Delete,
},
];
for cell in &cells {
let short = "v1";
let full = match cell.ns {
RefNamespace::Branch => format!("refs/heads/{short}"),
RefNamespace::Tag => format!("refs/tags/{short}"),
RefNamespace::Note => format!("refs/notes/{short}"),
};
let served: Vec<RefUpdate> = if cell.desired {
vec![RefUpdate {
name: short.to_string(),
target: cell.target,
namespace: cell.ns,
}]
} else {
Vec::new()
};
let mut old_map: HashMap<String, ObjectId> = HashMap::new();
if let Some(o) = cell.old {
old_map.insert(full.clone(), o);
}
let mut recorded_map: HashMap<String, ObjectId> = HashMap::new();
if let Some(r) = cell.recorded {
recorded_map.insert(full.clone(), r);
}
let result =
plan_destination_reconcile(&mirror, &served, None, &old_map, &recorded_map, cell.force);
if let Outcome::NonFastForward = cell.expect {
let err = result.expect_err(&format!(
"cell `{}`: expected Err(NonFastForwardRef)",
cell.label
));
assert!(
matches!(err, GitBridgeError::NonFastForwardRef { .. }),
"cell `{}`: expected NonFastForwardRef, got {err:?}",
cell.label
);
continue;
}
let plan =
result.unwrap_or_else(|e| panic!("cell `{}`: expected Ok, got {e:?}", cell.label));
match cell.expect {
Outcome::Write(exp_new) => {
assert!(
plan.deletes.is_empty(),
"cell `{}`: expected no deletes",
cell.label
);
assert_eq!(
plan.writes.len(),
1,
"cell `{}`: expected exactly one write",
cell.label
);
let w = &plan.writes[0];
assert_eq!(w.full_name, full, "cell `{}`: write name", cell.label);
assert_eq!(w.new, exp_new, "cell `{}`: write new", cell.label);
assert_eq!(
w.force,
expected_write_force(&mirror, cell),
"cell `{}`: write force",
cell.label
);
}
Outcome::Delete => {
assert!(
plan.writes.is_empty(),
"cell `{}`: expected no writes",
cell.label
);
assert_eq!(
plan.deletes.len(),
1,
"cell `{}`: expected exactly one delete",
cell.label
);
let d = &plan.deletes[0];
assert_eq!(d.full_name, full, "cell `{}`: delete name", cell.label);
}
Outcome::Absent => {
assert!(
plan.writes.is_empty(),
"cell `{}`: expected no writes",
cell.label
);
assert!(
plan.deletes.is_empty(),
"cell `{}`: expected no deletes",
cell.label
);
}
Outcome::NonFastForward => unreachable!("handled above"),
}
}
}
#[test]
fn foreign_destination_ref_spared() {
use std::collections::HashMap;
let (_mirror_temp, mirror) = init_git_repo();
let a = commit_with_tree(&mirror, None, empty_tree_oid(&mirror), "A", &[]);
let full = "refs/tags/user-v1".to_string();
let served = vec![RefUpdate {
name: "user-v1".to_string(),
target: a,
namespace: RefNamespace::Tag,
}];
let mut old_map: HashMap<String, ObjectId> = HashMap::new();
old_map.insert(full.clone(), a);
let recorded_map: HashMap<String, ObjectId> = HashMap::new();
let plan =
plan_destination_reconcile(&mirror, &served, None, &old_map, &recorded_map, false).unwrap();
assert!(
plan.writes.is_empty(),
"a coincidental match must not be written"
);
assert!(
plan.deletes.is_empty(),
"a coincidental match must not be deleted"
);
assert!(
!plan.new_manifest.contains_key(&full),
"heddle must NOT claim ownership of a foreign destination ref it never recorded"
);
let plan2 = plan_destination_reconcile(&mirror, &[], None, &old_map, &plan.new_manifest, false)
.unwrap();
assert!(
plan2.deletes.is_empty(),
"the foreign ref must survive a later retraction — it was never heddle's to delete"
);
}
#[test]
fn out_of_band_destination_tag_not_overwritten() {
use cli::bridge::git_core::GitBridgeError;
use objects::object::{Attribution, Principal, State};
let heddle_temp = TempDir::new().expect("heddle temp");
let repo = Repository::init_default(heddle_temp.path()).expect("init heddle");
let mut cfg = repo.config().clone();
cfg.set_principal("Grace Hopper", "grace@example.com");
cfg.save(&repo.heddle_dir().join("config.toml"))
.expect("save principal");
let repo = Repository::open(heddle_temp.path()).expect("reopen heddle");
std::fs::write(heddle_temp.path().join("main.txt"), b"trunk\n").unwrap();
repo.snapshot(Some("trunk".into()), None).unwrap();
let r = {
let store = repo.store();
let blob = store
.put_blob(&Blob::from_slice(b"release\n"))
.expect("blob");
let tree = store
.put_tree(&Tree::from_entries(vec![
TreeEntry::file("release.txt".to_string(), blob, false).expect("entry"),
]))
.expect("tree");
let state = State::new(
tree,
Vec::new(),
Attribution::human(Principal::new("Grace Hopper", "grace@example.com")),
);
store.put_state(&state).expect("put R");
state
};
repo.refs()
.create_marker(&MarkerName::new("v1.0"), &r.change_id)
.expect("create marker at R");
let mut bridge = GitBridge::new(&repo);
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 publishes the tag (local)");
let served_tag = {
let dest = open_git(&dest_path).expect("open dest");
read_exported_refs(&dest).expect("read record")["refs/tags/v1.0"]
};
let oid_x = {
let dest = open_git(&dest_path).expect("open dest");
let x = commit_with_tree(&dest, None, empty_tree_oid(&dest), "out-of-band-tag", &[]);
set_reference(
&dest,
"refs/tags/v1.0",
x,
RefPrecondition::Any,
"test: out-of-band tag",
)
.expect("move tag out of band");
x
};
assert_ne!(
oid_x, served_tag,
"the out-of-band tag tip must differ from the served tip"
);
let err = bridge.export_to_path(&dest_path).expect_err(
"out-of-band tag must be FF-rejected at the local destination, not overwritten",
);
assert!(
matches!(err, GitBridgeError::NonFastForwardRef { .. }),
"expected a non-fast-forward rejection, got: {err:?}"
);
{
let dest = open_git(&dest_path).expect("reopen dest");
let tip = find_reference(&dest, "refs/tags/v1.0")
.unwrap()
.try_id()
.expect("tag id");
assert_eq!(
tip, oid_x,
"the out-of-band tag must survive — heddle must not overwrite it"
);
}
bridge
.push_with_scope_force(
dest_path.to_str().expect("dest path"),
GitPushScope::AllThreads,
true,
)
.expect("--force overrides the tag ownership gate at the local destination");
{
let dest = open_git(&dest_path).expect("reopen dest");
let tip = find_reference(&dest, "refs/tags/v1.0")
.unwrap()
.try_id()
.expect("tag id");
assert_eq!(
tip, served_tag,
"--force rewinds the local destination tag to the served tip"
);
}
let remote_root = TempDir::new().expect("remote root");
let _remote_repo = init_named_bare_git_repo(&remote_root, "remote.git");
std::fs::write(
remote_root.path().join("remote.git").join("HEAD"),
b"ref: refs/heads/__heddle_placeholder\n",
)
.unwrap();
let Some(daemon) = GitDaemon::spawn_push(remote_root.path()) else {
return;
};
let url = daemon.url("remote.git");
bridge
.push(&url)
.expect("first network push publishes the tag");
let remote_served_tag = {
let remote = open_git(remote_root.path().join("remote.git")).expect("open remote");
find_reference(&remote, "refs/tags/v1.0")
.unwrap()
.try_id()
.expect("tag id")
};
let remote_oid_x = {
let remote = open_git(remote_root.path().join("remote.git")).expect("open remote");
let x = commit_with_tree(
&remote,
None,
empty_tree_oid(&remote),
"out-of-band-tag",
&[],
);
set_reference(
&remote,
"refs/tags/v1.0",
x,
RefPrecondition::Any,
"test: out-of-band tag",
)
.expect("move remote tag out of band");
x
};
assert_ne!(
remote_oid_x, remote_served_tag,
"the out-of-band remote tag tip must differ"
);
let err = bridge
.push(&url)
.expect_err("out-of-band tag must be FF-rejected on the URL/network remote too");
assert!(
matches!(err, GitBridgeError::NonFastForwardRef { .. }),
"expected a non-fast-forward rejection on the wire, got: {err:?}"
);
{
let remote = open_git(remote_root.path().join("remote.git")).expect("reopen remote");
let tip = find_reference(&remote, "refs/tags/v1.0")
.unwrap()
.try_id()
.expect("tag id");
assert_eq!(
tip, remote_oid_x,
"the out-of-band tag must survive on the URL/network remote"
);
}
bridge
.push_with_scope_force(&url, GitPushScope::AllThreads, true)
.expect("--force overrides the tag ownership gate on the URL/network remote");
{
let remote = open_git(remote_root.path().join("remote.git")).expect("reopen remote");
let tip = find_reference(&remote, "refs/tags/v1.0")
.unwrap()
.try_id()
.expect("tag id");
assert_eq!(
tip, remote_served_tag,
"--force rewinds the remote tag to the served tip"
);
}
}
#[test]
fn heddle_owned_tag_overwrite_still_lands() {
use std::collections::HashMap;
let (_mirror_temp, mirror) = init_git_repo();
let a = commit_with_tree(&mirror, None, empty_tree_oid(&mirror), "A", &[]);
let b = commit_with_tree(&mirror, None, empty_tree_oid(&mirror), "B", &[a]);
let full = "refs/tags/v1.0".to_string();
let served = vec![RefUpdate {
name: "v1.0".to_string(),
target: b,
namespace: RefNamespace::Tag,
}];
let old_at_destination: HashMap<String, ObjectId> = [(full.clone(), a)].into_iter().collect();
let owned: HashMap<String, ObjectId> = [(full.clone(), a)].into_iter().collect();
let plan =
plan_destination_reconcile(&mirror, &served, None, &old_at_destination, &owned, false)
.expect("a heddle-owned tag move must reconcile without --force");
assert_eq!(
plan.writes.len(),
1,
"owned tag move must produce exactly one write"
);
assert_eq!(plan.writes[0].full_name, full);
assert!(
plan.writes[0].force,
"owned tag overwrite must force the destination update"
);
assert_eq!(plan.writes[0].new, b, "write lands the served target");
assert!(
plan.deletes.is_empty(),
"an owned overwrite is a write, not a delete"
);
let unrecorded: HashMap<String, ObjectId> = HashMap::new();
let err = plan_destination_reconcile(
&mirror,
&served,
None,
&old_at_destination,
&unrecorded,
false,
)
.expect_err("an unowned tag overwrite must be FF-rejected without --force");
assert!(
matches!(err, GitBridgeError::NonFastForwardRef { .. }),
"expected NonFastForwardRef, got {err:?}"
);
}
fn commit_with_signatures(
repo: &SleyRepository,
reference: Option<&str>,
tree_oid: ObjectId,
message: &str,
author: Signature,
committer: Signature,
extra_headers: Vec<(Vec<u8>, Vec<u8>)>,
) -> ObjectId {
let body = raw_commit_body(
tree_oid,
&[],
&author,
&committer,
extra_headers,
message.as_bytes(),
);
let id = repo
.write_object(EncodedObject::new(GitObjectType::Commit, body))
.expect("write commit");
if let Some(reference) = reference {
set_reference(
repo,
reference,
id,
RefPrecondition::Any,
"test: update ref",
)
.expect("update ref");
}
id
}
fn raw_commit_body(
tree_oid: ObjectId,
parents: &[ObjectId],
author: &Signature,
committer: &Signature,
extra_headers: Vec<(Vec<u8>, Vec<u8>)>,
message: &[u8],
) -> Vec<u8> {
let mut out = Vec::new();
out.extend_from_slice(format!("tree {tree_oid}\n").as_bytes());
for parent in parents {
out.extend_from_slice(format!("parent {parent}\n").as_bytes());
}
out.extend_from_slice(b"author ");
out.extend_from_slice(&author.to_ident_bytes());
out.push(b'\n');
out.extend_from_slice(b"committer ");
out.extend_from_slice(&committer.to_ident_bytes());
out.push(b'\n');
for (name, value) in extra_headers {
write_raw_commit_header(&mut out, &name, &value);
}
out.push(b'\n');
out.extend_from_slice(message);
out
}
fn write_raw_commit_header(out: &mut Vec<u8>, name: &[u8], value: &[u8]) {
out.extend_from_slice(name);
out.push(b' ');
let mut lines = value.split(|byte| *byte == b'\n');
if let Some(first) = lines.next() {
out.extend_from_slice(first);
}
for line in lines {
out.push(b'\n');
out.push(b' ');
out.extend_from_slice(line);
}
out.push(b'\n');
}
#[test]
fn import_preserves_commit_git_fidelity_fields() {
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 author = test_signature_at("Author", "author@example.com", 1_000_000, -7 * 60);
let committer = test_signature_at("Committer", "committer@example.com", 2_000_000, 2 * 60);
let gpgsig = "-----BEGIN PGP SIGNATURE-----\nABCD\n-----END PGP SIGNATURE-----";
let extra_headers = vec![
(b"gpgsig".to_vec(), gpgsig.as_bytes().to_vec()),
(b"mergetag".to_vec(), b"object deadbeef".to_vec()),
];
let commit_oid = commit_with_signatures(
&git_repo,
Some("refs/heads/main"),
tree_oid,
"feat: thing\n\nBody.\n",
author,
committer,
extra_headers,
);
let mut bridge = GitBridge::new(&repo);
import_all(
&mut bridge,
Some(git_repo.workdir().expect("workdir").as_path()),
)
.expect("import");
let change_id = test_support::mapping(&bridge)
.get_heddle(commit_oid)
.expect("commit mapped");
let state = repo
.store()
.get_state(&change_id)
.expect("load state")
.expect("state written");
let stored_committer = state.committer.expect("committer preserved");
assert_eq!(stored_committer.name, "Committer");
assert_eq!(stored_committer.email, "committer@example.com");
assert_eq!(state.authored_tz_offset, -7 * 3600);
assert_eq!(state.committer_tz_offset, 2 * 3600);
assert_eq!(
state.raw_message.as_deref(),
Some("feat: thing\n\nBody.\n".as_bytes())
);
assert_eq!(
state.extra_headers.len(),
2,
"gpgsig + mergetag, both inline"
);
assert_eq!(state.extra_headers[0].0, b"gpgsig".to_vec());
assert_eq!(
String::from_utf8_lossy(&state.extra_headers[0].1).trim_end(),
gpgsig,
"gpgsig preserved inline at its captured position"
);
assert_eq!(state.extra_headers[1].0, b"mergetag".to_vec());
assert_eq!(
String::from_utf8_lossy(&state.extra_headers[1].1).trim_end(),
"object deadbeef"
);
}
#[test]
fn non_utf8_git_fidelity_is_byte_identical_across_bridge_and_ingest() {
use ingest::import_git_into;
let (_git_temp, git_repo) = init_git_repo();
let tree_oid = empty_tree_oid(&git_repo);
let commit_message = b"caf\xe9 subject\n\nbody with a \xe9 byte\n".to_vec();
let gpgsig_value =
b"-----BEGIN PGP SIGNATURE-----\n\xe9 not-real-armor\n-----END PGP SIGNATURE-----".to_vec();
let custom_value = b"x-note caf\xe9 value".to_vec();
let mergetag_value = {
let mut v = Vec::new();
v.extend_from_slice(b"object 0123456789012345678901234567890123456789\n");
v.extend_from_slice(b"type commit\n");
v.extend_from_slice(b"tag merged-\xe9\n");
v.extend_from_slice(b"tagger Heddle Test <heddle@test> 0 +0000\n");
v.extend_from_slice(b"\n");
v.extend_from_slice(b"merge note caf\xe9\n");
v
};
let sig = test_signature();
let commit = raw_commit_body(
tree_oid,
&[],
&sig,
&sig,
vec![
(b"gpgsig".to_vec(), gpgsig_value.clone()),
(b"x-custom".to_vec(), custom_value.clone()),
(b"mergetag".to_vec(), mergetag_value.clone()),
],
&commit_message,
);
let commit_oid = git_repo
.write_object(EncodedObject::new(GitObjectType::Commit, commit))
.expect("write commit");
set_reference(
&git_repo,
"refs/heads/main",
commit_oid,
RefPrecondition::Any,
"test: main",
)
.expect("set main");
let git_workdir = git_repo.workdir().expect("workdir");
let bridge_heddle = TempDir::new().expect("bridge heddle temp");
let bridge_repo = Repository::init(bridge_heddle.path()).expect("init bridge heddle");
let mut bridge = GitBridge::new(&bridge_repo);
import_all(&mut bridge, Some(git_workdir.as_path())).expect("bridge import");
let bridge_cid = test_support::mapping(&bridge)
.get_heddle(commit_oid)
.expect("bridge mapped commit");
let bridge_state = bridge_repo
.store()
.get_state(&bridge_cid)
.expect("load bridge state")
.expect("bridge state written");
let ingest_heddle = TempDir::new().expect("ingest heddle temp");
let (_stats, ingest_map) =
import_git_into(git_workdir, ingest_heddle.path()).expect("ingest import");
let ingest_cid = ingest_map
.get_commit(&commit_oid.to_string())
.expect("ingest mapped commit");
let ingest_repo = Repository::open(ingest_heddle.path()).expect("open ingest heddle");
let ingest_state = ingest_repo
.store()
.get_state(&ingest_cid)
.expect("load ingest state")
.expect("ingest state written");
assert!(String::from_utf8(commit_message.clone()).is_err());
assert_eq!(
bridge_state.raw_message.as_deref(),
Some(commit_message.as_slice()),
"bridge must preserve the raw commit message verbatim"
);
assert_eq!(
ingest_state.raw_message, bridge_state.raw_message,
"raw_message must be byte-identical across bridge and ingest"
);
assert_eq!(
bridge_state.extra_headers, ingest_state.extra_headers,
"extra_headers must be byte-identical (and same order) across paths"
);
let keys: Vec<&[u8]> = bridge_state
.extra_headers
.iter()
.map(|(k, _)| k.as_slice())
.collect();
assert_eq!(
keys,
vec![
b"gpgsig".as_slice(),
b"x-custom".as_slice(),
b"mergetag".as_slice(),
],
"gpgsig stays inline at its captured position; all three remain in order"
);
for (key, value) in &bridge_state.extra_headers {
assert!(
value.contains(&0xe9u8),
"extra-header {:?} must preserve its non-UTF8 byte",
String::from_utf8_lossy(key)
);
assert!(String::from_utf8(value.clone()).is_err());
}
}