diff --git a/crates/uv-distribution/src/metadata/lowering.rs b/crates/uv-distribution/src/metadata/lowering.rs index 4cbc12b95b78a..1401cb09fd82c 100644 --- a/crates/uv-distribution/src/metadata/lowering.rs +++ b/crates/uv-distribution/src/metadata/lowering.rs @@ -56,6 +56,34 @@ impl LoweredRequirement { (None, RequirementOrigin::Project) }; + // If the project declares a non-workspace source for a workspace member (e.g., a Git + // source for standalone use), but the workspace root declares it as a workspace source, + // prefer the workspace root's source. + let (sources, origin) = if matches!(origin, RequirementOrigin::Project) + && workspace.packages().contains_key(&requirement.name) + && sources.is_some_and(|s| s.iter().any(|s| !matches!(s, Source::Workspace { .. }))) + { + if let Some(workspace_source) = workspace.sources().get(&requirement.name) { + if workspace_source.iter().any(|s| { + matches!( + s, + Source::Workspace { + workspace: true, + .. + } + ) + }) { + (Some(workspace_source), RequirementOrigin::Workspace) + } else { + (sources, origin) + } + } else { + (sources, origin) + } + } else { + (sources, origin) + }; + // If the source only applies to a given extra or dependency group, filter it out. let sources = sources.map(|sources| { sources diff --git a/crates/uv/tests/it/lock.rs b/crates/uv/tests/it/lock.rs index 3f9002609e6b5..a4408335974b2 100644 --- a/crates/uv/tests/it/lock.rs +++ b/crates/uv/tests/it/lock.rs @@ -9726,6 +9726,86 @@ fn lock_no_workspace_source() -> Result<()> { Ok(()) } +/// When a workspace member declares a non-workspace source (e.g., Git, path) for another workspace +/// member, but the workspace root declares `workspace = true` for that same member, the workspace +/// root's source should take precedence. This supports the pattern where members declare +/// non-workspace sources (e.g., Git, path) for standalone use, while the workspace root overrides +/// them as workspace sources. +/// +/// See: +#[test] +fn lock_member_non_workspace_source_with_root_workspace_source() -> Result<()> { + let context = uv_test::test_context!("3.12"); + + let root_pyproject = context.temp_dir.child("pyproject.toml"); + root_pyproject.write_str( + r#" + [project] + name = "monorepo" + version = "1.0.0" + requires-python = ">=3.12" + dependencies = ["app-a", "lib-x"] + + [tool.uv.sources] + app-a = { workspace = true } + lib-x = { workspace = true } + + [tool.uv.workspace] + members = ["packages/app-a", "packages/lib-x"] + "#, + )?; + + let app_a = context.temp_dir.child("packages").child("app-a"); + fs_err::create_dir_all(&app_a)?; + + let app_a_pyproject = app_a.child("pyproject.toml"); + app_a_pyproject.write_str( + r#" + [project] + name = "app-a" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["lib-x"] + + [tool.uv.sources] + lib-x = { path = "../../packages/lib-x", editable = false } + + [build-system] + requires = ["uv_build>=0.7,<10000"] + build-backend = "uv_build" + "#, + )?; + + let lib_x = context.temp_dir.child("packages").child("lib-x"); + fs_err::create_dir_all(&lib_x)?; + + let lib_x_pyproject = lib_x.child("pyproject.toml"); + lib_x_pyproject.write_str( + r#" + [project] + name = "lib-x" + version = "1.0.0" + requires-python = ">=3.12" + dependencies = [] + + [build-system] + requires = ["uv_build>=0.7,<10000"] + build-backend = "uv_build" + "#, + )?; + + uv_snapshot!(context.filters(), context.lock(), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 3 packages in [TIME] + "); + + Ok(()) +} + /// Lock a workspace with a member that's a peer to the root. #[test] fn lock_peer_member() -> Result<()> {