Skip to content

Commit a17e9a9

Browse files
authored
fix: solve build source relative to the manifest path (#4863)
1 parent e7b1d97 commit a17e9a9

File tree

7 files changed

+164
-9
lines changed

7 files changed

+164
-9
lines changed

crates/pixi/tests/integration_rust/build_tests.rs

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,3 +340,94 @@ my-package = {{ path = "./my-package" }}
340340
"my-package",
341341
));
342342
}
343+
344+
/// Test that verifies [package.build] source.path is resolved relative to the
345+
/// package manifest directory, not the workspace root.
346+
///
347+
/// This tests the fix for out-of-tree builds where a package manifest
348+
/// specifies `source.path = "src"` and expects it to be resolved relative
349+
/// to the package manifest's parent directory.
350+
#[tokio::test]
351+
async fn test_package_build_source_relative_to_manifest() {
352+
setup_tracing();
353+
354+
// Create a PixiControl instance with PassthroughBackend
355+
let backend_override = BackendOverride::from_memory(PassthroughBackend::instantiator());
356+
let pixi = PixiControl::new()
357+
.unwrap()
358+
.with_backend_override(backend_override);
359+
360+
// Create the package structure:
361+
// workspace/
362+
// pixi.toml (workspace and package manifest)
363+
// src/ (source directory - should be found relative to package manifest)
364+
// pixi.toml (build source manifest)
365+
366+
let package_source_dir = pixi.workspace_path().join("src");
367+
fs::create_dir_all(&package_source_dir).unwrap();
368+
369+
// Create a pixi.toml in the source directory that PassthroughBackend will read
370+
let source_pixi_toml = r#"
371+
[package]
372+
name = "test-build-source"
373+
version = "0.1.0"
374+
375+
[package.build]
376+
backend = { name = "in-memory", version = "0.1.0" }
377+
"#;
378+
fs::write(package_source_dir.join("pixi.toml"), source_pixi_toml).unwrap();
379+
380+
// Create a manifest where the package has [package.build] with source.path
381+
// The source.path should be resolved relative to the package manifest directory
382+
let manifest_content = format!(
383+
r#"
384+
[workspace]
385+
channels = []
386+
platforms = ["{}"]
387+
preview = ["pixi-build"]
388+
389+
[package]
390+
name = "test-build-source"
391+
version = "0.1.0"
392+
description = "Test package for build source path resolution"
393+
394+
[package.build]
395+
backend = {{ name = "in-memory", version = "0.1.0" }}
396+
# This should resolve to <package_manifest_dir>/src, not <workspace_root>/src
397+
source.path = "src"
398+
399+
[dependencies]
400+
test-build-source = {{ path = "." }}
401+
"#,
402+
Platform::current(),
403+
);
404+
405+
// Write the manifest
406+
fs::write(pixi.manifest_path(), manifest_content).unwrap();
407+
408+
// Actually trigger the build process to test the bug
409+
// This will call build_backend_metadata which uses alternative_root
410+
let result = pixi.update_lock_file().await;
411+
412+
// The test should succeed if the source path is resolved correctly
413+
// If the bug exists (manifest_path instead of manifest_path.parent()),
414+
// the build will fail because it will try to find src relative to pixi.toml (a file)
415+
// instead of relative to the directory containing pixi.toml
416+
assert!(
417+
result.is_ok(),
418+
"Lock file update should succeed when source.path is resolved correctly. Error: {:?}",
419+
result.err()
420+
);
421+
422+
let lock_file = result.unwrap();
423+
424+
// Verify the package was built and is in the lock file
425+
assert!(
426+
lock_file.contains_conda_package(
427+
consts::DEFAULT_ENVIRONMENT_NAME,
428+
Platform::current(),
429+
"test-build-source",
430+
),
431+
"Built package should be in the lock file"
432+
);
433+
}

crates/pixi_command_dispatcher/src/build_backend_metadata/mod.rs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,22 @@ impl BuildBackendMetadataSpec {
138138
} else if let Some(build_source) = &discovered_backend.init_params.build_source {
139139
Some(
140140
command_dispatcher
141-
.pin_and_checkout(build_source.clone())
141+
.pin_and_checkout(
142+
build_source.clone(),
143+
Some(
144+
discovered_backend
145+
.init_params
146+
.manifest_path
147+
.parent()
148+
.ok_or_else(|| {
149+
SourceCheckoutError::ParentDir(
150+
discovered_backend.init_params.manifest_path.clone(),
151+
)
152+
})
153+
.map_err(BuildBackendMetadataError::SourceCheckout)
154+
.map_err(CommandDispatcherError::Failed)?,
155+
),
156+
)
142157
.await
143158
.map_err_with(BuildBackendMetadataError::SourceCheckout)?,
144159
)

crates/pixi_command_dispatcher/src/command_dispatcher/mod.rs

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -565,7 +565,20 @@ impl CommandDispatcher {
565565
///
566566
/// This function resolves the source specification to a concrete checkout
567567
/// by:
568-
/// 1. For path sources: Resolving relative paths against the root directory
568+
/// 1. For path sources: Resolving relative paths against the root directory or against an alternative root path
569+
///
570+
/// i.e. in the case of an out-of-tree build.
571+
/// Some examples for different inputs:
572+
/// - `/foo/bar` => `/foo/bar` (absolute paths are unchanged)
573+
/// - `./bar` => `<root_dir>/bar`
574+
/// - `bar` => `<root_dir>/bar` (or `<alternative_root>/bar` if provided)
575+
/// - `../bar` => `<alternative_root>/../bar` (normalized, validated for security)
576+
/// - `~/bar` => `<home_dir>/bar`
577+
///
578+
/// Usually:
579+
/// * `root_dir` => workspace root directory (parent of workspace manifest)
580+
/// * `alternative_root` => package root directory (parent of package manifest)
581+
///
569582
/// 2. For git sources: Cloning or fetching the repository and checking out
570583
/// the specified reference
571584
/// 3. For URL sources: Downloading and extracting the archive (currently
@@ -578,6 +591,7 @@ impl CommandDispatcher {
578591
pub async fn pin_and_checkout(
579592
&self,
580593
source_location_spec: SourceLocationSpec,
594+
alternative_root: Option<&Path>,
581595
) -> Result<SourceCheckout, CommandDispatcherError<SourceCheckoutError>> {
582596
match source_location_spec {
583597
SourceLocationSpec::Url(url) => {
@@ -586,7 +600,7 @@ impl CommandDispatcher {
586600
SourceLocationSpec::Path(path) => {
587601
let source_path = self
588602
.data
589-
.resolve_typed_path(path.path.to_path())
603+
.resolve_typed_path(path.path.to_path(), alternative_root)
590604
.map_err(SourceCheckoutError::from)
591605
.map_err(CommandDispatcherError::Failed)?;
592606
Ok(SourceCheckout {
@@ -618,7 +632,7 @@ impl CommandDispatcher {
618632
PinnedSourceSpec::Path(ref path) => {
619633
let source_path = self
620634
.data
621-
.resolve_typed_path(path.path.to_path())
635+
.resolve_typed_path(path.path.to_path(), None)
622636
.map_err(SourceCheckoutError::from)
623637
.map_err(CommandDispatcherError::Failed)?;
624638
Ok(SourceCheckout {
@@ -653,7 +667,11 @@ impl CommandDispatcherData {
653667
///
654668
/// This function does not check if the path exists and also does not follow
655669
/// symlinks.
656-
fn resolve_typed_path(&self, path_spec: Utf8TypedPath) -> Result<PathBuf, InvalidPathError> {
670+
fn resolve_typed_path(
671+
&self,
672+
path_spec: Utf8TypedPath,
673+
alternative_root: Option<&Path>,
674+
) -> Result<PathBuf, InvalidPathError> {
657675
if path_spec.is_absolute() {
658676
Ok(Path::new(path_spec.as_str()).to_path_buf())
659677
} else if let Ok(user_path) = path_spec.strip_prefix("~/") {
@@ -663,7 +681,20 @@ impl CommandDispatcherData {
663681
debug_assert!(home_dir.is_absolute());
664682
normalize_absolute_path(&home_dir.join(Path::new(user_path.as_str())))
665683
} else {
666-
let root_dir = self.root_dir.as_path();
684+
let root_dir = match alternative_root {
685+
Some(root_path) => {
686+
debug_assert!(
687+
root_path.is_absolute(),
688+
"alternative_root must be absolute, got: {root_path:?}"
689+
);
690+
debug_assert!(
691+
!root_path.is_file(),
692+
"alternative_root should be a directory, not a file: {root_path:?}"
693+
);
694+
root_path
695+
}
696+
None => self.root_dir.as_path(),
697+
};
667698
let native_path = Path::new(path_spec.as_str());
668699
debug_assert!(root_dir.is_absolute());
669700
normalize_absolute_path(&root_dir.join(native_path))

crates/pixi_command_dispatcher/src/solve_pixi/source_metadata_collector.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ impl SourceMetadataCollector {
171171
// will pick build_source; we only override the build pin later.
172172
let source = self
173173
.command_queue
174-
.pin_and_checkout(spec.location)
174+
.pin_and_checkout(spec.location, None)
175175
.await
176176
.map_err(|err| CollectSourceMetadataError::SourceCheckoutError {
177177
name: name.as_source().to_string(),

crates/pixi_command_dispatcher/src/source_build/mod.rs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -267,7 +267,22 @@ impl SourceBuildSpec {
267267
discovered_backend.init_params.build_source.clone()
268268
{
269269
let build_source_checkout = command_dispatcher
270-
.pin_and_checkout(manifest_build_source)
270+
.pin_and_checkout(
271+
manifest_build_source,
272+
Some(
273+
discovered_backend
274+
.init_params
275+
.manifest_path
276+
.parent()
277+
.ok_or_else(|| {
278+
SourceCheckoutError::ParentDir(
279+
discovered_backend.init_params.manifest_path.clone(),
280+
)
281+
})
282+
.map_err(SourceBuildError::SourceCheckout)
283+
.map_err(CommandDispatcherError::Failed)?,
284+
),
285+
)
271286
.await
272287
.map_err_with(SourceBuildError::SourceCheckout)?;
273288
build_source_checkout.path

crates/pixi_command_dispatcher/src/source_checkout/mod.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ pub enum SourceCheckoutError {
3232

3333
#[error(transparent)]
3434
GitError(#[from] GitError),
35+
36+
#[error("the manifest path {0} should have a parent directory")]
37+
ParentDir(PathBuf),
3538
}
3639

3740
#[derive(Debug, Error)]

crates/pixi_global/src/project/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1372,7 +1372,7 @@ impl Project {
13721372
) -> Result<PackageName, InferPackageNameError> {
13731373
let command_dispatcher = self.command_dispatcher()?;
13741374
let checkout = command_dispatcher
1375-
.pin_and_checkout(source_spec.location)
1375+
.pin_and_checkout(source_spec.location, None)
13761376
.await
13771377
.map_err(|e| InferPackageNameError::BuildBackendMetadata(Box::new(e)))?;
13781378

0 commit comments

Comments
 (0)