use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use rayon::prelude::*;
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;
use super::quarantine::QuarantineConfig;
#[doc(hidden)]
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,
ancestors: &mut Vec<String>,
) -> Result<(), TreeError> {
self.record_depends_on(parent_id, manifest, state);
self.process_children(parent_id, manifest, state, ancestors)
}
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,
ancestors: &mut Vec<String>,
) -> Result<(), TreeError> {
for child in &manifest.children {
self.handle_child(parent_id, child, state, ancestors)?;
}
Ok(())
}
fn handle_child(
&self,
parent_id: usize,
child: &ChildRef,
state: &mut BuildState,
ancestors: &mut Vec<String>,
) -> Result<(), TreeError> {
let identity = pack_identity_for_child(child);
if ancestors.iter().any(|s| s == &identity) {
let mut chain = ancestors.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 });
ancestors.push(identity);
let result = self.walk_recursive(child_id, &child_manifest, state, ancestors);
ancestors.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 {
match child.r#ref.as_deref() {
Some(r) if !r.is_empty() => format!("url:{}@{}", child.url, r),
_ => format!("url:{}", child.url),
}
}
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,
pub parallel: Option<usize>,
pub quarantine: Option<QuarantineConfig>,
}
impl Default for SyncMetaOptions {
fn default() -> Self {
Self {
ref_override: None,
recurse: true,
max_depth: None,
force_prune: false,
force_prune_with_ignored: false,
parallel: None,
quarantine: None,
}
}
}
#[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> {
let initial_ancestors = vec![pack_identity_for_root(meta_dir)];
sync_meta_inner(
meta_dir,
backend,
loader,
opts,
prune_candidates,
0,
&initial_ancestors,
)
}
fn sync_meta_inner(
meta_dir: &Path,
backend: &dyn GitBackend,
loader: &dyn PackLoader,
opts: &SyncMetaOptions,
prune_candidates: &[PathBuf],
depth: usize,
ancestors: &[String],
) -> Result<SyncMetaReport, TreeError> {
let manifest = loader.load(meta_dir)?;
validate_children_paths(&manifest)?;
let mut report = SyncMetaReport { metas_visited: 1, ..SyncMetaReport::default() };
let pool = build_pool(opts.parallel)?;
phase1_sync_children(&pool, meta_dir, &manifest, backend, opts, &mut report);
phase2_prune_orphans(meta_dir, prune_candidates, opts, &mut report);
phase3_recurse(
&pool,
meta_dir,
&manifest,
backend,
loader,
opts,
depth,
ancestors,
&mut report,
)?;
Ok(report)
}
fn build_pool(parallel: Option<usize>) -> Result<rayon::ThreadPool, TreeError> {
let mut builder = rayon::ThreadPoolBuilder::new();
if let Some(n) = parallel {
builder = builder.num_threads(n.max(1));
}
builder.build().map_err(|e| {
TreeError::ManifestRead(format!("failed to build rayon pool for sync_meta: {e}"))
})
}
struct Phase1ChildOutcome {
classification: (PathBuf, PathBuf, DestClass),
error: Option<TreeError>,
undeclared: Option<(PathBuf, DestClass)>,
}
fn phase1_sync_children(
pool: &rayon::ThreadPool,
meta_dir: &Path,
manifest: &PackManifest,
backend: &dyn GitBackend,
opts: &SyncMetaOptions,
report: &mut SyncMetaReport,
) {
let outcomes: Vec<Phase1ChildOutcome> = pool.install(|| {
manifest
.children
.par_iter()
.map(|child| phase1_handle_child(meta_dir, child, backend, opts))
.collect()
});
let mut undeclared_seen: Vec<(PathBuf, DestClass)> = Vec::new();
for outcome in outcomes {
report.phase1_classifications.push(outcome.classification);
if let Some(e) = outcome.error {
report.errors.push(e);
}
if let Some(pair) = outcome.undeclared {
undeclared_seen.push(pair);
}
}
if let Err(e) = aggregate_untracked(undeclared_seen) {
report.errors.push(e);
}
}
fn phase1_handle_child(
meta_dir: &Path,
child: &ChildRef,
backend: &dyn GitBackend,
opts: &SyncMetaOptions,
) -> Phase1ChildOutcome {
let dest = meta_dir.join(child.effective_path());
let class = classify_dest(&dest, true, None);
let mut out = Phase1ChildOutcome {
classification: (meta_dir.to_path_buf(), dest.clone(), class),
error: None,
undeclared: None,
};
match class {
DestClass::Missing => {
if let Err(e) = phase1_clone(backend, child, &dest, opts) {
out.error = Some(e);
}
}
DestClass::PresentDeclared => {
if let Err(e) = phase1_fetch(backend, child, &dest, opts) {
out.error = Some(e);
}
}
DestClass::PresentDirty => {
}
DestClass::PresentInProgress => {
out.error = Some(TreeError::DirtyTreeRefusal {
path: dest.clone(),
kind: super::error::DirtyTreeRefusalKind::GitInProgress,
});
}
DestClass::PresentUndeclared => {
out.undeclared = Some((dest, class));
}
}
out
}
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()),
opts.quarantine.as_ref(),
) {
Ok(()) => report.phase2_pruned.push(dest),
Err(e) => report.errors.push(e),
}
}
}
enum Phase3ChildOutcome {
Skipped,
Recursed(SyncMetaReport),
Failed(TreeError),
Cancelled,
}
#[allow(clippy::too_many_arguments)]
fn phase3_handle_child(
meta_dir: &Path,
child: &ChildRef,
backend: &dyn GitBackend,
loader: &dyn PackLoader,
opts: &SyncMetaOptions,
next_depth: usize,
ancestors: &[String],
cancelled: &AtomicBool,
) -> Phase3ChildOutcome {
if cancelled.load(Ordering::Relaxed) {
return Phase3ChildOutcome::Cancelled;
}
let dest = meta_dir.join(child.effective_path());
if !dest.join(".grex").join("pack.yaml").is_file() {
return Phase3ChildOutcome::Skipped;
}
let id = pack_identity_for_child(child);
if ancestors.iter().any(|v| v == &id) {
cancelled.store(true, Ordering::Relaxed);
let mut chain = ancestors.to_vec();
chain.push(id);
return Phase3ChildOutcome::Failed(TreeError::CycleDetected { chain });
}
if let Some(cap) = opts.max_depth {
if next_depth > cap {
return Phase3ChildOutcome::Skipped;
}
}
let mut child_ancestors = ancestors.to_vec();
child_ancestors.push(id);
match sync_meta_inner(&dest, backend, loader, opts, &[], next_depth, &child_ancestors) {
Ok(sub) => Phase3ChildOutcome::Recursed(sub),
Err(e) => Phase3ChildOutcome::Failed(e),
}
}
#[allow(clippy::too_many_arguments)]
fn phase3_recurse(
pool: &rayon::ThreadPool,
meta_dir: &Path,
manifest: &PackManifest,
backend: &dyn GitBackend,
loader: &dyn PackLoader,
opts: &SyncMetaOptions,
depth: usize,
ancestors: &[String],
report: &mut SyncMetaReport,
) -> Result<(), TreeError> {
if !opts.recurse {
return Ok(());
}
let next_depth = depth + 1;
let cancelled = Arc::new(AtomicBool::new(false));
let outcomes: Vec<Phase3ChildOutcome> = pool.install(|| {
manifest
.children
.par_iter()
.map(|child| {
phase3_handle_child(
meta_dir, child, backend, loader, opts, next_depth, ancestors, &cancelled,
)
})
.collect()
});
let mut first_cycle_idx: Option<usize> = None;
for outcome in outcomes {
match outcome {
Phase3ChildOutcome::Skipped | Phase3ChildOutcome::Cancelled => {}
Phase3ChildOutcome::Recursed(sub) => report.merge(sub),
Phase3ChildOutcome::Failed(e) => {
if matches!(e, TreeError::CycleDetected { .. }) && first_cycle_idx.is_none() {
first_cycle_idx = Some(report.errors.len());
}
report.errors.push(e);
}
}
}
if let Some(idx) = first_cycle_idx {
let TreeError::CycleDetected { chain } = &report.errors[idx] else {
unreachable!("first_cycle_idx points at a CycleDetected variant by construction");
};
return Err(TreeError::CycleDetected { chain: chain.clone() });
}
Ok(())
}
#[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()]);
}
fn child_with_ref(url: &str, path: &str, r#ref: &str) -> ChildRef {
ChildRef {
url: url.to_string(),
path: Some(path.to_string()),
r#ref: Some(r#ref.to_string()),
}
}
#[test]
fn cycle_self_loop_aborts() {
let tmp = tempfile::tempdir().unwrap();
let root_dir = tmp.path().to_path_buf();
let a_dir = root_dir.join("a");
let a_self_dir = a_dir.join("a");
make_sub_meta_on_disk(&a_dir, "a");
make_sub_meta_on_disk(&a_self_dir, "a");
let url_a = "https://example.com/a.git";
let loader = InMemLoader::new()
.with(root_dir.clone(), meta_manifest_with("root", vec![child(url_a, "a")]))
.with(a_dir.clone(), meta_manifest_with("a", vec![child(url_a, "a")]))
.with(a_self_dir.clone(), meta_manifest_with("a", vec![]));
let backend = InMemGit::new();
let opts = SyncMetaOptions::default();
let err =
sync_meta(&root_dir, &backend, &loader, &opts, &[]).expect_err("self-loop must abort");
match err {
TreeError::CycleDetected { chain } => {
let id_a = format!("url:{url_a}");
assert!(
chain.iter().any(|s| s == &id_a),
"chain must mention the cyclic url, got {chain:?}"
);
assert!(chain.len() >= 2, "self-loop chain has at least 2 entries: {chain:?}");
let last = chain.last().unwrap();
assert_eq!(last, &id_a, "chain must end with the recurring child identity");
let first_match = chain.iter().position(|s| s == last).unwrap();
assert!(
first_match < chain.len() - 1,
"the recurring identity must appear earlier in the chain: {chain:?}"
);
assert!(
chain[0].starts_with("path:"),
"chain head is the root path identity: {chain:?}"
);
}
other => panic!("expected CycleDetected, got {other:?}"),
}
}
#[test]
fn cycle_three_node_aborts() {
let tmp = tempfile::tempdir().unwrap();
let root_dir = tmp.path().to_path_buf();
let a_dir = root_dir.join("a");
let b_dir = a_dir.join("b");
let c_dir = b_dir.join("c");
let a2_dir = c_dir.join("a");
make_sub_meta_on_disk(&a_dir, "a");
make_sub_meta_on_disk(&b_dir, "b");
make_sub_meta_on_disk(&c_dir, "c");
make_sub_meta_on_disk(&a2_dir, "a");
let url_a = "https://example.com/a.git";
let url_b = "https://example.com/b.git";
let url_c = "https://example.com/c.git";
let loader = InMemLoader::new()
.with(root_dir.clone(), meta_manifest_with("root", vec![child(url_a, "a")]))
.with(a_dir.clone(), meta_manifest_with("a", vec![child(url_b, "b")]))
.with(b_dir.clone(), meta_manifest_with("b", vec![child(url_c, "c")]))
.with(c_dir.clone(), meta_manifest_with("c", vec![child(url_a, "a")]))
.with(a2_dir.clone(), meta_manifest_with("a", vec![]));
let backend = InMemGit::new();
let opts = SyncMetaOptions::default();
let err = sync_meta(&root_dir, &backend, &loader, &opts, &[])
.expect_err("three-node cycle must abort");
match err {
TreeError::CycleDetected { chain } => {
let id_root = pack_identity_for_root(&root_dir);
let id_a = format!("url:{url_a}");
let id_b = format!("url:{url_b}");
let id_c = format!("url:{url_c}");
assert_eq!(chain, vec![id_root, id_a.clone(), id_b, id_c, id_a]);
}
other => panic!("expected CycleDetected, got {other:?}"),
}
}
#[test]
fn same_repo_two_refs_no_cycle() {
let tmp = tempfile::tempdir().unwrap();
let root_dir = tmp.path().to_path_buf();
let main_dir = root_dir.join("b-main");
let dev_dir = root_dir.join("b-dev");
make_sub_meta_on_disk(&main_dir, "b-main");
make_sub_meta_on_disk(&dev_dir, "b-dev");
let url_b = "https://example.com/b.git";
let loader = InMemLoader::new()
.with(
root_dir.clone(),
meta_manifest_with(
"root",
vec![
child_with_ref(url_b, "b-main", "main"),
child_with_ref(url_b, "b-dev", "dev"),
],
),
)
.with(main_dir.clone(), meta_manifest_with("b-main", vec![]))
.with(dev_dir.clone(), meta_manifest_with("b-dev", vec![]));
let backend = InMemGit::new();
let opts = SyncMetaOptions::default();
let report = sync_meta(&root_dir, &backend, &loader, &opts, &[])
.expect("same url at distinct refs is NOT a cycle");
assert_eq!(report.metas_visited, 3);
assert!(
report.errors.is_empty(),
"no errors expected when the two children differ only by ref: {:?}",
report.errors
);
}
#[test]
fn same_repo_two_refs_nested_no_cycle() {
let tmp = tempfile::tempdir().unwrap();
let root_dir = tmp.path().to_path_buf();
let a_dir = root_dir.join("a");
let b_dir = a_dir.join("b");
make_sub_meta_on_disk(&a_dir, "a");
make_sub_meta_on_disk(&b_dir, "b");
let url_foo = "https://example.com/foo.git";
let loader = InMemLoader::new()
.with(
root_dir.clone(),
meta_manifest_with("root", vec![child_with_ref(url_foo, "a", "main")]),
)
.with(a_dir.clone(), meta_manifest_with("a", vec![child_with_ref(url_foo, "b", "dev")]))
.with(b_dir.clone(), meta_manifest_with("b", vec![]));
let backend = InMemGit::new();
let opts = SyncMetaOptions::default();
let report = sync_meta(&root_dir, &backend, &loader, &opts, &[])
.expect("nested same-url at distinct refs is NOT a cycle");
assert_eq!(report.metas_visited, 3, "walker must recurse to depth 2");
assert!(
report.errors.is_empty(),
"no errors expected when ancestor and descendant differ only by ref: {:?}",
report.errors
);
}
#[test]
fn cycle_diamond_shared_descendant_no_cycle() {
let tmp = tempfile::tempdir().unwrap();
let root_dir = tmp.path().to_path_buf();
let a_dir = root_dir.join("a");
let b_dir = root_dir.join("b");
let c_under_a_dir = a_dir.join("c");
let c_under_b_dir = b_dir.join("c");
make_sub_meta_on_disk(&a_dir, "a");
make_sub_meta_on_disk(&b_dir, "b");
make_sub_meta_on_disk(&c_under_a_dir, "c");
make_sub_meta_on_disk(&c_under_b_dir, "c");
let url_a = "https://example.com/a.git";
let url_b = "https://example.com/b.git";
let url_c = "https://example.com/c.git";
let loader = InMemLoader::new()
.with(
root_dir.clone(),
meta_manifest_with("root", vec![child(url_a, "a"), child(url_b, "b")]),
)
.with(a_dir.clone(), meta_manifest_with("a", vec![child(url_c, "c")]))
.with(b_dir.clone(), meta_manifest_with("b", vec![child(url_c, "c")]))
.with(c_under_a_dir.clone(), meta_manifest_with("c", vec![]))
.with(c_under_b_dir.clone(), meta_manifest_with("c", vec![]));
let backend = InMemGit::new();
let opts = SyncMetaOptions::default();
let report =
sync_meta(&root_dir, &backend, &loader, &opts, &[]).expect("diamond is NOT a cycle");
assert_eq!(
report.metas_visited, 5,
"diamond: root + a + b + c-under-a + c-under-b = 5 visits"
);
assert!(
!report.errors.iter().any(|e| matches!(e, TreeError::CycleDetected { .. })),
"diamond must not surface CycleDetected; errors={:?}",
report.errors
);
assert!(report.errors.is_empty(), "diamond should produce no errors: {:?}", report.errors);
let dests_under_a = destinations_under(&report, &a_dir);
let dests_under_b = destinations_under(&report, &b_dir);
assert!(
dests_under_a.iter().any(|d| d == &c_under_a_dir),
"diamond: expected c-via-a in classifications under a, got {dests_under_a:?}"
);
assert!(
dests_under_b.iter().any(|d| d == &c_under_b_dir),
"diamond: expected c-via-b in classifications under b, got {dests_under_b:?}"
);
assert_ne!(
c_under_a_dir, c_under_b_dir,
"the two `c` visits must land on distinct on-disk dests"
);
}
#[test]
#[allow(clippy::too_many_lines)]
fn cycle_four_node_aborts() {
let tmp = tempfile::tempdir().unwrap();
let root_dir = tmp.path().to_path_buf();
let a_dir = root_dir.join("a");
let b_dir = a_dir.join("b");
let c_dir = b_dir.join("c");
let d_dir = c_dir.join("d");
let a2_dir = d_dir.join("a");
make_sub_meta_on_disk(&a_dir, "a");
make_sub_meta_on_disk(&b_dir, "b");
make_sub_meta_on_disk(&c_dir, "c");
make_sub_meta_on_disk(&d_dir, "d");
make_sub_meta_on_disk(&a2_dir, "a");
let url_a = "https://example.com/a.git";
let url_b = "https://example.com/b.git";
let url_c = "https://example.com/c.git";
let url_d = "https://example.com/d.git";
let loader = InMemLoader::new()
.with(root_dir.clone(), meta_manifest_with("root", vec![child(url_a, "a")]))
.with(a_dir.clone(), meta_manifest_with("a", vec![child(url_b, "b")]))
.with(b_dir.clone(), meta_manifest_with("b", vec![child(url_c, "c")]))
.with(c_dir.clone(), meta_manifest_with("c", vec![child(url_d, "d")]))
.with(d_dir.clone(), meta_manifest_with("d", vec![child(url_a, "a")]))
.with(a2_dir.clone(), meta_manifest_with("a", vec![]));
let backend = InMemGit::new();
let opts = SyncMetaOptions::default();
let err = sync_meta(&root_dir, &backend, &loader, &opts, &[])
.expect_err("four-node cycle must abort");
match err {
TreeError::CycleDetected { chain } => {
let id_root = pack_identity_for_root(&root_dir);
let id_a = format!("url:{url_a}");
let id_b = format!("url:{url_b}");
let id_c = format!("url:{url_c}");
let id_d = format!("url:{url_d}");
assert_eq!(
chain,
vec![id_root, id_a.clone(), id_b, id_c, id_d, id_a.clone()],
"expected full ancestor chain ending in the recurring A"
);
assert!(
chain.len() >= 5,
"four-node cycle chain has at least 5 entries: {chain:?}"
);
let last = chain.last().unwrap();
let first_match = chain.iter().position(|s| s == last).unwrap();
assert!(
first_match < chain.len() - 1,
"the recurring identity must appear earlier in the chain: {chain:?}"
);
}
other => panic!("expected CycleDetected, got {other:?}"),
}
}
#[test]
#[allow(clippy::too_many_lines)]
fn cycle_nested_prefix_aborts() {
let tmp = tempfile::tempdir().unwrap();
let root_dir = tmp.path().to_path_buf();
let a_dir = root_dir.join("a");
let b_dir = a_dir.join("b");
let c_dir = b_dir.join("c");
let d_dir = b_dir.join("d");
let b2_dir = d_dir.join("b");
make_sub_meta_on_disk(&a_dir, "a");
make_sub_meta_on_disk(&b_dir, "b");
make_sub_meta_on_disk(&c_dir, "c");
make_sub_meta_on_disk(&d_dir, "d");
make_sub_meta_on_disk(&b2_dir, "b");
let url_a = "https://example.com/a.git";
let url_b = "https://example.com/b.git";
let url_c = "https://example.com/c.git";
let url_d = "https://example.com/d.git";
let loader = InMemLoader::new()
.with(root_dir.clone(), meta_manifest_with("root", vec![child(url_a, "a")]))
.with(a_dir.clone(), meta_manifest_with("a", vec![child(url_b, "b")]))
.with(
b_dir.clone(),
meta_manifest_with("b", vec![child(url_c, "c"), child(url_d, "d")]),
)
.with(c_dir.clone(), meta_manifest_with("c", vec![]))
.with(d_dir.clone(), meta_manifest_with("d", vec![child(url_b, "b")]))
.with(b2_dir.clone(), meta_manifest_with("b", vec![]));
let backend = InMemGit::new();
let opts = SyncMetaOptions::default();
let err = sync_meta(&root_dir, &backend, &loader, &opts, &[])
.expect_err("nested-prefix cycle must abort");
match err {
TreeError::CycleDetected { chain } => {
let id_root = pack_identity_for_root(&root_dir);
let id_a = format!("url:{url_a}");
let id_b = format!("url:{url_b}");
let id_d = format!("url:{url_d}");
assert_eq!(
chain,
vec![id_root.clone(), id_a, id_b.clone(), id_d, id_b.clone()],
"cycle should appear inside the subtree, not at the top"
);
let last = chain.last().unwrap();
assert_eq!(last, &id_b, "recurring identity is B");
assert_ne!(
chain.first().unwrap(),
last,
"cycle must not start at the root frame: {chain:?}"
);
assert_eq!(
chain.first().unwrap(),
&id_root,
"chain must begin with the root path identity: {chain:?}"
);
}
other => panic!("expected CycleDetected, got {other:?}"),
}
}
#[test]
fn cycle_aborts_under_max_depth_cap() {
let tmp = tempfile::tempdir().unwrap();
let root_dir = tmp.path().to_path_buf();
let a_dir = root_dir.join("a");
let b_dir = a_dir.join("b");
let c_dir = b_dir.join("c");
let d_dir = c_dir.join("d");
let a2_dir = d_dir.join("a");
make_sub_meta_on_disk(&a_dir, "a");
make_sub_meta_on_disk(&b_dir, "b");
make_sub_meta_on_disk(&c_dir, "c");
make_sub_meta_on_disk(&d_dir, "d");
make_sub_meta_on_disk(&a2_dir, "a");
let url_a = "https://example.com/a.git";
let url_b = "https://example.com/b.git";
let url_c = "https://example.com/c.git";
let url_d = "https://example.com/d.git";
let loader = InMemLoader::new()
.with(root_dir.clone(), meta_manifest_with("root", vec![child(url_a, "a")]))
.with(a_dir.clone(), meta_manifest_with("a", vec![child(url_b, "b")]))
.with(b_dir.clone(), meta_manifest_with("b", vec![child(url_c, "c")]))
.with(c_dir.clone(), meta_manifest_with("c", vec![child(url_d, "d")]))
.with(d_dir.clone(), meta_manifest_with("d", vec![child(url_a, "a")]))
.with(a2_dir.clone(), meta_manifest_with("a", vec![]));
let backend = InMemGit::new();
let opts = SyncMetaOptions { max_depth: Some(4), ..SyncMetaOptions::default() };
let err = sync_meta(&root_dir, &backend, &loader, &opts, &[])
.expect_err("cycle must surface even when its closing frame exceeds max_depth");
match err {
TreeError::CycleDetected { chain } => {
let id_a = format!("url:{url_a}");
assert!(
chain.last() == Some(&id_a),
"recurring identity must be A, got chain={chain:?}"
);
let last = chain.last().unwrap();
let first_match = chain.iter().position(|s| s == last).unwrap();
assert!(
first_match < chain.len() - 1,
"the recurring identity must appear earlier in the chain: {chain:?}"
);
}
other => panic!("expected CycleDetected, got {other:?}"),
}
}
#[test]
fn child_identity_some_empty_ref_omits_at() {
let url = "https://example.com/a.git";
let with_none = ChildRef { url: url.to_string(), path: Some("a".to_string()), r#ref: None };
let with_empty = ChildRef {
url: url.to_string(),
path: Some("a".to_string()),
r#ref: Some(String::new()),
};
let id_none = pack_identity_for_child(&with_none);
let id_empty = pack_identity_for_child(&with_empty);
let expected = format!("url:{url}");
assert_eq!(id_none, expected, "None ref must produce bare url identity");
assert_eq!(
id_empty, expected,
"Some(\"\") ref must collapse to bare url identity (no trailing @)"
);
assert_eq!(id_none, id_empty, "Some(\"\") and None must yield the same identity");
assert!(!id_empty.ends_with('@'), "identity must not end with trailing @: {id_empty:?}");
}
#[test]
#[allow(clippy::too_many_lines)]
fn cancellation_aborts_siblings() {
let tmp = tempfile::tempdir().unwrap();
let root_dir = tmp.path().to_path_buf();
let a_dir = root_dir.join("a");
let a_cyclic_dir = a_dir.join("a-cyclic");
let x_dir = a_dir.join("x");
let x1_dir = x_dir.join("x1");
let x2_dir = x1_dir.join("x2");
let y_dir = a_dir.join("y");
let y1_dir = y_dir.join("y1");
let y2_dir = y1_dir.join("y2");
let z_dir = a_dir.join("z");
let z1_dir = z_dir.join("z1");
let z2_dir = z1_dir.join("z2");
for d in [
&a_dir,
&a_cyclic_dir,
&x_dir,
&x1_dir,
&x2_dir,
&y_dir,
&y1_dir,
&y2_dir,
&z_dir,
&z1_dir,
&z2_dir,
] {
make_sub_meta_on_disk(d, d.file_name().unwrap().to_str().unwrap());
}
let url_a = "https://example.com/a.git";
let url_x = "https://example.com/x.git";
let url_x1 = "https://example.com/x1.git";
let url_x2 = "https://example.com/x2.git";
let url_y = "https://example.com/y.git";
let url_y1 = "https://example.com/y1.git";
let url_y2 = "https://example.com/y2.git";
let url_z = "https://example.com/z.git";
let url_z1 = "https://example.com/z1.git";
let url_z2 = "https://example.com/z2.git";
let loader = InMemLoader::new()
.with(root_dir.clone(), meta_manifest_with("root", vec![child(url_a, "a")]))
.with(
a_dir.clone(),
meta_manifest_with(
"a",
vec![
child(url_a, "a-cyclic"),
child(url_x, "x"),
child(url_y, "y"),
child(url_z, "z"),
],
),
)
.with(a_cyclic_dir.clone(), meta_manifest_with("a-cyclic", vec![]))
.with(x_dir.clone(), meta_manifest_with("x", vec![child(url_x1, "x1")]))
.with(x1_dir.clone(), meta_manifest_with("x1", vec![child(url_x2, "x2")]))
.with(x2_dir.clone(), meta_manifest_with("x2", vec![]))
.with(y_dir.clone(), meta_manifest_with("y", vec![child(url_y1, "y1")]))
.with(y1_dir.clone(), meta_manifest_with("y1", vec![child(url_y2, "y2")]))
.with(y2_dir.clone(), meta_manifest_with("y2", vec![]))
.with(z_dir.clone(), meta_manifest_with("z", vec![child(url_z1, "z1")]))
.with(z1_dir.clone(), meta_manifest_with("z1", vec![child(url_z2, "z2")]))
.with(z2_dir.clone(), meta_manifest_with("z2", vec![]));
let backend = InMemGit::new();
let opts = SyncMetaOptions { parallel: Some(1), ..SyncMetaOptions::default() };
let err = sync_meta(&root_dir, &backend, &loader, &opts, &[])
.expect_err("cyclic input must surface CycleDetected");
match err {
TreeError::CycleDetected { chain } => {
let id_a = format!("url:{url_a}");
assert_eq!(
chain.last(),
Some(&id_a),
"recurring identity must be A (the cyclic arm), got chain={chain:?}"
);
}
other => panic!("expected CycleDetected, got {other:?}"),
}
let fetch_count =
backend.calls().iter().filter(|c| matches!(c, BackendCall::Fetch { .. })).count();
assert!(
fetch_count >= 5,
"Phase 1 fan-out for A's 4 direct children must complete even under cancellation; \
observed {fetch_count} fetches"
);
assert_eq!(
fetch_count, 5,
"cancellation flag must short-circuit X/Y/Z subtrees; \
observed {fetch_count} fetches (acyclic walk would do 11)"
);
let cancelled_dests = [&x1_dir, &x2_dir, &y1_dir, &y2_dir, &z1_dir, &z2_dir];
for dest in cancelled_dests {
for call in backend.calls() {
if let BackendCall::Fetch { dest: fetched } = &call {
assert_ne!(
fetched,
dest,
"cancellation must prevent recursion into {} (observed Fetch call)",
dest.display()
);
}
}
}
}
#[test]
#[allow(clippy::too_many_lines)]
fn cancellation_aborts_siblings_multithread() {
fn build_topology() -> (tempfile::TempDir, PathBuf, InMemLoader, InMemGit, String) {
let tmp = tempfile::tempdir().unwrap();
let root_dir = tmp.path().to_path_buf();
let a_dir = root_dir.join("a");
make_sub_meta_on_disk(&a_dir, "a");
let url_a = "https://example.com/a.git".to_string();
let a_cyc1_dir = a_dir.join("a-cyclic1");
let a_cyc2_dir = a_dir.join("a-cyclic2");
make_sub_meta_on_disk(&a_cyc1_dir, "a-cyclic1");
make_sub_meta_on_disk(&a_cyc2_dir, "a-cyclic2");
let mut a_children = vec![child(&url_a, "a-cyclic1"), child(&url_a, "a-cyclic2")];
let mut loader = InMemLoader::new()
.with(root_dir.clone(), meta_manifest_with("root", vec![child(&url_a, "a")]))
.with(a_cyc1_dir.clone(), meta_manifest_with("a-cyclic1", vec![]))
.with(a_cyc2_dir.clone(), meta_manifest_with("a-cyclic2", vec![]));
for i in 0..10 {
let xi_name = format!("x{i}");
let xi_dir = a_dir.join(&xi_name);
let xi1_name = format!("x{i}_1");
let xi1_dir = xi_dir.join(&xi1_name);
let xi2_name = format!("x{i}_2");
let xi2_dir = xi1_dir.join(&xi2_name);
make_sub_meta_on_disk(&xi_dir, &xi_name);
make_sub_meta_on_disk(&xi1_dir, &xi1_name);
make_sub_meta_on_disk(&xi2_dir, &xi2_name);
let url_xi = format!("https://example.com/x{i}.git");
let url_xi1 = format!("https://example.com/x{i}_1.git");
let url_xi2 = format!("https://example.com/x{i}_2.git");
a_children.push(child(&url_xi, &xi_name));
loader = loader
.with(xi_dir, meta_manifest_with(&xi_name, vec![child(&url_xi1, &xi1_name)]))
.with(xi1_dir, meta_manifest_with(&xi1_name, vec![child(&url_xi2, &xi2_name)]))
.with(xi2_dir, meta_manifest_with(&xi2_name, vec![]));
}
loader = loader.with(a_dir, meta_manifest_with("a", a_children));
let backend = InMemGit::new();
(tmp, root_dir, loader, backend, url_a)
}
for iter in 0..50 {
let (_tmp, root_dir, loader, backend, url_a) = build_topology();
let opts = SyncMetaOptions { parallel: Some(8), ..SyncMetaOptions::default() };
let result = sync_meta(&root_dir, &backend, &loader, &opts, &[]);
let err = match result {
Err(e) => e,
Ok(_) => panic!("iter {iter}: expected CycleDetected, got Ok"),
};
match err {
TreeError::CycleDetected { chain } => {
assert!(
!chain.is_empty(),
"iter {iter}: CycleDetected chain must be non-empty: {chain:?}"
);
let id_a = format!("url:{url_a}");
assert_eq!(
chain.last(),
Some(&id_a),
"iter {iter}: recurring identity must be A, got chain={chain:?}"
);
}
other => panic!("iter {iter}: expected CycleDetected, got {other:?}"),
}
let fetch_count =
backend.calls().iter().filter(|c| matches!(c, BackendCall::Fetch { .. })).count();
assert!(
fetch_count >= 1,
"iter {iter}: at least the root → A fetch must occur; got {fetch_count}"
);
assert!(
fetch_count < 200,
"iter {iter}: fetch count blew up under multi-thread cancellation; got {fetch_count}"
);
}
}
#[test]
#[allow(clippy::too_many_lines)]
fn cancellation_per_call_scope_isolates_subtrees() {
let tmp = tempfile::tempdir().unwrap();
let root_dir = tmp.path().to_path_buf();
let a_dir = root_dir.join("a");
let a1_dir = a_dir.join("a1");
let a2_dir = a1_dir.join("a2");
let a2cyc_dir = a2_dir.join("a2-cyclic");
let b_dir = root_dir.join("b");
let b1_dir = b_dir.join("b1");
let b2_dir = b1_dir.join("b2");
let b3_dir = b2_dir.join("b3");
for d in [&a_dir, &a1_dir, &a2_dir, &a2cyc_dir, &b_dir, &b1_dir, &b2_dir, &b3_dir] {
make_sub_meta_on_disk(d, d.file_name().unwrap().to_str().unwrap());
}
let url_a = "https://example.com/a.git";
let url_a1 = "https://example.com/a1.git";
let url_a2 = "https://example.com/a2.git";
let url_b = "https://example.com/b.git";
let url_b1 = "https://example.com/b1.git";
let url_b2 = "https://example.com/b2.git";
let url_b3 = "https://example.com/b3.git";
let loader = InMemLoader::new()
.with(
root_dir.clone(),
meta_manifest_with("root", vec![child(url_a, "a"), child(url_b, "b")]),
)
.with(a_dir.clone(), meta_manifest_with("a", vec![child(url_a1, "a1")]))
.with(a1_dir.clone(), meta_manifest_with("a1", vec![child(url_a2, "a2")]))
.with(a2_dir.clone(), meta_manifest_with("a2", vec![child(url_a2, "a2-cyclic")]))
.with(a2cyc_dir.clone(), meta_manifest_with("a2-cyclic", vec![]))
.with(b_dir.clone(), meta_manifest_with("b", vec![child(url_b1, "b1")]))
.with(b1_dir.clone(), meta_manifest_with("b1", vec![child(url_b2, "b2")]))
.with(b2_dir.clone(), meta_manifest_with("b2", vec![child(url_b3, "b3")]))
.with(b3_dir.clone(), meta_manifest_with("b3", vec![]));
let backend = InMemGit::new();
let opts = SyncMetaOptions { parallel: Some(2), ..SyncMetaOptions::default() };
let err = sync_meta(&root_dir, &backend, &loader, &opts, &[])
.expect_err("deep cycle inside A must surface CycleDetected");
match err {
TreeError::CycleDetected { chain } => {
let id_a2 = format!("url:{url_a2}");
assert_eq!(
chain.last(),
Some(&id_a2),
"recurring identity must be A2 (the cyclic arm), got chain={chain:?}"
);
}
other => panic!("expected CycleDetected, got {other:?}"),
}
let fetched_dests: Vec<PathBuf> = backend
.calls()
.iter()
.filter_map(|c| match c {
BackendCall::Fetch { dest } => Some(dest.clone()),
_ => None,
})
.collect();
for dest in [&b_dir, &b1_dir, &b2_dir, &b3_dir] {
assert!(
fetched_dests.iter().any(|f| f == dest),
"per-call scope: B's subtree must have been walked despite A's deep cycle; \
missing fetch for {} (observed {fetched_dests:?})",
dest.display()
);
}
}
}