fix: uv tree orphaned roots and premature deduplication

This commit is contained in:
Denizhan Dakılır 2025-12-22 01:47:58 +03:00
parent 137edcf239
commit 3d05d0bd71
2 changed files with 88 additions and 44 deletions

View file

@ -379,40 +379,8 @@ impl<'env> TreeDisplay<'env> {
roots
} else {
let mut edges = vec![];
// Remove any cycles.
let feedback_set: Vec<EdgeIndex> = 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::<Vec<_>>();
// 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() {

View file

@ -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");