diff --git a/Cargo.lock b/Cargo.lock index 727e3e3801..849fac4081 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5178,7 +5178,6 @@ dependencies = [ "pixi_build_discovery", "pixi_build_types", "rattler_conda_types", - "schemars 1.1.0", "serde", "serde_json", "thiserror 2.0.17", @@ -5757,6 +5756,7 @@ dependencies = [ "serde", "tempfile", "thiserror 2.0.17", + "typed-path", ] [[package]] @@ -5809,13 +5809,12 @@ dependencies = [ name = "pixi_record" version = "0.1.0" dependencies = [ - "dunce", "file_url", "insta", "itertools 0.14.0", "miette 7.6.0", - "pathdiff", "pixi_git", + "pixi_path", "pixi_spec", "pixi_spec_containers", "pixi_variant", @@ -5823,7 +5822,6 @@ dependencies = [ "rattler_digest", "rattler_lock", "serde", - "serde_json", "serde_with", "thiserror 2.0.17", "typed-path", @@ -5871,6 +5869,7 @@ dependencies = [ "itertools 0.14.0", "pixi_consts", "pixi_git", + "pixi_path", "pixi_toml", "rattler_conda_types", "rattler_digest", diff --git a/crates/pixi_build_frontend/Cargo.toml b/crates/pixi_build_frontend/Cargo.toml index daa0f5d9f6..8874f1ef03 100644 --- a/crates/pixi_build_frontend/Cargo.toml +++ b/crates/pixi_build_frontend/Cargo.toml @@ -24,7 +24,6 @@ thiserror = { workspace = true } tokio = { workspace = true, features = ["process", "io-std"] } tokio-util = { workspace = true, features = ["codec"] } tracing = { workspace = true } -schemars = { workspace = true, optional = true } [dev-dependencies] diff --git a/crates/pixi_command_dispatcher/src/build/mod.rs b/crates/pixi_command_dispatcher/src/build/mod.rs index 5ec6b1b08c..13c7ae9061 100644 --- a/crates/pixi_command_dispatcher/src/build/mod.rs +++ b/crates/pixi_command_dispatcher/src/build/mod.rs @@ -21,7 +21,7 @@ pub use dependencies::{ Dependencies, DependenciesError, DependencySource, KnownEnvironment, PixiRunExports, WithSource, }; pub(crate) use move_file::{MoveError, move_file}; -use pixi_record::PinnedSourceSpec; +use pixi_record::{PinnedBuildSourceSpec, PinnedSourceSpec}; use serde::{Deserialize, Serialize}; use url::Url; pub use work_dir_key::{SourceRecordOrCheckout, WorkDirKey}; @@ -44,11 +44,14 @@ pub struct SourceCodeLocation { /// The location of the manifest and the possible source code manifest_source: PinnedSourceSpec, /// The location of the source code that should be queried and build - build_source: Option, + build_source: Option, } impl SourceCodeLocation { - pub fn new(manifest_source: PinnedSourceSpec, build_source: Option) -> Self { + pub fn new( + manifest_source: PinnedSourceSpec, + build_source: Option, + ) -> Self { Self { manifest_source, build_source, @@ -64,17 +67,20 @@ impl SourceCodeLocation { /// This is the normally the path to the manifest_source /// but when set is the path to the build_source pub fn source_code(&self) -> &PinnedSourceSpec { - self.build_source.as_ref().unwrap_or(&self.manifest_source) + self.build_source + .as_ref() + .map(PinnedBuildSourceSpec::pinned) + .unwrap_or(&self.manifest_source) } /// Get the optional explicit build source override. - pub fn build_source(&self) -> Option<&PinnedSourceSpec> { + pub fn build_source(&self) -> Option<&PinnedBuildSourceSpec> { self.build_source.as_ref() } pub fn as_source_and_alternative_root(&self) -> (&PinnedSourceSpec, Option<&PinnedSourceSpec>) { if let Some(build_source) = &self.build_source { - (build_source, Some(&self.manifest_source)) + (build_source.pinned(), Some(&self.manifest_source)) } else { (&self.manifest_source, None) } diff --git a/crates/pixi_command_dispatcher/src/build_backend_metadata/mod.rs b/crates/pixi_command_dispatcher/src/build_backend_metadata/mod.rs index 569010a27d..bf4b1d66d3 100644 --- a/crates/pixi_command_dispatcher/src/build_backend_metadata/mod.rs +++ b/crates/pixi_command_dispatcher/src/build_backend_metadata/mod.rs @@ -5,7 +5,7 @@ use pixi_build_discovery::{CommandSpec, EnabledProtocols}; use pixi_build_frontend::Backend; use pixi_build_types::procedures::conda_outputs::CondaOutputsParams; use pixi_glob::GlobSet; -use pixi_record::{PinnedSourceSpec, VariantValue}; +use pixi_record::{PinnedBuildSourceSpec, PinnedSourceSpec, VariantValue}; use pixi_spec::{SourceAnchor, SourceLocationSpec}; use rattler_conda_types::{ChannelConfig, ChannelUrl}; use std::time::SystemTime; @@ -32,6 +32,7 @@ use crate::{ use pixi_build_discovery::BackendSpec; use pixi_build_frontend::BackendOverride; use pixi_path::AbsPath; +use pixi_path::normalize::normalize_typed; static WARNED_BACKENDS: Lazy>> = Lazy::new(|| Mutex::new(HashSet::new())); @@ -139,33 +140,52 @@ impl BuildBackendMetadataSpec { let build_source_checkout = match &discovered_backend.init_params.build_source { None => None, Some(build_source) => { - // An out of tree source is provided. Resolve it against the manifest source. + let relative_build_source_spec = if let SourceLocationSpec::Path(path) = + build_source + && path.path.is_relative() + { + Some(normalize_typed(path.path.to_path()).to_string()) + } else { + None + }; + + // An out-of-tree source is provided. Resolve it against the manifest source. let resolved_location = manifest_source_anchor.resolve(build_source.clone()); // Check if we have a preferred build source that matches this same location match &self.preferred_build_source { - Some(pinned) if pinned.matches_source_spec(&resolved_location) => Some( + Some(pinned) if pinned.matches_source_spec(&resolved_location) => Some(( command_dispatcher .checkout_pinned_source(pinned.clone()) .await .map_err_with(BuildBackendMetadataError::SourceCheckout)?, - ), - _ => Some( + relative_build_source_spec, + )), + _ => Some(( command_dispatcher .pin_and_checkout(resolved_location) .await .map_err_with(BuildBackendMetadataError::SourceCheckout)?, - ), + relative_build_source_spec, + )), } } }; - let (build_source_checkout, build_source) = if let Some(checkout) = build_source_checkout { - let pinned = checkout.pinned.clone(); - (checkout, Some(pinned)) - } else { - (manifest_source_checkout.clone(), None) - }; + let (build_source_checkout, build_source) = + if let Some((checkout, relative_build_source)) = build_source_checkout { + let pinned = checkout.pinned.clone(); + ( + checkout, + Some(if let Some(relative) = relative_build_source { + PinnedBuildSourceSpec::Relative(relative, pinned) + } else { + PinnedBuildSourceSpec::Absolute(pinned) + }), + ) + } else { + (manifest_source_checkout.clone(), None) + }; let manifest_source_location = SourceCodeLocation::new( manifest_source_checkout.pinned.clone(), build_source.clone(), diff --git a/crates/pixi_command_dispatcher/src/source_build/mod.rs b/crates/pixi_command_dispatcher/src/source_build/mod.rs index 160bceb13f..e7e3fc330e 100644 --- a/crates/pixi_command_dispatcher/src/source_build/mod.rs +++ b/crates/pixi_command_dispatcher/src/source_build/mod.rs @@ -10,7 +10,7 @@ use pixi_build_discovery::EnabledProtocols; use pixi_build_frontend::Backend; use pixi_build_types::procedures::conda_outputs::CondaOutputsParams; use pixi_path::AbsPath; -use pixi_record::{PinnedSourceSpec, PixiRecord, VariantValue}; +use pixi_record::{PinnedBuildSourceSpec, PinnedSourceSpec, PixiRecord, VariantValue}; use pixi_spec::{SourceAnchor, SourceLocationSpec}; use rattler_conda_types::{ ChannelConfig, ChannelUrl, ConvertSubdirError, InvalidPackageNameError, PackageRecord, @@ -255,7 +255,7 @@ impl SourceBuildSpec { // manifest so we check out the correct directory. let mut build_source = self.source.build_source().cloned(); if let (Some(PinnedSourceSpec::Git(pinned_git)), Some(SourceLocationSpec::Git(git_spec))) = ( - build_source.as_mut(), + build_source.as_mut().map(PinnedBuildSourceSpec::pinned_mut), discovered_backend.init_params.build_source.clone(), ) && pinned_git.source.subdirectory.is_none() { @@ -268,7 +268,7 @@ impl SourceBuildSpec { // 3. Manifest source. Just assume that source is located at the same directory as the manifest. let build_source_checkout = if let Some(pinned_build_source) = build_source { &command_dispatcher - .checkout_pinned_source(pinned_build_source) + .checkout_pinned_source(pinned_build_source.into_pinned()) .await .map_err_with(SourceBuildError::SourceCheckout)? } else if let Some(manifest_build_source) = diff --git a/crates/pixi_core/src/lock_file/satisfiability/mod.rs b/crates/pixi_core/src/lock_file/satisfiability/mod.rs index b9afeb6774..18655b0d2b 100644 --- a/crates/pixi_core/src/lock_file/satisfiability/mod.rs +++ b/crates/pixi_core/src/lock_file/satisfiability/mod.rs @@ -23,8 +23,8 @@ use pixi_manifest::{ pypi::pypi_options::{NoBuild, PrereleaseMode}, }; use pixi_record::{ - DevSourceRecord, LockedGitUrl, ParseLockFileError, PinnedSourceSpec, PixiRecord, - SourceMismatchError, SourceRecord, VariantValue, + DevSourceRecord, LockedGitUrl, ParseLockFileError, PinnedBuildSourceSpec, PinnedSourceSpec, + PixiRecord, SourceMismatchError, SourceRecord, VariantValue, }; use pixi_spec::{PixiSpec, SourceAnchor, SourceLocationSpec, SourceSpec, SpecConversionError}; use pixi_utils::variants::VariantConfig; @@ -1100,7 +1100,10 @@ async fn verify_source_metadata( package: source_record.package_record.name.clone(), backend_metadata: BuildBackendMetadataSpec { manifest_source: source_record.manifest_source.clone(), - preferred_build_source: source_record.build_source.clone(), + preferred_build_source: source_record + .build_source + .clone() + .map(PinnedBuildSourceSpec::into_pinned), channel_config, channels: channel_urls, build_environment: BuildEnvironment { @@ -2298,7 +2301,10 @@ fn verify_build_source_matches_manifest( )) }; - match (manifest_source_location, lockfile_source_location) { + match ( + manifest_source_location, + lockfile_source_location.map(PinnedBuildSourceSpec::into_pinned), + ) { (None, None) => ok, (Some(SourceLocationSpec::Url(murl_spec)), Some(PinnedSourceSpec::Url(lurl_spec))) => { lurl_spec.satisfies(&murl_spec).map_err(sat_err) diff --git a/crates/pixi_core/src/lock_file/update.rs b/crates/pixi_core/src/lock_file/update.rs index d11598b122..5bea5f2455 100644 --- a/crates/pixi_core/src/lock_file/update.rs +++ b/crates/pixi_core/src/lock_file/update.rs @@ -1625,7 +1625,9 @@ impl<'p> UpdateContext<'p> { PixiRecord::Source(src) => { let name = src.package_record.name.clone(); if targets.contains(name.as_source()) { - src.build_source.clone().map(|spec| (name, spec)) + src.build_source + .clone() + .map(|spec| (name, spec.into_pinned())) } else { None } diff --git a/crates/pixi_path/Cargo.toml b/crates/pixi_path/Cargo.toml index 4b5655b376..0fd3969840 100644 --- a/crates/pixi_path/Cargo.toml +++ b/crates/pixi_path/Cargo.toml @@ -13,6 +13,7 @@ version = "0.1.0" fs-err = { workspace = true } serde = { workspace = true, features = ["derive"] } thiserror = { workspace = true } +typed-path = { workspace = true } [dev-dependencies] tempfile = { workspace = true } diff --git a/crates/pixi_path/src/lib.rs b/crates/pixi_path/src/lib.rs index 744f84bfaf..ab935c25a4 100644 --- a/crates/pixi_path/src/lib.rs +++ b/crates/pixi_path/src/lib.rs @@ -51,6 +51,8 @@ use std::ops::Deref; use std::path::{Component, Path, PathBuf}; use thiserror::Error; +pub mod normalize; + /// Error type for path validation failures. #[derive(Debug, Error)] pub enum PathError { diff --git a/crates/pixi_path/src/normalize.rs b/crates/pixi_path/src/normalize.rs new file mode 100644 index 0000000000..2a0b5af697 --- /dev/null +++ b/crates/pixi_path/src/normalize.rs @@ -0,0 +1,103 @@ +use typed_path::{ + Utf8Component, Utf8Encoding, Utf8Path, Utf8PathBuf, Utf8TypedPath, Utf8TypedPathBuf, +}; + +/// A slightly modified version of [`Utf8TypedPath::normalize`] that retains +/// `..` components that lead outside the path. +pub fn normalize_typed(path: Utf8TypedPath<'_>) -> Utf8TypedPathBuf { + match path { + Utf8TypedPath::Unix(path) => Utf8TypedPathBuf::Unix(normalize(path)), + Utf8TypedPath::Windows(path) => Utf8TypedPathBuf::Windows(normalize(path)), + } +} + +/// A slightly modified version of [`Utf8Path::normalize`] that retains `..` +/// components that lead outside the path. +fn normalize(path: &Utf8Path) -> Utf8PathBuf { + let mut components = Vec::new(); + for component in path.components() { + if !component.is_current() && !component.is_parent() { + components.push(component); + } else if component.is_parent() { + if let Some(last) = components.last() { + if last.is_normal() { + components.pop(); + } else { + components.push(component); + } + } else { + components.push(component); + } + } + } + + let mut path = Utf8PathBuf::::new(); + + for component in components { + path.push(component.as_str()); + } + + path +} + +#[cfg(test)] +mod tests { + use super::*; + use typed_path::Utf8TypedPath; + + #[test] + fn test_normalize_collapses_parent_in_middle() { + // a/b/../c -> a/c + let path = Utf8TypedPath::derive("a/b/../c"); + let normalized = normalize_typed(path); + assert_eq!(normalized.as_str(), "a/c"); + } + + #[test] + fn test_normalize_retains_leading_parent() { + // ../a -> ../a (cannot collapse leading ..) + let path = Utf8TypedPath::derive("../a"); + let normalized = normalize_typed(path); + assert_eq!(normalized.as_str(), "../a"); + } + + #[test] + fn test_normalize_retains_multiple_leading_parents() { + // ../../a/b -> ../../a/b + let path = Utf8TypedPath::derive("../../a/b"); + let normalized = normalize_typed(path); + assert_eq!(normalized.as_str(), "../../a/b"); + } + + #[test] + fn test_normalize_collapses_current_dir() { + // a/./b -> a/b + let path = Utf8TypedPath::derive("a/./b"); + let normalized = normalize_typed(path); + assert_eq!(normalized.as_str(), "a/b"); + } + + #[test] + fn test_normalize_complex_path() { + // a/b/c/../../d -> a/d + let path = Utf8TypedPath::derive("a/b/c/../../d"); + let normalized = normalize_typed(path); + assert_eq!(normalized.as_str(), "a/d"); + } + + #[test] + fn test_normalize_parent_escapes_base() { + // a/../.. -> .. (goes outside the base) + let path = Utf8TypedPath::derive("a/../.."); + let normalized = normalize_typed(path); + assert_eq!(normalized.as_str(), ".."); + } + + #[test] + fn test_normalize_relative_going_up_then_down() { + // ../sibling/subdir -> ../sibling/subdir + let path = Utf8TypedPath::derive("../sibling/subdir"); + let normalized = normalize_typed(path); + assert_eq!(normalized.as_str(), "../sibling/subdir"); + } +} diff --git a/crates/pixi_record/Cargo.toml b/crates/pixi_record/Cargo.toml index a8dee62546..1508ee0e94 100644 --- a/crates/pixi_record/Cargo.toml +++ b/crates/pixi_record/Cargo.toml @@ -10,11 +10,9 @@ repository.workspace = true version = "0.1.0" [dependencies] -dunce = { workspace = true } file_url = { workspace = true } itertools = { workspace = true } miette = { workspace = true } -pathdiff = { workspace = true } pixi_git = { workspace = true } pixi_spec = { workspace = true, features = ["rattler_lock"] } pixi_spec_containers = { workspace = true } @@ -22,6 +20,7 @@ pixi_variant = { workspace = true, features = [ "rattler_lock", "pixi_build_types", ] } +pixi_path = { workspace = true } rattler_conda_types = { workspace = true } rattler_digest = { workspace = true, features = ["serde"] } rattler_lock = { workspace = true } @@ -32,5 +31,4 @@ typed-path = { workspace = true } url = { workspace = true } [dev-dependencies] -insta = { workspace = true, features = ["yaml"] } -serde_json = { workspace = true } +insta = { workspace = true, features = ["yaml"] } \ No newline at end of file diff --git a/crates/pixi_record/src/lib.rs b/crates/pixi_record/src/lib.rs index 21dfb43aa0..a43054e04c 100644 --- a/crates/pixi_record/src/lib.rs +++ b/crates/pixi_record/src/lib.rs @@ -3,7 +3,6 @@ mod pinned_source; mod source_record; pub use dev_source_record::DevSourceRecord; -mod path_utils; use std::path::Path; @@ -17,7 +16,7 @@ use rattler_conda_types::{ }; use rattler_lock::{CondaPackageData, ConversionError, UrlOrPath}; use serde::Serialize; -pub use source_record::SourceRecord; +pub use source_record::{PinnedBuildSourceSpec, SourceRecord}; use thiserror::Error; /// A record of a conda package that is either something installable from a diff --git a/crates/pixi_record/src/path_utils.rs b/crates/pixi_record/src/path_utils.rs deleted file mode 100644 index bd6efccc70..0000000000 --- a/crates/pixi_record/src/path_utils.rs +++ /dev/null @@ -1,95 +0,0 @@ -use std::path::{Component, Path, PathBuf}; - -use typed_path::{Utf8UnixPathBuf, Utf8WindowsPathBuf}; - -/// Normalize a path lexically (no filesystem access) and strip redundant segments. -pub(crate) fn normalize_path(path: &Path) -> PathBuf { - let simplified = dunce::simplified(path).to_path_buf(); - - let mut prefix: Option = None; - let mut has_root = false; - let mut parts: Vec = Vec::new(); - - for component in simplified.components() { - match component { - Component::Prefix(prefix_component) => { - prefix = Some(prefix_component.as_os_str().to_os_string()); - parts.clear(); - } - Component::RootDir => { - has_root = true; - parts.clear(); - } - Component::CurDir => {} - Component::ParentDir => { - if let Some(last) = parts.last() { - if last.as_os_str() == std::ffi::OsStr::new("..") { - parts.push(std::ffi::OsString::from("..")); - } else { - parts.pop(); - } - } else if !has_root { - parts.push(std::ffi::OsString::from("..")); - } - } - Component::Normal(part) => parts.push(part.to_os_string()), - } - } - - let mut normalized = PathBuf::new(); - if let Some(prefix) = prefix { - normalized.push(prefix); - } - if has_root { - normalized.push(std::path::MAIN_SEPARATOR.to_string()); - } - for part in parts { - normalized.push(part); - } - - normalized -} - -/// Make sure the path we get back out is always unix compatible -pub(crate) fn unixify_relative_path(path: &Path) -> Utf8UnixPathBuf { - // This function should only be called with relative paths - debug_assert!( - !path.is_absolute(), - "unixify_path should only be called with relative paths, got: {path:?}", - ); - - // Parse as Windows path to handle backslashes correctly, then convert to Unix - // because windows also supports forward slashes this should be okay! - Utf8WindowsPathBuf::from(path.to_string_lossy().into_owned()).with_unix_encoding() -} - -#[cfg(test)] -mod tests { - use std::str::FromStr; - - use super::*; - - #[test] - fn normalize_path_collapses_parent_segments() { - let normalized = normalize_path(Path::new("recipes/../")); - assert!(normalized.as_os_str().is_empty()); - } - - #[test] - fn unixify_windows_path() { - let win = PathBuf::from_str("my-windows\\path").unwrap(); - assert_eq!( - unixify_relative_path(&win).to_string(), - "my-windows/path".to_string() - ); - } - - #[test] - fn unixify_unix_path() { - let unix = PathBuf::from_str("my-unix/path").unwrap(); - assert_eq!( - unixify_relative_path(&unix).to_string(), - "my-unix/path".to_string() - ); - } -} diff --git a/crates/pixi_record/src/pinned_source.rs b/crates/pixi_record/src/pinned_source.rs index 2651c5ab58..363f365ffa 100644 --- a/crates/pixi_record/src/pinned_source.rs +++ b/crates/pixi_record/src/pinned_source.rs @@ -11,17 +11,16 @@ use pixi_git::{ sha::GitSha, url::{RepositoryUrl, redact_credentials}, }; +use pixi_path::normalize::normalize_typed; use pixi_spec::{GitReference, GitSpec, PathSourceSpec, SourceLocationSpec, UrlSourceSpec}; use rattler_digest::{Md5Hash, Sha256Hash}; use rattler_lock::UrlOrPath; use serde::{Deserialize, Serialize}; use serde_with::serde_as; use thiserror::Error; -use typed_path::{Utf8TypedPathBuf, Utf8UnixPathBuf}; +use typed_path::{Utf8TypedPath, Utf8TypedPathBuf}; use url::Url; -use crate::path_utils::unixify_relative_path; - /// Describes an exact revision of a source checkout. This is used to pin a /// particular source definition to a revision. A git source spec does not /// describe an exact commit. This struct describes an exact commit. @@ -214,167 +213,12 @@ impl PinnedSourceSpec { } } - /// Resolves a relative path from the lock file back into a full pinned - /// source spec. This is the inverse of `make_relative_to`. - /// - /// Given a relative path (typically from a lock file's `build_source`) and - /// a base pinned source (typically the `manifest_source`), this - /// reconstructs the full pinned source spec. - /// - /// Returns `None` if: - /// - The base is not a compatible type - /// - The path cannot be resolved - /// - /// # Arguments - /// * `build_source_path` - The possibly relative path from the lock file - /// * `base` - The base pinned source to resolve against (typically the - /// manifest_source) - /// * `workspace_root` - The workspace root directory - pub fn from_relative_to( - build_source_path: Utf8UnixPathBuf, - base: &PinnedSourceSpec, - workspace_root: &Path, - ) -> Option { - match base { - // Path-to-Path: Resolve the relative path against the base path - PinnedSourceSpec::Path(base_path) => { - match ( - base_path.path.is_absolute(), - build_source_path.is_absolute(), - ) { - // Both are absolute cannot do anything with these - (true, true) => return None, - // The source path is in a completely different location, - // so we need to return None as we cannot make this relative to the base - (false, true) => return None, - // In this case the source is relative to the absolute base path - // because the `base_path.resolve` will not do anything with absolute paths, - // we should be fine - (true, false) => {} - // Both are relative, so we can just continue - (false, false) => {} - } - - let base_absolute = base_path.resolve(workspace_root); - // We know that possible_relative_path is relative here - let relative_std_path = Path::new(build_source_path.as_str()); - // Join base with relative path to get the target absolute path - let target_path_abs = base_absolute.join(relative_std_path); - - // Normalize the path (resolve . and ..) - let normalized = crate::path_utils::normalize_path(&target_path_abs); - // Convert back to a path that's either absolute or relative to workspace - let path_spec = normalized.strip_prefix(workspace_root).expect( - "the workspace_root should be part of the source build path at this point", - ); - - Some(PinnedSourceSpec::Path(PinnedPathSpec { - path: Utf8TypedPathBuf::from(path_spec.to_string_lossy().as_ref()), - })) - } - - // Git-to-Git: If same repository, convert relative path to subdirectory - PinnedSourceSpec::Git(base_git) => { - // Base subdirectory - let base_subdir = base_git.source.subdirectory.as_deref().unwrap_or(""); - let base_path = Path::new(base_subdir); - - let relative_std_path = Path::new(build_source_path.as_str()); - - // `relative_std_path` is relative to the base_subdir, we want it relative to - // the repository root, because base_subdir is relative to the repo - // we should be able to join - let target_subdir = base_path.join(relative_std_path); - // Normalize the path, join does not do this per-se - let normalized = crate::path_utils::normalize_path(&target_subdir); - - // Convert to string for subdirectory - let subdir_str = normalized.to_string_lossy(); - let subdirectory = if subdir_str.is_empty() { - None - } else { - Some(subdir_str.into_owned()) - }; - - Some(PinnedSourceSpec::Git(PinnedGitSpec { - git: base_git.git.clone(), - source: PinnedGitCheckout { - commit: base_git.source.commit, - subdirectory, - reference: base_git.source.reference.clone(), - }, - })) - } - - PinnedSourceSpec::Url(_) => unreachable!("url specs have not been implemented"), - } - } - - /// Makes this pinned source relative to another pinned source if both are - /// path sources or both are git sources pointing to the same - /// repository. This is useful for making `build_source` relative to - /// `manifest_source` in lock files. - /// - /// Returns `None` if: - /// - Not a compatible combination (different types or different git repos) - /// - The sources cannot be made relative to each other - /// - /// # Arguments - /// * `base` - The base pinned source to make this path relative to - /// (typically the manifest_source) - pub fn make_relative_to( - &self, - base: &PinnedSourceSpec, - workspace_root: &Path, - ) -> Option { - match (self, base) { - // Path-to-Path: Make the path relative - (PinnedSourceSpec::Path(this_path), PinnedSourceSpec::Path(base_path)) => { - let this_path = this_path.resolve(workspace_root); - let base_path = base_path.resolve(workspace_root); - - let relative_path = pathdiff::diff_paths(this_path, base_path)?; - - // `pathdiff` yields native separators; convert to `/` for lock-file stability. - Some(Utf8UnixPathBuf::from(unixify_relative_path( - relative_path.as_path(), - ))) - } - // Git-to-Git: If same repository, convert to a relative path based on subdirectories - (PinnedSourceSpec::Git(this_git), PinnedSourceSpec::Git(base_git)) => { - // Check if both point to the same repository - let this_repo = RepositoryUrl::new(&this_git.git); - let base_repo = RepositoryUrl::new(&base_git.git); - - if this_repo != base_repo { - // Different repositories, can't make relative - return None; - } - - if this_git.source.commit != base_git.source.commit { - return None; - } - - // Same repository and commit - compute relative path between subdirectories - // Both subdirectories are relative to the repository root - let base_subdir = base_git.source.subdirectory.as_deref().unwrap_or(""); - let this_subdir = this_git.source.subdirectory.as_deref().unwrap_or(""); - - // Compute the relative path from base to this - let base_path = std::path::Path::new(base_subdir); - let this_path = std::path::Path::new(this_subdir); - - let relative = pathdiff::diff_paths(this_path, base_path)?; - // Same here: ensure lock only contains `/` even when diff runs on Windows - // paths. - let relative_str = unixify_relative_path(relative.as_path()); - - Some(Utf8UnixPathBuf::from(relative_str)) - } - (PinnedSourceSpec::Url(_), _) => unreachable!("url specs have not been implemented"), - (_, PinnedSourceSpec::Url(_)) => unreachable!("url specs have not been implemented"), - // Different types or incompatible sources - _ => None, + /// Returns a new pinned instance but with a different subdirectory. + pub fn join(&self, path: Utf8TypedPath<'_>) -> Self { + match self { + PinnedSourceSpec::Url(pinned) => PinnedSourceSpec::Url(pinned.join(path)), + PinnedSourceSpec::Git(pinned) => PinnedSourceSpec::Git(pinned.join(path)), + PinnedSourceSpec::Path(pinned) => PinnedSourceSpec::Path(pinned.join(path)), } } } @@ -429,6 +273,24 @@ impl PinnedUrlSpec { .append_pair("sha256", &format!("{:x}", self.sha256)); url } + + /// Construct a new pinned instance but with a different subdirectory. + pub fn join(&self, path: Utf8TypedPath<'_>) -> Self { + Self { + url: self.url.clone(), + sha256: self.sha256, + md5: self.md5, + subdirectory: match self.subdirectory.clone() { + Some(subdir) => Some( + Utf8TypedPath::from(subdir.as_str()) + .join(path) + .normalize() + .to_string(), + ), + None => Some(path.normalize().to_string()), + }, + } + } } impl From for PinnedSourceSpec { @@ -599,6 +461,26 @@ impl PinnedGitSpec { LockedGitUrl(url) } + + /// Construct a new pinned instance but with a different subdirectory. + pub fn join(&self, path: Utf8TypedPath<'_>) -> Self { + Self { + git: self.git.clone(), + source: PinnedGitCheckout { + commit: self.source.commit, + subdirectory: match &self.source.subdirectory { + None => Some(path.normalize().to_string()), + Some(subdir) => Some( + Utf8TypedPath::from(subdir.as_str()) + .join(path) + .normalize() + .to_string(), + ), + }, + reference: self.source.reference.clone(), + }, + } + } } impl From for PinnedSourceSpec { @@ -642,6 +524,13 @@ impl PinnedPathSpec { }; Url::from_directory_path(resolved).expect("expected valid URL") } + + /// Returns a new pinned instance but with a different subdirectory. + pub fn join(&self, path: Utf8TypedPath<'_>) -> Self { + Self { + path: normalize_typed(self.path.join(path).to_path()), + } + } } impl From for PinnedSourceSpec { @@ -1059,16 +948,15 @@ impl From for GitSpec { #[cfg(test)] mod tests { - use std::{path::Path, str::FromStr}; + use std::str::FromStr; use pixi_git::sha::GitSha; use pixi_spec::{GitReference, GitSpec}; use url::Url; - use crate::{ - PinnedGitCheckout, PinnedGitSpec, PinnedPathSpec, PinnedSourceSpec, PinnedUrlSpec, - SourceMismatchError, - }; + use crate::{PinnedGitCheckout, PinnedGitSpec, PinnedUrlSpec, SourceMismatchError}; + + use crate::{PinnedPathSpec, PinnedSourceSpec}; #[test] fn test_spec_satisfies() { @@ -1601,79 +1489,48 @@ mod tests { } #[test] - fn test_relative_to_relative() { - // Both paths are relative - after resolution they become absolute, then - // relative path is computed - let workspace_root = Path::new("/workspace"); - - let this_spec = PinnedSourceSpec::Path(PinnedPathSpec { - path: "foo/bar".into(), - }); - let base_spec = PinnedSourceSpec::Path(PinnedPathSpec { path: "foo".into() }); - - let result = this_spec.make_relative_to(&base_spec, workspace_root); - - // Both resolve to /workspace/foo/bar and /workspace/foo - // Relative path should be "bar" - let path = result.expect("Should return Some"); - assert_eq!(path.as_str(), "bar"); - } - - #[test] - fn test_absolute_to_absolute() { - // Both paths are absolute - let workspace_root = Path::new("/workspace"); + fn test_path_spec_join() { + use typed_path::Utf8TypedPath; - let this_spec = PinnedSourceSpec::Path(PinnedPathSpec { - path: "/foo/bar/baz".into(), - }); - let base_spec = PinnedSourceSpec::Path(PinnedPathSpec { - path: "/foo/bar".into(), - }); - - let result = this_spec.make_relative_to(&base_spec, workspace_root); + let base = PinnedPathSpec { + path: "/workspace/recipes".into(), + }; + let joined = base.join(Utf8TypedPath::derive("../src")); - // Should compute relative path - let path = result.expect("Should return Some"); - assert_eq!(path.as_str(), "baz"); + assert_eq!(joined.path.as_str(), "/workspace/src"); } #[test] - fn test_relative_to_absolute() { - // Self is relative, base is absolute - after resolution they're both absolute - let workspace_root = Path::new("/workspace"); + fn test_git_spec_join_with_subdir() { + use typed_path::Utf8TypedPath; - let this_spec = PinnedSourceSpec::Path(PinnedPathSpec { - path: "foo/bar".into(), // Resolves to /workspace/foo/bar - }); - let base_spec = PinnedSourceSpec::Path(PinnedPathSpec { - path: "/other/path".into(), // Already absolute - }); - - let result = this_spec.make_relative_to(&base_spec, workspace_root); + let git_spec = PinnedGitSpec { + git: Url::parse("https://github.com/example/repo.git").unwrap(), + source: PinnedGitCheckout { + commit: GitSha::from_str("abc123def456abc123def456abc123def456abc1").unwrap(), + subdirectory: Some("recipes".to_string()), + reference: GitReference::DefaultBranch, + }, + }; - // Both are absolute after resolution, pathdiff should compute relative path - let path = result.expect("Should return Some"); - // From /other/path to /workspace/foo/bar - assert_eq!(path.as_str(), "../../workspace/foo/bar"); + let joined = git_spec.join(Utf8TypedPath::derive("../src")); + assert_eq!(joined.source.subdirectory, Some("src".to_string())); } #[test] - fn test_absolute_with_parent_navigation() { - // Test paths that require .. navigation - let workspace_root = Path::new("/workspace"); + fn test_git_spec_join_without_subdir() { + use typed_path::Utf8TypedPath; - let this_spec = PinnedSourceSpec::Path(PinnedPathSpec { - path: "/foo/bar/qux".into(), - }); - let base_spec = PinnedSourceSpec::Path(PinnedPathSpec { - path: "/foo/baz/quux".into(), - }); - - let result = this_spec.make_relative_to(&base_spec, workspace_root); + let git_spec = PinnedGitSpec { + git: Url::parse("https://github.com/example/repo.git").unwrap(), + source: PinnedGitCheckout { + commit: GitSha::from_str("abc123def456abc123def456abc123def456abc1").unwrap(), + subdirectory: None, + reference: GitReference::DefaultBranch, + }, + }; - let path = result.expect("Should return Some"); - // From /foo/baz/quux to /foo/bar/qux requires ../../bar/qux - assert_eq!(path.as_str(), "../../bar/qux"); + let joined = git_spec.join(Utf8TypedPath::derive("src/lib")); + assert_eq!(joined.source.subdirectory, Some("src/lib".to_string())); } } diff --git a/crates/pixi_record/src/source_record.rs b/crates/pixi_record/src/source_record.rs index 2da3ef404b..ad5c66e8c4 100644 --- a/crates/pixi_record/src/source_record.rs +++ b/crates/pixi_record/src/source_record.rs @@ -1,17 +1,69 @@ +use pixi_git::sha::GitSha; +use pixi_spec::{GitReference, SourceLocationSpec}; +use rattler_conda_types::{MatchSpec, Matches, NamelessMatchSpec, PackageRecord}; +use rattler_lock::{CondaSourceData, GitShallowSpec, PackageBuildSource}; +use std::fmt::{Display, Formatter}; use std::{ collections::{BTreeMap, HashMap}, path::Path, str::FromStr, }; +use typed_path::Utf8TypedPathBuf; -use pixi_git::{sha::GitSha, url::RepositoryUrl}; -use pixi_spec::{GitReference, SourceLocationSpec}; -use rattler_conda_types::{MatchSpec, Matches, NamelessMatchSpec, PackageRecord}; -use rattler_lock::{CondaSourceData, GitShallowSpec, PackageBuildSource}; -use typed_path::{Utf8TypedPathBuf, Utf8UnixPathBuf}; -use url::Url; +use crate::{ + ParseLockFileError, PinnedGitCheckout, PinnedGitSpec, PinnedPathSpec, PinnedSourceSpec, + PinnedUrlSpec, VariantValue, +}; + +/// Represents a pinned build source with information about how it was originally specified in the +/// manifest. +/// +/// When a build source is specified as a relative path (e.g., `../src`), we preserve the original +/// relative path for lock file serialization. Without this, we couldn't distinguish between a path +/// that was originally relative vs. absolute when the resolved path lies outside the workspace. +#[derive(Debug, Clone, Eq, PartialEq, Hash, serde::Serialize, serde::Deserialize)] +pub enum PinnedBuildSourceSpec { + Absolute(PinnedSourceSpec), + Relative(String, PinnedSourceSpec), +} + +impl Display for PinnedBuildSourceSpec { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::Absolute(spec) => write!(f, "{spec}"), + Self::Relative(relative, spec) => write!(f, "{spec} ({relative})"), + } + } +} + +impl PinnedBuildSourceSpec { + pub fn pinned(&self) -> &PinnedSourceSpec { + match self { + PinnedBuildSourceSpec::Absolute(pinned) => pinned, + PinnedBuildSourceSpec::Relative(_, pinned) => pinned, + } + } + + pub fn into_pinned(self) -> PinnedSourceSpec { + match self { + PinnedBuildSourceSpec::Absolute(pinned) => pinned, + PinnedBuildSourceSpec::Relative(_, pinned) => pinned, + } + } + + pub fn pinned_mut(&mut self) -> &mut PinnedSourceSpec { + match self { + PinnedBuildSourceSpec::Absolute(pinned) => pinned, + PinnedBuildSourceSpec::Relative(_, pinned) => pinned, + } + } +} -use crate::{ParseLockFileError, PinnedGitCheckout, PinnedSourceSpec, VariantValue}; +impl From for PinnedSourceSpec { + fn from(pinned: PinnedBuildSourceSpec) -> Self { + pinned.into_pinned() + } +} /// A record of a conda package that still requires building. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] @@ -26,7 +78,7 @@ pub struct SourceRecord { /// The optional pinned source where the build should be executed /// This is used when the manifest is not in the same location as the /// source files. - pub build_source: Option, + pub build_source: Option, /// The variants that uniquely identify the way this package was built. pub variants: Option>, @@ -41,41 +93,35 @@ impl SourceRecord { /// The `build_source` in the SourceRecord is always relative to the workspace. /// However, when saving in the lock-file make these relative to the package manifest. /// This should be used when writing to the lock file. - pub fn into_conda_source_data(self, workspace_root: &Path) -> CondaSourceData { - let package_build_source = if let Some(package_build_source) = self.build_source.clone() { - // See if we can make it relative - let package_build_source_path = package_build_source - .clone() - .make_relative_to(&self.manifest_source, workspace_root) - .map(|path| PackageBuildSource::Path { - path: Utf8TypedPathBuf::Unix(path), - }); - - if package_build_source_path.is_none() { - match package_build_source { - PinnedSourceSpec::Url(pinned_url_spec) => Some(PackageBuildSource::Url { - url: pinned_url_spec.url, - sha256: pinned_url_spec.sha256, - subdir: pinned_url_spec.subdirectory.map(Into::into), - }), - PinnedSourceSpec::Git(pinned_git_spec) => Some(PackageBuildSource::Git { - url: pinned_git_spec.git, - spec: to_git_shallow(&pinned_git_spec.source.reference), - rev: pinned_git_spec.source.commit.to_string(), - subdir: pinned_git_spec - .source - .subdirectory - .map(Utf8TypedPathBuf::from), - }), - PinnedSourceSpec::Path(pinned_path_spec) => Some(PackageBuildSource::Path { - path: pinned_path_spec.path, - }), - } - } else { - package_build_source_path + pub fn into_conda_source_data(self, _workspace_root: &Path) -> CondaSourceData { + let package_build_source = match self.build_source { + Some(PinnedBuildSourceSpec::Relative(path, _)) => Some(PackageBuildSource::Path { + path: Utf8TypedPathBuf::from(path), + }), + Some(PinnedBuildSourceSpec::Absolute(PinnedSourceSpec::Url(pinned_url_spec))) => { + Some(PackageBuildSource::Url { + url: pinned_url_spec.url, + sha256: pinned_url_spec.sha256, + subdir: None, + }) + } + Some(PinnedBuildSourceSpec::Absolute(PinnedSourceSpec::Git(pinned_git_spec))) => { + Some(PackageBuildSource::Git { + url: pinned_git_spec.git, + spec: to_git_shallow(&pinned_git_spec.source.reference), + rev: pinned_git_spec.source.commit.to_string(), + subdir: pinned_git_spec + .source + .subdirectory + .map(Utf8TypedPathBuf::from), + }) } - } else { - None + Some(PinnedBuildSourceSpec::Absolute(PinnedSourceSpec::Path(pinned_path_spec))) => { + Some(PackageBuildSource::Path { + path: pinned_path_spec.path, + }) + } + None => None, }; CondaSourceData { @@ -103,71 +149,49 @@ impl SourceRecord { /// - build_source: relative to manifest_source (or absolute) → resolve to absolute pub fn from_conda_source_data( data: CondaSourceData, - workspace_root: &std::path::Path, + _workspace_root: &std::path::Path, ) -> Result { let manifest_source: PinnedSourceSpec = data.location.try_into()?; - - let build_source = data.package_build_source.map(|source| match source { - PackageBuildSource::Git { + let build_source = match data.package_build_source { + None => None, + Some(PackageBuildSource::Path { path }) if path.is_relative() => { + let pinned = manifest_source.join(path.to_path()); + Some(PinnedBuildSourceSpec::Relative(path.to_string(), pinned)) + } + Some(PackageBuildSource::Path { path }) => Some(PinnedBuildSourceSpec::Absolute( + PinnedSourceSpec::Path(PinnedPathSpec { path }), + )), + Some(PackageBuildSource::Git { url, spec, rev, subdir, - } => { - // Check if this is a relative subdirectory (same repo checkout) - if let (Some(subdir), PinnedSourceSpec::Git(manifest_git)) = - (&subdir, &manifest_source) - && same_git_checkout_url_commit(manifest_git, &url, &rev) { - // The subdirectory is relative to the manifest, use from_relative_to - let relative_path = Utf8UnixPathBuf::from(subdir.as_str()); - return PinnedSourceSpec::from_relative_to( - relative_path, - &manifest_source, - workspace_root, - ) - .expect("from_relative_to should succeed for same-repo git checkouts, this is a bug"); - } - - // Different repository + }) => { let reference = git_reference_from_shallow(spec, &rev); - PinnedSourceSpec::Git(crate::PinnedGitSpec { - git: url, - source: PinnedGitCheckout { - commit: GitSha::from_str(&rev).unwrap(), - subdirectory: subdir.map(|s| s.to_string()), - reference, + Some(PinnedBuildSourceSpec::Absolute(PinnedSourceSpec::Git( + PinnedGitSpec { + git: url, + source: PinnedGitCheckout { + commit: GitSha::from_str(&rev).unwrap(), + subdirectory: subdir.map(|s| s.to_string()), + reference, + }, }, - }) + ))) } - PackageBuildSource::Url { + Some(PackageBuildSource::Url { url, sha256, subdir, - } => PinnedSourceSpec::Url(crate::PinnedUrlSpec { - url, - sha256, - md5: None, - subdirectory: subdir.map(|s| s.to_string()), - }), - PackageBuildSource::Path { path } => { - // Convert path to Unix format for from_relative_to - let path_unix = match path { - Utf8TypedPathBuf::Unix(ref p) => p, - // If its a windows path, it can *ONLY* be absolute per the `into_conda_source_data` method - // so let's return as-is - Utf8TypedPathBuf::Windows(path) => { - return PinnedSourceSpec::Path(crate::PinnedPathSpec { path: Utf8TypedPathBuf::Windows(path) }) - } - }; - - // Try to resolve relative to manifest_source, or use absolute path if that fails - PinnedSourceSpec::from_relative_to(path_unix.to_path_buf(), &manifest_source, workspace_root) - .unwrap_or( - // If from_relative_to returns None (absolute paths), use as-is - PinnedSourceSpec::Path(crate::PinnedPathSpec { path }) - ) - } - }); + }) => Some(PinnedBuildSourceSpec::Absolute(PinnedSourceSpec::Url( + PinnedUrlSpec { + url, + sha256, + md5: None, + subdirectory: subdir.map(|s| s.to_string()), + }, + ))), + }; Ok(Self { package_record: data.package_record, @@ -247,13 +271,6 @@ impl AsRef for SourceRecord { } } -/// Returns true when the git URL and commit match the manifest git spec. -/// Used while parsing lock data where only the URL + rev string are available. -fn same_git_checkout_url_commit(manifest_git: &crate::PinnedGitSpec, url: &Url, rev: &str) -> bool { - RepositoryUrl::new(&manifest_git.git) == RepositoryUrl::new(url) - && manifest_git.source.commit.to_string() == rev -} - fn to_git_shallow(reference: &GitReference) -> Option { match reference { GitReference::Branch(branch) => Some(GitShallowSpec::Branch(branch.clone())), @@ -275,230 +292,13 @@ fn git_reference_from_shallow(spec: Option, rev: &str) -> GitRef #[cfg(test)] mod tests { use super::*; - use pixi_git::sha::GitSha; - use serde_json::json; use std::str::FromStr; - use url::Url; use rattler_conda_types::Platform; use rattler_lock::{ Channel, CondaPackageData, DEFAULT_ENVIRONMENT_NAME, LockFile, LockFileBuilder, }; - #[test] - fn package_build_source_path_is_made_relative() { - use typed_path::Utf8TypedPathBuf; - - let package_record: PackageRecord = serde_json::from_value(json!({ - "name": "example", - "version": "1.0.0", - "build": "0", - "build_number": 0, - "subdir": "noarch", - })) - .expect("valid package record"); - - // Manifest is in /workspace/recipes directory - let manifest_source = PinnedSourceSpec::Path(crate::PinnedPathSpec { - path: Utf8TypedPathBuf::from("/workspace/recipes"), - }); - - // Build source is in /workspace/src (sibling of recipes) - let build_source = PinnedSourceSpec::Path(crate::PinnedPathSpec { - path: Utf8TypedPathBuf::from("/workspace/src"), - }); - - let record = SourceRecord { - package_record, - manifest_source: manifest_source.clone(), - build_source: Some(build_source), - sources: Default::default(), - variants: None, - }; - - // Convert to CondaPackageData (serialization) - let conda_source = record - .clone() - .into_conda_source_data(&std::path::PathBuf::from("/workspace")); - - let package_build_source = conda_source - .package_build_source - .as_ref() - .expect("expected package build source"); - - let PackageBuildSource::Path { path } = package_build_source else { - panic!("expected path package build source"); - }; - - // Because manifest + build live in the same git repo we serialize the build as a git - // source with a subdir relative to the manifest checkout. - assert_eq!( - path.as_str(), - "../src", - "build_source should be relative to manifest_source directory" - ); - - // Convert back to SourceRecord (deserialization) and ensure we recover repo-root subdir - let roundtrip = SourceRecord::from_conda_source_data( - conda_source, - &std::path::PathBuf::from("/workspace"), - ) - .expect("roundtrip should succeed"); - - let Some(PinnedSourceSpec::Path(roundtrip_path)) = roundtrip.build_source else { - panic!("expected path pinned source"); - }; - - // After roundtrip the git subdirectory should be expressed from repo root again. - assert_eq!(roundtrip_path.path.as_str(), "src"); - } - - #[test] - fn package_build_source_roundtrip_git_with_subdir() { - let package_record: PackageRecord = serde_json::from_value(json!({ - "name": "example", - "version": "1.0.0", - "build": "0", - "build_number": 0, - "subdir": "noarch", - })) - .expect("valid package record"); - - let git_url = Url::parse("https://github.com/user/repo.git").unwrap(); - let commit = GitSha::from_str("0123456789abcdef0123456789abcdef01234567").unwrap(); - - // Manifest is in recipes/ subdirectory - let manifest_source = PinnedSourceSpec::Git(crate::PinnedGitSpec { - git: git_url.clone(), - source: PinnedGitCheckout { - commit, - subdirectory: Some("recipes".to_string()), - reference: GitReference::Branch("main".to_string()), - }, - }); - - // Build source is in src/ subdirectory (sibling of recipes) - let build_source = PinnedSourceSpec::Git(crate::PinnedGitSpec { - git: git_url.clone(), - source: PinnedGitCheckout { - commit, - subdirectory: Some("src".to_string()), - reference: GitReference::Branch("main".to_string()), - }, - }); - - let record = SourceRecord { - package_record, - manifest_source: manifest_source.clone(), - build_source: Some(build_source), - sources: Default::default(), - variants: None, - }; - - // Convert to CondaPackageData (serialization) - let conda_source = record - .clone() - .into_conda_source_data(&std::path::PathBuf::from("/workspace")); - - let package_build_source = conda_source - .package_build_source - .as_ref() - .expect("expected package build source"); - - let PackageBuildSource::Path { path, .. } = package_build_source else { - panic!("expected path build source with relative subdir"); - }; - - // Path is relative to manifest checkout (recipes -> ../src) - assert_eq!(path.as_str(), "../src"); - - // Convert back to SourceRecord (deserialization) - let roundtrip = SourceRecord::from_conda_source_data( - conda_source, - &std::path::PathBuf::from("/workspace"), - ) - .expect("roundtrip should succeed"); - - let Some(PinnedSourceSpec::Git(roundtrip_path)) = roundtrip.build_source else { - panic!( - "expected path pinned source after roundtrip (deserialized from relative path in lock file)" - ); - }; - - // After roundtrip, the path will contain .. components (not normalized) - assert_eq!( - roundtrip_path - .source - .subdirectory - .expect("subdirectory should be set") - .as_str(), - "src" - ); - } - - #[test] - fn package_build_source_git_different_repos_stays_git() { - let package_record: PackageRecord = serde_json::from_value(json!({ - "name": "example", - "version": "1.0.0", - "build": "0", - "build_number": 0, - "subdir": "noarch", - })) - .expect("valid package record"); - - let manifest_git_url = Url::parse("https://github.com/user/repo1.git").unwrap(); - let build_git_url = Url::parse("https://github.com/user/repo2.git").unwrap(); - let commit1 = GitSha::from_str("0123456789abcdef0123456789abcdef01234567").unwrap(); - let commit2 = GitSha::from_str("abcdef0123456789abcdef0123456789abcdef01").unwrap(); - - // Manifest is in one repository - let manifest_source = PinnedSourceSpec::Git(crate::PinnedGitSpec { - git: manifest_git_url.clone(), - source: PinnedGitCheckout { - commit: commit1, - subdirectory: Some("recipes".to_string()), - reference: GitReference::Branch("main".to_string()), - }, - }); - - // Build source is in a different repository - let build_source = PinnedSourceSpec::Git(crate::PinnedGitSpec { - git: build_git_url.clone(), - source: PinnedGitCheckout { - commit: commit2, - subdirectory: Some("src".to_string()), - reference: GitReference::Branch("main".to_string()), - }, - }); - - let record = SourceRecord { - package_record, - manifest_source: manifest_source.clone(), - build_source: Some(build_source), - sources: Default::default(), - variants: None, - }; - - // Convert to CondaPackageData (serialization) - let conda_source = record - .clone() - .into_conda_source_data(&std::path::PathBuf::from("/workspace")); - - let package_build_source = conda_source - .package_build_source - .as_ref() - .expect("expected package build source"); - - let PackageBuildSource::Git { url, subdir, .. } = package_build_source else { - panic!("expected git package build source (different repos should stay git)"); - }; - - // Different repositories - should stay as Git source - assert_eq!(url, &build_git_url); - assert_eq!(subdir.as_ref().map(|s| s.as_str()), Some("src")); - } - #[test] fn roundtrip_conda_source_data() { let workspace_root = std::path::Path::new("/workspace"); diff --git a/crates/pixi_spec/Cargo.toml b/crates/pixi_spec/Cargo.toml index 25bb6ca0b8..e22a492dfa 100644 --- a/crates/pixi_spec/Cargo.toml +++ b/crates/pixi_spec/Cargo.toml @@ -28,6 +28,7 @@ typed-path = { workspace = true } url = { workspace = true } pixi_toml = { workspace = true } +pixi_path = { workspace = true } toml-span = { workspace = true } [dev-dependencies] diff --git a/crates/pixi_spec/src/source_anchor.rs b/crates/pixi_spec/src/source_anchor.rs index ffa48ff792..2cf7bf7df6 100644 --- a/crates/pixi_spec/src/source_anchor.rs +++ b/crates/pixi_spec/src/source_anchor.rs @@ -1,9 +1,7 @@ -use pixi_consts::consts::{KNOWN_MANIFEST_FILES, RATTLER_BUILD_FILE_NAMES, ROS_BACKEND_FILE_NAMES}; -use typed_path::{ - Utf8Component, Utf8Encoding, Utf8Path, Utf8PathBuf, Utf8TypedPath, Utf8TypedPathBuf, -}; - use crate::{GitSpec, PathSourceSpec, SourceLocationSpec, SourceSpec, UrlSourceSpec}; +use pixi_consts::consts::{KNOWN_MANIFEST_FILES, RATTLER_BUILD_FILE_NAMES, ROS_BACKEND_FILE_NAMES}; +use pixi_path::normalize; +use typed_path::Utf8TypedPath; /// `SourceAnchor` represents the resolved base location of a `SourceSpec`. /// It serves as a reference point for interpreting relative or recursive @@ -41,7 +39,7 @@ impl SourceAnchor { SourceLocationSpec::Path(PathSourceSpec { path }) => { SourceLocationSpec::Path(PathSourceSpec { // Normalize the input path. - path: normalize_typed(path.to_path()), + path: normalize::normalize_typed(path.to_path()), }) } }; @@ -72,7 +70,7 @@ impl SourceAnchor { base.to_path() }; - let relative_path = normalize_typed(base_dir.join(path).to_path()); + let relative_path = normalize::normalize_typed(base_dir.join(path).to_path()); SourceLocationSpec::Path(PathSourceSpec { path: relative_path, }) @@ -85,7 +83,7 @@ impl SourceAnchor { rev, subdirectory, }) => { - let relative_subdir = normalize_typed( + let relative_subdir = normalize::normalize_typed( Utf8TypedPath::from(subdirectory.as_deref().unwrap_or_default()) .join(path) .to_path(), @@ -115,41 +113,3 @@ fn is_known_manifest_file(path: Utf8TypedPath<'_>) -> bool { }) .unwrap_or(false) } - -/// A slightly modified version of [`Utf8TypedPath::normalize`] that retains -/// `..` components that lead outside the path. -fn normalize_typed(path: Utf8TypedPath<'_>) -> Utf8TypedPathBuf { - match path { - Utf8TypedPath::Unix(path) => Utf8TypedPathBuf::Unix(normalize(path)), - Utf8TypedPath::Windows(path) => Utf8TypedPathBuf::Windows(normalize(path)), - } -} - -/// A slightly modified version of [`Utf8Path::normalize`] that retains `..` -/// components that lead outside the path. -fn normalize(path: &Utf8Path) -> Utf8PathBuf { - let mut components = Vec::new(); - for component in path.components() { - if !component.is_current() && !component.is_parent() { - components.push(component); - } else if component.is_parent() { - if let Some(last) = components.last() { - if last.is_normal() { - components.pop(); - } else { - components.push(component); - } - } else { - components.push(component); - } - } - } - - let mut path = Utf8PathBuf::::new(); - - for component in components { - path.push(component.as_str()); - } - - path -}