use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use crate::git::GitBackend;
use crate::pack::validate::child_path::{
boundary_fs_reject_reason, boundary_reject_reason, check_one as check_child_path,
nfc_duplicate_path,
};
use crate::pack::{ChildRef, PackManifest, PackType, PackValidationError, SchemaVersion};
use super::consent::phase2_prune;
use super::dest_class::{aggregate_untracked, classify_dest, DestClass};
use super::error::TreeError;
use super::graph::{EdgeKind, PackEdge, PackGraph, PackNode};
use super::loader::PackLoader;
pub struct Walker<'a> {
loader: &'a dyn PackLoader,
backend: &'a dyn GitBackend,
workspace: PathBuf,
ref_override: Option<String>,
}
impl<'a> Walker<'a> {
#[must_use]
pub fn new(
loader: &'a dyn PackLoader,
backend: &'a dyn GitBackend,
workspace: PathBuf,
) -> Self {
Self { loader, backend, workspace, ref_override: None }
}
#[must_use]
pub fn with_ref_override(mut self, r#ref: Option<String>) -> Self {
self.ref_override = r#ref.filter(|s| !s.is_empty());
self
}
pub fn walk(&self, root_pack_path: &Path) -> Result<PackGraph, TreeError> {
let mut state = BuildState::default();
let root_manifest = self.loader.load(root_pack_path)?;
validate_children_paths(&root_manifest)?;
let root_commit_sha = probe_head_sha(self.backend, root_pack_path);
let root_id = state.push_node(PackNode {
id: 0,
name: root_manifest.name.clone(),
path: root_pack_path.to_path_buf(),
source_url: None,
manifest: root_manifest.clone(),
parent: None,
commit_sha: root_commit_sha,
synthetic: false,
});
let root_identity = pack_identity_for_root(root_pack_path);
self.walk_recursive(root_id, &root_manifest, &mut state, &mut vec![root_identity])?;
Ok(PackGraph::new(state.nodes, state.edges))
}
fn walk_recursive(
&self,
parent_id: usize,
manifest: &PackManifest,
state: &mut BuildState,
stack: &mut Vec<String>,
) -> Result<(), TreeError> {
self.record_depends_on(parent_id, manifest, state);
self.process_children(parent_id, manifest, state, stack)
}
fn record_depends_on(&self, parent_id: usize, manifest: &PackManifest, state: &mut BuildState) {
for dep in &manifest.depends_on {
if let Some(to) = find_node_id_by_name_or_url(&state.nodes, dep) {
state.edges.push(PackEdge { from: parent_id, to, kind: EdgeKind::DependsOn });
}
}
}
fn process_children(
&self,
parent_id: usize,
manifest: &PackManifest,
state: &mut BuildState,
stack: &mut Vec<String>,
) -> Result<(), TreeError> {
for child in &manifest.children {
self.handle_child(parent_id, child, state, stack)?;
}
Ok(())
}
fn handle_child(
&self,
parent_id: usize,
child: &ChildRef,
state: &mut BuildState,
stack: &mut Vec<String>,
) -> Result<(), TreeError> {
let identity = pack_identity_for_child(child);
if stack.iter().any(|s| s == &identity) {
let mut chain = stack.clone();
chain.push(identity);
return Err(TreeError::CycleDetected { chain });
}
let prospective_dest = self.workspace.join(child.effective_path());
check_dest_boundary(&prospective_dest, &child.effective_path())?;
let dest = self.resolve_destination(child, state)?;
let (child_manifest, is_synthetic) = match self.loader.load(&dest) {
Ok(m) => (m, false),
Err(TreeError::ManifestNotFound(_)) if dest_has_git_repo(&dest) => {
(synthesize_plain_git_manifest(child), true)
}
Err(e) => return Err(e),
};
verify_child_name(&child_manifest.name, child, &dest)?;
validate_children_paths(&child_manifest)?;
let commit_sha = probe_head_sha(self.backend, &dest);
let child_id = state.push_node(PackNode {
id: state.nodes.len(),
name: child_manifest.name.clone(),
path: dest.clone(),
source_url: Some(child.url.clone()),
manifest: child_manifest.clone(),
parent: Some(parent_id),
commit_sha,
synthetic: is_synthetic,
});
state.edges.push(PackEdge { from: parent_id, to: child_id, kind: EdgeKind::Child });
stack.push(identity);
let result = self.walk_recursive(child_id, &child_manifest, state, stack);
stack.pop();
result
}
fn resolve_destination(
&self,
child: &ChildRef,
_state: &mut BuildState,
) -> Result<PathBuf, TreeError> {
let dest = self.workspace.join(child.effective_path());
let effective_ref = self.ref_override.as_deref().or(child.r#ref.as_deref());
if dest_has_git_repo(&dest) {
self.backend.fetch(&dest)?;
if let Some(r) = effective_ref {
self.backend.checkout(&dest, r)?;
}
} else {
self.backend.clone(&child.url, &dest, effective_ref)?;
}
Ok(dest)
}
}
fn probe_head_sha(backend: &dyn GitBackend, path: &Path) -> Option<String> {
let dir =
if path.extension().and_then(|e| e.to_str()).is_some_and(|e| matches!(e, "yaml" | "yml")) {
path.parent()
.and_then(Path::parent)
.map_or_else(|| path.to_path_buf(), Path::to_path_buf)
} else {
path.to_path_buf()
};
if !dir.join(".git").exists() {
return None;
}
match backend.head_sha(&dir) {
Ok(s) => Some(s),
Err(e) => {
tracing::warn!(
target: "grex::walker",
"HEAD probe failed for {}: {e}",
dir.display()
);
None
}
}
}
#[derive(Default)]
struct BuildState {
nodes: Vec<PackNode>,
edges: Vec<PackEdge>,
}
impl BuildState {
fn push_node(&mut self, node: PackNode) -> usize {
let id = node.id;
self.nodes.push(node);
id
}
}
fn pack_identity_for_root(path: &Path) -> String {
format!("path:{}", path.display())
}
fn pack_identity_for_child(child: &ChildRef) -> String {
let rref = child.r#ref.as_deref().unwrap_or("");
format!("url:{}@{}", child.url, rref)
}
pub fn dest_has_git_repo(dest: &Path) -> bool {
if let Ok(meta) = std::fs::symlink_metadata(dest) {
if meta.file_type().is_symlink() {
return false;
}
}
dest.join(".git").exists()
}
pub fn synthesize_plain_git_manifest(child: &ChildRef) -> PackManifest {
PackManifest {
schema_version: SchemaVersion::current(),
name: child.effective_path(),
r#type: PackType::Scripted,
version: None,
depends_on: Vec::new(),
children: Vec::new(),
actions: Vec::new(),
teardown: None,
extensions: BTreeMap::new(),
}
}
fn verify_child_name(got: &str, child: &ChildRef, dest: &Path) -> Result<(), TreeError> {
let expected = child.effective_path();
if got == expected {
return Ok(());
}
Err(TreeError::PackNameMismatch { got: got.to_string(), expected, path: dest.to_path_buf() })
}
fn find_node_id_by_name_or_url(nodes: &[PackNode], dep: &str) -> Option<usize> {
if looks_like_url(dep) {
nodes.iter().find(|n| n.source_url.as_deref() == Some(dep)).map(|n| n.id)
} else {
nodes.iter().find(|n| n.name == dep).map(|n| n.id)
}
}
fn validate_children_paths(manifest: &PackManifest) -> Result<(), TreeError> {
if let Some(path) = nfc_duplicate_path(&manifest.children) {
return Err(TreeError::ManifestPathEscape {
path,
reason: "duplicate child path under Unicode NFC normalization (case-insensitive FS collision risk)"
.to_string(),
});
}
for child in &manifest.children {
let segment = child.path.as_deref().map_or_else(|| child.effective_path(), str::to_string);
if let Some(reason) = boundary_reject_reason(&segment) {
return Err(TreeError::ManifestPathEscape {
path: segment,
reason: reason.to_string(),
});
}
let Some(err) = check_child_path(child) else { continue };
match err {
PackValidationError::ChildPathInvalid { child_name, path, reason } => {
return Err(TreeError::ChildPathInvalid { child_name, path, reason });
}
other @ (PackValidationError::DuplicateSymlinkDst { .. }
| PackValidationError::GraphCycle { .. }
| PackValidationError::DependsOnUnsatisfied { .. }
| PackValidationError::ChildPathDuplicate { .. }) => {
tracing::error!(
target: "grex::walker",
"check_child_path returned unexpected variant: {other:?}",
);
debug_assert!(false, "check_child_path returned unexpected variant: {other:?}");
}
}
}
Ok(())
}
pub(super) fn check_dest_boundary(dest: &Path, segment: &str) -> Result<(), TreeError> {
if let Some(reason) = boundary_fs_reject_reason(dest) {
return Err(TreeError::ManifestPathEscape {
path: segment.to_string(),
reason: reason.to_string(),
});
}
Ok(())
}
pub(super) fn looks_like_url(s: &str) -> bool {
s.starts_with("http://")
|| s.starts_with("https://")
|| s.starts_with("ssh://")
|| s.starts_with("git@")
|| s.ends_with(".git")
}
#[derive(Debug, Clone)]
pub struct SyncMetaOptions {
pub ref_override: Option<String>,
pub recurse: bool,
pub max_depth: Option<usize>,
pub force_prune: bool,
pub force_prune_with_ignored: bool,
}
impl Default for SyncMetaOptions {
fn default() -> Self {
Self {
ref_override: None,
recurse: true,
max_depth: None,
force_prune: false,
force_prune_with_ignored: false,
}
}
}
#[derive(Debug, Default)]
pub struct SyncMetaReport {
pub metas_visited: usize,
pub phase1_classifications: Vec<(PathBuf, PathBuf, DestClass)>,
pub phase2_pruned: Vec<PathBuf>,
pub errors: Vec<TreeError>,
}
impl SyncMetaReport {
fn merge(&mut self, mut child: SyncMetaReport) {
self.metas_visited += child.metas_visited;
self.phase1_classifications.append(&mut child.phase1_classifications);
self.phase2_pruned.append(&mut child.phase2_pruned);
self.errors.append(&mut child.errors);
}
}
pub fn sync_meta(
meta_dir: &Path,
backend: &dyn GitBackend,
loader: &dyn PackLoader,
opts: &SyncMetaOptions,
prune_candidates: &[PathBuf],
) -> Result<SyncMetaReport, TreeError> {
sync_meta_inner(meta_dir, backend, loader, opts, prune_candidates, 0)
}
fn sync_meta_inner(
meta_dir: &Path,
backend: &dyn GitBackend,
loader: &dyn PackLoader,
opts: &SyncMetaOptions,
prune_candidates: &[PathBuf],
depth: usize,
) -> Result<SyncMetaReport, TreeError> {
let manifest = loader.load(meta_dir)?;
validate_children_paths(&manifest)?;
let mut report = SyncMetaReport { metas_visited: 1, ..SyncMetaReport::default() };
phase1_sync_children(meta_dir, &manifest, backend, opts, &mut report);
phase2_prune_orphans(meta_dir, prune_candidates, opts, &mut report);
phase3_recurse(meta_dir, &manifest, backend, loader, opts, depth, &mut report);
Ok(report)
}
fn phase1_sync_children(
meta_dir: &Path,
manifest: &PackManifest,
backend: &dyn GitBackend,
opts: &SyncMetaOptions,
report: &mut SyncMetaReport,
) {
let mut undeclared_seen: Vec<(PathBuf, DestClass)> = Vec::new();
for child in &manifest.children {
let dest = meta_dir.join(child.effective_path());
let class = classify_dest(&dest, true, None);
report.phase1_classifications.push((meta_dir.to_path_buf(), dest.clone(), class));
match class {
DestClass::Missing => {
if let Err(e) = phase1_clone(backend, child, &dest, opts) {
report.errors.push(e);
}
}
DestClass::PresentDeclared => {
if let Err(e) = phase1_fetch(backend, child, &dest, opts) {
report.errors.push(e);
}
}
DestClass::PresentDirty => {
}
DestClass::PresentInProgress => {
report.errors.push(TreeError::DirtyTreeRefusal {
path: dest,
kind: super::error::DirtyTreeRefusalKind::GitInProgress,
});
}
DestClass::PresentUndeclared => {
undeclared_seen.push((dest, class));
}
}
}
if let Err(e) = aggregate_untracked(undeclared_seen) {
report.errors.push(e);
}
}
fn phase1_clone(
backend: &dyn GitBackend,
child: &ChildRef,
dest: &Path,
opts: &SyncMetaOptions,
) -> Result<(), TreeError> {
let effective_ref = opts.ref_override.as_deref().or(child.r#ref.as_deref());
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
TreeError::ManifestRead(format!("failed to mkdir parent {}: {e}", parent.display()))
})?;
}
backend.clone(&child.url, dest, effective_ref)?;
Ok(())
}
fn phase1_fetch(
backend: &dyn GitBackend,
child: &ChildRef,
dest: &Path,
opts: &SyncMetaOptions,
) -> Result<(), TreeError> {
backend.fetch(dest)?;
let effective_ref = opts.ref_override.as_deref().or(child.r#ref.as_deref());
if let Some(r) = effective_ref {
backend.checkout(dest, r)?;
}
Ok(())
}
fn phase2_prune_orphans(
meta_dir: &Path,
prune_candidates: &[PathBuf],
opts: &SyncMetaOptions,
report: &mut SyncMetaReport,
) {
let audit_log = crate::manifest::event_log_path(meta_dir);
for candidate in prune_candidates {
let dest = meta_dir.join(candidate);
match phase2_prune(
&dest,
opts.force_prune,
opts.force_prune_with_ignored,
Some(audit_log.as_path()),
) {
Ok(()) => report.phase2_pruned.push(dest),
Err(e) => report.errors.push(e),
}
}
}
fn phase3_recurse(
meta_dir: &Path,
manifest: &PackManifest,
backend: &dyn GitBackend,
loader: &dyn PackLoader,
opts: &SyncMetaOptions,
depth: usize,
report: &mut SyncMetaReport,
) {
if !opts.recurse {
return;
}
let next_depth = depth + 1;
if let Some(cap) = opts.max_depth {
if next_depth > cap {
return;
}
}
for child in &manifest.children {
let dest = meta_dir.join(child.effective_path());
if !dest.join(".grex").join("pack.yaml").is_file() {
continue;
}
match sync_meta_inner(&dest, backend, loader, opts, &[], next_depth) {
Ok(sub) => report.merge(sub),
Err(e) => report.errors.push(e),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn synthesize_plain_git_manifest_yields_leaf_scripted_pack() {
let child = ChildRef {
url: "https://example.com/algo-leet.git".to_string(),
path: None,
r#ref: None,
};
let manifest = synthesize_plain_git_manifest(&child);
assert_eq!(manifest.name, child.effective_path());
assert_eq!(manifest.name, "algo-leet");
assert_eq!(manifest.r#type, PackType::Scripted);
assert_eq!(manifest.schema_version.as_str(), "1");
assert!(manifest.depends_on.is_empty());
assert!(manifest.children.is_empty());
assert!(manifest.actions.is_empty());
assert!(manifest.teardown.is_none());
assert!(manifest.extensions.is_empty());
assert!(manifest.version.is_none());
}
#[test]
fn synthesize_plain_git_manifest_honours_explicit_path() {
let child = ChildRef {
url: "https://example.com/some-repo.git".to_string(),
path: Some("custom-name".to_string()),
r#ref: None,
};
let manifest = synthesize_plain_git_manifest(&child);
assert_eq!(manifest.name, "custom-name");
}
#[test]
fn dest_has_git_repo_rejects_symlinked_dest() {
let outer = tempfile::tempdir().unwrap();
let real = outer.path().join("real-repo");
std::fs::create_dir_all(real.join(".git")).unwrap();
let link = outer.path().join("via-link");
#[cfg(unix)]
let symlink_result = std::os::unix::fs::symlink(&real, &link);
#[cfg(windows)]
let symlink_result = std::os::windows::fs::symlink_dir(&real, &link);
if symlink_result.is_err() {
return;
}
assert!(link.join(".git").exists(), "symlink target should expose .git through traversal");
assert!(
!dest_has_git_repo(&link),
"dest_has_git_repo must refuse a symlinked destination even when target has .git"
);
assert!(dest_has_git_repo(&real));
}
use std::collections::HashMap;
use std::sync::Mutex;
struct InMemLoader {
manifests: HashMap<PathBuf, PackManifest>,
}
impl InMemLoader {
fn new() -> Self {
Self { manifests: HashMap::new() }
}
fn with(mut self, dir: impl Into<PathBuf>, m: PackManifest) -> Self {
self.manifests.insert(dir.into(), m);
self
}
}
impl PackLoader for InMemLoader {
fn load(&self, path: &Path) -> Result<PackManifest, TreeError> {
self.manifests
.get(path)
.cloned()
.ok_or_else(|| TreeError::ManifestNotFound(path.to_path_buf()))
}
}
#[allow(dead_code)] #[derive(Debug, Clone)]
enum BackendCall {
Clone { url: String, dest: PathBuf, r#ref: Option<String> },
Fetch { dest: PathBuf },
Checkout { dest: PathBuf, r#ref: String },
HeadSha { dest: PathBuf },
}
struct InMemGit {
calls: Mutex<Vec<BackendCall>>,
materialise_on_clone: bool,
}
impl InMemGit {
fn new() -> Self {
Self { calls: Mutex::new(Vec::new()), materialise_on_clone: true }
}
fn calls(&self) -> Vec<BackendCall> {
self.calls.lock().unwrap().clone()
}
}
impl GitBackend for InMemGit {
fn name(&self) -> &'static str {
"v1_2_0-mock-git"
}
fn clone(
&self,
url: &str,
dest: &Path,
r#ref: Option<&str>,
) -> Result<crate::ClonedRepo, crate::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.materialise_on_clone {
std::fs::create_dir_all(dest.join(".git")).unwrap();
}
Ok(crate::ClonedRepo { path: dest.to_path_buf(), head_sha: "0".repeat(40) })
}
fn fetch(&self, dest: &Path) -> Result<(), crate::GitError> {
self.calls.lock().unwrap().push(BackendCall::Fetch { dest: dest.to_path_buf() });
Ok(())
}
fn checkout(&self, dest: &Path, r#ref: &str) -> Result<(), crate::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, crate::GitError> {
self.calls.lock().unwrap().push(BackendCall::HeadSha { dest: dest.to_path_buf() });
Ok("0".repeat(40))
}
}
fn meta_manifest_with(name: &str, children: Vec<ChildRef>) -> PackManifest {
PackManifest {
schema_version: SchemaVersion::current(),
name: name.to_string(),
r#type: PackType::Meta,
version: None,
depends_on: Vec::new(),
children,
actions: Vec::new(),
teardown: None,
extensions: BTreeMap::new(),
}
}
fn child(url: &str, path: &str) -> ChildRef {
ChildRef { url: url.to_string(), path: Some(path.to_string()), r#ref: None }
}
fn host_has_git_binary() -> bool {
std::process::Command::new("git")
.arg("--version")
.output()
.is_ok_and(|o| o.status.success())
}
#[test]
fn test_walker_v1_2_0_simple_meta_no_children() {
let tmp = tempfile::tempdir().unwrap();
let meta_dir = tmp.path().to_path_buf();
let loader = InMemLoader::new().with(meta_dir.clone(), meta_manifest_with("solo", vec![]));
let backend = InMemGit::new();
let opts = SyncMetaOptions::default();
let report = sync_meta(&meta_dir, &backend, &loader, &opts, &[]).expect("ok");
assert_eq!(report.metas_visited, 1);
assert!(report.phase1_classifications.is_empty());
assert!(report.phase2_pruned.is_empty());
assert!(report.errors.is_empty());
assert!(backend.calls().is_empty(), "no children → no git ops");
}
#[test]
fn test_walker_v1_2_0_phase1_classifies_each_child() {
let tmp = tempfile::tempdir().unwrap();
let meta_dir = tmp.path().to_path_buf();
let kids = vec![
child("https://example.com/a.git", "alpha"),
child("https://example.com/b.git", "beta"),
];
let loader =
InMemLoader::new().with(meta_dir.clone(), meta_manifest_with("root", kids.clone()));
let backend = InMemGit::new();
let opts = SyncMetaOptions { recurse: false, ..SyncMetaOptions::default() };
let report = sync_meta(&meta_dir, &backend, &loader, &opts, &[]).expect("ok");
assert_eq!(report.phase1_classifications.len(), 2);
for (parent, _, class) in &report.phase1_classifications {
assert_eq!(parent, &meta_dir);
assert_eq!(*class, DestClass::Missing);
}
assert!(report.errors.is_empty());
let calls = backend.calls();
assert_eq!(calls.len(), 2, "one clone per child");
for call in calls {
assert!(matches!(call, BackendCall::Clone { .. }));
}
}
#[test]
fn test_walker_v1_2_0_phase1_aggregates_untracked_error() {
let tmp = tempfile::tempdir().unwrap();
let alpha = tmp.path().join("alpha");
let beta = tmp.path().join("beta");
std::fs::create_dir_all(alpha.join(".git")).unwrap();
std::fs::create_dir_all(beta.join(".git")).unwrap();
let pairs: Vec<(PathBuf, DestClass)> = vec![
(alpha.clone(), DestClass::PresentUndeclared),
(beta.clone(), DestClass::PresentUndeclared),
];
let err = aggregate_untracked(pairs).expect_err("two undeclared → error");
match err {
TreeError::UntrackedGitRepos { paths } => {
assert_eq!(paths, vec![alpha, beta]);
}
other => panic!("expected UntrackedGitRepos, got {other:?}"),
}
}
#[test]
fn test_walker_v1_2_0_phase2_prunes_clean_orphans() {
if !host_has_git_binary() {
return;
}
let tmp = tempfile::tempdir().unwrap();
let meta_dir = tmp.path().to_path_buf();
let orphan = meta_dir.join("ghost");
std::fs::create_dir_all(&orphan).unwrap();
let init =
std::process::Command::new("git").arg("-C").arg(&orphan).args(["init", "-q"]).status();
if !matches!(init, Ok(s) if s.success()) {
return;
}
let loader = InMemLoader::new().with(meta_dir.clone(), meta_manifest_with("root", vec![]));
let backend = InMemGit::new();
let opts = SyncMetaOptions { recurse: false, ..SyncMetaOptions::default() };
let prune_list = vec![PathBuf::from("ghost")];
let report = sync_meta(&meta_dir, &backend, &loader, &opts, &prune_list).expect("ok");
assert_eq!(report.phase2_pruned.len(), 1, "clean orphan must be pruned");
assert_eq!(report.phase2_pruned[0], orphan);
assert!(!orphan.exists(), "dest must be removed after a clean prune");
assert!(report.errors.is_empty());
}
#[test]
fn test_walker_v1_2_0_phase2_refuses_dirty_orphan() {
if !host_has_git_binary() {
return;
}
let tmp = tempfile::tempdir().unwrap();
let meta_dir = tmp.path().to_path_buf();
let orphan = meta_dir.join("dirty-ghost");
std::fs::create_dir_all(&orphan).unwrap();
let init =
std::process::Command::new("git").arg("-C").arg(&orphan).args(["init", "-q"]).status();
if !matches!(init, Ok(s) if s.success()) {
return;
}
std::fs::write(orphan.join("scratch.txt"), b"unsaved").unwrap();
let loader = InMemLoader::new().with(meta_dir.clone(), meta_manifest_with("root", vec![]));
let backend = InMemGit::new();
let opts = SyncMetaOptions { recurse: false, ..SyncMetaOptions::default() };
let prune_list = vec![PathBuf::from("dirty-ghost")];
let report = sync_meta(&meta_dir, &backend, &loader, &opts, &prune_list).expect("ok");
assert!(report.phase2_pruned.is_empty(), "dirty orphan must NOT be pruned");
assert!(orphan.exists(), "dest stays on disk when refused");
assert_eq!(report.errors.len(), 1);
assert!(matches!(report.errors[0], TreeError::DirtyTreeRefusal { .. }));
}
#[test]
fn test_walker_v1_2_0_phase3_recurses_into_sub_meta() {
let tmp = tempfile::tempdir().unwrap();
let meta_dir = tmp.path().to_path_buf();
let child_dest = meta_dir.join("sub");
make_sub_meta_on_disk(&child_dest, "sub");
let loader = InMemLoader::new()
.with(
meta_dir.clone(),
meta_manifest_with("root", vec![child("https://example.com/sub.git", "sub")]),
)
.with(child_dest.clone(), meta_manifest_with("sub", vec![]));
let backend = InMemGit::new();
let opts = SyncMetaOptions::default();
let report = sync_meta(&meta_dir, &backend, &loader, &opts, &[]).expect("ok");
assert_eq!(report.metas_visited, 2, "parent + sub-meta visited");
assert!(report.errors.is_empty());
}
#[test]
fn test_walker_v1_2_0_phase3_max_depth_zero_skips_recursion() {
let tmp = tempfile::tempdir().unwrap();
let meta_dir = tmp.path().to_path_buf();
let child_dest = meta_dir.join("sub");
make_sub_meta_on_disk(&child_dest, "sub");
let loader = InMemLoader::new()
.with(
meta_dir.clone(),
meta_manifest_with("root", vec![child("https://example.com/sub.git", "sub")]),
)
.with(child_dest.clone(), meta_manifest_with("sub", vec![]));
let backend = InMemGit::new();
let opts = SyncMetaOptions { recurse: false, ..SyncMetaOptions::default() };
let report = sync_meta(&meta_dir, &backend, &loader, &opts, &[]).expect("ok");
assert_eq!(report.metas_visited, 1, "no recursion → only the root meta");
}
#[test]
fn test_walker_v1_2_0_phase3_max_depth_n_stops_at_n_levels() {
let tmp = tempfile::tempdir().unwrap();
let root_dir = tmp.path().to_path_buf();
let mid_dir = root_dir.join("mid");
let leaf_dir = mid_dir.join("leaf");
make_sub_meta_on_disk(&mid_dir, "mid");
make_sub_meta_on_disk(&leaf_dir, "leaf");
let loader = InMemLoader::new()
.with(
root_dir.clone(),
meta_manifest_with("root", vec![child("https://example.com/mid.git", "mid")]),
)
.with(
mid_dir.clone(),
meta_manifest_with("mid", vec![child("https://example.com/leaf.git", "leaf")]),
)
.with(leaf_dir.clone(), meta_manifest_with("leaf", vec![]));
let backend = InMemGit::new();
let opts = SyncMetaOptions { max_depth: Some(1), ..SyncMetaOptions::default() };
let report = sync_meta(&root_dir, &backend, &loader, &opts, &[]).expect("ok");
assert_eq!(report.metas_visited, 2, "max_depth: Some(1) visits root + mid only");
}
fn make_sub_meta_on_disk(dir: &Path, name: &str) {
std::fs::create_dir_all(dir.join(".grex")).unwrap();
std::fs::create_dir_all(dir.join(".git")).unwrap();
let yaml = format!("schema_version: \"1\"\nname: {name}\ntype: meta\n");
std::fs::write(dir.join(".grex/pack.yaml"), yaml).unwrap();
}
fn destinations_under(report: &SyncMetaReport, parent: &Path) -> Vec<PathBuf> {
report
.phase1_classifications
.iter()
.filter(|(p, _, _)| p == parent)
.map(|(_, d, _)| d.clone())
.collect()
}
#[test]
fn test_walker_v1_2_0_parent_relative_path_resolution() {
let tmp = tempfile::tempdir().unwrap();
let root_dir = tmp.path().to_path_buf();
let tools_dir = root_dir.join("tools");
let foo_dir = tools_dir.join("foo");
make_sub_meta_on_disk(&tools_dir, "tools");
make_sub_meta_on_disk(&foo_dir, "foo");
let loader = InMemLoader::new()
.with(
root_dir.clone(),
meta_manifest_with("root", vec![child("https://example.com/tools.git", "tools")]),
)
.with(
tools_dir.clone(),
meta_manifest_with("tools", vec![child("https://example.com/foo.git", "foo")]),
)
.with(foo_dir.clone(), meta_manifest_with("foo", vec![]));
let backend = InMemGit::new();
let opts = SyncMetaOptions::default();
let report = sync_meta(&root_dir, &backend, &loader, &opts, &[]).expect("ok");
assert_eq!(report.metas_visited, 3);
for (parent, dest, _class) in &report.phase1_classifications {
assert!(
dest.starts_with(parent),
"child dest {} must descend from parent {}",
dest.display(),
parent.display()
);
}
assert_eq!(destinations_under(&report, &root_dir), vec![tools_dir.clone()]);
assert_eq!(destinations_under(&report, &tools_dir), vec![foo_dir.clone()]);
}
}