use objects::object::ThreadName;
use super::*;
fn init_colocated_git_repo(path: &std::path::Path) {
assert!(
Command::new("git")
.arg("init")
.current_dir(path)
.status()
.unwrap()
.success()
);
for (k, v) in [
("user.name", "Heddle Test"),
("user.email", "heddle@example.com"),
("init.defaultBranch", "main"),
] {
Command::new("git")
.args(["config", k, v])
.current_dir(path)
.status()
.unwrap();
}
Command::new("git")
.args(["checkout", "-B", "main"])
.current_dir(path)
.status()
.unwrap();
}
fn git_commit_all_in(path: &std::path::Path, message: &str) {
assert!(
Command::new("git")
.args(["add", "."])
.current_dir(path)
.status()
.unwrap()
.success()
);
assert!(
Command::new("git")
.args(["commit", "-m", message])
.current_dir(path)
.status()
.unwrap()
.success()
);
}
fn git_status_porcelain(path: &std::path::Path) -> String {
let out = Command::new("git")
.args(["status", "--porcelain"])
.current_dir(path)
.output()
.unwrap();
assert!(out.status.success(), "git status --porcelain must succeed");
String::from_utf8(out.stdout).unwrap()
}
fn seed_gitlink_repo(path: &std::path::Path) {
init_colocated_git_repo(path);
std::fs::write(path.join("README.md"), "root\n").unwrap();
git_commit_all_in(path, "initial");
let submodule_oid = "0606060606060606060606060606060606060606";
let status = Command::new("git")
.args(["update-index", "--add", "--cacheinfo"])
.arg(format!("160000,{submodule_oid},vendor"))
.current_dir(path)
.status()
.expect("git update-index should run");
assert!(status.success(), "git update-index should succeed");
let status = Command::new("git")
.args(["commit", "-m", "add gitlink"])
.current_dir(path)
.status()
.expect("git commit should run");
assert!(status.success(), "git commit should succeed");
}
const EMPTY_BLOB_OID: &str = "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391";
#[test]
fn bridge_git_import_refuses_gitlink_by_default_and_lossy_summarizes() {
let source = TempDir::new().unwrap();
seed_gitlink_repo(source.path());
let default_target = TempDir::new().unwrap();
heddle(&["init"], Some(default_target.path())).expect("init default target");
let default_output = heddle_output(
&[
"bridge",
"git",
"import",
"--path",
source.path().to_str().unwrap(),
],
Some(default_target.path()),
)
.expect("run default import");
assert!(
!default_output.status.success(),
"default git import must fail on gitlink"
);
let default_stderr = String::from_utf8_lossy(&default_output.stderr);
assert!(
default_stderr.contains("vendor"),
"error should name the offending entry: {default_stderr}"
);
assert!(
default_stderr.contains("--lossy"),
"error should name the opt-in flag: {default_stderr}"
);
let lossy_target = TempDir::new().unwrap();
heddle(&["init"], Some(lossy_target.path())).expect("init lossy target");
let lossy = heddle(
&[
"bridge",
"git",
"import",
"--lossy",
"--path",
source.path().to_str().unwrap(),
],
Some(lossy_target.path()),
)
.expect("lossy import should succeed");
assert!(
lossy.contains("lossy import accepted"),
"lossy import should emit an end-of-run summary: {lossy}"
);
assert!(lossy.contains("vendor"), "summary names entry: {lossy}");
assert!(lossy.contains("dropped"), "summary names action: {lossy}");
}
#[test]
fn bridge_git_import_help_documents_lossy_flag() {
let output = heddle_help(&["bridge", "git", "import", "--help"]);
assert!(output.contains("--lossy"), "help should document --lossy");
}
#[test]
fn capture_marks_new_file_intent_to_add_in_colocated_index() {
let source = TempDir::new().unwrap();
init_colocated_git_repo(source.path());
std::fs::write(source.path().join("tracked.txt"), "already tracked\n").unwrap();
git_commit_all_in(source.path(), "initial");
heddle(&["adopt", "--ref", "main"], Some(source.path()))
.expect("adopt should import Git history into Heddle");
std::fs::write(source.path().join("new_file.txt"), "brand new content\n").unwrap();
heddle(&["capture", "-m", "add new file"], Some(source.path()))
.expect("capture should record the new file");
let status = git_status_porcelain(source.path());
let new_file_line = status
.lines()
.find(|line| line.ends_with("new_file.txt"))
.unwrap_or_else(|| panic!("new_file.txt must appear in git status. Status was:\n{status}"));
assert!(
new_file_line[..2].contains('A'),
"new file should be intent-to-add (contains `A`), got {new_file_line:?}. Status was:\n{status}"
);
assert!(
!status.contains("?? new_file.txt"),
"new file must no longer show as untracked (??). Status was:\n{status}"
);
let staged = Command::new("git")
.args(["ls-files", "--stage", "new_file.txt"])
.current_dir(source.path())
.output()
.unwrap();
let staged = String::from_utf8(staged.stdout).unwrap();
assert!(
staged.contains(EMPTY_BLOB_OID),
"new file's index entry must be intent-to-add (empty-blob oid), got: {staged:?}"
);
assert!(
!status.lines().any(|line| line.ends_with("tracked.txt")),
"an already-tracked, unchanged file must not be marked. Status was:\n{status}"
);
}
#[test]
fn recapture_prunes_stale_intent_to_add_for_removed_file() {
let source = TempDir::new().unwrap();
init_colocated_git_repo(source.path());
std::fs::write(source.path().join("tracked.txt"), "already tracked\n").unwrap();
git_commit_all_in(source.path(), "initial");
heddle(&["adopt", "--ref", "main"], Some(source.path()))
.expect("adopt should import Git history into Heddle");
std::fs::write(source.path().join("new_file.txt"), "brand new content\n").unwrap();
heddle(&["capture", "-m", "add new file"], Some(source.path()))
.expect("capture should record the new file");
let status = git_status_porcelain(source.path());
assert!(
status.lines().any(|line| line.ends_with("new_file.txt")),
"precondition: new_file.txt must be intent-to-add after first capture. Status was:\n{status}"
);
std::fs::remove_file(source.path().join("new_file.txt")).unwrap();
heddle(&["capture", "-m", "remove new file"], Some(source.path()))
.expect("recapture should record the deletion");
let status = git_status_porcelain(source.path());
assert!(
!status.lines().any(|line| line.ends_with("new_file.txt")),
"stale intent-to-add for a deleted file must be pruned — no phantom ` D` entry. Status was:\n{status}"
);
let staged = Command::new("git")
.args(["ls-files", "--stage", "new_file.txt"])
.current_dir(source.path())
.output()
.unwrap();
let staged = String::from_utf8(staged.stdout).unwrap();
assert!(
staged.trim().is_empty(),
"stale intent-to-add index entry must be removed, got: {staged:?}"
);
}
#[test]
fn recapture_to_empty_tree_prunes_stale_intent_to_add() {
let source = TempDir::new().unwrap();
init_colocated_git_repo(source.path());
std::fs::write(source.path().join("tracked.txt"), "already tracked\n").unwrap();
git_commit_all_in(source.path(), "initial");
heddle(&["adopt", "--ref", "main"], Some(source.path()))
.expect("adopt should import Git history into Heddle");
std::fs::write(source.path().join("new_file.txt"), "brand new content\n").unwrap();
heddle(&["capture", "-m", "add new file"], Some(source.path()))
.expect("capture should record the new file");
let staged = Command::new("git")
.args(["ls-files", "--stage", "new_file.txt"])
.current_dir(source.path())
.output()
.unwrap();
assert!(
String::from_utf8(staged.stdout)
.unwrap()
.contains(EMPTY_BLOB_OID),
"precondition: new_file.txt must be intent-to-add after first capture"
);
std::fs::remove_file(source.path().join("new_file.txt")).unwrap();
std::fs::remove_file(source.path().join("tracked.txt")).unwrap();
heddle(&["capture", "-m", "remove everything"], Some(source.path()))
.expect("recapture should record the empty tree");
let status = git_status_porcelain(source.path());
assert!(
!status.lines().any(|line| line.ends_with("new_file.txt")),
"stale intent-to-add must be pruned even when the recapture yields an empty tree. Status was:\n{status}"
);
let staged = Command::new("git")
.args(["ls-files", "--stage", "new_file.txt"])
.current_dir(source.path())
.output()
.unwrap();
let staged = String::from_utf8(staged.stdout).unwrap();
assert!(
staged.trim().is_empty(),
"stale intent-to-add index entry must be removed on the empty-tree path, got: {staged:?}"
);
}
#[test]
fn recapture_skips_intent_to_add_that_conflicts_with_tracked_file() {
let source = TempDir::new().unwrap();
init_colocated_git_repo(source.path());
std::fs::write(source.path().join("foo"), "i am a file\n").unwrap();
git_commit_all_in(source.path(), "initial");
heddle(&["adopt", "--ref", "main"], Some(source.path()))
.expect("adopt should import Git history into Heddle");
std::fs::remove_file(source.path().join("foo")).unwrap();
std::fs::create_dir(source.path().join("foo")).unwrap();
std::fs::write(source.path().join("foo").join("bar"), "now a dir\n").unwrap();
heddle(
&["capture", "-m", "file becomes directory"],
Some(source.path()),
)
.expect("recapture should record the file→dir change");
let staged = Command::new("git")
.args(["ls-files", "--stage"])
.current_dir(source.path())
.output()
.unwrap();
let staged = String::from_utf8(staged.stdout).unwrap();
let has_foo = staged.lines().any(|l| l.ends_with("\tfoo"));
let has_foo_bar = staged.lines().any(|l| l.ends_with("\tfoo/bar"));
assert!(
!(has_foo && has_foo_bar),
"index must not hold both `foo` and `foo/bar` (file/dir conflict). ls-files:\n{staged}"
);
let _ = git_status_porcelain(source.path());
}
#[test]
fn recapture_skips_intent_to_add_that_conflicts_with_tracked_dir() {
let source = TempDir::new().unwrap();
init_colocated_git_repo(source.path());
std::fs::create_dir(source.path().join("foo")).unwrap();
std::fs::write(source.path().join("foo").join("bar"), "i am under a dir\n").unwrap();
git_commit_all_in(source.path(), "initial");
heddle(&["adopt", "--ref", "main"], Some(source.path()))
.expect("adopt should import Git history into Heddle");
std::fs::remove_dir_all(source.path().join("foo")).unwrap();
std::fs::write(source.path().join("foo"), "now a file\n").unwrap();
heddle(&["capture", "-m", "dir becomes file"], Some(source.path()))
.expect("recapture should record the dir→file change");
let staged = Command::new("git")
.args(["ls-files", "--stage"])
.current_dir(source.path())
.output()
.unwrap();
let staged = String::from_utf8(staged.stdout).unwrap();
let has_foo = staged.lines().any(|l| l.ends_with("\tfoo"));
let has_foo_bar = staged.lines().any(|l| l.ends_with("\tfoo/bar"));
assert!(
!(has_foo && has_foo_bar),
"index must not hold both `foo` and `foo/bar` (file/dir conflict). ls-files:\n{staged}"
);
let _ = git_status_porcelain(source.path());
}
#[test]
fn test_cli_bridge_git_init() {
let temp = TempDir::new().unwrap();
heddle(&["init"], Some(temp.path())).unwrap();
std::fs::write(temp.path().join("file.txt"), "content").unwrap();
heddle(&["capture", "-m", "Initial"], Some(temp.path())).unwrap();
assert!(heddle(&["bridge", "init"], Some(temp.path())).is_ok());
assert!(
temp.path().join(".heddle/git").exists(),
"Git mirror should exist"
);
}
#[test]
fn test_cli_bridge_git_export_and_pull_roundtrip() {
let source = TempDir::new().unwrap();
let target = TempDir::new().unwrap();
let dest_holder = TempDir::new().unwrap();
let dest = dest_holder.path().join("export");
heddle(&["init"], Some(source.path())).unwrap();
std::fs::write(source.path().join("file.txt"), "bridge export").unwrap();
heddle(&["capture", "-m", "Bridge source"], Some(source.path())).unwrap();
let export = heddle(
&["bridge", "export", "--destination", dest.to_str().unwrap()],
Some(source.path()),
);
assert!(export.is_ok(), "bridge export failed: {:?}", export.err());
let dest_repo = open_git(&dest).unwrap();
assert!(find_reference(&dest_repo, "refs/heads/main").is_ok());
heddle(&["init"], Some(target.path())).unwrap();
let pull = heddle(
&["bridge", "pull", dest.to_str().unwrap()],
Some(target.path()),
);
assert!(pull.is_ok(), "Bridge pull failed: {:?}", pull.err());
let target_repo = Repository::open(target.path()).unwrap();
assert!(
target_repo
.refs()
.get_thread(&ThreadName::new("main"))
.unwrap()
.is_some()
);
}
#[test]
fn test_cli_bridge_git_import_from_external_repo() {
let heddle_repo_dir = TempDir::new().unwrap();
let git_repo_dir = TempDir::new().unwrap();
let git_repo = SleyRepository::init(git_repo_dir.path()).unwrap();
let tree_oid = git_empty_tree_oid(&git_repo);
git_commit_with_tree(
&git_repo,
Some("refs/heads/main"),
tree_oid,
"Imported commit",
&[],
);
heddle(&["init"], Some(heddle_repo_dir.path())).unwrap();
let result = heddle(
&[
"bridge",
"import",
"--path",
git_repo_dir.path().to_str().unwrap(),
],
Some(heddle_repo_dir.path()),
);
assert!(result.is_ok(), "Bridge import failed: {:?}", result.err());
let repo = Repository::open(heddle_repo_dir.path()).unwrap();
assert!(
repo.refs()
.get_thread(&ThreadName::new("main"))
.unwrap()
.is_some()
);
}
#[test]
fn test_cli_bridge_git_push_to_local_bare_remote() {
let source = TempDir::new().unwrap();
let remote = TempDir::new().unwrap();
let remote_repo = SleyRepository::init_bare(remote.path()).unwrap();
heddle(&["init"], Some(source.path())).unwrap();
std::fs::write(source.path().join("push.txt"), "bridge push").unwrap();
heddle(&["capture", "-m", "Bridge push"], Some(source.path())).unwrap();
let result = heddle(
&["bridge", "push", remote.path().to_str().unwrap()],
Some(source.path()),
);
assert!(result.is_ok(), "Bridge push failed: {:?}", result.err());
assert!(find_reference(&remote_repo, "refs/heads/main").is_ok());
}
#[test]
fn test_cli_push_mirror_dual_push_to_weft_and_git_remote() {
let source = TempDir::new().unwrap();
let weft_target = TempDir::new().unwrap();
let git_remote = TempDir::new().unwrap();
let mirror_repo = SleyRepository::init_bare(git_remote.path()).unwrap();
heddle(&["init"], Some(source.path())).unwrap();
heddle(&["init"], Some(weft_target.path())).unwrap();
std::fs::write(source.path().join("file.txt"), "dual push").unwrap();
heddle(&["capture", "-m", "Initial"], Some(source.path())).unwrap();
let weft_path = weft_target.path().to_string_lossy().to_string();
let git_path = git_remote.path().to_string_lossy().to_string();
let mirror_arg = format!("--mirror={}", git_path);
let stdout = heddle(
&[
"--output",
"text",
"push",
&weft_path,
"--thread",
"main",
&mirror_arg,
],
Some(source.path()),
)
.expect("dual push (--mirror=<remote>) should succeed");
assert!(
stdout.contains("mirrored to") && stdout.contains(&git_path),
"text-mode success line missing: {}",
stdout
);
let threads = heddle(&["thread", "list"], Some(weft_target.path())).unwrap();
assert!(
threads.contains("main"),
"weft target should have main thread after primary push: {}",
threads
);
assert!(
find_reference(&mirror_repo, "refs/heads/main").is_ok(),
"git mirror remote should have refs/heads/main after mirror push"
);
}
#[test]
fn test_cli_push_mirror_failure_does_not_abort_primary_push() {
let source = TempDir::new().unwrap();
let weft_target = TempDir::new().unwrap();
heddle(&["init"], Some(source.path())).unwrap();
heddle(&["init"], Some(weft_target.path())).unwrap();
std::fs::write(source.path().join("file.txt"), "warn on mirror fail").unwrap();
heddle(&["capture", "-m", "Initial"], Some(source.path())).unwrap();
let weft_path = weft_target.path().to_string_lossy().to_string();
let bogus_mirror = source
.path()
.join("does-not-exist-mirror")
.to_string_lossy()
.to_string();
let mirror_arg = format!("--mirror={}", bogus_mirror);
let result = heddle(
&[
"--output",
"text",
"push",
&weft_path,
"--thread",
"main",
&mirror_arg,
],
Some(source.path()),
);
assert!(
result.is_ok(),
"primary push must still succeed even when mirror push fails: {:?}",
result.err()
);
let threads = heddle(&["thread", "list"], Some(weft_target.path())).unwrap();
assert!(
threads.contains("main"),
"primary push should land even if mirror push fails: {}",
threads
);
}
#[test]
fn test_cli_push_mirror_requires_equals_does_not_swallow_positional() {
let source = TempDir::new().unwrap();
let weft_target = TempDir::new().unwrap();
heddle(&["init"], Some(source.path())).unwrap();
heddle(&["init"], Some(weft_target.path())).unwrap();
std::fs::write(source.path().join("file.txt"), "require equals").unwrap();
heddle(&["capture", "-m", "Initial"], Some(source.path())).unwrap();
let weft_path = weft_target.path().to_string_lossy().to_string();
let result = heddle(
&["push", "--mirror", &weft_path, "--thread", "main"],
Some(source.path()),
);
assert!(
result.is_ok(),
"push must succeed; primary should land at <PRIMARY> and mirror default (origin) is best-effort: {:?}",
result.err()
);
let threads = heddle(&["thread", "list"], Some(weft_target.path())).unwrap();
assert!(
threads.contains("main"),
"primary push should land at the positional remote, not be swallowed by --mirror: {}",
threads
);
}
#[test]
fn test_cli_push_mirror_explicit_equals_form_parses_value() {
let source = TempDir::new().unwrap();
let weft_target = TempDir::new().unwrap();
let git_remote = TempDir::new().unwrap();
let mirror_repo = SleyRepository::init_bare(git_remote.path()).unwrap();
heddle(&["init"], Some(source.path())).unwrap();
heddle(&["init"], Some(weft_target.path())).unwrap();
std::fs::write(source.path().join("file.txt"), "explicit eq").unwrap();
heddle(&["capture", "-m", "Initial"], Some(source.path())).unwrap();
let weft_path = weft_target.path().to_string_lossy().to_string();
let git_path = git_remote.path().to_string_lossy().to_string();
let mirror_arg = format!("--mirror={}", git_path);
let result = heddle(
&["push", &mirror_arg, "--thread", "main", &weft_path],
Some(source.path()),
);
assert!(
result.is_ok(),
"--mirror=<remote> followed by positional must parse cleanly: {:?}",
result.err()
);
let threads = heddle(&["thread", "list"], Some(weft_target.path())).unwrap();
assert!(threads.contains("main"));
assert!(
find_reference(&mirror_repo, "refs/heads/main").is_ok(),
"mirror push should land at the explicit <git_path>"
);
}
#[test]
fn test_cli_push_mirror_json_success_emits_mirrored_true() {
let source = TempDir::new().unwrap();
let weft_target = TempDir::new().unwrap();
let git_remote = TempDir::new().unwrap();
SleyRepository::init_bare(git_remote.path()).unwrap();
heddle(&["init"], Some(source.path())).unwrap();
heddle(&["init"], Some(weft_target.path())).unwrap();
std::fs::write(source.path().join("file.txt"), "json ok").unwrap();
heddle(&["capture", "-m", "Initial"], Some(source.path())).unwrap();
let weft_path = weft_target.path().to_string_lossy().to_string();
let git_path = git_remote.path().to_string_lossy().to_string();
let mirror_arg = format!("--mirror={}", git_path);
let output = heddle_output(
&[
"--output",
"json",
"push",
&weft_path,
"--thread",
"main",
&mirror_arg,
],
Some(source.path()),
)
.expect("push --output json --mirror=<remote> must invoke");
let stderr = std::str::from_utf8(&output.stderr).unwrap_or("");
assert!(
output.status.success(),
"primary push must succeed: stderr={stderr}"
);
assert!(
stderr.contains("\"mirrored\":true"),
"JSON mirror success line missing on stderr: {stderr}"
);
assert!(
stderr.contains(&git_path),
"stderr should echo the mirror remote: {stderr}"
);
}
#[test]
fn test_cli_push_mirror_in_git_overlay_pushes_to_both_remotes() {
let source = TempDir::new().unwrap();
let primary_remote = TempDir::new().unwrap();
let mirror_remote = TempDir::new().unwrap();
let primary_repo = SleyRepository::init_bare(primary_remote.path()).unwrap();
let mirror_repo = SleyRepository::init_bare(mirror_remote.path()).unwrap();
assert!(
Command::new("git")
.arg("init")
.current_dir(source.path())
.status()
.unwrap()
.success()
);
for (k, v) in [
("user.name", "Heddle Test"),
("user.email", "heddle@example.com"),
("init.defaultBranch", "main"),
] {
Command::new("git")
.args(["config", k, v])
.current_dir(source.path())
.status()
.unwrap();
}
Command::new("git")
.args(["checkout", "-B", "main"])
.current_dir(source.path())
.status()
.unwrap();
std::fs::write(source.path().join("file.txt"), "overlay dual push").unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(source.path())
.status()
.unwrap();
Command::new("git")
.args(["commit", "-m", "initial"])
.current_dir(source.path())
.status()
.unwrap();
heddle(
&["bridge", "git", "import", "--ref", "main"],
Some(source.path()),
)
.expect("bridge git import should bootstrap the overlay sidecar");
let primary_path = primary_remote.path().to_string_lossy().to_string();
let mirror_path = mirror_remote.path().to_string_lossy().to_string();
let mirror_arg = format!("--mirror={}", mirror_path);
heddle(&["push", &primary_path, &mirror_arg], Some(source.path()))
.expect("push --mirror in GitOverlay repo should succeed");
assert!(
find_reference(&primary_repo, "refs/heads/main").is_ok(),
"primary remote should have refs/heads/main after overlay push"
);
assert!(
find_reference(&mirror_repo, "refs/heads/main").is_ok(),
"mirror remote MUST ALSO have refs/heads/main — the GitOverlay early-return previously bypassed --mirror"
);
}
#[test]
fn test_cli_push_mirror_json_uses_rfc8259_escaping_for_unicode() {
let source = TempDir::new().unwrap();
let weft_target = TempDir::new().unwrap();
let mirror_parent = TempDir::new().unwrap();
let mirror_dir = mirror_parent.path().join("mirror\u{2028}suffix");
std::fs::create_dir_all(&mirror_dir).unwrap();
let mirror_repo = SleyRepository::init_bare(&mirror_dir).unwrap();
heddle(&["init"], Some(source.path())).unwrap();
heddle(&["init"], Some(weft_target.path())).unwrap();
std::fs::write(source.path().join("file.txt"), "u+2028").unwrap();
heddle(&["capture", "-m", "Initial"], Some(source.path())).unwrap();
let weft_path = weft_target.path().to_string_lossy().to_string();
let mirror_path = mirror_dir.to_string_lossy().to_string();
let mirror_arg = format!("--mirror={}", mirror_path);
let output = heddle_output(
&[
"--output",
"json",
"push",
&weft_path,
"--thread",
"main",
&mirror_arg,
],
Some(source.path()),
)
.expect("push --output json --mirror=<U+2028> must invoke");
let stderr = std::str::from_utf8(&output.stderr).unwrap_or("");
assert!(
output.status.success(),
"primary push must succeed: stderr={stderr}"
);
let mirror_line = stderr
.lines()
.find(|line| line.contains("\"mirrored\""))
.unwrap_or_else(|| panic!("mirror outcome JSON line missing on stderr: {stderr}"));
let parsed: serde_json::Value = serde_json::from_str(mirror_line).unwrap_or_else(|err| {
panic!(
"mirror outcome must be RFC 8259 JSON, got {}: {:?}",
err, mirror_line
)
});
assert_eq!(
parsed["remote"].as_str(),
Some(mirror_path.as_str()),
"remote field must round-trip the U+2028 codepoint exactly"
);
assert!(
find_reference(&mirror_repo, "refs/heads/main").is_ok(),
"mirror push should have landed at the U+2028 path"
);
}
#[test]
fn test_cli_push_mirror_json_failure_emits_mirrored_false_with_error() {
let source = TempDir::new().unwrap();
let weft_target = TempDir::new().unwrap();
heddle(&["init"], Some(source.path())).unwrap();
heddle(&["init"], Some(weft_target.path())).unwrap();
std::fs::write(source.path().join("file.txt"), "json err").unwrap();
heddle(&["capture", "-m", "Initial"], Some(source.path())).unwrap();
let weft_path = weft_target.path().to_string_lossy().to_string();
let bogus = source
.path()
.join("nope-mirror")
.to_string_lossy()
.to_string();
let mirror_arg = format!("--mirror={}", bogus);
let output = heddle_output(
&[
"--output",
"json",
"push",
&weft_path,
"--thread",
"main",
&mirror_arg,
],
Some(source.path()),
)
.expect("primary push must invoke even when mirror push fails");
let stderr = std::str::from_utf8(&output.stderr).unwrap_or("");
assert!(
output.status.success(),
"primary push must succeed even when mirror push fails: stderr={stderr}"
);
assert!(
stderr.contains("\"mirrored\":false"),
"JSON mirror-failure line missing on stderr: {stderr}"
);
assert!(
stderr.contains("\"error\""),
"JSON mirror failure must include error field: {stderr}"
);
}