diff --git a/crates/uv-resolver/src/lock/tree.rs b/crates/uv-resolver/src/lock/tree.rs index d850c2f4c..c490fcc73 100644 --- a/crates/uv-resolver/src/lock/tree.rs +++ b/crates/uv-resolver/src/lock/tree.rs @@ -379,40 +379,8 @@ impl<'env> TreeDisplay<'env> { roots } else { - let mut edges = vec![]; - - // Remove any cycles. - let feedback_set: Vec = petgraph::algo::greedy_feedback_arc_set(&graph) - .map(|e| e.id()) - .collect(); - for edge_id in feedback_set { - if let Some((source, target)) = graph.edge_endpoints(edge_id) { - if let Some(weight) = graph.remove_edge(edge_id) { - edges.push((source, target, weight)); - } - } - } - - // Find the root nodes: nodes with no incoming edges, or only an edge from the proxy. - let mut roots = graph - .node_indices() - .filter(|index| { - graph - .edges_directed(*index, Direction::Incoming) - .next() - .is_none() - }) - .collect::>(); - - // Sort the roots. - roots.sort_by_key(|index| &graph[*index]); - - // Re-add the removed edges. - for (source, target, weight) in edges { - graph.add_edge(source, target, weight); - } - - roots + // Use the root node directly. + vec![root] } }; @@ -527,16 +495,19 @@ impl<'env> TreeDisplay<'env> { let mut lines = vec![line]; // Keep track of the dependency path to avoid cycles. - visited.insert( - package_id, - dependencies - .iter() - .filter_map(|node| match self.graph[node.node()] { - Node::Package(package_id) => Some(package_id), - Node::Root => None, - }) - .collect(), - ); + // Only mark as visited if we're going to expand children (not at depth limit). + if path.len() < self.depth { + visited.insert( + package_id, + dependencies + .iter() + .filter_map(|node| match self.graph[node.node()] { + Node::Package(package_id) => Some(package_id), + Node::Root => None, + }) + .collect(), + ); + } path.push(package_id); for (index, dep) in dependencies.iter().enumerate() { diff --git a/crates/uv/tests/it/tree.rs b/crates/uv/tests/it/tree.rs index b58313c6a..9783fde52 100644 --- a/crates/uv/tests/it/tree.rs +++ b/crates/uv/tests/it/tree.rs @@ -1004,6 +1004,79 @@ fn cycle() -> Result<()> { Ok(()) } +#[test] +fn cycle_no_orphaned_roots() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["apache-airflow"] + "#, + )?; + + // With --depth 1, only project should appear as a root + uv_snapshot!(context.filters(), context.tree().arg("--universal").arg("--depth").arg("1"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + project v0.1.0 + └── apache-airflow v2.8.3 + + ----- stderr ----- + Resolved 135 packages in [TIME] + "###); + + Ok(()) +} + +#[test] +fn cycle_no_infinite_loop() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["testtools==2.3.0", "fixtures==3.0.0"] + "#, + )?; + + // This should complete without hanging, and cycles should be marked with (*) + uv_snapshot!(context.filters(), context.tree().arg("--universal").arg("--depth").arg("2"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + project v0.1.0 + ├── fixtures v3.0.0 + │ ├── pbr v6.0.0 + │ ├── six v1.16.0 + │ └── testtools v2.3.0 + └── testtools v2.3.0 + ├── extras v1.0.0 + ├── fixtures v3.0.0 (*) + ├── pbr v6.0.0 + ├── python-mimeparse v1.6.0 + ├── six v1.16.0 + ├── traceback2 v1.4.0 + └── unittest2 v1.1.0 + (*) Package tree already displayed + + ----- stderr ----- + Resolved 11 packages in [TIME] + "### + ); + + Ok(()) +} + #[test] fn workspace_dev() -> Result<()> { let context = TestContext::new("3.12");