#![allow(clippy::too_many_lines)]
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use grex_core::git::gix_backend::file_url_from_path;
use grex_core::pack::{parse, PackValidationError};
use grex_core::{
ClonedRepo, EdgeKind, FsPackLoader, GitBackend, GitError, GixBackend, PackLoader, PackManifest,
TreeError, Walker,
};
use tempfile::TempDir;
struct MockLoader {
manifests: HashMap<PathBuf, Result<PackManifest, MockLoaderError>>,
}
#[derive(Debug, Clone)]
enum MockLoaderError {
Parse(String),
}
impl MockLoader {
fn new() -> Self {
Self { manifests: HashMap::new() }
}
fn with(mut self, path: impl Into<PathBuf>, manifest: PackManifest) -> Self {
self.manifests.insert(path.into(), Ok(manifest));
self
}
fn with_parse_error(mut self, path: impl Into<PathBuf>, detail: &str) -> Self {
self.manifests.insert(path.into(), Err(MockLoaderError::Parse(detail.to_string())));
self
}
}
impl PackLoader for MockLoader {
fn load(&self, path: &Path) -> Result<PackManifest, TreeError> {
match self.manifests.get(path) {
Some(Ok(m)) => Ok(m.clone()),
Some(Err(MockLoaderError::Parse(d))) => {
Err(TreeError::ManifestParse { path: path.to_path_buf(), detail: d.clone() })
}
None => Err(TreeError::ManifestNotFound(path.to_path_buf())),
}
}
}
#[derive(Debug, Clone)]
#[allow(dead_code)] enum BackendCall {
Clone { url: String, dest: PathBuf, r#ref: Option<String> },
Fetch { dest: PathBuf },
Checkout { dest: PathBuf, r#ref: String },
HeadSha { dest: PathBuf },
}
struct MockGitBackend {
calls: Mutex<Vec<BackendCall>>,
create_on_clone: bool,
}
impl MockGitBackend {
fn new() -> Self {
Self { calls: Mutex::new(Vec::new()), create_on_clone: true }
}
fn calls(&self) -> Vec<BackendCall> {
self.calls.lock().unwrap().clone()
}
}
impl GitBackend for MockGitBackend {
fn name(&self) -> &'static str {
"mock-git"
}
fn clone(&self, url: &str, dest: &Path, r#ref: Option<&str>) -> Result<ClonedRepo, GitError> {
self.calls.lock().unwrap().push(BackendCall::Clone {
url: url.to_string(),
dest: dest.to_path_buf(),
r#ref: r#ref.map(str::to_string),
});
if self.create_on_clone {
fs::create_dir_all(dest).unwrap();
}
Ok(ClonedRepo { path: dest.to_path_buf(), head_sha: "0".repeat(40) })
}
fn fetch(&self, dest: &Path) -> Result<(), GitError> {
self.calls.lock().unwrap().push(BackendCall::Fetch { dest: dest.to_path_buf() });
Ok(())
}
fn checkout(&self, dest: &Path, r#ref: &str) -> Result<(), GitError> {
self.calls
.lock()
.unwrap()
.push(BackendCall::Checkout { dest: dest.to_path_buf(), r#ref: r#ref.to_string() });
Ok(())
}
fn head_sha(&self, dest: &Path) -> Result<String, GitError> {
self.calls.lock().unwrap().push(BackendCall::HeadSha { dest: dest.to_path_buf() });
Ok("0".repeat(40))
}
}
fn pack_yaml(name: &str) -> String {
format!("schema_version: \"1\"\nname: {name}\ntype: declarative\n")
}
fn pack_yaml_with_children(name: &str, children: &[(&str, &str, Option<&str>)]) -> String {
let mut s = format!("schema_version: \"1\"\nname: {name}\ntype: meta\nchildren:\n");
for (url, path, r) in children {
s.push_str(&format!(" - url: {url}\n path: {path}\n"));
if let Some(rr) = r {
s.push_str(&format!(" ref: {rr}\n"));
}
}
s
}
fn pack_yaml_with_deps(name: &str, deps: &[&str]) -> String {
let mut s = format!("schema_version: \"1\"\nname: {name}\ntype: declarative\ndepends_on:\n");
for d in deps {
s.push_str(&format!(" - {d}\n"));
}
s
}
fn parse_pack(yaml: &str) -> PackManifest {
parse(yaml).expect("fixture yaml must parse")
}
fn mock_git() -> MockGitBackend {
MockGitBackend::new()
}
#[test]
fn walk_single_pack_no_children() {
let root_path = PathBuf::from("/virt/root");
let loader = MockLoader::new().with(root_path.clone(), parse_pack(&pack_yaml("solo")));
let backend = mock_git();
let workspace = PathBuf::from("/virt/ws");
let walker = Walker::new(&loader, &backend, workspace);
let graph = walker.walk(&root_path).expect("walk");
assert_eq!(graph.nodes().len(), 1);
assert_eq!(graph.edges().len(), 0);
assert_eq!(graph.root().name, "solo");
}
#[test]
fn walk_two_level_children() {
let ws = TempDir::new().unwrap();
let root_path = ws.path().join("root");
fs::create_dir_all(&root_path).unwrap();
let child_a_path = ws.path().join("a");
let child_b_path = ws.path().join("b");
let root_yaml = pack_yaml_with_children(
"root",
&[("git://x/a.git", "a", None), ("git://x/b.git", "b", None)],
);
let loader = MockLoader::new()
.with(root_path.clone(), parse_pack(&root_yaml))
.with(child_a_path.clone(), parse_pack(&pack_yaml("a")))
.with(child_b_path.clone(), parse_pack(&pack_yaml("b")));
let backend = mock_git();
let walker = Walker::new(&loader, &backend, ws.path().to_path_buf());
let graph = walker.walk(&root_path).expect("walk");
assert_eq!(graph.nodes().len(), 3);
assert_eq!(graph.edges().iter().filter(|e| e.kind == EdgeKind::Child).count(), 2);
assert_eq!(graph.root().name, "root");
let kids: Vec<&str> = graph.children_of(0).map(|n| n.name.as_str()).collect();
assert_eq!(kids, vec!["a", "b"]);
}
#[test]
fn walk_three_level_nested_via_mock() {
let ws = TempDir::new().unwrap();
let root = ws.path().join("root");
let a = ws.path().join("a");
let b = ws.path().join("b");
let c = ws.path().join("c");
fs::create_dir_all(&root).unwrap();
let root_yaml = pack_yaml_with_children("root", &[("git://x/a.git", "a", None)]);
let a_yaml =
pack_yaml_with_children("a", &[("git://x/b.git", "b", None), ("git://x/c.git", "c", None)]);
let loader = MockLoader::new()
.with(root.clone(), parse_pack(&root_yaml))
.with(a.clone(), parse_pack(&a_yaml))
.with(b.clone(), parse_pack(&pack_yaml("b")))
.with(c.clone(), parse_pack(&pack_yaml("c")));
let backend = mock_git();
let walker = Walker::new(&loader, &backend, ws.path().to_path_buf());
let graph = walker.walk(&root).expect("walk");
assert_eq!(graph.nodes().len(), 4);
let a_node = graph.find_by_name("a").unwrap();
assert_eq!(a_node.parent, Some(0));
let b_node = graph.find_by_name("b").unwrap();
let c_node = graph.find_by_name("c").unwrap();
assert_eq!(b_node.parent, Some(a_node.id));
assert_eq!(c_node.parent, Some(a_node.id));
}
#[test]
fn walker_rejects_parent_traversal_in_child_path_pre_clone() {
let ws = TempDir::new().unwrap();
let root = ws.path().join("root");
fs::create_dir_all(&root).unwrap();
let root_yaml =
pack_yaml_with_children("root", &[("git://host/escape.git", "../escape", None)]);
let loader = MockLoader::new().with(root.clone(), parse_pack(&root_yaml));
let backend = mock_git();
let walker = Walker::new(&loader, &backend, ws.path().to_path_buf());
let err = walker.walk(&root).expect_err("walker must reject path traversal");
match err {
TreeError::ChildPathInvalid { path, reason, .. } => {
assert_eq!(path, "../escape");
assert!(reason.contains("separator"), "reason: {reason}");
}
other => panic!("wrong variant: {other:?}"),
}
let calls = backend.calls();
assert!(
!calls.iter().any(|c| matches!(c, BackendCall::Clone { .. })),
"no clone may fire for a traversal-bearing child path; got: {calls:?}",
);
let escaped = ws.path().join("../escape");
assert!(
!escaped.exists(),
"no `../escape` directory must be created; found: {}",
escaped.display(),
);
}
#[test]
fn walker_rejects_traversal_in_grandchild_pack_pre_clone() {
let ws = TempDir::new().unwrap();
let root = ws.path().join("root");
let mid = ws.path().join("mid");
fs::create_dir_all(&root).unwrap();
let root_yaml = pack_yaml_with_children("root", &[("git://host/mid.git", "mid", None)]);
let mid_yaml = pack_yaml_with_children("mid", &[("git://host/grand.git", "../grand", None)]);
let loader = MockLoader::new()
.with(root.clone(), parse_pack(&root_yaml))
.with(mid.clone(), parse_pack(&mid_yaml));
let backend = mock_git();
let walker = Walker::new(&loader, &backend, ws.path().to_path_buf());
let err = walker.walk(&root).expect_err("grandchild traversal must be rejected");
assert!(matches!(err, TreeError::ChildPathInvalid { .. }), "got: {err:?}");
let calls = backend.calls();
let mid_clones = calls
.iter()
.filter(|c| matches!(c, BackendCall::Clone { url, .. } if url.contains("mid")))
.count();
let grand_clones = calls
.iter()
.filter(|c| matches!(c, BackendCall::Clone { url, .. } if url.contains("grand")))
.count();
assert_eq!(
mid_clones, 1,
"mid is a legitimate intermediate child and MUST be cloned exactly once; got: {calls:?}",
);
assert_eq!(grand_clones, 0, "no clone may fire for grandchild traversal; got: {calls:?}");
}
#[test]
fn walker_uses_git_backend_for_children() {
let ws = TempDir::new().unwrap();
let root = ws.path().join("root");
let a = ws.path().join("a");
let root_yaml = pack_yaml_with_children("root", &[("git://host/a.git", "a", Some("v1"))]);
let loader = MockLoader::new()
.with(root.clone(), parse_pack(&root_yaml))
.with(a.clone(), parse_pack(&pack_yaml("a")));
let backend = mock_git();
let walker = Walker::new(&loader, &backend, ws.path().to_path_buf());
walker.walk(&root).expect("walk");
let calls = backend.calls();
assert_eq!(calls.len(), 1);
match &calls[0] {
BackendCall::Clone { url, dest, r#ref } => {
assert_eq!(url, "git://host/a.git");
assert_eq!(dest, &a);
assert_eq!(r#ref.as_deref(), Some("v1"));
}
other => panic!("expected Clone, got {other:?}"),
}
}
#[test]
fn walker_skips_existing_child_destinations() {
let ws = TempDir::new().unwrap();
let root = ws.path().join("root");
let a = ws.path().join("a");
fs::create_dir_all(a.join(".git")).unwrap();
let root_yaml = pack_yaml_with_children("root", &[("git://host/a.git", "a", None)]);
let loader = MockLoader::new()
.with(root.clone(), parse_pack(&root_yaml))
.with(a.clone(), parse_pack(&pack_yaml("a")));
let backend = mock_git();
let walker = Walker::new(&loader, &backend, ws.path().to_path_buf());
walker.walk(&root).expect("walk");
let calls = backend.calls();
assert!(calls.iter().any(|c| matches!(c, BackendCall::Fetch { .. })));
assert!(
!calls.iter().any(|c| matches!(c, BackendCall::Clone { .. })),
"clone must NOT be called when dest exists"
);
}
#[test]
fn walker_ref_override_wins_over_declared_on_clone() {
let ws = TempDir::new().unwrap();
let root = ws.path().join("root");
let a = ws.path().join("a");
let root_yaml = pack_yaml_with_children("root", &[("git://host/a.git", "a", Some("v1"))]);
let loader = MockLoader::new()
.with(root.clone(), parse_pack(&root_yaml))
.with(a.clone(), parse_pack(&pack_yaml("a")));
let backend = mock_git();
let walker = Walker::new(&loader, &backend, ws.path().to_path_buf())
.with_ref_override(Some("v2-override".to_string()));
walker.walk(&root).expect("walk");
let calls = backend.calls();
match calls.iter().find(|c| matches!(c, BackendCall::Clone { .. })) {
Some(BackendCall::Clone { r#ref, .. }) => {
assert_eq!(
r#ref.as_deref(),
Some("v2-override"),
"override must win over declared `v1`"
);
}
other => panic!("expected Clone with override ref, got {other:?}"),
}
}
#[test]
fn walker_ref_override_wins_over_declared_on_checkout() {
let ws = TempDir::new().unwrap();
let root = ws.path().join("root");
let a = ws.path().join("a");
fs::create_dir_all(a.join(".git")).unwrap();
let root_yaml = pack_yaml_with_children("root", &[("git://host/a.git", "a", Some("main"))]);
let loader = MockLoader::new()
.with(root.clone(), parse_pack(&root_yaml))
.with(a.clone(), parse_pack(&pack_yaml("a")));
let backend = mock_git();
let walker = Walker::new(&loader, &backend, ws.path().to_path_buf())
.with_ref_override(Some("release/1.0".to_string()));
walker.walk(&root).expect("walk");
let calls = backend.calls();
let checkout_ref = calls.iter().find_map(|c| match c {
BackendCall::Checkout { r#ref, .. } => Some(r#ref.clone()),
_ => None,
});
assert_eq!(
checkout_ref.as_deref(),
Some("release/1.0"),
"override must win over declared `main`"
);
}
#[test]
fn walker_empty_ref_override_is_equivalent_to_none() {
let ws = TempDir::new().unwrap();
let root = ws.path().join("root");
let a = ws.path().join("a");
let root_yaml = pack_yaml_with_children("root", &[("git://host/a.git", "a", Some("v1"))]);
let loader = MockLoader::new()
.with(root.clone(), parse_pack(&root_yaml))
.with(a.clone(), parse_pack(&pack_yaml("a")));
let backend = mock_git();
let walker = Walker::new(&loader, &backend, ws.path().to_path_buf())
.with_ref_override(Some(String::new()));
walker.walk(&root).expect("walk");
let calls = backend.calls();
match calls.iter().find(|c| matches!(c, BackendCall::Clone { .. })) {
Some(BackendCall::Clone { r#ref, .. }) => {
assert_eq!(r#ref.as_deref(), Some("v1"), "empty override must be inert");
}
other => panic!("expected Clone, got {other:?}"),
}
}
#[test]
fn walker_applies_ref_when_specified_on_existing_dest() {
let ws = TempDir::new().unwrap();
let root = ws.path().join("root");
let a = ws.path().join("a");
fs::create_dir_all(a.join(".git")).unwrap();
let root_yaml = pack_yaml_with_children("root", &[("git://host/a.git", "a", Some("main"))]);
let loader = MockLoader::new()
.with(root.clone(), parse_pack(&root_yaml))
.with(a.clone(), parse_pack(&pack_yaml("a")));
let backend = mock_git();
let walker = Walker::new(&loader, &backend, ws.path().to_path_buf());
walker.walk(&root).expect("walk");
let calls = backend.calls();
assert!(calls
.iter()
.any(|c| matches!(c, BackendCall::Checkout { r#ref, .. } if r#ref == "main")));
}
#[test]
fn walker_error_on_manifest_parse_fail() {
let ws = TempDir::new().unwrap();
let root = ws.path().join("root");
let a = ws.path().join("a");
let root_yaml = pack_yaml_with_children("root", &[("git://host/a.git", "a", None)]);
let loader = MockLoader::new()
.with(root.clone(), parse_pack(&root_yaml))
.with_parse_error(a.clone(), "bogus YAML");
let backend = mock_git();
let walker = Walker::new(&loader, &backend, ws.path().to_path_buf());
let err = walker.walk(&root).unwrap_err();
match err {
TreeError::ManifestParse { detail, .. } => assert!(detail.contains("bogus")),
other => panic!("expected ManifestParse, got {other:?}"),
}
}
#[test]
fn walker_cycle_self_reference() {
let ws = TempDir::new().unwrap();
let root = ws.path().join("root");
let yaml = pack_yaml_with_children("root", &[("git://self/self.git", "self", None)]);
let self_path = ws.path().join("self");
let self_yaml = pack_yaml_with_children("self", &[("git://self/self.git", "self", None)]);
let loader = MockLoader::new()
.with(root.clone(), parse_pack(&yaml))
.with(self_path, parse_pack(&self_yaml));
let backend = mock_git();
let walker = Walker::new(&loader, &backend, ws.path().to_path_buf());
let err = walker.walk(&root).unwrap_err();
match err {
TreeError::CycleDetected { chain } => {
assert!(chain.len() >= 2, "chain must include at least 2 entries: {chain:?}");
}
other => panic!("expected CycleDetected, got {other:?}"),
}
}
#[test]
fn walker_cycle_indirect_a_b_a() {
let ws = TempDir::new().unwrap();
let root = ws.path().join("root");
let a = ws.path().join("a");
let b = ws.path().join("b");
let root_yaml = pack_yaml_with_children("root", &[("git://x/a.git", "a", None)]);
let a_yaml = pack_yaml_with_children("a", &[("git://x/b.git", "b", None)]);
let b_yaml = pack_yaml_with_children("b", &[("git://x/a.git", "a", None)]);
let loader = MockLoader::new()
.with(root.clone(), parse_pack(&root_yaml))
.with(a.clone(), parse_pack(&a_yaml))
.with(b.clone(), parse_pack(&b_yaml));
let backend = mock_git();
let walker = Walker::new(&loader, &backend, ws.path().to_path_buf());
let err = walker.walk(&root).unwrap_err();
match err {
TreeError::CycleDetected { chain } => {
assert!(
chain.len() >= 3,
"A→B→A cycle should produce chain length >= 3, got {chain:?}"
);
}
other => panic!("expected CycleDetected, got {other:?}"),
}
}
#[test]
fn walker_pack_name_mismatch_errors() {
let ws = TempDir::new().unwrap();
let root = ws.path().join("root");
let a = ws.path().join("a");
let root_yaml = pack_yaml_with_children("root", &[("git://x/a.git", "a", None)]);
let loader = MockLoader::new()
.with(root.clone(), parse_pack(&root_yaml))
.with(a.clone(), parse_pack(&pack_yaml("not-a")));
let backend = mock_git();
let walker = Walker::new(&loader, &backend, ws.path().to_path_buf());
let err = walker.walk(&root).unwrap_err();
match err {
TreeError::PackNameMismatch { got, expected, .. } => {
assert_eq!(got, "not-a");
assert_eq!(expected, "a");
}
other => panic!("expected PackNameMismatch, got {other:?}"),
}
}
#[test]
fn graph_validate_passes_for_clean_tree() {
let ws = TempDir::new().unwrap();
let root = ws.path().join("root");
let a = ws.path().join("a");
let root_yaml = pack_yaml_with_children("root", &[("git://x/a.git", "a", None)]);
let loader = MockLoader::new()
.with(root.clone(), parse_pack(&root_yaml))
.with(a.clone(), parse_pack(&pack_yaml("a")));
let backend = mock_git();
let walker = Walker::new(&loader, &backend, ws.path().to_path_buf());
let graph = walker.walk(&root).unwrap();
graph.validate().expect("clean graph validates");
}
#[test]
fn graph_validate_depends_on_satisfied_by_name() {
let ws = TempDir::new().unwrap();
let root = ws.path().join("root");
let b = ws.path().join("b");
let root_yaml = "schema_version: \"1\"\nname: root\ntype: meta\ndepends_on:\n - b\nchildren:\n - url: git://x/b.git\n path: b\n".to_string();
let loader = MockLoader::new()
.with(root.clone(), parse_pack(&root_yaml))
.with(b.clone(), parse_pack(&pack_yaml("b")));
let backend = mock_git();
let walker = Walker::new(&loader, &backend, ws.path().to_path_buf());
let graph = walker.walk(&root).unwrap();
graph.validate().expect("depends_on satisfied");
}
#[test]
fn graph_validate_depends_on_unsatisfied() {
let ws = TempDir::new().unwrap();
let root = ws.path().join("root");
let yaml = pack_yaml_with_deps("root", &["z"]);
let loader = MockLoader::new().with(root.clone(), parse_pack(&yaml));
let backend = mock_git();
let walker = Walker::new(&loader, &backend, ws.path().to_path_buf());
let graph = walker.walk(&root).unwrap();
let errs = graph.validate().unwrap_err();
assert!(
errs.iter().any(|e| matches!(
e,
PackValidationError::DependsOnUnsatisfied { required, .. } if required == "z"
)),
"must flag missing z, got {errs:?}"
);
}
#[test]
fn graph_validate_depends_on_satisfied_by_url() {
let ws = TempDir::new().unwrap();
let root = ws.path().join("root");
let b = ws.path().join("b");
let root_yaml = "schema_version: \"1\"\nname: root\ntype: meta\ndepends_on:\n - git://x/b.git\nchildren:\n - url: git://x/b.git\n path: b\n".to_string();
let loader = MockLoader::new()
.with(root.clone(), parse_pack(&root_yaml))
.with(b.clone(), parse_pack(&pack_yaml("b")));
let backend = mock_git();
let walker = Walker::new(&loader, &backend, ws.path().to_path_buf());
let graph = walker.walk(&root).unwrap();
graph.validate().expect("depends_on by url satisfied");
}
fn init_git_identity() {
static ONCE: OnceLock<()> = OnceLock::new();
ONCE.get_or_init(|| {
std::env::set_var("GIT_AUTHOR_NAME", "grex-test");
std::env::set_var("GIT_AUTHOR_EMAIL", "test@grex.local");
std::env::set_var("GIT_COMMITTER_NAME", "grex-test");
std::env::set_var("GIT_COMMITTER_EMAIL", "test@grex.local");
});
}
fn run_git(cwd: &Path, args: &[&str]) {
let out = std::process::Command::new("git")
.args(args)
.current_dir(cwd)
.output()
.expect("git on PATH");
assert!(
out.status.success(),
"git {:?} failed: {}",
args,
String::from_utf8_lossy(&out.stderr)
);
}
fn bare_with_manifest(tmp: &Path, name: &str, yaml: &str) -> PathBuf {
init_git_identity();
let work = tmp.join(format!("seed-{name}-work"));
fs::create_dir_all(work.join(".grex")).unwrap();
run_git(&work, &["init", "-q", "-b", "main"]);
run_git(&work, &["config", "user.email", "grex-test@example.com"]);
run_git(&work, &["config", "user.name", "grex-test"]);
fs::write(work.join(".grex/pack.yaml"), yaml).unwrap();
run_git(&work, &["add", ".grex/pack.yaml"]);
run_git(&work, &["commit", "-q", "-m", "seed"]);
let bare = tmp.join(format!("{name}.git"));
run_git(tmp, &["clone", "-q", "--bare", work.to_str().unwrap(), bare.to_str().unwrap()]);
bare
}
#[test]
fn walker_integrates_with_real_git_backend() {
let tmp = TempDir::new().unwrap();
let child_bare = bare_with_manifest(tmp.path(), "child", &pack_yaml("child"));
let child_url = file_url_from_path(&child_bare);
let root_dir = tmp.path().join("root");
fs::create_dir_all(root_dir.join(".grex")).unwrap();
let root_yaml = format!(
"schema_version: \"1\"\nname: root\ntype: meta\nchildren:\n - url: {child_url}\n path: child\n"
);
fs::write(root_dir.join(".grex/pack.yaml"), &root_yaml).unwrap();
let ws = tmp.path().join("ws");
fs::create_dir_all(&ws).unwrap();
let loader = FsPackLoader::new();
let backend = GixBackend::new();
let walker = Walker::new(&loader, &backend, ws.clone());
let graph = walker.walk(&root_dir).expect("walk");
assert_eq!(graph.nodes().len(), 2);
assert_eq!(graph.root().name, "root");
assert!(ws.join("child").join(".grex").join("pack.yaml").is_file());
assert!(ws.join("child").join(".git").exists());
graph.validate().expect("clean real-backend graph validates");
}
#[test]
fn walker_synthesises_plain_git_child_when_manifest_missing() {
let ws = TempDir::new().unwrap();
let root = ws.path().join("root");
let a = ws.path().join("a");
fs::create_dir_all(&root).unwrap();
fs::create_dir_all(a.join(".git")).unwrap();
let root_yaml = pack_yaml_with_children("root", &[("git://x/a.git", "a", None)]);
let loader = MockLoader::new().with(root.clone(), parse_pack(&root_yaml));
let backend = mock_git();
let walker = Walker::new(&loader, &backend, ws.path().to_path_buf());
let graph = walker.walk(&root).expect("walk must succeed via synthesis");
assert_eq!(graph.nodes().len(), 2, "root + synthesised child");
let child = graph.find_by_name("a").expect("synthesised child node");
assert!(child.synthetic, "child must carry the synthetic flag");
assert_eq!(child.manifest.name, "a");
assert!(child.manifest.children.is_empty());
assert!(child.manifest.actions.is_empty());
assert!(!graph.root().synthetic, "root must not be marked synthetic");
}
#[test]
fn walker_propagates_manifest_not_found_when_no_git_repo() {
let ws = TempDir::new().unwrap();
let root = ws.path().join("root");
let a = ws.path().join("a");
fs::create_dir_all(&root).unwrap();
let _ = a;
let root_yaml = pack_yaml_with_children("root", &[("git://x/a.git", "a", None)]);
let loader = MockLoader::new().with(root.clone(), parse_pack(&root_yaml));
let backend = MockGitBackend { calls: Mutex::new(Vec::new()), create_on_clone: false };
let walker = Walker::new(&loader, &backend, ws.path().to_path_buf());
let err = walker.walk(&root).expect_err("must propagate ManifestNotFound");
assert!(matches!(err, TreeError::ManifestNotFound(_)), "got: {err:?}");
}